Skip to content

Commit 690d9b1

Browse files
dickwuclaude
andcommitted
feat(accounts): add per-bucket public domain for public access
Restore public-domain configuration to the redesigned account editor so R2 (and S3-family) buckets can be served over a public URL instead of temporary signed links. The redesigned AccountEditModal had dropped the editor and hard-saved public_domain: null, wiping any configured domain on every save. - AccountEditModal: per-bucket scheme + host editor with a live public-URL preview and Public/Private status; loads existing values and persists real values for every provider (R2 -> public_domain, S3 -> public_domain_host) - accountStore.toStorageConfig: pass publicDomain for MinIO/RustFS (was dropped) - minio/rustfs providers: prefer publicDomain in buildBucketBaseUrl (match R2/AWS) - FilePreviewModal: build public URLs via buildPublicUrl so object keys are encoded (fixes broken links for keys with spaces/unicode) - Inspector: wire the Copy URL button to the public link with a signed-URL fallback, and reflect real public/private visibility - Import/export already round-trips both field shapes (verified) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3c94f0c commit 690d9b1

8 files changed

Lines changed: 426 additions & 91 deletions

File tree

src/app/components/AccountEditModal.tsx

Lines changed: 208 additions & 82 deletions
Large diffs are not rendered by default.

src/app/components/FilePreviewModal.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@ant-design/icons';
1818
import { openUrl } from '@tauri-apps/plugin-opener';
1919
import dynamic from 'next/dynamic';
20-
import { generateSignedUrl, uploadContent, StorageConfig } from '@/app/lib/r2cache';
20+
import { buildPublicUrl, generateSignedUrl, uploadContent, StorageConfig } from '@/app/lib/r2cache';
2121
import { TEXT_EXTENSIONS } from '@/app/components/preview/TextViewer';
2222
import { useVideoThumbnail } from '@/app/hooks/useVideoThumbnail';
2323
import { usePreviewStore } from '@/app/stores/previewStore';
@@ -178,9 +178,9 @@ export default function FilePreviewModal({ config, onFileUpdated }: FilePreviewM
178178
}
179179

180180
if (config?.publicDomain) {
181-
const domain = config.publicDomain.replace(/\/$/, '');
182-
const scheme = config.publicDomainScheme || 'https';
183-
setSignedUrl(`${scheme}://${domain}/${file.key}`);
181+
// Reuse the provider's URL builder so the object key is encoded
182+
// consistently (spaces, unicode, reserved characters).
183+
setSignedUrl(buildPublicUrl(config, file.key));
184184
return;
185185
}
186186

src/app/components/Inspector.tsx

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ import {
1111
FileZipOutlined,
1212
FileOutlined,
1313
} from '@ant-design/icons';
14+
import { App } from 'antd';
1415
import { FileItem } from '@/app/hooks/useR2Files';
16+
import { buildPublicUrl, generateSignedUrl, type StorageConfig } from '@/app/lib/r2cache';
1517
import { formatBytes } from '@/app/utils/formatBytes';
1618
import dayjs from 'dayjs';
1719

1820
interface InspectorProps {
1921
item: FileItem;
2022
bucket: string;
2123
path: string;
24+
storageConfig?: StorageConfig | null;
2225
onClose: () => void;
2326
onDownload?: (item: FileItem) => void;
2427
}
@@ -123,12 +126,44 @@ function KV({ label, value }: { label: string; value: React.ReactNode }) {
123126
);
124127
}
125128

126-
export default function Inspector({ item, bucket, path, onClose, onDownload }: InspectorProps) {
129+
export default function Inspector({
130+
item,
131+
bucket,
132+
path,
133+
storageConfig,
134+
onClose,
135+
onDownload,
136+
}: InspectorProps) {
137+
const { message } = App.useApp();
127138
const type = itemType(item);
128139

129140
const sizeLabel = item.isFolder ? '—' : formatBytes(item.size || 0);
130141
const modLabel = item.lastModified ? dayjs(item.lastModified).format('YYYY-MM-DD HH:mm') : '—';
131142

143+
const isPublic = !!storageConfig?.publicDomain;
144+
// Only treat as a public link when a public domain is configured. Without one,
145+
// buildPublicUrl would return the S3 API endpoint (e.g. *.r2.cloudflarestorage.com),
146+
// which requires signing — so we fall back to a temporary signed URL instead.
147+
const publicUrl =
148+
!item.isFolder && isPublic && storageConfig ? buildPublicUrl(storageConfig, item.key) : null;
149+
150+
async function handleCopyUrl() {
151+
if (item.isFolder || !storageConfig) return;
152+
try {
153+
const url = publicUrl ?? (await generateSignedUrl(storageConfig, item.key));
154+
if (!url) {
155+
message.warning('No public domain or credentials available for this bucket');
156+
return;
157+
}
158+
await navigator.clipboard.writeText(url);
159+
message.success(
160+
publicUrl ? 'Public URL copied to clipboard' : 'Signed URL copied to clipboard'
161+
);
162+
} catch {
163+
message.error('Failed to copy URL');
164+
}
165+
}
166+
132167
return (
133168
<div className="inspector">
134169
<div className="inspector-header">
@@ -162,9 +197,15 @@ export default function Inspector({ item, bucket, path, onClose, onDownload }: I
162197
<DownloadOutlined style={{ fontSize: 12 }} /> Download
163198
</button>
164199
)}
165-
<button className="btn btn-sm">
166-
<LinkOutlined style={{ fontSize: 12 }} /> Copy URL
167-
</button>
200+
{!item.isFolder && (
201+
<button
202+
className="btn btn-sm"
203+
onClick={handleCopyUrl}
204+
title={isPublic ? 'Copy public URL' : 'Copy temporary signed URL'}
205+
>
206+
<LinkOutlined style={{ fontSize: 12 }} /> {isPublic ? 'Copy link' : 'Copy URL'}
207+
</button>
208+
)}
168209
<button className="btn btn-sm" style={{ padding: '0 8px' }}>
169210
<MoreOutlined style={{ fontSize: 12 }} />
170211
</button>
@@ -180,7 +221,16 @@ export default function Inspector({ item, bucket, path, onClose, onDownload }: I
180221
<KV label="ETag" value="—" />
181222
<KV label="Storage class" value="STANDARD" />
182223
<KV label="Encryption" value="AES-256" />
183-
<KV label="Visibility" value="private" />
224+
<KV
225+
label="Visibility"
226+
value={
227+
isPublic ? (
228+
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>public</span>
229+
) : (
230+
'private'
231+
)
232+
}
233+
/>
184234
</>
185235
)}
186236
</div>

src/app/globals.css

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3819,6 +3819,152 @@ html.dark .download-task-list > div::-webkit-scrollbar-thumb:hover {
38193819
color: var(--text-muted);
38203820
}
38213821

3822+
/* ── Bucket + public-access editor (AccountEditModal) ──────────── */
3823+
.bkt-section-head {
3824+
display: flex;
3825+
align-items: center;
3826+
justify-content: space-between;
3827+
gap: 8px;
3828+
margin-bottom: 6px;
3829+
}
3830+
.bkt-section-count {
3831+
font-size: 11px;
3832+
font-weight: 600;
3833+
color: var(--text-subtle);
3834+
font-variant-numeric: tabular-nums;
3835+
white-space: nowrap;
3836+
}
3837+
.bkt-list {
3838+
display: flex;
3839+
flex-direction: column;
3840+
gap: 8px;
3841+
}
3842+
.bkt-card {
3843+
position: relative;
3844+
border: 1px solid var(--border);
3845+
border-radius: 9px;
3846+
background: var(--bg-panel);
3847+
overflow: hidden;
3848+
transition:
3849+
border-color 140ms ease,
3850+
box-shadow 140ms ease;
3851+
}
3852+
.bkt-card:focus-within {
3853+
border-color: var(--accent);
3854+
box-shadow: 0 0 0 3px var(--accent-soft);
3855+
}
3856+
.bkt-card.is-public {
3857+
border-color: var(--border-strong);
3858+
}
3859+
.bkt-card.is-public::before {
3860+
content: '';
3861+
position: absolute;
3862+
left: 0;
3863+
top: 0;
3864+
bottom: 0;
3865+
width: 2px;
3866+
background: var(--accent);
3867+
}
3868+
.bkt-head {
3869+
display: flex;
3870+
align-items: center;
3871+
gap: 9px;
3872+
padding: 9px 11px 7px;
3873+
}
3874+
.bkt-ico {
3875+
font-size: 13px;
3876+
color: var(--text-muted);
3877+
flex-shrink: 0;
3878+
}
3879+
.bkt-name {
3880+
flex: 1;
3881+
min-width: 0;
3882+
font-family: var(--font-mono);
3883+
font-size: 12px;
3884+
color: var(--text);
3885+
overflow: hidden;
3886+
text-overflow: ellipsis;
3887+
white-space: nowrap;
3888+
}
3889+
.bkt-pill {
3890+
font-size: 10px;
3891+
font-weight: 700;
3892+
letter-spacing: 0.05em;
3893+
text-transform: uppercase;
3894+
padding: 2px 7px;
3895+
border-radius: 999px;
3896+
flex-shrink: 0;
3897+
}
3898+
.bkt-pill.public {
3899+
color: var(--accent);
3900+
background: var(--accent-soft);
3901+
}
3902+
.bkt-pill.private {
3903+
color: var(--text-subtle);
3904+
background: var(--bg-sunken);
3905+
}
3906+
.bkt-domain {
3907+
display: flex;
3908+
align-items: center;
3909+
gap: 7px;
3910+
padding: 0 11px 9px;
3911+
}
3912+
.bkt-domain-ico {
3913+
font-size: 12px;
3914+
color: var(--text-subtle);
3915+
flex-shrink: 0;
3916+
}
3917+
.bkt-scheme {
3918+
width: auto;
3919+
min-width: 88px;
3920+
flex-shrink: 0;
3921+
height: 30px;
3922+
padding: 0 8px;
3923+
font-family: var(--font-mono);
3924+
font-size: 11.5px;
3925+
}
3926+
.bkt-host {
3927+
flex: 1;
3928+
min-width: 0;
3929+
height: 30px;
3930+
font-size: 11.5px;
3931+
}
3932+
.bkt-preview {
3933+
display: flex;
3934+
align-items: center;
3935+
gap: 7px;
3936+
padding: 7px 11px;
3937+
border-top: 1px solid var(--border);
3938+
background: var(--bg-sunken);
3939+
font-family: var(--font-mono);
3940+
font-size: 11px;
3941+
line-height: 1.3;
3942+
min-width: 0;
3943+
}
3944+
.bkt-preview-arrow {
3945+
color: var(--accent);
3946+
flex-shrink: 0;
3947+
}
3948+
.bkt-preview-url {
3949+
color: var(--text-muted);
3950+
overflow: hidden;
3951+
text-overflow: ellipsis;
3952+
white-space: nowrap;
3953+
min-width: 0;
3954+
}
3955+
.bkt-preview-obj {
3956+
color: var(--text-subtle);
3957+
}
3958+
.bkt-preview-hint {
3959+
color: var(--text-subtle);
3960+
font-family: var(--font-sans);
3961+
font-size: 11px;
3962+
}
3963+
.bkt-load-btn {
3964+
align-self: flex-start;
3965+
margin-top: 2px;
3966+
}
3967+
38223968
/* Account list items inside SettingsAccountPanel */
38233969
.acct-list-item {
38243970
display: flex;

src/app/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,7 @@ export default function Home() {
11751175
item={focusedItem}
11761176
bucket={currentConfig?.bucket ?? ''}
11771177
path={currentPath}
1178+
storageConfig={config}
11781179
onClose={() => setShowInspector(false)}
11791180
onDownload={handleDownload}
11801181
/>

src/app/providers/minio/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ export const minioProvider: StorageProviderAdapter<MinioStorageConfig> = {
143143
},
144144

145145
buildBucketBaseUrl: (config) => {
146+
if (config.publicDomain) {
147+
const scheme = config.publicDomainScheme || 'https';
148+
return `${scheme}://${config.publicDomain.replace(/\/+$/, '')}`;
149+
}
146150
if (!config.bucket || !config.endpointHost) return null;
147151
const scheme = config.endpointScheme || 'https';
148152
const host = config.endpointHost;

src/app/providers/rustfs/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ export const rustfsProvider: StorageProviderAdapter<RustfsStorageConfig> = {
143143
},
144144

145145
buildBucketBaseUrl: (config) => {
146+
if (config.publicDomain) {
147+
const scheme = config.publicDomainScheme || 'https';
148+
return `${scheme}://${config.publicDomain.replace(/\/+$/, '')}`;
149+
}
146150
if (!config.bucket || !config.endpointHost) return null;
147151
const scheme = config.endpointScheme || 'https';
148152
const host = config.endpointHost;

src/app/stores/accountStore.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,8 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
759759
endpointHost: currentConfig.endpoint_host,
760760
forcePathStyle: currentConfig.force_path_style ?? false,
761761
bucket: currentConfig.bucket,
762+
publicDomain: currentConfig.public_domain || undefined,
763+
publicDomainScheme: currentConfig.public_domain_scheme || undefined,
762764
};
763765
}
764766

@@ -775,6 +777,8 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
775777
endpointHost: currentConfig.endpoint_host,
776778
forcePathStyle: true,
777779
bucket: currentConfig.bucket,
780+
publicDomain: currentConfig.public_domain || undefined,
781+
publicDomainScheme: currentConfig.public_domain_scheme || undefined,
778782
};
779783
}
780784

0 commit comments

Comments
 (0)