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
@@ -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,15 @@
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
+ };
@@ -0,0 +1,103 @@
1
+ import type { ApiErrorPayload } from "../http/response";
2
+ import type { 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 invalidProfileError(): ApiErrorPayload {
99
+ return {
100
+ message: "Invalid profile. endpoint, accessKeyId, and secretAccessKey are required.",
101
+ code: "InvalidProfile",
102
+ };
103
+ }
@@ -0,0 +1,56 @@
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
+ }
@@ -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
+ };