1
28
29
30export const MAX_KEY_NAME_LENGTH = 180;
31
32
33export const THUMBNAIL_SIZES = ["160", "480", "1280"] as const;
34export type ThumbnailSize = (typeof THUMBNAIL_SIZES)[number];
35
36export const THUMBNAIL_SIZE_WIDTH: Record<ThumbnailSize, number> = {
37 "160": 160,
38 "480": 480,
39 "1280": 1280,
40};
41
42
57export function sanitizeFilename(name: string): string {
58 let base = name;
59
60 const lastSep = Math.max(base.lastIndexOf("/"), base.lastIndexOf("\\"));
61 if (lastSep !== -1) base = base.slice(lastSep + 1);
62
63
64
65 base = base.replace(/[\x00-\x1f\x7f<>:"\\/*?|]/g, "_");
66
67
68 if (base === "" || /^\.+$/.test(base)) base = "file";
69
70
71
72 const dot = base.lastIndexOf(".");
73 if (dot > 0 && dot < base.length - 1) {
74 const stem = base.slice(0, dot);
75 const ext = base.slice(dot);
76 if (stem.length + ext.length > MAX_KEY_NAME_LENGTH) {
77 base = stem.slice(0, Math.max(1, MAX_KEY_NAME_LENGTH - ext.length)) + ext;
78 }
79 } else if (base.length > MAX_KEY_NAME_LENGTH) {
80 base = base.slice(0, MAX_KEY_NAME_LENGTH);
81 }
82
83 return base === "" ? "file" : base;
84}
85
86
93export function withCollisionSuffix(name: string, hex: string): string {
94 const suffix = `-${hex.slice(0, 8).padEnd(8, "0")}`;
95 const dot = name.lastIndexOf(".");
96 if (dot > 0 && dot < name.length - 1) {
97 const stem = name.slice(0, dot);
98 const ext = name.slice(dot);
99 const maxStem = Math.max(1, MAX_KEY_NAME_LENGTH - suffix.length - ext.length);
100 return stem.slice(0, maxStem) + suffix + ext;
101 }
102 const max = Math.max(1, MAX_KEY_NAME_LENGTH - suffix.length);
103 return name.slice(0, max) + suffix;
104}
105
106
107export interface FileKeyParts {
108 workspaceId: string;
109 fileId: string;
110
111 version: number;
112
113 name: string;
114}
115
116
122export function buildFileKey(parts: FileKeyParts): string {
123 if (!Number.isInteger(parts.version) || parts.version < 1) {
124 throw new Error(`version must be a positive integer, got ${parts.version}`);
125 }
126 const safe = sanitizeFilename(parts.name);
127 return `files/${parts.workspaceId}/${parts.fileId}/${parts.version}/${safe}`;
128}
129
130
131export interface ThumbKeyParts {
132 workspaceId: string;
133 fileId: string;
134
135 size: ThumbnailSize;
136
137 name: string;
138
142 ext: string;
143}
144
145
151export function buildThumbKey(parts: ThumbKeyParts): string {
152 if (!THUMBNAIL_SIZES.includes(parts.size)) {
153 throw new Error(`unknown thumbnail size: ${parts.size}`);
154 }
155 const safe = sanitizeFilename(parts.name);
156 const ext = parts.ext.replace(/^\./, "").toLowerCase();
157 return `thumbs/${parts.workspaceId}/${parts.fileId}/${parts.size}/${safe}.${ext}`;
158}
159
160
161export function workspaceFilePrefix(workspaceId: string): string {
162 return `files/${workspaceId}/`;
163}
164
165
166export function workspaceThumbPrefix(workspaceId: string): string {
167 return `thumbs/${workspaceId}/`;
168}
169
170
171export interface ParsedFileKey {
172 workspaceId: string;
173 fileId: string;
174 version: number;
175
176 name: string;
177}
178
179const PARSE_FILE_KEY_RE = /^files\/([^/]+)\/([^/]+)\/(\d+)\/(.+)$/;
180
181
185export function parseFileKey(key: string): ParsedFileKey {
186 const m = PARSE_FILE_KEY_RE.exec(key);
187 if (!m) throw new Error(`malformed file key: ${key}`);
188 const [, workspaceId, fileId, versionStr, name] = m;
189 const version = Number(versionStr);
190 if (!Number.isSafeInteger(version) || version < 1) {
191 throw new Error(`malformed file key version: ${key}`);
192 }
193 if (name === undefined || name === "" || name.includes("/") || name.includes("\\")) {
194 throw new Error(`malformed file key name: ${key}`);
195 }
196 return { workspaceId: workspaceId!, fileId: fileId!, version, name: name! };
197}
198
199
200export interface ParsedThumbKey {
201 workspaceId: string;
202 fileId: string;
203 size: ThumbnailSize;
204
205 name: string;
206 ext: string;
207}
208
209const PARSE_THUMB_KEY_RE = /^thumbs\/([^/]+)\/([^/]+)\/(160|480|1280)\/(.+)$/;
210
211
215export function parseThumbKey(key: string): ParsedThumbKey {
216 const m = PARSE_THUMB_KEY_RE.exec(key);
217 if (!m) throw new Error(`malformed thumbnail key: ${key}`);
218 const nameDot = m[4]!.lastIndexOf(".");
219 const name = nameDot > 0 ? m[4]!.slice(0, nameDot) : m[4]!;
220 const ext = nameDot > 0 ? m[4]!.slice(nameDot + 1) : "";
221 return {
222 workspaceId: m[1]!,
223 fileId: m[2]!,
224 size: m[3] as ThumbnailSize,
225 name,
226 ext,
227 };
228}
229
230
238export function isWithinBucketPrefix(key: string, prefix: string): boolean {
239 if (key === prefix) return true;
240 if (!prefix.endsWith("/")) return key.startsWith(prefix + "/");
241 return key.startsWith(prefix) && key.length > prefix.length;
242}
243