9drive 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 (104) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +389 -0
  3. package/backend/dist/app.js +113 -0
  4. package/backend/dist/config/env.js +75 -0
  5. package/backend/dist/config/prisma.js +30 -0
  6. package/backend/dist/middleware/api-key.middleware.js +38 -0
  7. package/backend/dist/middleware/auth.middleware.js +21 -0
  8. package/backend/dist/middleware/error.middleware.js +7 -0
  9. package/backend/dist/modules/api-keys/api-key.routes.js +90 -0
  10. package/backend/dist/modules/audit-logs/audit-log.routes.js +20 -0
  11. package/backend/dist/modules/auth/auth.routes.js +201 -0
  12. package/backend/dist/modules/cdn/cdn.routes.js +105 -0
  13. package/backend/dist/modules/connected-accounts/connected-account.routes.js +313 -0
  14. package/backend/dist/modules/files/file.routes.js +1013 -0
  15. package/backend/dist/modules/files/stream-file.js +10 -0
  16. package/backend/dist/modules/files/stream-google-file.js +79 -0
  17. package/backend/dist/modules/folders/folder.routes.js +546 -0
  18. package/backend/dist/modules/google/google.service.js +485 -0
  19. package/backend/dist/modules/invites/invite.routes.js +127 -0
  20. package/backend/dist/modules/provider-configs/provider-config.routes.js +53 -0
  21. package/backend/dist/modules/public/public.routes.js +44 -0
  22. package/backend/dist/modules/public-api/public-api.routes.js +8 -0
  23. package/backend/dist/modules/s3/s3.service.js +104 -0
  24. package/backend/dist/modules/storage/storage.routes.js +108 -0
  25. package/backend/dist/modules/system/system.routes.js +663 -0
  26. package/backend/dist/modules/uploads/upload.routes.js +569 -0
  27. package/backend/dist/scripts/debug-query.js +19 -0
  28. package/backend/dist/scripts/seed-google-config-if-present.js +49 -0
  29. package/backend/dist/scripts/seed-google-config.js +40 -0
  30. package/backend/dist/scripts/test-api-upload.js +37 -0
  31. package/backend/dist/scripts/test-external-upload.js +181 -0
  32. package/backend/dist/scripts/test-s3-connection.js +33 -0
  33. package/backend/dist/server.js +7 -0
  34. package/backend/dist/utils/audit.js +20 -0
  35. package/backend/dist/utils/crypto.js +33 -0
  36. package/backend/dist/utils/jwt.js +15 -0
  37. package/backend/dist/utils/password.js +14 -0
  38. package/backend/prisma/generated/sqlite-client/client.d.ts +1 -0
  39. package/backend/prisma/generated/sqlite-client/client.js +5 -0
  40. package/backend/prisma/generated/sqlite-client/default.d.ts +1 -0
  41. package/backend/prisma/generated/sqlite-client/default.js +5 -0
  42. package/backend/prisma/generated/sqlite-client/edge.d.ts +1 -0
  43. package/backend/prisma/generated/sqlite-client/edge.js +436 -0
  44. package/backend/prisma/generated/sqlite-client/index-browser.js +422 -0
  45. package/backend/prisma/generated/sqlite-client/index.d.ts +35178 -0
  46. package/backend/prisma/generated/sqlite-client/index.js +457 -0
  47. package/backend/prisma/generated/sqlite-client/package.json +183 -0
  48. package/backend/prisma/generated/sqlite-client/query_engine-windows.dll.node +0 -0
  49. package/backend/prisma/generated/sqlite-client/query_engine_bg.js +2 -0
  50. package/backend/prisma/generated/sqlite-client/query_engine_bg.wasm +0 -0
  51. package/backend/prisma/generated/sqlite-client/runtime/edge-esm.js +35 -0
  52. package/backend/prisma/generated/sqlite-client/runtime/edge.js +35 -0
  53. package/backend/prisma/generated/sqlite-client/runtime/index-browser.d.ts +370 -0
  54. package/backend/prisma/generated/sqlite-client/runtime/index-browser.js +17 -0
  55. package/backend/prisma/generated/sqlite-client/runtime/library.d.ts +3982 -0
  56. package/backend/prisma/generated/sqlite-client/runtime/library.js +147 -0
  57. package/backend/prisma/generated/sqlite-client/runtime/react-native.js +84 -0
  58. package/backend/prisma/generated/sqlite-client/runtime/wasm-compiler-edge.js +85 -0
  59. package/backend/prisma/generated/sqlite-client/runtime/wasm-engine-edge.js +38 -0
  60. package/backend/prisma/generated/sqlite-client/schema.prisma +354 -0
  61. package/backend/prisma/generated/sqlite-client/wasm-edge-light-loader.mjs +5 -0
  62. package/backend/prisma/generated/sqlite-client/wasm-worker-loader.mjs +5 -0
  63. package/backend/prisma/generated/sqlite-client/wasm.d.ts +1 -0
  64. package/backend/prisma/generated/sqlite-client/wasm.js +443 -0
  65. package/backend/prisma/migrations/20260604084325_init/migration.sql +191 -0
  66. package/backend/prisma/migrations/20260604090020_global_provider_config/migration.sql +8 -0
  67. package/backend/prisma/migrations/20260604091957_virtual_folders/migration.sql +32 -0
  68. package/backend/prisma/migrations/20260604095418_nested_folders/migration.sql +8 -0
  69. package/backend/prisma/migrations/20260604095705_file_shares/migration.sql +22 -0
  70. package/backend/prisma/migrations/20260604100802_file_preview_tokens/migration.sql +20 -0
  71. package/backend/prisma/migrations/20260604120000_add_file_share_token/migration.sql +3 -0
  72. package/backend/prisma/migrations/20260604130000_workspace_invites/migration.sql +17 -0
  73. package/backend/prisma/migrations/20260604133000_invite_targets/migration.sql +9 -0
  74. package/backend/prisma/migrations/20260605090000_query_indexes/migration.sql +12 -0
  75. package/backend/prisma/migrations/20260605100000_folder_icons/migration.sql +1 -0
  76. package/backend/prisma/migrations/20260605110000_google_auth_handoff/migration.sql +22 -0
  77. package/backend/prisma/migrations/20260611120000_s3_storage_support/migration.sql +31 -0
  78. package/backend/prisma/migrations/20260611130000_upload_routing_policy/migration.sql +14 -0
  79. package/backend/prisma/migrations/20260611140000_api_keys/migration.sql +21 -0
  80. package/backend/prisma/migrations/20260630030000_upload_session_resumable_fields/migration.sql +9 -0
  81. package/backend/prisma/migrations/migration_lock.toml +3 -0
  82. package/backend/prisma/schema.prisma +353 -0
  83. package/backend/prisma/schema.sqlite.prisma +354 -0
  84. package/backend/public/assets/index-B4ifIM_G.js +135 -0
  85. package/backend/public/assets/index-DwOPZrmt.css +2 -0
  86. package/backend/public/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm +0 -0
  87. package/backend/public/assets/ort.bundle.min-CpXgll2e.js +2746 -0
  88. package/backend/public/assets/ort.bundle.min-OfoG_cy9.mjs +2817 -0
  89. package/backend/public/assets/ort.webgpu.bundle.min-C1vdBqT7.js +2746 -0
  90. package/backend/public/assets/ort.webgpu.bundle.min-CEayb2S6.mjs +2817 -0
  91. package/backend/public/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
  92. package/backend/public/favicon.ico +74 -0
  93. package/backend/public/favicon.svg +74 -0
  94. package/backend/public/icons.svg +24 -0
  95. package/backend/public/index.html +34 -0
  96. package/backend/public/manifest.webmanifest +1 -0
  97. package/backend/public/maskable-icon.svg +22 -0
  98. package/backend/public/pwa-192x192.svg +22 -0
  99. package/backend/public/pwa-512x512.svg +22 -0
  100. package/backend/public/registerSW.js +1 -0
  101. package/backend/public/sw.js +1 -0
  102. package/backend/public/workbox-9c191d2f.js +1 -0
  103. package/bin/9drive.js +320 -0
  104. package/package.json +60 -0
@@ -0,0 +1,569 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.uploadRouter = void 0;
7
+ exports.getBaseUrl = getBaseUrl;
8
+ exports.formatFileResponse = formatFileResponse;
9
+ exports.handleUpload = handleUpload;
10
+ const busboy_1 = __importDefault(require("busboy"));
11
+ const express_1 = require("express");
12
+ const stream_1 = require("stream");
13
+ const zod_1 = require("zod");
14
+ const googleapis_1 = require("googleapis");
15
+ const env_js_1 = require("../../config/env.js");
16
+ const prisma_js_1 = require("../../config/prisma.js");
17
+ const auth_middleware_js_1 = require("../../middleware/auth.middleware.js");
18
+ const google_service_js_1 = require("../google/google.service.js");
19
+ const s3_service_js_1 = require("../s3/s3.service.js");
20
+ const audit_js_1 = require("../../utils/audit.js");
21
+ exports.uploadRouter = (0, express_1.Router)();
22
+ function getBaseUrl(req) {
23
+ if (process.env.CDN_BASE_URL) {
24
+ return process.env.CDN_BASE_URL.replace(/\/+$/, '');
25
+ }
26
+ const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
27
+ const host = req.headers['x-forwarded-host'] || req.get('host') || `localhost:${env_js_1.env.APP_PORT}`;
28
+ return `${proto}://${host}`;
29
+ }
30
+ function formatFileResponse(file, baseUrl) {
31
+ const sizeNum = typeof file.sizeBytes === 'bigint' ? Number(file.sizeBytes) : Number(file.sizeBytes || 0);
32
+ const sizeStr = file.sizeBytes !== undefined && file.sizeBytes !== null ? file.sizeBytes.toString() : '0';
33
+ return {
34
+ id: file.id,
35
+ name: file.name,
36
+ mimeType: file.mimeType,
37
+ size: sizeNum,
38
+ sizeBytes: sizeStr,
39
+ provider: file.provider,
40
+ folderId: file.folderId ?? null,
41
+ status: file.status ?? 'active',
42
+ createdAt: file.createdAt,
43
+ updatedAt: file.updatedAt,
44
+ url: `${baseUrl}/cdn/view/${file.id}`,
45
+ downloadUrl: `${baseUrl}/cdn/raw/${file.id}`,
46
+ };
47
+ }
48
+ function logUpload(message, metadata) {
49
+ console.info('[upload]', message, metadata ?? '');
50
+ }
51
+ function syncQuotaInBackground(accountId, sessionId) {
52
+ logUpload('quota sync started', { accountId, sessionId });
53
+ (0, google_service_js_1.syncGoogleQuota)(accountId)
54
+ .then(() => logUpload('quota sync completed', { accountId, sessionId }))
55
+ .catch((error) => logUpload('quota sync failed', { accountId, sessionId, message: error instanceof Error ? error.message : 'Unknown error' }));
56
+ }
57
+ function normalizePriorityAccountIds(value) {
58
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
59
+ }
60
+ function byPriority(items, priorityAccountIds) {
61
+ const order = new Map(priorityAccountIds.map((id, index) => [id, index]));
62
+ return [...items].sort((a, b) => {
63
+ const aOrder = order.get(a.account.id);
64
+ const bOrder = order.get(b.account.id);
65
+ if (aOrder !== undefined && bOrder !== undefined)
66
+ return aOrder - bOrder;
67
+ if (aOrder !== undefined)
68
+ return -1;
69
+ if (bOrder !== undefined)
70
+ return 1;
71
+ return a.account.createdAt.getTime() - b.account.createdAt.getTime();
72
+ });
73
+ }
74
+ async function selectAccount(userId, sizeBytes, reservedBytesByAccount = new Map(), targetAccountId) {
75
+ const accounts = await prisma_js_1.prisma.connectedAccount.findMany({
76
+ where: { userId, provider: { in: ['google_drive', 's3'] }, status: 'connected', ...(targetAccountId ? { id: targetAccountId } : {}) },
77
+ include: { storageAccount: true },
78
+ });
79
+ const stale = accounts.filter((account) => !account.storageAccount?.lastSyncedAt || account.storageAccount.lastSyncedAt.getTime() < Date.now() - 5 * 60_000);
80
+ await Promise.allSettled(stale.map(async (account) => {
81
+ try {
82
+ if (account.provider === 's3') {
83
+ await (0, s3_service_js_1.syncS3Quota)(account.id);
84
+ }
85
+ else {
86
+ await (0, google_service_js_1.syncGoogleQuota)(account.id);
87
+ }
88
+ }
89
+ catch (err) {
90
+ console.error(`[upload] failed to sync quota for account ${account.email} (${account.id}):`, err.message || err);
91
+ await prisma_js_1.prisma.connectedAccount.update({
92
+ where: { id: account.id },
93
+ data: { lastError: err.message || 'Quota sync failed' }
94
+ }).catch(() => undefined);
95
+ }
96
+ }));
97
+ const fresh = await prisma_js_1.prisma.connectedAccount.findMany({
98
+ where: { userId, provider: { in: ['google_drive', 's3'] }, status: 'connected' },
99
+ include: { storageAccount: true },
100
+ });
101
+ const eligible = fresh
102
+ .map((account) => ({ account, availableBytes: account.storageAccount?.availableBytes === null || account.storageAccount?.availableBytes === undefined ? null : account.storageAccount.availableBytes - (reservedBytesByAccount.get(account.id) ?? 0n) }))
103
+ .filter(({ availableBytes }) => availableBytes === null || availableBytes >= sizeBytes);
104
+ if (eligible.length === 0)
105
+ return null;
106
+ if (targetAccountId) {
107
+ const target = eligible.find(e => e.account.id === targetAccountId);
108
+ return target?.account ?? null;
109
+ }
110
+ const policy = await prisma_js_1.prisma.uploadRoutingPolicy.upsert({ where: { userId }, create: { userId, mode: 'most_available', priorityAccountIds: [] }, update: {} });
111
+ const mode = (['most_available', 'round_robin', 'priority'].includes(policy.mode) ? policy.mode : 'most_available');
112
+ const priorityAccountIds = normalizePriorityAccountIds(policy.priorityAccountIds);
113
+ if (mode === 'priority')
114
+ return byPriority(eligible, priorityAccountIds)[0]?.account ?? null;
115
+ if (mode === 'round_robin') {
116
+ const ordered = byPriority(eligible, priorityAccountIds);
117
+ const selected = ordered[policy.roundRobinCursor % ordered.length]?.account ?? ordered[0]?.account ?? null;
118
+ await prisma_js_1.prisma.uploadRoutingPolicy.update({ where: { userId }, data: { roundRobinCursor: policy.roundRobinCursor + 1 } });
119
+ return selected;
120
+ }
121
+ return eligible
122
+ .sort((a, b) => {
123
+ if (a.availableBytes === null && b.availableBytes === null)
124
+ return a.account.provider === 's3' ? -1 : 1;
125
+ if (a.availableBytes === null)
126
+ return a.account.provider === 's3' ? -1 : 1;
127
+ if (b.availableBytes === null)
128
+ return b.account.provider === 's3' ? 1 : -1;
129
+ return Number(b.availableBytes - a.availableBytes);
130
+ })[0]?.account;
131
+ }
132
+ async function handleUpload(req, res, next) {
133
+ try {
134
+ logUpload('request started', { userId: req.user.id, contentLength: req.headers['content-length'] });
135
+ const contentType = req.headers['content-type'];
136
+ if (!contentType?.includes('multipart/form-data'))
137
+ return res.status(400).json({ code: 'UPLOAD_INVALID_CONTENT_TYPE', message: 'multipart/form-data required.' });
138
+ const busboy = (0, busboy_1.default)({ headers: req.headers, limits: { files: 25, fileSize: env_js_1.env.MAX_UPLOAD_BYTES } });
139
+ const fields = {};
140
+ let batchMeta = null;
141
+ let responded = false;
142
+ let fileSeen = false;
143
+ const reservedBytesByAccount = new Map();
144
+ const completed = [];
145
+ const failed = [];
146
+ const pendingUploads = [];
147
+ const baseUrl = getBaseUrl(req);
148
+ const fail = async (status, code, message) => {
149
+ if (responded)
150
+ return;
151
+ responded = true;
152
+ req.unpipe(busboy);
153
+ req.resume();
154
+ return res.status(status).json({ code, message });
155
+ };
156
+ const parseBatchMeta = (value) => JSON.parse(value).map((item) => ({
157
+ fieldName: item.fieldName,
158
+ fileName: item.fileName,
159
+ mimeType: item.mimeType,
160
+ sizeBytes: BigInt(item.sizeBytes),
161
+ folderId: item.folderId,
162
+ }));
163
+ const metaForFile = (fieldName, info) => {
164
+ if (batchMeta)
165
+ return batchMeta.find((item) => item.fieldName === fieldName);
166
+ const sizeBytes = fields.sizeBytes;
167
+ return {
168
+ fieldName,
169
+ sizeBytes: sizeBytes ?? undefined,
170
+ fileName: fields.fileName || info.filename,
171
+ mimeType: fields.mimeType || info.mimeType || 'application/octet-stream',
172
+ folderId: fields.folderId,
173
+ };
174
+ };
175
+ const uploadOne = async (fieldName, fileStream, info) => {
176
+ const meta = metaForFile(fieldName, info);
177
+ const fileName = meta?.fileName || info.filename;
178
+ try {
179
+ let limitHit = false;
180
+ fileStream.on('limit', () => {
181
+ limitHit = true;
182
+ logUpload('file stream size limit reached', { fileName });
183
+ });
184
+ const chunks = [];
185
+ fileStream.on('data', (chunk) => {
186
+ chunks.push(chunk);
187
+ });
188
+ await new Promise((resolve, reject) => {
189
+ fileStream.on('end', resolve);
190
+ fileStream.on('error', reject);
191
+ });
192
+ if (limitHit) {
193
+ failed.push({ fileName, code: 'UPLOAD_TOO_LARGE', message: 'File exceeds max upload size.' });
194
+ return;
195
+ }
196
+ const fileBuffer = Buffer.concat(chunks);
197
+ const streamedBytes = BigInt(fileBuffer.length);
198
+ if (streamedBytes <= 0n) {
199
+ failed.push({ fileName, code: 'UPLOAD_SIZE_REQUIRED', message: 'File cannot be empty.' });
200
+ return;
201
+ }
202
+ const effectiveSizeBytes = (meta?.sizeBytes && meta.sizeBytes > 0n) ? meta.sizeBytes : streamedBytes;
203
+ if (effectiveSizeBytes > BigInt(env_js_1.env.MAX_UPLOAD_BYTES) || streamedBytes > BigInt(env_js_1.env.MAX_UPLOAD_BYTES)) {
204
+ failed.push({ fileName, code: 'UPLOAD_TOO_LARGE', message: 'File exceeds max upload size.' });
205
+ return;
206
+ }
207
+ if (meta?.sizeBytes && meta.sizeBytes > 0n && streamedBytes !== meta.sizeBytes) {
208
+ failed.push({ fileName, code: 'UPLOAD_SIZE_MISMATCH', message: 'Streamed byte count did not match declared size.' });
209
+ return;
210
+ }
211
+ const folderId = meta?.folderId || fields.folderId || null;
212
+ let targetAccountId = undefined;
213
+ if (folderId) {
214
+ const folderRecord = await prisma_js_1.prisma.folder.findFirstOrThrow({ where: { id: folderId, userId: req.user.id, deletedAt: null } });
215
+ if (folderRecord.connectedAccountId) {
216
+ targetAccountId = folderRecord.connectedAccountId;
217
+ }
218
+ }
219
+ const account = await selectAccount(req.user.id, effectiveSizeBytes, reservedBytesByAccount, targetAccountId);
220
+ if (!account) {
221
+ failed.push({ fileName, code: 'NO_ACCOUNT_WITH_ENOUGH_SPACE', message: 'No connected storage account has enough space for this upload.' });
222
+ return;
223
+ }
224
+ reservedBytesByAccount.set(account.id, (reservedBytesByAccount.get(account.id) ?? 0n) + effectiveSizeBytes);
225
+ const session = await prisma_js_1.prisma.uploadSession.create({ data: { userId: req.user.id, targetConnectedAccountId: account.id, folderId, fileName, mimeType: meta?.mimeType || info.mimeType || 'application/octet-stream', sizeBytes: effectiveSizeBytes, status: 'uploading' } });
226
+ logUpload('file upload started', { sessionId: session.id, accountId: account.id, fileName, sizeBytes: effectiveSizeBytes.toString() });
227
+ let providerFileId = '';
228
+ let uploadedName = fileName;
229
+ let uploadedMimeType = meta?.mimeType || info.mimeType || 'application/octet-stream';
230
+ if (account.provider === 's3') {
231
+ const config = await (0, s3_service_js_1.getS3ConfigForAccount)(account.id, req.user.id);
232
+ const provisionalFile = await prisma_js_1.prisma.file.create({
233
+ data: { userId: req.user.id, connectedAccountId: account.id, folderId, provider: 's3', providerFileId: 'pending', name: fileName, mimeType: uploadedMimeType, sizeBytes: effectiveSizeBytes, status: 'uploading' },
234
+ });
235
+ providerFileId = (0, s3_service_js_1.buildS3ObjectKey)(config, req.user.id, provisionalFile.id, fileName);
236
+ await (0, s3_service_js_1.uploadS3Object)(config, providerFileId, stream_1.Readable.from(fileBuffer), uploadedMimeType);
237
+ const updated = await prisma_js_1.prisma.file.update({ where: { id: provisionalFile.id }, data: { providerFileId, status: 'active' } });
238
+ completed.push(formatFileResponse(updated, baseUrl));
239
+ logUpload('s3 upload completed', { sessionId: session.id, accountId: account.id, fileName });
240
+ }
241
+ else {
242
+ const auth = await (0, google_service_js_1.getAuthedGoogleClient)(account);
243
+ const drive = googleapis_1.google.drive({ version: 'v3', auth });
244
+ let targetParentId = undefined;
245
+ if (folderId) {
246
+ const folderRecord = await prisma_js_1.prisma.folder.findFirst({ where: { id: folderId, userId: req.user.id } });
247
+ if (folderRecord?.providerFolderId) {
248
+ targetParentId = folderRecord.providerFolderId;
249
+ }
250
+ }
251
+ const uploaded = await drive.files.create({
252
+ requestBody: {
253
+ name: fileName,
254
+ ...(targetParentId ? { parents: [targetParentId] } : {}),
255
+ },
256
+ media: { mimeType: uploadedMimeType, body: stream_1.Readable.from(fileBuffer) },
257
+ fields: 'id,name,mimeType,size',
258
+ });
259
+ providerFileId = uploaded.data.id ?? '';
260
+ uploadedName = uploaded.data.name ?? fileName;
261
+ uploadedMimeType = uploaded.data.mimeType ?? uploadedMimeType;
262
+ logUpload('google upload completed', { sessionId: session.id, accountId: account.id, fileName });
263
+ // Make the file public (anyone with link can edit/download)
264
+ try {
265
+ await drive.permissions.create({
266
+ fileId: providerFileId,
267
+ requestBody: {
268
+ role: 'writer',
269
+ type: 'anyone'
270
+ }
271
+ });
272
+ logUpload('google file permissions set to public writer', { sessionId: session.id, providerFileId });
273
+ }
274
+ catch (err) {
275
+ console.error('Failed to make Google Drive file public:', err.message || err);
276
+ }
277
+ const file = await prisma_js_1.prisma.file.create({ data: { userId: req.user.id, connectedAccountId: account.id, folderId, provider: 'google_drive', providerFileId, name: uploadedName, mimeType: uploadedMimeType, sizeBytes: effectiveSizeBytes } });
278
+ if (file) {
279
+ logUpload('database file created', { sessionId: session.id, fileId: file.id, accountId: account.id });
280
+ completed.push(formatFileResponse(file, baseUrl));
281
+ }
282
+ }
283
+ await prisma_js_1.prisma.uploadSession.update({ where: { id: session.id }, data: { status: 'completed', completedAt: new Date() } });
284
+ if (account.provider === 's3')
285
+ (0, s3_service_js_1.syncS3Quota)(account.id).catch(() => undefined);
286
+ else
287
+ syncQuotaInBackground(account.id, session.id);
288
+ }
289
+ catch (error) {
290
+ fileStream.resume();
291
+ logUpload('file upload failed', { fileName, message: error instanceof Error ? error.message : 'Upload failed' });
292
+ failed.push({ fileName, code: 'UPLOAD_FAILED', message: error instanceof Error ? error.message : 'Upload failed' });
293
+ }
294
+ };
295
+ busboy.on('field', (name, value) => {
296
+ if (name === 'sizeBytes')
297
+ fields.sizeBytes = BigInt(value);
298
+ if (name === 'fileName')
299
+ fields.fileName = value;
300
+ if (name === 'mimeType')
301
+ fields.mimeType = value;
302
+ if (name === 'folderId')
303
+ fields.folderId = value;
304
+ if (name === 'filesMeta')
305
+ batchMeta = parseBatchMeta(value);
306
+ });
307
+ busboy.on('file', (name, fileStream, info) => {
308
+ fileSeen = true;
309
+ pendingUploads.push(uploadOne(name, fileStream, info));
310
+ });
311
+ busboy.on('error', (error) => {
312
+ logUpload('multipart parser failed', { message: error instanceof Error ? error.message : 'Unknown error' });
313
+ if (!responded) {
314
+ responded = true;
315
+ next(error);
316
+ }
317
+ });
318
+ busboy.on('finish', () => {
319
+ if (!responded && !fileSeen)
320
+ return fail(400, 'UPLOAD_FILE_REQUIRED', 'file field required.');
321
+ Promise.all(pendingUploads).then(() => {
322
+ if (responded)
323
+ return;
324
+ responded = true;
325
+ logUpload('response sent', { completed: completed.length, failed: failed.length });
326
+ if (completed.length === 0)
327
+ return res.status(400).json({ code: failed[0]?.code ?? 'UPLOAD_FAILED', message: failed[0]?.message ?? 'Upload failed', failed });
328
+ if (completed.length === 1 && failed.length === 0) {
329
+ return res.status(201).json({
330
+ success: true,
331
+ file: completed[0],
332
+ files: completed,
333
+ failed: [],
334
+ });
335
+ }
336
+ return res.status(201).json({ success: failed.length === 0, files: completed, failed });
337
+ }).catch(next);
338
+ });
339
+ req.pipe(busboy);
340
+ }
341
+ catch (error) {
342
+ return next(error);
343
+ }
344
+ }
345
+ exports.uploadRouter.post('/', auth_middleware_js_1.requireAuth, handleUpload);
346
+ // Resumable upload endpoints
347
+ // 1. Initialize resumable session
348
+ exports.uploadRouter.post('/resumable/init', auth_middleware_js_1.requireAuth, async (req, res, next) => {
349
+ try {
350
+ const body = zod_1.z.object({
351
+ fileName: zod_1.z.string().min(1),
352
+ mimeType: zod_1.z.string().min(1),
353
+ sizeBytes: zod_1.z.string(),
354
+ folderId: zod_1.z.string().nullable().optional(),
355
+ targetAccountId: zod_1.z.string().nullable().optional()
356
+ }).parse(req.body);
357
+ const sizeBytes = BigInt(body.sizeBytes);
358
+ if (sizeBytes <= 0n)
359
+ return res.status(400).json({ code: 'UPLOAD_SIZE_REQUIRED', message: 'Valid sizeBytes required.' });
360
+ if (sizeBytes > BigInt(env_js_1.env.MAX_UPLOAD_BYTES))
361
+ return res.status(400).json({ code: 'UPLOAD_TOO_LARGE', message: 'File exceeds max upload size.' });
362
+ const folderId = body.folderId || null;
363
+ let targetAccountId = body.targetAccountId;
364
+ if (folderId) {
365
+ const folderRecord = await prisma_js_1.prisma.folder.findFirstOrThrow({ where: { id: folderId, userId: req.user.id, deletedAt: null } });
366
+ if (folderRecord.connectedAccountId) {
367
+ targetAccountId = folderRecord.connectedAccountId;
368
+ }
369
+ }
370
+ const account = await selectAccount(req.user.id, sizeBytes, undefined, targetAccountId);
371
+ if (!account)
372
+ return res.status(400).json({ code: 'NO_ACCOUNT_WITH_ENOUGH_SPACE', message: 'No connected storage account has enough space.' });
373
+ if (account.provider !== 'google_drive') {
374
+ const session = await prisma_js_1.prisma.uploadSession.create({
375
+ data: {
376
+ userId: req.user.id,
377
+ targetConnectedAccountId: account.id,
378
+ folderId,
379
+ fileName: body.fileName,
380
+ mimeType: body.mimeType,
381
+ sizeBytes,
382
+ status: 'uploading'
383
+ }
384
+ });
385
+ return res.status(201).json({ sessionId: session.id, provider: account.provider, offset: 0 });
386
+ }
387
+ const auth = await (0, google_service_js_1.getAuthedGoogleClient)(account);
388
+ let targetParentId = undefined;
389
+ if (folderId) {
390
+ const folderRecord = await prisma_js_1.prisma.folder.findFirst({ where: { id: folderId, userId: req.user.id } });
391
+ if (folderRecord?.providerFolderId) {
392
+ targetParentId = folderRecord.providerFolderId;
393
+ }
394
+ }
395
+ // Initiate Google Drive Resumable Session
396
+ const headers = new Headers();
397
+ const token = await auth.getAccessToken();
398
+ headers.set('Authorization', `Bearer ${token.token}`);
399
+ headers.set('Content-Type', 'application/json');
400
+ headers.set('X-Upload-Content-Type', body.mimeType);
401
+ headers.set('X-Upload-Content-Length', sizeBytes.toString());
402
+ const initRes = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable', {
403
+ method: 'POST',
404
+ headers,
405
+ body: JSON.stringify({
406
+ name: body.fileName,
407
+ ...(targetParentId ? { parents: [targetParentId] } : {}),
408
+ })
409
+ });
410
+ if (!initRes.ok) {
411
+ const errText = await initRes.text();
412
+ throw new Error(`Google API Init Error: ${errText}`);
413
+ }
414
+ const sessionUri = initRes.headers.get('location');
415
+ if (!sessionUri)
416
+ throw new Error('Google API did not return Location header.');
417
+ const session = await prisma_js_1.prisma.uploadSession.create({
418
+ data: {
419
+ userId: req.user.id,
420
+ targetConnectedAccountId: account.id,
421
+ folderId,
422
+ fileName: body.fileName,
423
+ mimeType: body.mimeType,
424
+ sizeBytes,
425
+ status: 'uploading',
426
+ googleSessionUri: sessionUri
427
+ }
428
+ });
429
+ return res.status(201).json({ sessionId: session.id, provider: 'google_drive', offset: 0 });
430
+ }
431
+ catch (error) {
432
+ return next(error);
433
+ }
434
+ });
435
+ // 2. Query/Resume resumable status
436
+ exports.uploadRouter.get('/resumable/status/:id', auth_middleware_js_1.requireAuth, async (req, res, next) => {
437
+ try {
438
+ const session = await prisma_js_1.prisma.uploadSession.findFirstOrThrow({
439
+ where: { id: String(req.params.id), userId: req.user.id }
440
+ });
441
+ if (session.status === 'completed') {
442
+ return res.json({ status: 'completed', offset: session.sizeBytes.toString() });
443
+ }
444
+ if (!session.googleSessionUri || !session.targetConnectedAccountId) {
445
+ return res.json({ status: 'uploading', offset: '0' });
446
+ }
447
+ const account = await prisma_js_1.prisma.connectedAccount.findFirstOrThrow({
448
+ where: { id: session.targetConnectedAccountId, userId: req.user.id }
449
+ });
450
+ const auth = await (0, google_service_js_1.getAuthedGoogleClient)(account);
451
+ const token = await auth.getAccessToken();
452
+ // Query Google Drive for uploaded offset
453
+ const queryHeaders = new Headers();
454
+ queryHeaders.set('Authorization', `Bearer ${token.token}`);
455
+ queryHeaders.set('Content-Range', `bytes */${session.sizeBytes}`);
456
+ const queryRes = await fetch(session.googleSessionUri, {
457
+ method: 'PUT',
458
+ headers: queryHeaders
459
+ });
460
+ if (queryRes.status === 308) {
461
+ const range = queryRes.headers.get('range');
462
+ if (range) {
463
+ // e.g. bytes=0-1048575
464
+ const parts = range.split('-');
465
+ const lastByte = BigInt(parts[1]);
466
+ return res.json({ status: 'uploading', offset: (lastByte + 1n).toString() });
467
+ }
468
+ }
469
+ else if (queryRes.ok) {
470
+ return res.json({ status: 'completed', offset: session.sizeBytes.toString() });
471
+ }
472
+ return res.json({ status: 'uploading', offset: '0' });
473
+ }
474
+ catch (error) {
475
+ return res.json({ status: 'failed', offset: '0' });
476
+ }
477
+ });
478
+ // 3. Upload chunk
479
+ exports.uploadRouter.put('/resumable/chunk/:id', auth_middleware_js_1.requireAuth, async (req, res, next) => {
480
+ try {
481
+ const session = await prisma_js_1.prisma.uploadSession.findFirstOrThrow({
482
+ where: { id: String(req.params.id), userId: req.user.id }
483
+ });
484
+ const rangeHeader = req.headers['content-range'];
485
+ if (!rangeHeader || typeof rangeHeader !== 'string') {
486
+ return res.status(400).json({ code: 'MISSING_CONTENT_RANGE', message: 'Content-Range header is required.' });
487
+ }
488
+ // Parse Content-Range, e.g. bytes 0-5242879/10485760
489
+ const match = rangeHeader.match(/bytes\s+(\d+)-(\d+)\/(\d+)/);
490
+ if (!match)
491
+ return res.status(400).json({ code: 'INVALID_CONTENT_RANGE', message: 'Invalid Content-Range format.' });
492
+ const startByte = BigInt(match[1]);
493
+ const endByte = BigInt(match[2]);
494
+ const totalBytes = BigInt(match[3]);
495
+ if (!session.googleSessionUri || !session.targetConnectedAccountId) {
496
+ return res.status(400).json({ code: 'UNSUPPORTED_PROVIDER', message: 'Only Google Drive resumable uploads supported.' });
497
+ }
498
+ const account = await prisma_js_1.prisma.connectedAccount.findFirstOrThrow({
499
+ where: { id: session.targetConnectedAccountId, userId: req.user.id }
500
+ });
501
+ const auth = await (0, google_service_js_1.getAuthedGoogleClient)(account);
502
+ const drive = googleapis_1.google.drive({ version: 'v3', auth });
503
+ const token = await auth.getAccessToken();
504
+ // Stream chunk body from client to Google Drive resumable URI
505
+ const putHeaders = new Headers();
506
+ putHeaders.set('Authorization', `Bearer ${token.token}`);
507
+ putHeaders.set('Content-Range', rangeHeader);
508
+ putHeaders.set('Content-Length', (endByte - startByte + 1n).toString());
509
+ const putRes = await fetch(session.googleSessionUri, {
510
+ method: 'PUT',
511
+ headers: putHeaders,
512
+ body: req,
513
+ duplex: 'half'
514
+ });
515
+ if (putRes.status === 308) {
516
+ return res.json({ status: 'uploading', offset: (endByte + 1n).toString() });
517
+ }
518
+ if (putRes.ok) {
519
+ // Completed! Parse metadata
520
+ const fileMeta = await putRes.json();
521
+ // Make the file public (anyone with link can edit/download)
522
+ try {
523
+ await drive.permissions.create({
524
+ fileId: fileMeta.id,
525
+ requestBody: {
526
+ role: 'writer',
527
+ type: 'anyone'
528
+ }
529
+ });
530
+ }
531
+ catch (err) {
532
+ console.error('Failed to make Google Drive resumable file public:', err.message || err);
533
+ }
534
+ let existingFile = await prisma_js_1.prisma.file.findFirst({
535
+ where: { providerFileId: fileMeta.id, userId: req.user.id }
536
+ });
537
+ if (!existingFile) {
538
+ existingFile = await prisma_js_1.prisma.file.create({
539
+ data: {
540
+ userId: req.user.id,
541
+ connectedAccountId: account.id,
542
+ folderId: session.folderId,
543
+ provider: 'google_drive',
544
+ providerFileId: fileMeta.id,
545
+ name: fileMeta.name || session.fileName,
546
+ mimeType: fileMeta.mimeType || session.mimeType,
547
+ sizeBytes: totalBytes
548
+ }
549
+ });
550
+ }
551
+ await prisma_js_1.prisma.uploadSession.update({
552
+ where: { id: session.id },
553
+ data: { status: 'completed', completedAt: new Date() }
554
+ });
555
+ await (0, audit_js_1.createAuditLog)(req.user.id, 'UPLOAD_FILE', 'file', existingFile.id, { name: existingFile.name, size: existingFile.sizeBytes.toString() });
556
+ syncQuotaInBackground(account.id, session.id);
557
+ return res.status(201).json({ status: 'completed', file: formatFileResponse(existingFile, getBaseUrl(req)) });
558
+ }
559
+ const errorMsg = await putRes.text();
560
+ await prisma_js_1.prisma.uploadSession.update({
561
+ where: { id: session.id },
562
+ data: { status: 'failed', errorMessage: errorMsg }
563
+ });
564
+ return res.status(putRes.status).json({ code: 'UPLOAD_FAILED', message: errorMsg });
565
+ }
566
+ catch (error) {
567
+ return next(error);
568
+ }
569
+ });
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const prisma_js_1 = require("../config/prisma.js");
4
+ async function main() {
5
+ const users = await prisma_js_1.prisma.user.findMany({ select: { id: true, email: true, name: true } });
6
+ console.log('USERS:', users);
7
+ const files = await prisma_js_1.prisma.file.findMany({
8
+ select: { id: true, name: true, userId: true, status: true, provider: true, providerFileId: true },
9
+ });
10
+ console.log('FILES COUNT:', files.length);
11
+ console.log('FILES:', files);
12
+ const folders = await prisma_js_1.prisma.folder.findMany({
13
+ select: { id: true, name: true, userId: true, providerFolderId: true },
14
+ });
15
+ console.log('FOLDERS:', folders);
16
+ const shares = await prisma_js_1.prisma.fileShare.findMany();
17
+ console.log('SHARES:', shares);
18
+ }
19
+ main().catch(console.error).finally(() => process.exit(0));
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const prisma_js_1 = require("../config/prisma.js");
4
+ const crypto_js_1 = require("../utils/crypto.js");
5
+ const scopes = [
6
+ 'https://www.googleapis.com/auth/drive',
7
+ 'https://www.googleapis.com/auth/userinfo.email',
8
+ 'https://www.googleapis.com/auth/userinfo.profile',
9
+ ];
10
+ function isConfigured(value, placeholders) {
11
+ if (!value?.trim())
12
+ return false;
13
+ return !placeholders.includes(value.trim());
14
+ }
15
+ async function main() {
16
+ const clientId = process.env.GOOGLE_CLIENT_ID?.trim();
17
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim();
18
+ const redirectUri = process.env.GOOGLE_REDIRECT_URI?.trim() || 'http://localhost:4000/connected-accounts/google/callback';
19
+ const hasClientId = isConfigured(clientId, ['your-google-client-id', 'your-client-id']);
20
+ const hasClientSecret = isConfigured(clientSecret, ['your-google-client-secret', 'your-client-secret']);
21
+ if (!hasClientId || !hasClientSecret) {
22
+ console.warn('Skipping Google Drive config seed: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET not configured. Set them in .env and run docker compose up -d --build.');
23
+ return;
24
+ }
25
+ await prisma_js_1.prisma.providerConfig.updateMany({
26
+ where: { userId: null, provider: 'google_drive', status: 'active' },
27
+ data: { status: 'disabled' },
28
+ });
29
+ const config = await prisma_js_1.prisma.providerConfig.create({
30
+ data: {
31
+ userId: null,
32
+ provider: 'google_drive',
33
+ clientIdEncrypted: (0, crypto_js_1.encryptText)(clientId),
34
+ clientSecretEncrypted: (0, crypto_js_1.encryptText)(clientSecret),
35
+ redirectUri,
36
+ scopes,
37
+ status: 'active',
38
+ },
39
+ });
40
+ console.log(`Seeded global Google Drive config: ${config.id}`);
41
+ }
42
+ main()
43
+ .catch((error) => {
44
+ console.error(error);
45
+ process.exit(1);
46
+ })
47
+ .finally(async () => {
48
+ await prisma_js_1.prisma.$disconnect();
49
+ });