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.
@@ -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-veptbhs8.css"><script type="module" crossorigin src="./chunk-fa0pf3pw.js"></script></head>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storage-explorer",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "storage-explorer": "./cli.js"
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
+ }
@@ -13,3 +13,9 @@ export type ParsedListObjectsInput = {
13
13
  continuationToken?: string;
14
14
  maxKeys: number;
15
15
  };
16
+
17
+ export type ParsedDownloadInput = {
18
+ profile: S3ProfileInput;
19
+ bucket: string;
20
+ key: string;
21
+ };
@@ -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.",
@@ -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
+ }