storage-explorer 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-0sdjygxy.js +11 -0
- package/dist/{chunk-fa0pf3pw.js.map → chunk-0sdjygxy.js.map} +5 -5
- package/dist/{chunk-veptbhs8.css → chunk-aq15a172.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/App.tsx +25 -1
- package/src/features/objects/ObjectExplorer.tsx +14 -1
- package/src/index.css +17 -1
- package/src/server/routes/s3Routes.ts +4 -0
- package/src/server/s3/handlers.ts +39 -1
- package/src/server/s3/types.ts +6 -0
- package/src/server/s3/validate.ts +38 -1
- package/src/shared/api/s3Api.ts +30 -0
- package/dist/chunk-fa0pf3pw.js +0 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{color:#152234;--bg-top:#f9f3ea;--bg-bottom:#e8eff8;--panel:#ffffffd8;--panel-border:#cdd9e8;--text:#152234;--muted:#5d6f84;--accent:#146c94;--accent-soft:#e1f0f8;--danger:#b9414f;--ok:#1f8b4c;--radius:14px;background:#f6f8fb;font-family:Segoe UI,Avenir Next,Helvetica Neue,sans-serif;font-weight:400;line-height:1.4}*{box-sizing:border-box}body{color:var(--text);background:linear-gradient(168deg,var(--bg-top),var(--bg-bottom));min-width:320px;min-height:100vh;margin:0}body:before{content:"";position:fixed;z-index:-1;pointer-events:none;background:radial-gradient(circle at 20% 18%,#fffa 0,#0000 36%),radial-gradient(circle at 85% 12%,#d9e9fa88 0,#0000 28%),radial-gradient(circle at 70% 80%,#f8e8d6aa 0,#0000 35%);inset:0}#root{width:100%;min-height:100vh}.app-shell{display:grid;grid-template-columns:360px minmax(0,1fr);gap:1rem;width:min(1320px,100% - 2rem);margin:1rem auto}.main-column{display:grid;align-content: start;gap:1rem}.panel{background:var(--panel);backdrop-filter:blur(10px);border:1px solid var(--panel-border);border-radius:var(--radius);box-shadow:0 10px 24px #12233712}.panel-step{display:inline-block;color:var(--accent);background:var(--accent-soft);border-radius:999px;margin-bottom:.6rem;padding:.2rem .55rem;font-size:.73rem;font-weight:600}.profile-panel{position:sticky;overflow:auto;max-height:calc(100vh - 2rem);padding:1rem;top:1rem}.explorer-panel,.object-panel{padding:1rem}.panel-header h1,.explorer-header h2,.objects-header h3{letter-spacing:.01em;margin:0}.panel-header p,.explorer-header p,.objects-header p{color:var(--muted);margin:.35rem 0 0;font-size:.92rem}.profiles-toolbar{display:flex;justify-content:space-between;align-items: center;margin-top:1rem}.profiles-list{display:grid;gap:.5rem;margin-top:.65rem}.profile-card{border:1px solid var(--panel-border);overflow:hidden;background:#fff;border-radius:10px}.profile-card.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.profile-select{text-align:left;display:grid;cursor:pointer;background:0 0;border:0;gap:.25rem;width:100%;padding:.65rem .75rem}.profile-name{font-weight:600}.profile-endpoint{color:var(--muted);overflow-wrap:anywhere;font-size:.84rem}.delete-button{border:0;border-top:1px solid var(--panel-border);color:var(--danger);cursor:pointer;background:#fff;width:100%;padding:.45rem .75rem}.profile-form{display:grid;gap:.7rem;margin-top:1rem}.profile-form label{display:grid;gap:.35rem;font-size:.9rem}input,button{font:inherit}.profile-form input,.manual-bucket-row input{border:1px solid var(--panel-border);color:var(--text);background:#fff;border-radius:9px;flex:1;padding:.55rem .65rem}.profile-form input:focus,.manual-bucket-row input:focus{outline:2px solid #0000;border-color:var(--accent);box-shadow:0 0 0 2px #146c9430}.secret-row{display:flex;gap:.5rem}.secret-row input{flex:1}.checkbox-row{grid-template-columns:auto 1fr;align-items: center}.checkbox-row input{width:1rem;height:1rem}.form-actions{display:flex;flex-wrap:wrap;gap:.55rem}.primary-button,.secondary-button,.ghost-button{cursor:pointer;border-radius:9px;padding:.55rem .85rem;transition:all .15s}.primary-button{border:1px solid var(--accent);background:var(--accent);color:#fff}.primary-button:hover{filter:brightness(1.05)}.secondary-button{border:1px solid var(--panel-border);color:var(--text);background:#fff}.ghost-button{border:1px solid var(--panel-border);color:var(--muted);background:#fff}.secondary-button:hover,.ghost-button:hover,.delete-button:hover,.bucket-item:hover,.object-row.folder:hover,.breadcrumb-link:hover{background:var(--accent-soft)}button:disabled{opacity:.58;cursor:not-allowed}.status-ok,.status-error,.empty-copy{margin:.75rem 0 0;font-size:.88rem}.status-ok{color:var(--ok)}.status-error{color:var(--danger)}.empty-copy,.muted,.helper-copy{color:var(--muted)}.explorer-header,.objects-header{display:flex;justify-content:space-between;align-items: flex-start;gap:1rem}.manual-bucket-form{display:grid;border:1px solid var(--panel-border);background:#fff;border-radius:10px;gap:.45rem;margin-top:.9rem;padding:.7rem}.manual-bucket-form label{color:var(--muted);font-size:.88rem}.manual-bucket-row{display:flex;align-items: center;gap:.5rem}.helper-copy{margin:0;font-size:.82rem}.buckets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.5rem;margin-top:.8rem}.bucket-item{border:1px solid var(--panel-border);text-align:left;display:grid;cursor:pointer;background:#fff;border-radius:10px;gap:.2rem;padding:.7rem}.bucket-item.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.bucket-item small{color:var(--muted)}.objects-actions{display:flex;flex-wrap:wrap;gap:.5rem}.path-breadcrumb{border:1px solid var(--panel-border);display:flex;background:#fff;border-radius:10px;flex-wrap:wrap;align-items: center;gap:.1rem;margin-top:.8rem;padding:.55rem .7rem;font-size:.88rem}.path-breadcrumb.muted{color:var(--muted)}.breadcrumb-link{cursor:pointer;color:var(--text);background:0 0;border:0;border-radius:6px;padding:.2rem .4rem}.breadcrumb-segment{display:inline-flex;align-items: center}.breadcrumb-separator{color:var(--muted);margin:0 .05rem}.objects-list{border:1px solid var(--panel-border);overflow:auto;background:#fff;border-radius:10px;min-height:320px;margin-top:.8rem;padding:.5rem}.object-row{color:var(--text);display:grid;grid-template-columns:minmax(0,1fr)140px 210px;text-align:left;background:#fff;border:0;border-bottom:1px solid #e7edf6;align-items: center;gap:.8rem;width:100%;padding:.65rem}.object-row:last-child{border-bottom:0}.object-row.folder{cursor:pointer}.object-name{overflow-wrap:anywhere}.object-meta{color:var(--muted);text-align:right;white-space:nowrap;font-size:.82rem}@media (max-width:1020px){.app-shell{grid-template-columns:1fr}.profile-panel{position:static;max-height:none}.explorer-header,.objects-header{flex-direction:column}.manual-bucket-row{flex-direction:column;align-items:stretch}.object-row{grid-template-columns:1fr}.object-meta{text-align:left;white-space:normal}}
|
|
1
|
+
:root{color:#152234;--bg-top:#f9f3ea;--bg-bottom:#e8eff8;--panel:#ffffffd8;--panel-border:#cdd9e8;--text:#152234;--muted:#5d6f84;--accent:#146c94;--accent-soft:#e1f0f8;--danger:#b9414f;--ok:#1f8b4c;--radius:14px;background:#f6f8fb;font-family:Segoe UI,Avenir Next,Helvetica Neue,sans-serif;font-weight:400;line-height:1.4}*{box-sizing:border-box}body{color:var(--text);background:linear-gradient(168deg,var(--bg-top),var(--bg-bottom));min-width:320px;min-height:100vh;margin:0}body:before{content:"";position:fixed;z-index:-1;pointer-events:none;background:radial-gradient(circle at 20% 18%,#fffa 0,#0000 36%),radial-gradient(circle at 85% 12%,#d9e9fa88 0,#0000 28%),radial-gradient(circle at 70% 80%,#f8e8d6aa 0,#0000 35%);inset:0}#root{width:100%;min-height:100vh}.app-shell{display:grid;grid-template-columns:360px minmax(0,1fr);gap:1rem;width:min(1320px,100% - 2rem);margin:1rem auto}.main-column{display:grid;align-content: start;gap:1rem}.panel{background:var(--panel);backdrop-filter:blur(10px);border:1px solid var(--panel-border);border-radius:var(--radius);box-shadow:0 10px 24px #12233712}.panel-step{display:inline-block;color:var(--accent);background:var(--accent-soft);border-radius:999px;margin-bottom:.6rem;padding:.2rem .55rem;font-size:.73rem;font-weight:600}.profile-panel{position:sticky;overflow:auto;max-height:calc(100vh - 2rem);padding:1rem;top:1rem}.explorer-panel,.object-panel{padding:1rem}.panel-header h1,.explorer-header h2,.objects-header h3{letter-spacing:.01em;margin:0}.panel-header p,.explorer-header p,.objects-header p{color:var(--muted);margin:.35rem 0 0;font-size:.92rem}.profiles-toolbar{display:flex;justify-content:space-between;align-items: center;margin-top:1rem}.profiles-list{display:grid;gap:.5rem;margin-top:.65rem}.profile-card{border:1px solid var(--panel-border);overflow:hidden;background:#fff;border-radius:10px}.profile-card.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.profile-select{text-align:left;display:grid;cursor:pointer;background:0 0;border:0;gap:.25rem;width:100%;padding:.65rem .75rem}.profile-name{font-weight:600}.profile-endpoint{color:var(--muted);overflow-wrap:anywhere;font-size:.84rem}.delete-button{border:0;border-top:1px solid var(--panel-border);color:var(--danger);cursor:pointer;background:#fff;width:100%;padding:.45rem .75rem}.profile-form{display:grid;gap:.7rem;margin-top:1rem}.profile-form label{display:grid;gap:.35rem;font-size:.9rem}input,button{font:inherit}.profile-form input,.manual-bucket-row input{border:1px solid var(--panel-border);color:var(--text);background:#fff;border-radius:9px;flex:1;padding:.55rem .65rem}.profile-form input:focus,.manual-bucket-row input:focus{outline:2px solid #0000;border-color:var(--accent);box-shadow:0 0 0 2px #146c9430}.secret-row{display:flex;gap:.5rem}.secret-row input{flex:1}.checkbox-row{grid-template-columns:auto 1fr;align-items: center}.checkbox-row input{width:1rem;height:1rem}.form-actions{display:flex;flex-wrap:wrap;gap:.55rem}.primary-button,.secondary-button,.ghost-button{cursor:pointer;border-radius:9px;padding:.55rem .85rem;transition:all .15s}.primary-button{border:1px solid var(--accent);background:var(--accent);color:#fff}.primary-button:hover{filter:brightness(1.05)}.secondary-button{border:1px solid var(--panel-border);color:var(--text);background:#fff}.ghost-button{border:1px solid var(--panel-border);color:var(--muted);background:#fff}.secondary-button:hover,.ghost-button:hover,.delete-button:hover,.bucket-item:hover,.object-row.folder:hover,.breadcrumb-link:hover{background:var(--accent-soft)}button:disabled{opacity:.58;cursor:not-allowed}.status-ok,.status-error,.empty-copy{margin:.75rem 0 0;font-size:.88rem}.status-ok{color:var(--ok)}.status-error{color:var(--danger)}.empty-copy,.muted,.helper-copy{color:var(--muted)}.explorer-header,.objects-header{display:flex;justify-content:space-between;align-items: flex-start;gap:1rem}.manual-bucket-form{display:grid;border:1px solid var(--panel-border);background:#fff;border-radius:10px;gap:.45rem;margin-top:.9rem;padding:.7rem}.manual-bucket-form label{color:var(--muted);font-size:.88rem}.manual-bucket-row{display:flex;align-items: center;gap:.5rem}.helper-copy{margin:0;font-size:.82rem}.buckets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.5rem;margin-top:.8rem}.bucket-item{border:1px solid var(--panel-border);text-align:left;display:grid;cursor:pointer;background:#fff;border-radius:10px;gap:.2rem;padding:.7rem}.bucket-item.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.bucket-item small{color:var(--muted)}.objects-actions{display:flex;flex-wrap:wrap;gap:.5rem}.path-breadcrumb{border:1px solid var(--panel-border);display:flex;background:#fff;border-radius:10px;flex-wrap:wrap;align-items: center;gap:.1rem;margin-top:.8rem;padding:.55rem .7rem;font-size:.88rem}.path-breadcrumb.muted{color:var(--muted)}.breadcrumb-link{cursor:pointer;color:var(--text);background:0 0;border:0;border-radius:6px;padding:.2rem .4rem}.breadcrumb-segment{display:inline-flex;align-items: center}.breadcrumb-separator{color:var(--muted);margin:0 .05rem}.objects-list{border:1px solid var(--panel-border);overflow:auto;background:#fff;border-radius:10px;min-height:320px;margin-top:.8rem;padding:.5rem}.object-row{color:var(--text);display:grid;grid-template-columns:minmax(0,1fr)140px 210px auto;text-align:left;background:#fff;border:0;border-bottom:1px solid #e7edf6;align-items: center;gap:.8rem;width:100%;padding:.65rem}.object-row:last-child{border-bottom:0}.object-row.folder{cursor:pointer}.object-name{overflow-wrap:anywhere}.object-meta{color:var(--muted);text-align:right;white-space:nowrap;font-size:.82rem}.download-button{border:1px solid var(--panel-border);color:var(--accent);cursor:pointer;white-space:nowrap;background:#fff;border-radius:7px;padding:.3rem .6rem;transition:all .15s;font-size:.8rem}.download-button:hover{background:var(--accent-soft)}@media (max-width:1020px){.app-shell{grid-template-columns:1fr}.profile-panel{position:static;max-height:none}.explorer-header,.objects-header{flex-direction:column}.manual-bucket-row{flex-direction:column;align-items:stretch}.object-row{grid-template-columns:1fr}.object-meta{text-align:left;white-space:normal}}
|
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="./logo-kygw735p.svg" />
|
|
7
7
|
<title>S3 Explorer</title>
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./chunk-
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./chunk-aq15a172.css"><script type="module" crossorigin src="./chunk-0sdjygxy.js"></script></head>
|
|
9
9
|
<body>
|
|
10
10
|
<div id="root"></div>
|
|
11
11
|
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useEffect, useState, type FormEvent } from "react";
|
|
|
3
3
|
import { BucketPanel } from "./features/buckets/BucketPanel";
|
|
4
4
|
import { ObjectExplorer } from "./features/objects/ObjectExplorer";
|
|
5
5
|
import { ProfileSidebar } from "./features/profiles/ProfileSidebar";
|
|
6
|
-
import { listBuckets, listObjects, testConnection } from "./shared/api/s3Api";
|
|
6
|
+
import { downloadObject, listBuckets, listObjects, testConnection } from "./shared/api/s3Api";
|
|
7
7
|
import { useProfilesStorage } from "./shared/hooks/useProfilesStorage";
|
|
8
8
|
import type {
|
|
9
9
|
BucketItem,
|
|
@@ -74,6 +74,7 @@ export function App() {
|
|
|
74
74
|
const [objects, setObjects] = useState<ObjectsPayload | null>(null);
|
|
75
75
|
const [nextToken, setNextToken] = useState<string | null>(null);
|
|
76
76
|
const [isNextPage, setIsNextPage] = useState(false);
|
|
77
|
+
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
|
77
78
|
|
|
78
79
|
useEffect(() => {
|
|
79
80
|
if (!selectedProfile) {
|
|
@@ -335,6 +336,27 @@ export function App() {
|
|
|
335
336
|
await loadObjects({ bucket: selectedBucket, targetPrefix: prefix, continuationToken: null });
|
|
336
337
|
};
|
|
337
338
|
|
|
339
|
+
const onDownloadFile = async (key: string) => {
|
|
340
|
+
if (!selectedBucket || !profileValid) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
setDownloadingKey(key);
|
|
345
|
+
setStatusError("");
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await downloadObject({
|
|
349
|
+
profile: profilePayload(),
|
|
350
|
+
bucket: selectedBucket,
|
|
351
|
+
key,
|
|
352
|
+
});
|
|
353
|
+
} catch (err) {
|
|
354
|
+
setStatusError(err instanceof Error ? err.message : "Download failed.");
|
|
355
|
+
} finally {
|
|
356
|
+
setDownloadingKey(null);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
338
360
|
return (
|
|
339
361
|
<div className="app-shell">
|
|
340
362
|
<ProfileSidebar
|
|
@@ -381,6 +403,8 @@ export function App() {
|
|
|
381
403
|
onLoadNextPage={onLoadNextPage}
|
|
382
404
|
onOpenFolder={onOpenFolder}
|
|
383
405
|
onNavigatePrefix={onNavigatePrefix}
|
|
406
|
+
onDownloadFile={onDownloadFile}
|
|
407
|
+
downloadingKey={downloadingKey}
|
|
384
408
|
/>
|
|
385
409
|
</main>
|
|
386
410
|
</div>
|
|
@@ -13,6 +13,8 @@ type ObjectExplorerProps = {
|
|
|
13
13
|
onLoadNextPage: () => void;
|
|
14
14
|
onOpenFolder: (folderPrefix: string) => void;
|
|
15
15
|
onNavigatePrefix: (prefix: string) => void;
|
|
16
|
+
onDownloadFile: (key: string) => void;
|
|
17
|
+
downloadingKey: string | null;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
function formatObjectName(key: string, prefix: string): string {
|
|
@@ -44,6 +46,8 @@ export function ObjectExplorer(props: ObjectExplorerProps) {
|
|
|
44
46
|
onLoadNextPage,
|
|
45
47
|
onOpenFolder,
|
|
46
48
|
onNavigatePrefix,
|
|
49
|
+
onDownloadFile,
|
|
50
|
+
downloadingKey,
|
|
47
51
|
} = props;
|
|
48
52
|
|
|
49
53
|
return (
|
|
@@ -109,16 +113,25 @@ export function ObjectExplorer(props: ObjectExplorerProps) {
|
|
|
109
113
|
<span className="object-name">{formatFolderName(folder, prefix)}/</span>
|
|
110
114
|
<span className="object-meta">folder</span>
|
|
111
115
|
<span className="object-meta">-</span>
|
|
116
|
+
<span />
|
|
112
117
|
</button>
|
|
113
118
|
))}
|
|
114
119
|
|
|
115
120
|
{objects.files.map(file => (
|
|
116
|
-
<div key={file.key} className="object-row">
|
|
121
|
+
<div key={file.key} className="object-row file">
|
|
117
122
|
<span className="object-name">{formatObjectName(file.key, prefix)}</span>
|
|
118
123
|
<span className="object-meta">{file.size.toLocaleString()} bytes</span>
|
|
119
124
|
<span className="object-meta">
|
|
120
125
|
{file.lastModified ? new Date(file.lastModified).toLocaleString() : "-"}
|
|
121
126
|
</span>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="download-button"
|
|
130
|
+
onClick={() => onDownloadFile(file.key)}
|
|
131
|
+
disabled={downloadingKey === file.key}
|
|
132
|
+
>
|
|
133
|
+
{downloadingKey === file.key ? "Downloading..." : "Download"}
|
|
134
|
+
</button>
|
|
122
135
|
</div>
|
|
123
136
|
))}
|
|
124
137
|
</>
|
package/src/index.css
CHANGED
|
@@ -408,7 +408,7 @@ button:disabled {
|
|
|
408
408
|
background: #fff;
|
|
409
409
|
color: var(--text);
|
|
410
410
|
display: grid;
|
|
411
|
-
grid-template-columns: minmax(0, 1fr) 140px 210px;
|
|
411
|
+
grid-template-columns: minmax(0, 1fr) 140px 210px auto;
|
|
412
412
|
gap: 0.8rem;
|
|
413
413
|
padding: 0.65rem;
|
|
414
414
|
text-align: left;
|
|
@@ -434,6 +434,22 @@ button:disabled {
|
|
|
434
434
|
white-space: nowrap;
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
.download-button {
|
|
438
|
+
border: 1px solid var(--panel-border);
|
|
439
|
+
border-radius: 7px;
|
|
440
|
+
padding: 0.3rem 0.6rem;
|
|
441
|
+
background: #fff;
|
|
442
|
+
color: var(--accent);
|
|
443
|
+
font-size: 0.8rem;
|
|
444
|
+
cursor: pointer;
|
|
445
|
+
white-space: nowrap;
|
|
446
|
+
transition: 0.15s ease;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.download-button:hover {
|
|
450
|
+
background: var(--accent-soft);
|
|
451
|
+
}
|
|
452
|
+
|
|
437
453
|
@media (max-width: 1020px) {
|
|
438
454
|
.app-shell {
|
|
439
455
|
grid-template-columns: 1fr;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
downloadObjectHandler,
|
|
2
3
|
listBucketsHandler,
|
|
3
4
|
listObjectsHandler,
|
|
4
5
|
testConnectionHandler,
|
|
@@ -14,4 +15,7 @@ export const s3Routes = {
|
|
|
14
15
|
"/api/s3/list-objects": {
|
|
15
16
|
POST: listObjectsHandler,
|
|
16
17
|
},
|
|
18
|
+
"/api/s3/download-object": {
|
|
19
|
+
POST: downloadObjectHandler,
|
|
20
|
+
},
|
|
17
21
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
GetObjectCommand,
|
|
2
3
|
ListBucketsCommand,
|
|
3
4
|
ListObjectsV2Command,
|
|
4
5
|
S3ServiceException,
|
|
@@ -6,7 +7,7 @@ import {
|
|
|
6
7
|
import { fail, ok } from "../http/response";
|
|
7
8
|
import { createS3Client } from "./client";
|
|
8
9
|
import { mapListBucketsResult, mapListObjectsResult, mapS3Error } from "./mappers";
|
|
9
|
-
import { invalidProfileError, parseListObjectsInput, parseProfileFromBody } from "./validate";
|
|
10
|
+
import { invalidProfileError, parseDownloadInput, parseListObjectsInput, parseProfileFromBody } from "./validate";
|
|
10
11
|
|
|
11
12
|
async function parseJsonBody(req: Request): Promise<unknown> {
|
|
12
13
|
try {
|
|
@@ -94,3 +95,40 @@ export async function listObjectsHandler(req: Request): Promise<Response> {
|
|
|
94
95
|
return fail(400, mapS3Error(err));
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
export async function downloadObjectHandler(req: Request): Promise<Response> {
|
|
100
|
+
const body = await parseJsonBody(req);
|
|
101
|
+
const parsed = parseDownloadInput(body);
|
|
102
|
+
|
|
103
|
+
if (!parsed.ok) {
|
|
104
|
+
return fail(400, parsed.error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { profile, bucket, key } = parsed.data;
|
|
108
|
+
const client = createS3Client(profile);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const result = await client.send(
|
|
112
|
+
new GetObjectCommand({
|
|
113
|
+
Bucket: bucket,
|
|
114
|
+
Key: key,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (!result.Body) {
|
|
119
|
+
return fail(404, { message: "Object body is empty.", code: "EmptyBody" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const filename = key.split("/").filter(Boolean).pop() ?? key;
|
|
123
|
+
|
|
124
|
+
return new Response(result.Body.transformToWebStream(), {
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": result.ContentType || "application/octet-stream",
|
|
127
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
128
|
+
...(result.ContentLength != null ? { "Content-Length": String(result.ContentLength) } : {}),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return fail(400, mapS3Error(err));
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/server/s3/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ApiErrorPayload } from "../http/response";
|
|
2
|
-
import type { ParsedListObjectsInput, S3ProfileInput } from "./types";
|
|
2
|
+
import type { ParsedDownloadInput, ParsedListObjectsInput, S3ProfileInput } from "./types";
|
|
3
3
|
|
|
4
4
|
function parseProfile(value: unknown): S3ProfileInput | null {
|
|
5
5
|
if (typeof value !== "object" || value === null) {
|
|
@@ -95,6 +95,43 @@ export function parseListObjectsInput(body: unknown):
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export function parseDownloadInput(body: unknown):
|
|
99
|
+
| { ok: true; data: ParsedDownloadInput }
|
|
100
|
+
| { ok: false; error: ApiErrorPayload } {
|
|
101
|
+
const record = body as
|
|
102
|
+
| { profile?: unknown; bucket?: unknown; key?: unknown }
|
|
103
|
+
| null;
|
|
104
|
+
|
|
105
|
+
const profile = parseProfile(record?.profile);
|
|
106
|
+
if (!profile) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: {
|
|
110
|
+
message: "Invalid profile. endpoint, accessKeyId, and secretAccessKey are required.",
|
|
111
|
+
code: "InvalidProfile",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const bucket = typeof record?.bucket === "string" ? record.bucket.trim() : "";
|
|
117
|
+
if (!bucket) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: { message: "Bucket is required.", code: "MissingBucket" },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const key = typeof record?.key === "string" ? record.key.trim() : "";
|
|
125
|
+
if (!key) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: { message: "Object key is required.", code: "MissingKey" },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ok: true, data: { profile, bucket, key } };
|
|
133
|
+
}
|
|
134
|
+
|
|
98
135
|
export function invalidProfileError(): ApiErrorPayload {
|
|
99
136
|
return {
|
|
100
137
|
message: "Invalid profile. endpoint, accessKeyId, and secretAccessKey are required.",
|
package/src/shared/api/s3Api.ts
CHANGED
|
@@ -54,3 +54,33 @@ export function listObjects(input: {
|
|
|
54
54
|
}): Promise<ObjectsPayload> {
|
|
55
55
|
return postJson<ObjectsPayload>("/api/s3/list-objects", input);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
export async function downloadObject(input: {
|
|
59
|
+
profile: S3ConnectionProfile;
|
|
60
|
+
bucket: string;
|
|
61
|
+
key: string;
|
|
62
|
+
}): Promise<void> {
|
|
63
|
+
const response = await fetch("/api/s3/download-object", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify(input),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const payload = (await response.json().catch(() => null)) as ApiErrorEnvelope | null;
|
|
71
|
+
const message = payload?.error?.message || "Download failed.";
|
|
72
|
+
throw new Error(message);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const disposition = response.headers.get("Content-Disposition");
|
|
76
|
+
const match = disposition?.match(/filename="(.+)"/);
|
|
77
|
+
const filename = match?.[1] ?? input.key.split("/").filter(Boolean).pop() ?? "download";
|
|
78
|
+
|
|
79
|
+
const blob = await response.blob();
|
|
80
|
+
const url = URL.createObjectURL(blob);
|
|
81
|
+
const a = document.createElement("a");
|
|
82
|
+
a.href = url;
|
|
83
|
+
a.download = filename;
|
|
84
|
+
a.click();
|
|
85
|
+
URL.revokeObjectURL(url);
|
|
86
|
+
}
|