storage-explorer 0.1.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/README.md +14 -0
- package/bunfig.toml +2 -0
- package/cli.js +98 -0
- package/dist/chunk-0sdjygxy.js +11 -0
- package/dist/chunk-0sdjygxy.js.map +24 -0
- package/dist/chunk-aq15a172.css +1 -0
- package/dist/index.html +1 -1
- package/package.json +16 -2
- package/src/App.tsx +414 -0
- package/src/features/buckets/BucketPanel.tsx +93 -0
- package/src/features/objects/ObjectExplorer.tsx +142 -0
- package/src/features/objects/PathBreadcrumb.tsx +50 -0
- package/src/features/profiles/ProfileSidebar.tsx +167 -0
- package/src/frontend.tsx +26 -0
- package/{dist/chunk-js4y3bna.css → src/index.css} +151 -112
- package/src/index.html +13 -0
- package/src/index.ts +22 -0
- package/src/logo.svg +1 -0
- package/src/server/http/response.ts +12 -0
- package/src/server/routes/s3Routes.ts +21 -0
- package/src/server/s3/client.ts +14 -0
- package/src/server/s3/handlers.ts +134 -0
- package/src/server/s3/mappers.ts +73 -0
- package/src/server/s3/types.ts +21 -0
- package/src/server/s3/validate.ts +140 -0
- package/src/shared/api/s3Api.ts +86 -0
- package/src/shared/hooks/useProfilesStorage.ts +175 -0
- package/src/shared/types/s3.ts +42 -0
- package/dist/chunk-vtsn1g38.js +0 -1022
- package/dist/index-3xfxtfws.js +0 -238
- package/dist/index-3xfxtfws.js.map +0 -24
- package/dist/index-67w6q0ny.css +0 -1
- package/dist/index-9t8tyk25.js +0 -238
- package/dist/index-9t8tyk25.js.map +0 -24
- package/dist/index-b7b12360.css +0 -1
- package/dist/index-bz8f0q85.js +0 -238
- package/dist/index-bz8f0q85.js.map +0 -18
- package/dist/index-vw9287sb.js +0 -238
- package/dist/index-vw9287sb.js.map +0 -18
- package/dist/index-xde44bqw.css +0 -1
- package/dist/index.js +0 -29485
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GetObjectCommand,
|
|
3
|
+
ListBucketsCommand,
|
|
4
|
+
ListObjectsV2Command,
|
|
5
|
+
S3ServiceException,
|
|
6
|
+
} from "@aws-sdk/client-s3";
|
|
7
|
+
import { fail, ok } from "../http/response";
|
|
8
|
+
import { createS3Client } from "./client";
|
|
9
|
+
import { mapListBucketsResult, mapListObjectsResult, mapS3Error } from "./mappers";
|
|
10
|
+
import { invalidProfileError, parseDownloadInput, parseListObjectsInput, parseProfileFromBody } from "./validate";
|
|
11
|
+
|
|
12
|
+
async function parseJsonBody(req: Request): Promise<unknown> {
|
|
13
|
+
try {
|
|
14
|
+
return await req.json();
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function testConnectionHandler(req: Request): Promise<Response> {
|
|
21
|
+
const body = await parseJsonBody(req);
|
|
22
|
+
const profile = parseProfileFromBody(body);
|
|
23
|
+
|
|
24
|
+
if (!profile) {
|
|
25
|
+
return fail(400, invalidProfileError());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const client = createS3Client(profile);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await client.send(new ListBucketsCommand({}));
|
|
32
|
+
return ok({
|
|
33
|
+
connected: true,
|
|
34
|
+
message: "Connection successful.",
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (
|
|
38
|
+
err instanceof S3ServiceException &&
|
|
39
|
+
(err.name === "AccessDenied" || err.name === "AccessDeniedException")
|
|
40
|
+
) {
|
|
41
|
+
return ok({
|
|
42
|
+
connected: true,
|
|
43
|
+
limitedPermissions: true,
|
|
44
|
+
message:
|
|
45
|
+
"Connected, but this key cannot list buckets. You can still browse known buckets.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return fail(400, mapS3Error(err));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function listBucketsHandler(req: Request): Promise<Response> {
|
|
54
|
+
const body = await parseJsonBody(req);
|
|
55
|
+
const profile = parseProfileFromBody(body);
|
|
56
|
+
|
|
57
|
+
if (!profile) {
|
|
58
|
+
return fail(400, invalidProfileError());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const client = createS3Client(profile);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const result = await client.send(new ListBucketsCommand({}));
|
|
65
|
+
return ok(mapListBucketsResult(result));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return fail(400, mapS3Error(err));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listObjectsHandler(req: Request): Promise<Response> {
|
|
72
|
+
const body = await parseJsonBody(req);
|
|
73
|
+
const parsed = parseListObjectsInput(body);
|
|
74
|
+
|
|
75
|
+
if (!parsed.ok) {
|
|
76
|
+
return fail(400, parsed.error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { profile, bucket, prefix, continuationToken, maxKeys } = parsed.data;
|
|
80
|
+
const client = createS3Client(profile);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const result = await client.send(
|
|
84
|
+
new ListObjectsV2Command({
|
|
85
|
+
Bucket: bucket,
|
|
86
|
+
Prefix: prefix,
|
|
87
|
+
ContinuationToken: continuationToken,
|
|
88
|
+
Delimiter: "/",
|
|
89
|
+
MaxKeys: maxKeys,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return ok(mapListObjectsResult(result, bucket, prefix));
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return fail(400, mapS3Error(err));
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { S3ServiceException } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { ApiErrorPayload } from "../http/response";
|
|
3
|
+
|
|
4
|
+
export function mapS3Error(err: unknown): ApiErrorPayload {
|
|
5
|
+
if (err instanceof S3ServiceException) {
|
|
6
|
+
return {
|
|
7
|
+
message: err.message || "S3 request failed.",
|
|
8
|
+
code: err.name,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (err instanceof Error) {
|
|
13
|
+
return {
|
|
14
|
+
message: err.message,
|
|
15
|
+
code: "UnknownError",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
message: "Unexpected error while talking to S3.",
|
|
21
|
+
code: "UnknownError",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mapListBucketsResult(result: {
|
|
26
|
+
Buckets?: Array<{ Name?: string; CreationDate?: Date }>;
|
|
27
|
+
}) {
|
|
28
|
+
return {
|
|
29
|
+
buckets: (result.Buckets ?? []).map(bucket => ({
|
|
30
|
+
name: bucket.Name ?? "",
|
|
31
|
+
creationDate: bucket.CreationDate?.toISOString() ?? null,
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mapListObjectsResult(
|
|
37
|
+
result: {
|
|
38
|
+
CommonPrefixes?: Array<{ Prefix?: string }>;
|
|
39
|
+
Contents?: Array<{
|
|
40
|
+
Key?: string;
|
|
41
|
+
Size?: number;
|
|
42
|
+
LastModified?: Date;
|
|
43
|
+
ETag?: string;
|
|
44
|
+
StorageClass?: string;
|
|
45
|
+
}>;
|
|
46
|
+
IsTruncated?: boolean;
|
|
47
|
+
NextContinuationToken?: string;
|
|
48
|
+
},
|
|
49
|
+
bucket: string,
|
|
50
|
+
prefix?: string,
|
|
51
|
+
) {
|
|
52
|
+
const folders = (result.CommonPrefixes ?? [])
|
|
53
|
+
.map(entry => entry.Prefix)
|
|
54
|
+
.filter((value): value is string => Boolean(value));
|
|
55
|
+
const files = (result.Contents ?? [])
|
|
56
|
+
.filter(item => item.Key && item.Key !== prefix)
|
|
57
|
+
.map(item => ({
|
|
58
|
+
key: item.Key ?? "",
|
|
59
|
+
size: item.Size ?? 0,
|
|
60
|
+
lastModified: item.LastModified?.toISOString() ?? null,
|
|
61
|
+
eTag: item.ETag ?? null,
|
|
62
|
+
storageClass: item.StorageClass ?? null,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
bucket,
|
|
67
|
+
prefix: prefix ?? "",
|
|
68
|
+
folders,
|
|
69
|
+
files,
|
|
70
|
+
isTruncated: Boolean(result.IsTruncated),
|
|
71
|
+
nextContinuationToken: result.NextContinuationToken ?? null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type S3ProfileInput = {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
region: string;
|
|
4
|
+
accessKeyId: string;
|
|
5
|
+
secretAccessKey: string;
|
|
6
|
+
forcePathStyle: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ParsedListObjectsInput = {
|
|
10
|
+
profile: S3ProfileInput;
|
|
11
|
+
bucket: string;
|
|
12
|
+
prefix?: string;
|
|
13
|
+
continuationToken?: string;
|
|
14
|
+
maxKeys: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ParsedDownloadInput = {
|
|
18
|
+
profile: S3ProfileInput;
|
|
19
|
+
bucket: string;
|
|
20
|
+
key: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ApiErrorPayload } from "../http/response";
|
|
2
|
+
import type { ParsedDownloadInput, ParsedListObjectsInput, S3ProfileInput } from "./types";
|
|
3
|
+
|
|
4
|
+
function parseProfile(value: unknown): S3ProfileInput | null {
|
|
5
|
+
if (typeof value !== "object" || value === null) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const record = value as Record<string, unknown>;
|
|
10
|
+
const endpoint = typeof record.endpoint === "string" ? record.endpoint.trim() : "";
|
|
11
|
+
const accessKeyId =
|
|
12
|
+
typeof record.accessKeyId === "string" ? record.accessKeyId.trim() : "";
|
|
13
|
+
const secretAccessKey =
|
|
14
|
+
typeof record.secretAccessKey === "string" ? record.secretAccessKey.trim() : "";
|
|
15
|
+
|
|
16
|
+
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const region =
|
|
21
|
+
typeof record.region === "string" && record.region.trim()
|
|
22
|
+
? record.region.trim()
|
|
23
|
+
: "us-east-1";
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
endpoint,
|
|
27
|
+
region,
|
|
28
|
+
accessKeyId,
|
|
29
|
+
secretAccessKey,
|
|
30
|
+
forcePathStyle: record.forcePathStyle !== false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseProfileFromBody(body: unknown): S3ProfileInput | null {
|
|
35
|
+
const profile = (body as { profile?: unknown } | null)?.profile;
|
|
36
|
+
return parseProfile(profile);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseListObjectsInput(body: unknown):
|
|
40
|
+
| { ok: true; data: ParsedListObjectsInput }
|
|
41
|
+
| { ok: false; error: ApiErrorPayload } {
|
|
42
|
+
const record = body as
|
|
43
|
+
| {
|
|
44
|
+
profile?: unknown;
|
|
45
|
+
bucket?: unknown;
|
|
46
|
+
prefix?: unknown;
|
|
47
|
+
continuationToken?: unknown;
|
|
48
|
+
maxKeys?: unknown;
|
|
49
|
+
}
|
|
50
|
+
| null;
|
|
51
|
+
|
|
52
|
+
const profile = parseProfile(record?.profile);
|
|
53
|
+
if (!profile) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: {
|
|
57
|
+
message: "Invalid profile. endpoint, accessKeyId, and secretAccessKey are required.",
|
|
58
|
+
code: "InvalidProfile",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bucket = typeof record?.bucket === "string" ? record.bucket.trim() : "";
|
|
64
|
+
if (!bucket) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: {
|
|
68
|
+
message: "Bucket is required.",
|
|
69
|
+
code: "MissingBucket",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const prefix =
|
|
75
|
+
typeof record?.prefix === "string" && record.prefix.length > 0 ? record.prefix : undefined;
|
|
76
|
+
const continuationToken =
|
|
77
|
+
typeof record?.continuationToken === "string" && record.continuationToken.length > 0
|
|
78
|
+
? record.continuationToken
|
|
79
|
+
: undefined;
|
|
80
|
+
const maxKeysRaw = typeof record?.maxKeys === "number" ? record.maxKeys : Number.NaN;
|
|
81
|
+
const maxKeys =
|
|
82
|
+
Number.isFinite(maxKeysRaw) && maxKeysRaw >= 1
|
|
83
|
+
? Math.min(Math.floor(maxKeysRaw), 1000)
|
|
84
|
+
: 200;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
data: {
|
|
89
|
+
profile,
|
|
90
|
+
bucket,
|
|
91
|
+
prefix,
|
|
92
|
+
continuationToken,
|
|
93
|
+
maxKeys,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
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
|
+
|
|
135
|
+
export function invalidProfileError(): ApiErrorPayload {
|
|
136
|
+
return {
|
|
137
|
+
message: "Invalid profile. endpoint, accessKeyId, and secretAccessKey are required.",
|
|
138
|
+
code: "InvalidProfile",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BucketItem,
|
|
3
|
+
ObjectsPayload,
|
|
4
|
+
S3ConnectionProfile,
|
|
5
|
+
TestConnectionPayload,
|
|
6
|
+
} from "../types/s3";
|
|
7
|
+
|
|
8
|
+
type ApiEnvelope<T> = {
|
|
9
|
+
ok: true;
|
|
10
|
+
data: T;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ApiErrorEnvelope = {
|
|
14
|
+
ok: false;
|
|
15
|
+
error?: {
|
|
16
|
+
message?: string;
|
|
17
|
+
code?: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const payload = (await response.json()) as ApiEnvelope<T> | ApiErrorEnvelope;
|
|
31
|
+
|
|
32
|
+
if (!response.ok || payload.ok === false) {
|
|
33
|
+
const message = payload.ok === false ? payload.error?.message : "Request failed.";
|
|
34
|
+
throw new Error(message || "Request failed.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return payload.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function testConnection(profile: S3ConnectionProfile): Promise<TestConnectionPayload> {
|
|
41
|
+
return postJson<TestConnectionPayload>("/api/s3/test-connection", { profile });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listBuckets(profile: S3ConnectionProfile): Promise<{ buckets: BucketItem[] }> {
|
|
45
|
+
return postJson<{ buckets: BucketItem[] }>("/api/s3/list-buckets", { profile });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function listObjects(input: {
|
|
49
|
+
profile: S3ConnectionProfile;
|
|
50
|
+
bucket: string;
|
|
51
|
+
prefix?: string;
|
|
52
|
+
continuationToken?: string | null;
|
|
53
|
+
maxKeys?: number;
|
|
54
|
+
}): Promise<ObjectsPayload> {
|
|
55
|
+
return postJson<ObjectsPayload>("/api/s3/list-objects", input);
|
|
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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { SavedProfile } from "../types/s3";
|
|
3
|
+
|
|
4
|
+
const PROFILES_KEY = "s3-explorer:profiles:v1";
|
|
5
|
+
const LAST_PROFILE_KEY = "s3-explorer:last-profile:v1";
|
|
6
|
+
const PROFILE_VIEW_KEY = "s3-explorer:profile-view:v1";
|
|
7
|
+
|
|
8
|
+
export type ProfileViewState = {
|
|
9
|
+
bucket: string;
|
|
10
|
+
prefix: string;
|
|
11
|
+
manualBucketName: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const emptyView: ProfileViewState = {
|
|
15
|
+
bucket: "",
|
|
16
|
+
prefix: "",
|
|
17
|
+
manualBucketName: "",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function parseStoredProfiles(raw: string | null): SavedProfile[] {
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!Array.isArray(parsed)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return parsed
|
|
32
|
+
.filter(item => typeof item === "object" && item !== null)
|
|
33
|
+
.map(item => {
|
|
34
|
+
const record = item as Record<string, unknown>;
|
|
35
|
+
return {
|
|
36
|
+
id: String(record.id ?? crypto.randomUUID()),
|
|
37
|
+
name: String(record.name ?? ""),
|
|
38
|
+
endpoint: String(record.endpoint ?? ""),
|
|
39
|
+
region: String(record.region ?? "us-east-1"),
|
|
40
|
+
accessKeyId: String(record.accessKeyId ?? ""),
|
|
41
|
+
secretAccessKey: String(record.secretAccessKey ?? ""),
|
|
42
|
+
forcePathStyle: record.forcePathStyle !== false,
|
|
43
|
+
};
|
|
44
|
+
})
|
|
45
|
+
.filter(profile => profile.endpoint && profile.accessKeyId && profile.secretAccessKey);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseStoredViews(raw: string | null): Record<string, ProfileViewState> {
|
|
52
|
+
if (!raw) {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const record = parsed as Record<string, unknown>;
|
|
63
|
+
const next: Record<string, ProfileViewState> = {};
|
|
64
|
+
|
|
65
|
+
for (const [profileId, value] of Object.entries(record)) {
|
|
66
|
+
if (typeof value !== "object" || value === null) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const view = value as Record<string, unknown>;
|
|
71
|
+
next[profileId] = {
|
|
72
|
+
bucket: typeof view.bucket === "string" ? view.bucket : "",
|
|
73
|
+
prefix: typeof view.prefix === "string" ? view.prefix : "",
|
|
74
|
+
manualBucketName:
|
|
75
|
+
typeof view.manualBucketName === "string" ? view.manualBucketName : "",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return next;
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useProfilesStorage() {
|
|
86
|
+
const [profiles, setProfiles] = useState<SavedProfile[]>(() =>
|
|
87
|
+
parseStoredProfiles(localStorage.getItem(PROFILES_KEY)),
|
|
88
|
+
);
|
|
89
|
+
const [profileViews, setProfileViews] = useState<Record<string, ProfileViewState>>(() =>
|
|
90
|
+
parseStoredViews(localStorage.getItem(PROFILE_VIEW_KEY)),
|
|
91
|
+
);
|
|
92
|
+
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(() => {
|
|
93
|
+
const loadedProfiles = parseStoredProfiles(localStorage.getItem(PROFILES_KEY));
|
|
94
|
+
const storedProfileId = localStorage.getItem(LAST_PROFILE_KEY);
|
|
95
|
+
const fallbackProfile = loadedProfiles[0]?.id ?? null;
|
|
96
|
+
|
|
97
|
+
return loadedProfiles.some(profile => profile.id === storedProfileId)
|
|
98
|
+
? storedProfileId
|
|
99
|
+
: fallbackProfile;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
|
|
104
|
+
}, [profiles]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
localStorage.setItem(PROFILE_VIEW_KEY, JSON.stringify(profileViews));
|
|
108
|
+
}, [profileViews]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (selectedProfileId) {
|
|
112
|
+
localStorage.setItem(LAST_PROFILE_KEY, selectedProfileId);
|
|
113
|
+
} else {
|
|
114
|
+
localStorage.removeItem(LAST_PROFILE_KEY);
|
|
115
|
+
}
|
|
116
|
+
}, [selectedProfileId]);
|
|
117
|
+
|
|
118
|
+
const selectedProfile = useMemo(
|
|
119
|
+
() => profiles.find(profile => profile.id === selectedProfileId) ?? null,
|
|
120
|
+
[profiles, selectedProfileId],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const saveProfile = useCallback((profile: SavedProfile) => {
|
|
124
|
+
setProfiles(prev => {
|
|
125
|
+
const existing = prev.findIndex(entry => entry.id === profile.id);
|
|
126
|
+
if (existing === -1) {
|
|
127
|
+
return [profile, ...prev];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const next = [...prev];
|
|
131
|
+
next[existing] = profile;
|
|
132
|
+
return next;
|
|
133
|
+
});
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const deleteProfile = useCallback((profileId: string) => {
|
|
137
|
+
setProfiles(prev => prev.filter(profile => profile.id !== profileId));
|
|
138
|
+
setProfileViews(prev => {
|
|
139
|
+
const next = { ...prev };
|
|
140
|
+
delete next[profileId];
|
|
141
|
+
return next;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
setSelectedProfileId(current => (current === profileId ? null : current));
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const updateProfileView = useCallback((profileId: string, patch: Partial<ProfileViewState>) => {
|
|
148
|
+
setProfileViews(prev => ({
|
|
149
|
+
...prev,
|
|
150
|
+
[profileId]: {
|
|
151
|
+
...(prev[profileId] ?? emptyView),
|
|
152
|
+
...patch,
|
|
153
|
+
},
|
|
154
|
+
}));
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const getProfileView = useCallback((profileId: string | null): ProfileViewState => {
|
|
158
|
+
if (!profileId) {
|
|
159
|
+
return emptyView;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return profileViews[profileId] ?? emptyView;
|
|
163
|
+
}, [profileViews]);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
profiles,
|
|
167
|
+
selectedProfileId,
|
|
168
|
+
selectedProfile,
|
|
169
|
+
setSelectedProfileId,
|
|
170
|
+
saveProfile,
|
|
171
|
+
deleteProfile,
|
|
172
|
+
updateProfileView,
|
|
173
|
+
getProfileView,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type S3ConnectionProfile = {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
region: string;
|
|
4
|
+
accessKeyId: string;
|
|
5
|
+
secretAccessKey: string;
|
|
6
|
+
forcePathStyle: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SavedProfile = S3ConnectionProfile & {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type EditableProfile = Omit<SavedProfile, "id">;
|
|
15
|
+
|
|
16
|
+
export type BucketItem = {
|
|
17
|
+
name: string;
|
|
18
|
+
creationDate: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ObjectFile = {
|
|
22
|
+
key: string;
|
|
23
|
+
size: number;
|
|
24
|
+
lastModified: string | null;
|
|
25
|
+
eTag: string | null;
|
|
26
|
+
storageClass: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ObjectsPayload = {
|
|
30
|
+
bucket: string;
|
|
31
|
+
prefix: string;
|
|
32
|
+
folders: string[];
|
|
33
|
+
files: ObjectFile[];
|
|
34
|
+
isTruncated: boolean;
|
|
35
|
+
nextContinuationToken: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type TestConnectionPayload = {
|
|
39
|
+
connected: boolean;
|
|
40
|
+
limitedPermissions?: boolean;
|
|
41
|
+
message: string;
|
|
42
|
+
};
|