storage-explorer 0.1.0 → 1.0.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.
Files changed (41) hide show
  1. package/README.md +14 -0
  2. package/bunfig.toml +2 -0
  3. package/cli.js +98 -0
  4. package/dist/chunk-fa0pf3pw.js +11 -0
  5. package/dist/chunk-fa0pf3pw.js.map +24 -0
  6. package/dist/chunk-veptbhs8.css +1 -0
  7. package/dist/index.html +1 -1
  8. package/package.json +16 -2
  9. package/src/App.tsx +390 -0
  10. package/src/features/buckets/BucketPanel.tsx +93 -0
  11. package/src/features/objects/ObjectExplorer.tsx +129 -0
  12. package/src/features/objects/PathBreadcrumb.tsx +50 -0
  13. package/src/features/profiles/ProfileSidebar.tsx +167 -0
  14. package/src/frontend.tsx +26 -0
  15. package/{dist/chunk-js4y3bna.css → src/index.css} +134 -111
  16. package/src/index.html +13 -0
  17. package/src/index.ts +22 -0
  18. package/src/logo.svg +1 -0
  19. package/src/server/http/response.ts +12 -0
  20. package/src/server/routes/s3Routes.ts +17 -0
  21. package/src/server/s3/client.ts +14 -0
  22. package/src/server/s3/handlers.ts +96 -0
  23. package/src/server/s3/mappers.ts +73 -0
  24. package/src/server/s3/types.ts +15 -0
  25. package/src/server/s3/validate.ts +103 -0
  26. package/src/shared/api/s3Api.ts +56 -0
  27. package/src/shared/hooks/useProfilesStorage.ts +175 -0
  28. package/src/shared/types/s3.ts +42 -0
  29. package/dist/chunk-vtsn1g38.js +0 -1022
  30. package/dist/index-3xfxtfws.js +0 -238
  31. package/dist/index-3xfxtfws.js.map +0 -24
  32. package/dist/index-67w6q0ny.css +0 -1
  33. package/dist/index-9t8tyk25.js +0 -238
  34. package/dist/index-9t8tyk25.js.map +0 -24
  35. package/dist/index-b7b12360.css +0 -1
  36. package/dist/index-bz8f0q85.js +0 -238
  37. package/dist/index-bz8f0q85.js.map +0 -18
  38. package/dist/index-vw9287sb.js +0 -238
  39. package/dist/index-vw9287sb.js.map +0 -18
  40. package/dist/index-xde44bqw.css +0 -1
  41. package/dist/index.js +0 -29485
@@ -1,6 +1,9 @@
1
- /* src/index.css */
2
1
  :root {
2
+ font-family: "Segoe UI", "Avenir Next", "Helvetica Neue", sans-serif;
3
+ line-height: 1.4;
4
+ font-weight: 400;
3
5
  color: #152234;
6
+ background: #f6f8fb;
4
7
  --bg-top: #f9f3ea;
5
8
  --bg-bottom: #e8eff8;
6
9
  --panel: #ffffffd8;
@@ -12,10 +15,6 @@
12
15
  --danger: #b9414f;
13
16
  --ok: #1f8b4c;
14
17
  --radius: 14px;
15
- background: #f6f8fb;
16
- font-family: Segoe UI, Avenir Next, Helvetica Neue, sans-serif;
17
- font-weight: 400;
18
- line-height: 1.4;
19
18
  }
20
19
 
21
20
  * {
@@ -23,20 +22,23 @@
23
22
  }
24
23
 
25
24
  body {
26
- color: var(--text);
27
- background: linear-gradient(168deg, var(--bg-top), var(--bg-bottom));
25
+ margin: 0;
28
26
  min-width: 320px;
29
27
  min-height: 100vh;
30
- margin: 0;
28
+ color: var(--text);
29
+ background: linear-gradient(168deg, var(--bg-top), var(--bg-bottom));
31
30
  }
32
31
 
33
- body:before {
32
+ body::before {
34
33
  content: "";
35
34
  position: fixed;
35
+ inset: 0;
36
36
  z-index: -1;
37
37
  pointer-events: none;
38
- 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%);
39
- inset: 0;
38
+ background:
39
+ radial-gradient(circle at 20% 18%, #ffffffaa 0, transparent 36%),
40
+ radial-gradient(circle at 85% 12%, #d9e9fa88 0, transparent 28%),
41
+ radial-gradient(circle at 70% 80%, #f8e8d6aa 0, transparent 35%);
40
42
  }
41
43
 
42
44
  #root {
@@ -45,17 +47,17 @@ body:before {
45
47
  }
46
48
 
47
49
  .app-shell {
50
+ width: min(1320px, 100% - 2rem);
51
+ margin: 1rem auto;
48
52
  display: grid;
49
53
  grid-template-columns: 360px minmax(0, 1fr);
50
54
  gap: 1rem;
51
- width: min(1320px, 100% - 2rem);
52
- margin: 1rem auto;
53
55
  }
54
56
 
55
57
  .main-column {
56
58
  display: grid;
57
- align-content: start;
58
59
  gap: 1rem;
60
+ align-content: start;
59
61
  }
60
62
 
61
63
  .panel {
@@ -68,56 +70,61 @@ body:before {
68
70
 
69
71
  .panel-step {
70
72
  display: inline-block;
71
- color: var(--accent);
72
- background: var(--accent-soft);
73
+ margin-bottom: 0.6rem;
74
+ padding: 0.2rem 0.55rem;
73
75
  border-radius: 999px;
74
- margin-bottom: .6rem;
75
- padding: .2rem .55rem;
76
- font-size: .73rem;
76
+ font-size: 0.73rem;
77
77
  font-weight: 600;
78
+ color: var(--accent);
79
+ background: var(--accent-soft);
78
80
  }
79
81
 
80
82
  .profile-panel {
81
- position: sticky;
82
- overflow: auto;
83
- max-height: calc(100vh - 2rem);
84
83
  padding: 1rem;
84
+ position: sticky;
85
85
  top: 1rem;
86
+ max-height: calc(100vh - 2rem);
87
+ overflow: auto;
86
88
  }
87
89
 
88
- .explorer-panel, .object-panel {
90
+ .explorer-panel,
91
+ .object-panel {
89
92
  padding: 1rem;
90
93
  }
91
94
 
92
- .panel-header h1, .explorer-header h2, .objects-header h3 {
93
- letter-spacing: .01em;
95
+ .panel-header h1,
96
+ .explorer-header h2,
97
+ .objects-header h3 {
94
98
  margin: 0;
99
+ letter-spacing: 0.01em;
95
100
  }
96
101
 
97
- .panel-header p, .explorer-header p, .objects-header p {
102
+ .panel-header p,
103
+ .explorer-header p,
104
+ .objects-header p {
105
+ margin: 0.35rem 0 0;
98
106
  color: var(--muted);
99
- margin: .35rem 0 0;
100
- font-size: .92rem;
107
+ font-size: 0.92rem;
101
108
  }
102
109
 
103
110
  .profiles-toolbar {
111
+ margin-top: 1rem;
104
112
  display: flex;
113
+ align-items: center;
105
114
  justify-content: space-between;
106
- align-items: center;
107
- margin-top: 1rem;
108
115
  }
109
116
 
110
117
  .profiles-list {
118
+ margin-top: 0.65rem;
111
119
  display: grid;
112
- gap: .5rem;
113
- margin-top: .65rem;
120
+ gap: 0.5rem;
114
121
  }
115
122
 
116
123
  .profile-card {
117
124
  border: 1px solid var(--panel-border);
118
- overflow: hidden;
119
- background: #fff;
120
125
  border-radius: 10px;
126
+ background: #fff;
127
+ overflow: hidden;
121
128
  }
122
129
 
123
130
  .profile-card.active {
@@ -126,14 +133,14 @@ body:before {
126
133
  }
127
134
 
128
135
  .profile-select {
136
+ width: 100%;
137
+ border: 0;
138
+ background: transparent;
129
139
  text-align: left;
140
+ padding: 0.65rem 0.75rem;
130
141
  display: grid;
142
+ gap: 0.25rem;
131
143
  cursor: pointer;
132
- background: none;
133
- border: 0;
134
- gap: .25rem;
135
- width: 100%;
136
- padding: .65rem .75rem;
137
144
  }
138
145
 
139
146
  .profile-name {
@@ -142,46 +149,49 @@ body:before {
142
149
 
143
150
  .profile-endpoint {
144
151
  color: var(--muted);
152
+ font-size: 0.84rem;
145
153
  overflow-wrap: anywhere;
146
- font-size: .84rem;
147
154
  }
148
155
 
149
156
  .delete-button {
157
+ width: 100%;
150
158
  border: 0;
151
159
  border-top: 1px solid var(--panel-border);
160
+ background: #fff;
152
161
  color: var(--danger);
162
+ padding: 0.45rem 0.75rem;
153
163
  cursor: pointer;
154
- background: #fff;
155
- width: 100%;
156
- padding: .45rem .75rem;
157
164
  }
158
165
 
159
166
  .profile-form {
160
- display: grid;
161
- gap: .7rem;
162
167
  margin-top: 1rem;
168
+ display: grid;
169
+ gap: 0.7rem;
163
170
  }
164
171
 
165
172
  .profile-form label {
166
173
  display: grid;
167
- gap: .35rem;
168
- font-size: .9rem;
174
+ gap: 0.35rem;
175
+ font-size: 0.9rem;
169
176
  }
170
177
 
171
- input, button {
178
+ input,
179
+ button {
172
180
  font: inherit;
173
181
  }
174
182
 
175
- .profile-form input, .manual-bucket-row input {
183
+ .profile-form input,
184
+ .manual-bucket-row input {
176
185
  border: 1px solid var(--panel-border);
186
+ border-radius: 9px;
187
+ padding: 0.55rem 0.65rem;
177
188
  color: var(--text);
178
189
  background: #fff;
179
- border-radius: 9px;
180
190
  flex: 1;
181
- padding: .55rem .65rem;
182
191
  }
183
192
 
184
- .profile-form input:focus, .manual-bucket-row input:focus {
193
+ .profile-form input:focus,
194
+ .manual-bucket-row input:focus {
185
195
  outline: 2px solid #0000;
186
196
  border-color: var(--accent);
187
197
  box-shadow: 0 0 0 2px #146c9430;
@@ -189,7 +199,7 @@ input, button {
189
199
 
190
200
  .secret-row {
191
201
  display: flex;
192
- gap: .5rem;
202
+ gap: 0.5rem;
193
203
  }
194
204
 
195
205
  .secret-row input {
@@ -198,7 +208,7 @@ input, button {
198
208
 
199
209
  .checkbox-row {
200
210
  grid-template-columns: auto 1fr;
201
- align-items: center;
211
+ align-items: center;
202
212
  }
203
213
 
204
214
  .checkbox-row input {
@@ -208,15 +218,17 @@ input, button {
208
218
 
209
219
  .form-actions {
210
220
  display: flex;
221
+ gap: 0.55rem;
211
222
  flex-wrap: wrap;
212
- gap: .55rem;
213
223
  }
214
224
 
215
- .primary-button, .secondary-button, .ghost-button {
216
- cursor: pointer;
225
+ .primary-button,
226
+ .secondary-button,
227
+ .ghost-button {
217
228
  border-radius: 9px;
218
- padding: .55rem .85rem;
219
- transition: all .15s;
229
+ padding: 0.55rem 0.85rem;
230
+ cursor: pointer;
231
+ transition: 0.15s ease;
220
232
  }
221
233
 
222
234
  .primary-button {
@@ -231,28 +243,35 @@ input, button {
231
243
 
232
244
  .secondary-button {
233
245
  border: 1px solid var(--panel-border);
234
- color: var(--text);
235
246
  background: #fff;
247
+ color: var(--text);
236
248
  }
237
249
 
238
250
  .ghost-button {
239
251
  border: 1px solid var(--panel-border);
240
- color: var(--muted);
241
252
  background: #fff;
253
+ color: var(--muted);
242
254
  }
243
255
 
244
- .secondary-button:hover, .ghost-button:hover, .delete-button:hover, .bucket-item:hover, .object-row.folder:hover, .breadcrumb-link:hover {
256
+ .secondary-button:hover,
257
+ .ghost-button:hover,
258
+ .delete-button:hover,
259
+ .bucket-item:hover,
260
+ .object-row.folder:hover,
261
+ .breadcrumb-link:hover {
245
262
  background: var(--accent-soft);
246
263
  }
247
264
 
248
265
  button:disabled {
249
- opacity: .58;
266
+ opacity: 0.58;
250
267
  cursor: not-allowed;
251
268
  }
252
269
 
253
- .status-ok, .status-error, .empty-copy {
254
- margin: .75rem 0 0;
255
- font-size: .88rem;
270
+ .status-ok,
271
+ .status-error,
272
+ .empty-copy {
273
+ margin: 0.75rem 0 0;
274
+ font-size: 0.88rem;
256
275
  }
257
276
 
258
277
  .status-ok {
@@ -263,59 +282,62 @@ button:disabled {
263
282
  color: var(--danger);
264
283
  }
265
284
 
266
- .empty-copy, .muted, .helper-copy {
285
+ .empty-copy,
286
+ .muted,
287
+ .helper-copy {
267
288
  color: var(--muted);
268
289
  }
269
290
 
270
- .explorer-header, .objects-header {
291
+ .explorer-header,
292
+ .objects-header {
271
293
  display: flex;
272
294
  justify-content: space-between;
273
- align-items: flex-start;
295
+ align-items: flex-start;
274
296
  gap: 1rem;
275
297
  }
276
298
 
277
299
  .manual-bucket-form {
300
+ margin-top: 0.9rem;
278
301
  display: grid;
302
+ gap: 0.45rem;
279
303
  border: 1px solid var(--panel-border);
280
- background: #fff;
281
304
  border-radius: 10px;
282
- gap: .45rem;
283
- margin-top: .9rem;
284
- padding: .7rem;
305
+ background: #fff;
306
+ padding: 0.7rem;
285
307
  }
286
308
 
287
309
  .manual-bucket-form label {
310
+ font-size: 0.88rem;
288
311
  color: var(--muted);
289
- font-size: .88rem;
290
312
  }
291
313
 
292
314
  .manual-bucket-row {
293
315
  display: flex;
294
- align-items: center;
295
- gap: .5rem;
316
+ gap: 0.5rem;
317
+ align-items: center;
296
318
  }
297
319
 
298
320
  .helper-copy {
299
321
  margin: 0;
300
- font-size: .82rem;
322
+ font-size: 0.82rem;
301
323
  }
302
324
 
303
325
  .buckets-grid {
326
+ margin-top: 0.8rem;
304
327
  display: grid;
305
328
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
306
- gap: .5rem;
307
- margin-top: .8rem;
329
+ gap: 0.5rem;
308
330
  }
309
331
 
310
332
  .bucket-item {
311
333
  border: 1px solid var(--panel-border);
334
+ border-radius: 10px;
335
+ background: #fff;
336
+ padding: 0.7rem;
312
337
  text-align: left;
313
338
  display: grid;
339
+ gap: 0.2rem;
314
340
  cursor: pointer;
315
- background: #fff;
316
- border-radius: 10px;
317
- gap: .2rem;
318
- padding: .7rem;
319
341
  }
320
342
 
321
343
  .bucket-item.active {
@@ -329,21 +351,21 @@ button:disabled {
329
351
 
330
352
  .objects-actions {
331
353
  display: flex;
354
+ gap: 0.5rem;
332
355
  flex-wrap: wrap;
333
- gap: .5rem;
334
356
  }
335
357
 
336
358
  .path-breadcrumb {
359
+ margin-top: 0.8rem;
337
360
  border: 1px solid var(--panel-border);
338
- display: flex;
339
- background: #fff;
340
361
  border-radius: 10px;
362
+ background: #fff;
363
+ padding: 0.55rem 0.7rem;
364
+ display: flex;
341
365
  flex-wrap: wrap;
342
- align-items: center;
343
- gap: .1rem;
344
- margin-top: .8rem;
345
- padding: .55rem .7rem;
346
- font-size: .88rem;
366
+ gap: 0.1rem;
367
+ align-items: center;
368
+ font-size: 0.88rem;
347
369
  }
348
370
 
349
371
  .path-breadcrumb.muted {
@@ -351,46 +373,46 @@ button:disabled {
351
373
  }
352
374
 
353
375
  .breadcrumb-link {
354
- cursor: pointer;
355
- color: var(--text);
356
- background: none;
357
376
  border: 0;
358
377
  border-radius: 6px;
359
- padding: .2rem .4rem;
378
+ padding: 0.2rem 0.4rem;
379
+ background: transparent;
380
+ cursor: pointer;
381
+ color: var(--text);
360
382
  }
361
383
 
362
384
  .breadcrumb-segment {
363
385
  display: inline-flex;
364
- align-items: center;
386
+ align-items: center;
365
387
  }
366
388
 
367
389
  .breadcrumb-separator {
368
390
  color: var(--muted);
369
- margin: 0 .05rem;
391
+ margin: 0 0.05rem;
370
392
  }
371
393
 
372
394
  .objects-list {
395
+ margin-top: 0.8rem;
373
396
  border: 1px solid var(--panel-border);
374
- overflow: auto;
375
- background: #fff;
376
397
  border-radius: 10px;
398
+ background: #fff;
399
+ padding: 0.5rem;
377
400
  min-height: 320px;
378
- margin-top: .8rem;
379
- padding: .5rem;
401
+ overflow: auto;
380
402
  }
381
403
 
382
404
  .object-row {
405
+ border: 0;
406
+ border-bottom: 1px solid #e7edf6;
407
+ width: 100%;
408
+ background: #fff;
383
409
  color: var(--text);
384
410
  display: grid;
385
411
  grid-template-columns: minmax(0, 1fr) 140px 210px;
412
+ gap: 0.8rem;
413
+ padding: 0.65rem;
386
414
  text-align: left;
387
- background: #fff;
388
- border: 0;
389
- border-bottom: 1px solid #e7edf6;
390
- align-items: center;
391
- gap: .8rem;
392
- width: 100%;
393
- padding: .65rem;
415
+ align-items: center;
394
416
  }
395
417
 
396
418
  .object-row:last-child {
@@ -407,12 +429,12 @@ button:disabled {
407
429
 
408
430
  .object-meta {
409
431
  color: var(--muted);
432
+ font-size: 0.82rem;
410
433
  text-align: right;
411
434
  white-space: nowrap;
412
- font-size: .82rem;
413
435
  }
414
436
 
415
- @media (width <= 1020px) {
437
+ @media (max-width: 1020px) {
416
438
  .app-shell {
417
439
  grid-template-columns: 1fr;
418
440
  }
@@ -422,7 +444,8 @@ button:disabled {
422
444
  max-height: none;
423
445
  }
424
446
 
425
- .explorer-header, .objects-header {
447
+ .explorer-header,
448
+ .objects-header {
426
449
  flex-direction: column;
427
450
  }
428
451
 
package/src/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="./logo.svg" />
7
+ <title>S3 Explorer</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./frontend.tsx"></script>
12
+ </body>
13
+ </html>
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { serve } from "bun";
2
+ import index from "./index.html";
3
+ import { s3Routes } from "./server/routes/s3Routes";
4
+
5
+ const port = Number(process.env.PORT || 3000);
6
+ const hostname = process.env.HOST || "127.0.0.1";
7
+
8
+ const server = serve({
9
+ port,
10
+ hostname,
11
+ routes: {
12
+ ...s3Routes,
13
+ "/*": index,
14
+ },
15
+
16
+ development: process.env.NODE_ENV !== "production" && {
17
+ hmr: true,
18
+ console: true,
19
+ },
20
+ });
21
+
22
+ console.log(`Server running at ${server.url}`);
package/src/logo.svg ADDED
@@ -0,0 +1 @@
1
+ <svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
@@ -0,0 +1,12 @@
1
+ export type ApiErrorPayload = {
2
+ message: string;
3
+ code?: string;
4
+ };
5
+
6
+ export function ok<T>(data: T): Response {
7
+ return Response.json({ ok: true, data });
8
+ }
9
+
10
+ export function fail(status: number, error: ApiErrorPayload): Response {
11
+ return Response.json({ ok: false, error }, { status });
12
+ }
@@ -0,0 +1,17 @@
1
+ import {
2
+ listBucketsHandler,
3
+ listObjectsHandler,
4
+ testConnectionHandler,
5
+ } from "../s3/handlers";
6
+
7
+ export const s3Routes = {
8
+ "/api/s3/test-connection": {
9
+ POST: testConnectionHandler,
10
+ },
11
+ "/api/s3/list-buckets": {
12
+ POST: listBucketsHandler,
13
+ },
14
+ "/api/s3/list-objects": {
15
+ POST: listObjectsHandler,
16
+ },
17
+ };
@@ -0,0 +1,14 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import type { S3ProfileInput } from "./types";
3
+
4
+ export function createS3Client(profile: S3ProfileInput): S3Client {
5
+ return new S3Client({
6
+ region: profile.region,
7
+ endpoint: profile.endpoint,
8
+ forcePathStyle: profile.forcePathStyle,
9
+ credentials: {
10
+ accessKeyId: profile.accessKeyId,
11
+ secretAccessKey: profile.secretAccessKey,
12
+ },
13
+ });
14
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ ListBucketsCommand,
3
+ ListObjectsV2Command,
4
+ S3ServiceException,
5
+ } from "@aws-sdk/client-s3";
6
+ import { fail, ok } from "../http/response";
7
+ import { createS3Client } from "./client";
8
+ import { mapListBucketsResult, mapListObjectsResult, mapS3Error } from "./mappers";
9
+ import { invalidProfileError, parseListObjectsInput, parseProfileFromBody } from "./validate";
10
+
11
+ async function parseJsonBody(req: Request): Promise<unknown> {
12
+ try {
13
+ return await req.json();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export async function testConnectionHandler(req: Request): Promise<Response> {
20
+ const body = await parseJsonBody(req);
21
+ const profile = parseProfileFromBody(body);
22
+
23
+ if (!profile) {
24
+ return fail(400, invalidProfileError());
25
+ }
26
+
27
+ const client = createS3Client(profile);
28
+
29
+ try {
30
+ await client.send(new ListBucketsCommand({}));
31
+ return ok({
32
+ connected: true,
33
+ message: "Connection successful.",
34
+ });
35
+ } catch (err) {
36
+ if (
37
+ err instanceof S3ServiceException &&
38
+ (err.name === "AccessDenied" || err.name === "AccessDeniedException")
39
+ ) {
40
+ return ok({
41
+ connected: true,
42
+ limitedPermissions: true,
43
+ message:
44
+ "Connected, but this key cannot list buckets. You can still browse known buckets.",
45
+ });
46
+ }
47
+
48
+ return fail(400, mapS3Error(err));
49
+ }
50
+ }
51
+
52
+ export async function listBucketsHandler(req: Request): Promise<Response> {
53
+ const body = await parseJsonBody(req);
54
+ const profile = parseProfileFromBody(body);
55
+
56
+ if (!profile) {
57
+ return fail(400, invalidProfileError());
58
+ }
59
+
60
+ const client = createS3Client(profile);
61
+
62
+ try {
63
+ const result = await client.send(new ListBucketsCommand({}));
64
+ return ok(mapListBucketsResult(result));
65
+ } catch (err) {
66
+ return fail(400, mapS3Error(err));
67
+ }
68
+ }
69
+
70
+ export async function listObjectsHandler(req: Request): Promise<Response> {
71
+ const body = await parseJsonBody(req);
72
+ const parsed = parseListObjectsInput(body);
73
+
74
+ if (!parsed.ok) {
75
+ return fail(400, parsed.error);
76
+ }
77
+
78
+ const { profile, bucket, prefix, continuationToken, maxKeys } = parsed.data;
79
+ const client = createS3Client(profile);
80
+
81
+ try {
82
+ const result = await client.send(
83
+ new ListObjectsV2Command({
84
+ Bucket: bucket,
85
+ Prefix: prefix,
86
+ ContinuationToken: continuationToken,
87
+ Delimiter: "/",
88
+ MaxKeys: maxKeys,
89
+ }),
90
+ );
91
+
92
+ return ok(mapListObjectsResult(result, bucket, prefix));
93
+ } catch (err) {
94
+ return fail(400, mapS3Error(err));
95
+ }
96
+ }