@autumnsgrove/groveengine 0.5.0 → 0.6.2
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 +5 -3
- package/dist/components/OnboardingChecklist.svelte +118 -0
- package/dist/components/OnboardingChecklist.svelte.d.ts +14 -0
- package/dist/components/quota/UpgradePrompt.svelte +8 -7
- package/dist/groveauth/limits.js +11 -3
- package/dist/groveauth/types.d.ts +9 -7
- package/dist/groveauth/types.js +16 -12
- package/dist/payments/types.d.ts +7 -2
- package/dist/server/services/__mocks__/cloudflare.d.ts +54 -0
- package/dist/server/services/__mocks__/cloudflare.js +470 -0
- package/dist/server/services/cache.d.ts +170 -0
- package/dist/server/services/cache.js +335 -0
- package/dist/server/services/database.d.ts +236 -0
- package/dist/server/services/database.js +450 -0
- package/dist/server/services/index.d.ts +34 -0
- package/dist/server/services/index.js +77 -0
- package/dist/server/services/storage.d.ts +221 -0
- package/dist/server/services/storage.js +485 -0
- package/package.json +12 -2
- package/static/fonts/Calistoga-Regular.ttf +1438 -0
- package/static/fonts/Caveat-Regular.ttf +0 -0
- package/static/fonts/EBGaramond-Regular.ttf +0 -0
- package/static/fonts/Fraunces-Regular.ttf +0 -0
- package/static/fonts/InstrumentSans-Regular.ttf +0 -0
- package/static/fonts/Lora-Regular.ttf +0 -0
- package/static/fonts/Luciole-Regular.ttf +1438 -0
- package/static/fonts/Manrope-Regular.ttf +0 -0
- package/static/fonts/Merriweather-Regular.ttf +1439 -0
- package/static/fonts/Nunito-Regular.ttf +0 -0
- package/static/fonts/PlusJakartaSans-Regular.ttf +0 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Service - R2 Object Storage Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean interface for file storage operations with:
|
|
5
|
+
* - Automatic cache control based on content type
|
|
6
|
+
* - Integrated metadata storage in D1
|
|
7
|
+
* - Specific error types for debugging
|
|
8
|
+
* - ETag support for conditional requests
|
|
9
|
+
*
|
|
10
|
+
* REQUIRED SCHEMA: This service expects a `cdn_files` table in D1:
|
|
11
|
+
*
|
|
12
|
+
* ```sql
|
|
13
|
+
* CREATE TABLE cdn_files (
|
|
14
|
+
* id TEXT PRIMARY KEY,
|
|
15
|
+
* filename TEXT NOT NULL,
|
|
16
|
+
* original_filename TEXT NOT NULL,
|
|
17
|
+
* key TEXT NOT NULL UNIQUE,
|
|
18
|
+
* content_type TEXT NOT NULL,
|
|
19
|
+
* size_bytes INTEGER NOT NULL,
|
|
20
|
+
* folder TEXT DEFAULT '/',
|
|
21
|
+
* alt_text TEXT,
|
|
22
|
+
* uploaded_by TEXT NOT NULL,
|
|
23
|
+
* created_at TEXT NOT NULL
|
|
24
|
+
* );
|
|
25
|
+
*
|
|
26
|
+
* CREATE INDEX idx_cdn_files_folder ON cdn_files(folder);
|
|
27
|
+
* CREATE INDEX idx_cdn_files_key ON cdn_files(key);
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export interface StorageFile {
|
|
31
|
+
id: string;
|
|
32
|
+
filename: string;
|
|
33
|
+
originalFilename: string;
|
|
34
|
+
key: string;
|
|
35
|
+
contentType: string;
|
|
36
|
+
sizeBytes: number;
|
|
37
|
+
folder: string;
|
|
38
|
+
altText: string | null;
|
|
39
|
+
uploadedBy: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}
|
|
42
|
+
export interface UploadOptions {
|
|
43
|
+
/** The file data as ArrayBuffer */
|
|
44
|
+
data: ArrayBuffer;
|
|
45
|
+
/** Original filename from the user */
|
|
46
|
+
filename: string;
|
|
47
|
+
/** MIME type of the file */
|
|
48
|
+
contentType: string;
|
|
49
|
+
/** Folder path (e.g., "/images", "/fonts") - defaults to "/" */
|
|
50
|
+
folder?: string;
|
|
51
|
+
/** Alt text for accessibility */
|
|
52
|
+
altText?: string;
|
|
53
|
+
/** User ID who uploaded the file */
|
|
54
|
+
uploadedBy: string;
|
|
55
|
+
/** Override default file size limit (in bytes) */
|
|
56
|
+
maxFileSize?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface GetFileResult {
|
|
59
|
+
body: ReadableStream<Uint8Array>;
|
|
60
|
+
contentType: string;
|
|
61
|
+
cacheControl: string;
|
|
62
|
+
etag: string;
|
|
63
|
+
size: number;
|
|
64
|
+
}
|
|
65
|
+
export interface FileMetadata {
|
|
66
|
+
contentType: string;
|
|
67
|
+
cacheControl: string;
|
|
68
|
+
etag: string;
|
|
69
|
+
size: number;
|
|
70
|
+
}
|
|
71
|
+
export declare class StorageError extends Error {
|
|
72
|
+
readonly code: StorageErrorCode;
|
|
73
|
+
readonly cause?: unknown | undefined;
|
|
74
|
+
constructor(message: string, code: StorageErrorCode, cause?: unknown | undefined);
|
|
75
|
+
}
|
|
76
|
+
export type StorageErrorCode = 'FILE_NOT_FOUND' | 'FILE_TOO_LARGE' | 'INVALID_TYPE' | 'UPLOAD_FAILED' | 'DELETE_FAILED' | 'METADATA_FAILED' | 'BUCKET_UNAVAILABLE';
|
|
77
|
+
/** Storage configuration - can be customized per-upload */
|
|
78
|
+
export interface StorageConfig {
|
|
79
|
+
/** Maximum file size in bytes (default: 50MB) */
|
|
80
|
+
maxFileSize?: number;
|
|
81
|
+
/** Additional allowed content types beyond the defaults */
|
|
82
|
+
additionalContentTypes?: string[];
|
|
83
|
+
}
|
|
84
|
+
/** Default storage configuration */
|
|
85
|
+
export declare const STORAGE_DEFAULTS: {
|
|
86
|
+
readonly MAX_FILE_SIZE: number;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Validate file size and content type
|
|
90
|
+
*
|
|
91
|
+
* @param data - File data as ArrayBuffer
|
|
92
|
+
* @param contentType - MIME type of the file
|
|
93
|
+
* @param config - Optional configuration to override defaults
|
|
94
|
+
*/
|
|
95
|
+
export declare function validateFile(data: ArrayBuffer, contentType: string, config?: StorageConfig): void;
|
|
96
|
+
/**
|
|
97
|
+
* Check if a content type is allowed
|
|
98
|
+
*
|
|
99
|
+
* @param contentType - MIME type to check
|
|
100
|
+
* @param additionalTypes - Additional types to allow beyond defaults
|
|
101
|
+
*/
|
|
102
|
+
export declare function isAllowedContentType(contentType: string, additionalTypes?: string[]): boolean;
|
|
103
|
+
type D1DatabaseOrSession = D1Database | D1DatabaseSession;
|
|
104
|
+
/**
|
|
105
|
+
* Upload a file to storage with metadata
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* const file = await storage.uploadFile(bucket, db, {
|
|
110
|
+
* data: arrayBuffer,
|
|
111
|
+
* filename: 'photo.jpg',
|
|
112
|
+
* contentType: 'image/jpeg',
|
|
113
|
+
* folder: '/images',
|
|
114
|
+
* uploadedBy: userId
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export declare function uploadFile(bucket: R2Bucket, db: D1DatabaseOrSession, options: UploadOptions): Promise<StorageFile>;
|
|
119
|
+
/**
|
|
120
|
+
* Get a file from storage
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const file = await storage.getFile(bucket, 'images/photo.jpg');
|
|
125
|
+
* if (file) {
|
|
126
|
+
* return new Response(file.body, {
|
|
127
|
+
* headers: { 'Content-Type': file.contentType }
|
|
128
|
+
* });
|
|
129
|
+
* }
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export declare function getFile(bucket: R2Bucket, key: string): Promise<GetFileResult | null>;
|
|
133
|
+
/**
|
|
134
|
+
* Get file metadata without downloading the body
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* const meta = await storage.getFileMetadata(bucket, 'images/photo.jpg');
|
|
139
|
+
* if (meta) {
|
|
140
|
+
* console.log(`File size: ${meta.size}`);
|
|
141
|
+
* }
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export declare function getFileMetadata(bucket: R2Bucket, key: string): Promise<FileMetadata | null>;
|
|
145
|
+
/**
|
|
146
|
+
* Check if a file exists in storage
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* if (await storage.fileExists(bucket, 'images/photo.jpg')) {
|
|
151
|
+
* // File exists
|
|
152
|
+
* }
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export declare function fileExists(bucket: R2Bucket, key: string): Promise<boolean>;
|
|
156
|
+
/**
|
|
157
|
+
* Delete a file from storage and its metadata
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* await storage.deleteFile(bucket, db, fileId);
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export declare function deleteFile(bucket: R2Bucket, db: D1DatabaseOrSession, fileId: string): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* Delete a file by its storage key (for cleanup operations)
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* await storage.deleteFileByKey(bucket, db, 'images/photo.jpg');
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export declare function deleteFileByKey(bucket: R2Bucket, db: D1DatabaseOrSession, key: string): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* Get file metadata from D1 by ID
|
|
176
|
+
*/
|
|
177
|
+
export declare function getFileRecord(db: D1DatabaseOrSession, fileId: string): Promise<StorageFile | null>;
|
|
178
|
+
/**
|
|
179
|
+
* Get file metadata from D1 by key
|
|
180
|
+
*/
|
|
181
|
+
export declare function getFileRecordByKey(db: D1DatabaseOrSession, key: string): Promise<StorageFile | null>;
|
|
182
|
+
/**
|
|
183
|
+
* List files in a folder
|
|
184
|
+
*/
|
|
185
|
+
export declare function listFiles(db: D1DatabaseOrSession, options?: {
|
|
186
|
+
folder?: string;
|
|
187
|
+
limit?: number;
|
|
188
|
+
offset?: number;
|
|
189
|
+
}): Promise<{
|
|
190
|
+
files: StorageFile[];
|
|
191
|
+
total: number;
|
|
192
|
+
}>;
|
|
193
|
+
/**
|
|
194
|
+
* List all files across all folders
|
|
195
|
+
*/
|
|
196
|
+
export declare function listAllFiles(db: D1DatabaseOrSession, options?: {
|
|
197
|
+
limit?: number;
|
|
198
|
+
offset?: number;
|
|
199
|
+
}): Promise<{
|
|
200
|
+
files: StorageFile[];
|
|
201
|
+
total: number;
|
|
202
|
+
}>;
|
|
203
|
+
/**
|
|
204
|
+
* Get all unique folders
|
|
205
|
+
*/
|
|
206
|
+
export declare function listFolders(db: D1DatabaseOrSession): Promise<string[]>;
|
|
207
|
+
/**
|
|
208
|
+
* Update file alt text
|
|
209
|
+
*/
|
|
210
|
+
export declare function updateAltText(db: D1DatabaseOrSession, fileId: string, altText: string): Promise<void>;
|
|
211
|
+
/**
|
|
212
|
+
* Check if request can use cached version (304 Not Modified)
|
|
213
|
+
*/
|
|
214
|
+
export declare function shouldReturn304(request: Request, etag: string): boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Build response headers for a file
|
|
217
|
+
*/
|
|
218
|
+
export declare function buildFileHeaders(file: GetFileResult | FileMetadata, options?: {
|
|
219
|
+
enableCors?: boolean;
|
|
220
|
+
}): Headers;
|
|
221
|
+
export {};
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Service - R2 Object Storage Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean interface for file storage operations with:
|
|
5
|
+
* - Automatic cache control based on content type
|
|
6
|
+
* - Integrated metadata storage in D1
|
|
7
|
+
* - Specific error types for debugging
|
|
8
|
+
* - ETag support for conditional requests
|
|
9
|
+
*
|
|
10
|
+
* REQUIRED SCHEMA: This service expects a `cdn_files` table in D1:
|
|
11
|
+
*
|
|
12
|
+
* ```sql
|
|
13
|
+
* CREATE TABLE cdn_files (
|
|
14
|
+
* id TEXT PRIMARY KEY,
|
|
15
|
+
* filename TEXT NOT NULL,
|
|
16
|
+
* original_filename TEXT NOT NULL,
|
|
17
|
+
* key TEXT NOT NULL UNIQUE,
|
|
18
|
+
* content_type TEXT NOT NULL,
|
|
19
|
+
* size_bytes INTEGER NOT NULL,
|
|
20
|
+
* folder TEXT DEFAULT '/',
|
|
21
|
+
* alt_text TEXT,
|
|
22
|
+
* uploaded_by TEXT NOT NULL,
|
|
23
|
+
* created_at TEXT NOT NULL
|
|
24
|
+
* );
|
|
25
|
+
*
|
|
26
|
+
* CREATE INDEX idx_cdn_files_folder ON cdn_files(folder);
|
|
27
|
+
* CREATE INDEX idx_cdn_files_key ON cdn_files(key);
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Errors
|
|
32
|
+
// ============================================================================
|
|
33
|
+
export class StorageError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
cause;
|
|
36
|
+
constructor(message, code, cause) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.cause = cause;
|
|
40
|
+
this.name = 'StorageError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Configuration
|
|
45
|
+
// ============================================================================
|
|
46
|
+
/** Default max file size: 50MB */
|
|
47
|
+
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
48
|
+
/** Default storage configuration */
|
|
49
|
+
export const STORAGE_DEFAULTS = {
|
|
50
|
+
MAX_FILE_SIZE: DEFAULT_MAX_FILE_SIZE
|
|
51
|
+
};
|
|
52
|
+
const ALLOWED_CONTENT_TYPES = new Set([
|
|
53
|
+
// Images
|
|
54
|
+
'image/jpeg',
|
|
55
|
+
'image/png',
|
|
56
|
+
'image/gif',
|
|
57
|
+
'image/webp',
|
|
58
|
+
'image/avif',
|
|
59
|
+
'image/svg+xml',
|
|
60
|
+
// Documents
|
|
61
|
+
'application/pdf',
|
|
62
|
+
// Video
|
|
63
|
+
'video/mp4',
|
|
64
|
+
'video/webm',
|
|
65
|
+
// Audio
|
|
66
|
+
'audio/mpeg',
|
|
67
|
+
'audio/wav',
|
|
68
|
+
'audio/webm',
|
|
69
|
+
// Fonts
|
|
70
|
+
'font/woff',
|
|
71
|
+
'font/woff2',
|
|
72
|
+
'font/ttf',
|
|
73
|
+
'font/otf',
|
|
74
|
+
// Web assets
|
|
75
|
+
'application/json',
|
|
76
|
+
'text/css',
|
|
77
|
+
'text/javascript',
|
|
78
|
+
'application/javascript'
|
|
79
|
+
]);
|
|
80
|
+
const CACHE_CONTROL = {
|
|
81
|
+
// Immutable assets (1 year) - content-addressed or versioned
|
|
82
|
+
'image/jpeg': 'public, max-age=31536000, immutable',
|
|
83
|
+
'image/png': 'public, max-age=31536000, immutable',
|
|
84
|
+
'image/gif': 'public, max-age=31536000, immutable',
|
|
85
|
+
'image/webp': 'public, max-age=31536000, immutable',
|
|
86
|
+
'image/avif': 'public, max-age=31536000, immutable',
|
|
87
|
+
'image/svg+xml': 'public, max-age=31536000, immutable',
|
|
88
|
+
'font/woff': 'public, max-age=31536000, immutable',
|
|
89
|
+
'font/woff2': 'public, max-age=31536000, immutable',
|
|
90
|
+
'font/ttf': 'public, max-age=31536000, immutable',
|
|
91
|
+
'font/otf': 'public, max-age=31536000, immutable',
|
|
92
|
+
'video/mp4': 'public, max-age=31536000, immutable',
|
|
93
|
+
'video/webm': 'public, max-age=31536000, immutable',
|
|
94
|
+
'audio/mpeg': 'public, max-age=31536000, immutable',
|
|
95
|
+
'audio/wav': 'public, max-age=31536000, immutable',
|
|
96
|
+
'audio/webm': 'public, max-age=31536000, immutable',
|
|
97
|
+
// Mutable assets (shorter TTL)
|
|
98
|
+
'application/pdf': 'public, max-age=86400',
|
|
99
|
+
'application/json': 'public, max-age=3600',
|
|
100
|
+
'text/css': 'public, max-age=86400',
|
|
101
|
+
'text/javascript': 'public, max-age=86400',
|
|
102
|
+
'application/javascript': 'public, max-age=86400'
|
|
103
|
+
};
|
|
104
|
+
const DEFAULT_CACHE_CONTROL = 'public, max-age=86400';
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Utility Functions
|
|
107
|
+
// ============================================================================
|
|
108
|
+
function generateId() {
|
|
109
|
+
return crypto.randomUUID();
|
|
110
|
+
}
|
|
111
|
+
function now() {
|
|
112
|
+
return new Date().toISOString();
|
|
113
|
+
}
|
|
114
|
+
function sanitizeFilename(filename) {
|
|
115
|
+
return filename
|
|
116
|
+
.replace(/[/\\:*?"<>|]/g, '-')
|
|
117
|
+
.replace(/\s+/g, '-')
|
|
118
|
+
.replace(/-+/g, '-')
|
|
119
|
+
.toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
function generateUniqueFilename(originalFilename) {
|
|
122
|
+
const ext = originalFilename.split('.').pop() || '';
|
|
123
|
+
const nameWithoutExt = originalFilename.slice(0, originalFilename.lastIndexOf('.')) || originalFilename;
|
|
124
|
+
const sanitized = sanitizeFilename(nameWithoutExt);
|
|
125
|
+
const timestamp = Date.now();
|
|
126
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
127
|
+
return ext ? `${sanitized}-${timestamp}-${random}.${ext}` : `${sanitized}-${timestamp}-${random}`;
|
|
128
|
+
}
|
|
129
|
+
function getCacheControl(contentType) {
|
|
130
|
+
return CACHE_CONTROL[contentType] || DEFAULT_CACHE_CONTROL;
|
|
131
|
+
}
|
|
132
|
+
function normalizeFolder(folder) {
|
|
133
|
+
if (!folder)
|
|
134
|
+
return '/';
|
|
135
|
+
return folder.startsWith('/') ? folder : `/${folder}`;
|
|
136
|
+
}
|
|
137
|
+
function buildStorageKey(folder, filename) {
|
|
138
|
+
const cleanFolder = normalizeFolder(folder);
|
|
139
|
+
return cleanFolder === '/' ? filename : `${cleanFolder.slice(1)}/${filename}`;
|
|
140
|
+
}
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Validation
|
|
143
|
+
// ============================================================================
|
|
144
|
+
/**
|
|
145
|
+
* Validate file size and content type
|
|
146
|
+
*
|
|
147
|
+
* @param data - File data as ArrayBuffer
|
|
148
|
+
* @param contentType - MIME type of the file
|
|
149
|
+
* @param config - Optional configuration to override defaults
|
|
150
|
+
*/
|
|
151
|
+
export function validateFile(data, contentType, config) {
|
|
152
|
+
const maxSize = config?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
153
|
+
if (data.byteLength > maxSize) {
|
|
154
|
+
throw new StorageError(`File too large. Maximum size is ${maxSize / 1024 / 1024}MB`, 'FILE_TOO_LARGE');
|
|
155
|
+
}
|
|
156
|
+
const isAllowed = ALLOWED_CONTENT_TYPES.has(contentType) ||
|
|
157
|
+
config?.additionalContentTypes?.includes(contentType);
|
|
158
|
+
if (!isAllowed) {
|
|
159
|
+
throw new StorageError(`Content type not allowed: ${contentType}`, 'INVALID_TYPE');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Check if a content type is allowed
|
|
164
|
+
*
|
|
165
|
+
* @param contentType - MIME type to check
|
|
166
|
+
* @param additionalTypes - Additional types to allow beyond defaults
|
|
167
|
+
*/
|
|
168
|
+
export function isAllowedContentType(contentType, additionalTypes) {
|
|
169
|
+
return ALLOWED_CONTENT_TYPES.has(contentType) || additionalTypes?.includes(contentType) || false;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Upload a file to storage with metadata
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* const file = await storage.uploadFile(bucket, db, {
|
|
177
|
+
* data: arrayBuffer,
|
|
178
|
+
* filename: 'photo.jpg',
|
|
179
|
+
* contentType: 'image/jpeg',
|
|
180
|
+
* folder: '/images',
|
|
181
|
+
* uploadedBy: userId
|
|
182
|
+
* });
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export async function uploadFile(bucket, db, options) {
|
|
186
|
+
const { data, filename, contentType, folder, altText, uploadedBy, maxFileSize } = options;
|
|
187
|
+
// Validate with optional custom size limit
|
|
188
|
+
validateFile(data, contentType, { maxFileSize });
|
|
189
|
+
// Generate unique key
|
|
190
|
+
const uniqueFilename = generateUniqueFilename(filename);
|
|
191
|
+
const normalizedFolder = normalizeFolder(folder);
|
|
192
|
+
const key = buildStorageKey(normalizedFolder, uniqueFilename);
|
|
193
|
+
// Upload to R2
|
|
194
|
+
try {
|
|
195
|
+
await bucket.put(key, data, {
|
|
196
|
+
httpMetadata: {
|
|
197
|
+
contentType,
|
|
198
|
+
cacheControl: getCacheControl(contentType)
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
throw new StorageError('Failed to upload file to storage', 'UPLOAD_FAILED', err);
|
|
204
|
+
}
|
|
205
|
+
// Store metadata in D1
|
|
206
|
+
const id = generateId();
|
|
207
|
+
const timestamp = now();
|
|
208
|
+
try {
|
|
209
|
+
await db
|
|
210
|
+
.prepare(`INSERT INTO cdn_files (id, filename, original_filename, key, content_type, size_bytes, folder, alt_text, uploaded_by, created_at)
|
|
211
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
212
|
+
.bind(id, uniqueFilename, filename, key, contentType, data.byteLength, normalizedFolder, altText ?? null, uploadedBy, timestamp)
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
// Attempt to clean up the uploaded file
|
|
217
|
+
try {
|
|
218
|
+
await bucket.delete(key);
|
|
219
|
+
}
|
|
220
|
+
catch (cleanupErr) {
|
|
221
|
+
console.error('[Storage] Failed to cleanup R2 object after metadata failure:', cleanupErr);
|
|
222
|
+
}
|
|
223
|
+
throw new StorageError('Failed to store file metadata', 'METADATA_FAILED', err);
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
id,
|
|
227
|
+
filename: uniqueFilename,
|
|
228
|
+
originalFilename: filename,
|
|
229
|
+
key,
|
|
230
|
+
contentType,
|
|
231
|
+
sizeBytes: data.byteLength,
|
|
232
|
+
folder: normalizedFolder,
|
|
233
|
+
altText: altText ?? null,
|
|
234
|
+
uploadedBy,
|
|
235
|
+
createdAt: timestamp
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Get a file from storage
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* const file = await storage.getFile(bucket, 'images/photo.jpg');
|
|
244
|
+
* if (file) {
|
|
245
|
+
* return new Response(file.body, {
|
|
246
|
+
* headers: { 'Content-Type': file.contentType }
|
|
247
|
+
* });
|
|
248
|
+
* }
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
export async function getFile(bucket, key) {
|
|
252
|
+
const object = await bucket.get(key);
|
|
253
|
+
if (!object) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const contentType = object.httpMetadata?.contentType || 'application/octet-stream';
|
|
257
|
+
return {
|
|
258
|
+
body: object.body,
|
|
259
|
+
contentType,
|
|
260
|
+
cacheControl: getCacheControl(contentType),
|
|
261
|
+
etag: object.httpEtag,
|
|
262
|
+
size: object.size
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get file metadata without downloading the body
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```ts
|
|
270
|
+
* const meta = await storage.getFileMetadata(bucket, 'images/photo.jpg');
|
|
271
|
+
* if (meta) {
|
|
272
|
+
* console.log(`File size: ${meta.size}`);
|
|
273
|
+
* }
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
export async function getFileMetadata(bucket, key) {
|
|
277
|
+
const object = await bucket.head(key);
|
|
278
|
+
if (!object) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const contentType = object.httpMetadata?.contentType || 'application/octet-stream';
|
|
282
|
+
return {
|
|
283
|
+
contentType,
|
|
284
|
+
cacheControl: getCacheControl(contentType),
|
|
285
|
+
etag: object.httpEtag,
|
|
286
|
+
size: object.size
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Check if a file exists in storage
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```ts
|
|
294
|
+
* if (await storage.fileExists(bucket, 'images/photo.jpg')) {
|
|
295
|
+
* // File exists
|
|
296
|
+
* }
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
299
|
+
export async function fileExists(bucket, key) {
|
|
300
|
+
const object = await bucket.head(key);
|
|
301
|
+
return object !== null;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Delete a file from storage and its metadata
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* await storage.deleteFile(bucket, db, fileId);
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
export async function deleteFile(bucket, db, fileId) {
|
|
312
|
+
// Get file metadata first
|
|
313
|
+
const file = await db
|
|
314
|
+
.prepare('SELECT key FROM cdn_files WHERE id = ?')
|
|
315
|
+
.bind(fileId)
|
|
316
|
+
.first();
|
|
317
|
+
if (!file) {
|
|
318
|
+
throw new StorageError('File not found', 'FILE_NOT_FOUND');
|
|
319
|
+
}
|
|
320
|
+
// Delete from R2
|
|
321
|
+
try {
|
|
322
|
+
await bucket.delete(file.key);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
throw new StorageError('Failed to delete file from storage', 'DELETE_FAILED', err);
|
|
326
|
+
}
|
|
327
|
+
// Delete metadata from D1
|
|
328
|
+
try {
|
|
329
|
+
await db.prepare('DELETE FROM cdn_files WHERE id = ?').bind(fileId).run();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
throw new StorageError('Failed to delete file metadata', 'METADATA_FAILED', err);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Delete a file by its storage key (for cleanup operations)
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* await storage.deleteFileByKey(bucket, db, 'images/photo.jpg');
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
343
|
+
export async function deleteFileByKey(bucket, db, key) {
|
|
344
|
+
// Delete from R2
|
|
345
|
+
try {
|
|
346
|
+
await bucket.delete(key);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
throw new StorageError('Failed to delete file from storage', 'DELETE_FAILED', err);
|
|
350
|
+
}
|
|
351
|
+
// Delete metadata from D1 (if it exists)
|
|
352
|
+
try {
|
|
353
|
+
await db.prepare('DELETE FROM cdn_files WHERE key = ?').bind(key).run();
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
throw new StorageError('Failed to delete file metadata', 'METADATA_FAILED', err);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// ============================================================================
|
|
360
|
+
// Metadata Operations (D1 only)
|
|
361
|
+
// ============================================================================
|
|
362
|
+
/**
|
|
363
|
+
* Get file metadata from D1 by ID
|
|
364
|
+
*/
|
|
365
|
+
export async function getFileRecord(db, fileId) {
|
|
366
|
+
const row = await db
|
|
367
|
+
.prepare('SELECT * FROM cdn_files WHERE id = ?')
|
|
368
|
+
.bind(fileId)
|
|
369
|
+
.first();
|
|
370
|
+
return row ? mapRowToStorageFile(row) : null;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Get file metadata from D1 by key
|
|
374
|
+
*/
|
|
375
|
+
export async function getFileRecordByKey(db, key) {
|
|
376
|
+
const row = await db
|
|
377
|
+
.prepare('SELECT * FROM cdn_files WHERE key = ?')
|
|
378
|
+
.bind(key)
|
|
379
|
+
.first();
|
|
380
|
+
return row ? mapRowToStorageFile(row) : null;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* List files in a folder
|
|
384
|
+
*/
|
|
385
|
+
export async function listFiles(db, options) {
|
|
386
|
+
const folder = normalizeFolder(options?.folder);
|
|
387
|
+
const limit = options?.limit ?? 50;
|
|
388
|
+
const offset = options?.offset ?? 0;
|
|
389
|
+
const [filesResult, countResult] = await Promise.all([
|
|
390
|
+
db
|
|
391
|
+
.prepare(`SELECT * FROM cdn_files
|
|
392
|
+
WHERE folder = ?
|
|
393
|
+
ORDER BY created_at DESC
|
|
394
|
+
LIMIT ? OFFSET ?`)
|
|
395
|
+
.bind(folder, limit, offset)
|
|
396
|
+
.all(),
|
|
397
|
+
db
|
|
398
|
+
.prepare('SELECT COUNT(*) as count FROM cdn_files WHERE folder = ?')
|
|
399
|
+
.bind(folder)
|
|
400
|
+
.first()
|
|
401
|
+
]);
|
|
402
|
+
return {
|
|
403
|
+
files: (filesResult.results ?? []).map(mapRowToStorageFile),
|
|
404
|
+
total: countResult?.count ?? 0
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* List all files across all folders
|
|
409
|
+
*/
|
|
410
|
+
export async function listAllFiles(db, options) {
|
|
411
|
+
const limit = options?.limit ?? 50;
|
|
412
|
+
const offset = options?.offset ?? 0;
|
|
413
|
+
const [filesResult, countResult] = await Promise.all([
|
|
414
|
+
db
|
|
415
|
+
.prepare(`SELECT * FROM cdn_files
|
|
416
|
+
ORDER BY created_at DESC
|
|
417
|
+
LIMIT ? OFFSET ?`)
|
|
418
|
+
.bind(limit, offset)
|
|
419
|
+
.all(),
|
|
420
|
+
db.prepare('SELECT COUNT(*) as count FROM cdn_files').first()
|
|
421
|
+
]);
|
|
422
|
+
return {
|
|
423
|
+
files: (filesResult.results ?? []).map(mapRowToStorageFile),
|
|
424
|
+
total: countResult?.count ?? 0
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Get all unique folders
|
|
429
|
+
*/
|
|
430
|
+
export async function listFolders(db) {
|
|
431
|
+
const result = await db
|
|
432
|
+
.prepare('SELECT DISTINCT folder FROM cdn_files ORDER BY folder')
|
|
433
|
+
.all();
|
|
434
|
+
return (result.results ?? []).map((r) => r.folder);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Update file alt text
|
|
438
|
+
*/
|
|
439
|
+
export async function updateAltText(db, fileId, altText) {
|
|
440
|
+
const result = await db
|
|
441
|
+
.prepare('UPDATE cdn_files SET alt_text = ? WHERE id = ?')
|
|
442
|
+
.bind(altText, fileId)
|
|
443
|
+
.run();
|
|
444
|
+
if (result.meta.changes === 0) {
|
|
445
|
+
throw new StorageError('File not found', 'FILE_NOT_FOUND');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function mapRowToStorageFile(row) {
|
|
449
|
+
return {
|
|
450
|
+
id: row.id,
|
|
451
|
+
filename: row.filename,
|
|
452
|
+
originalFilename: row.original_filename,
|
|
453
|
+
key: row.key,
|
|
454
|
+
contentType: row.content_type,
|
|
455
|
+
sizeBytes: row.size_bytes,
|
|
456
|
+
folder: row.folder,
|
|
457
|
+
altText: row.alt_text,
|
|
458
|
+
uploadedBy: row.uploaded_by,
|
|
459
|
+
createdAt: row.created_at
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// Response Helpers
|
|
464
|
+
// ============================================================================
|
|
465
|
+
/**
|
|
466
|
+
* Check if request can use cached version (304 Not Modified)
|
|
467
|
+
*/
|
|
468
|
+
export function shouldReturn304(request, etag) {
|
|
469
|
+
const ifNoneMatch = request.headers.get('If-None-Match');
|
|
470
|
+
return ifNoneMatch === etag;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Build response headers for a file
|
|
474
|
+
*/
|
|
475
|
+
export function buildFileHeaders(file, options) {
|
|
476
|
+
const headers = new Headers();
|
|
477
|
+
headers.set('Content-Type', file.contentType);
|
|
478
|
+
headers.set('Cache-Control', file.cacheControl);
|
|
479
|
+
headers.set('ETag', file.etag);
|
|
480
|
+
// Enable CORS for fonts (required for cross-origin font loading)
|
|
481
|
+
if (options?.enableCors || file.contentType.startsWith('font/')) {
|
|
482
|
+
headers.set('Access-Control-Allow-Origin', '*');
|
|
483
|
+
}
|
|
484
|
+
return headers;
|
|
485
|
+
}
|