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.
- package/LICENSE +201 -0
- package/README.md +389 -0
- package/backend/dist/app.js +113 -0
- package/backend/dist/config/env.js +75 -0
- package/backend/dist/config/prisma.js +30 -0
- package/backend/dist/middleware/api-key.middleware.js +38 -0
- package/backend/dist/middleware/auth.middleware.js +21 -0
- package/backend/dist/middleware/error.middleware.js +7 -0
- package/backend/dist/modules/api-keys/api-key.routes.js +90 -0
- package/backend/dist/modules/audit-logs/audit-log.routes.js +20 -0
- package/backend/dist/modules/auth/auth.routes.js +201 -0
- package/backend/dist/modules/cdn/cdn.routes.js +105 -0
- package/backend/dist/modules/connected-accounts/connected-account.routes.js +313 -0
- package/backend/dist/modules/files/file.routes.js +1013 -0
- package/backend/dist/modules/files/stream-file.js +10 -0
- package/backend/dist/modules/files/stream-google-file.js +79 -0
- package/backend/dist/modules/folders/folder.routes.js +546 -0
- package/backend/dist/modules/google/google.service.js +485 -0
- package/backend/dist/modules/invites/invite.routes.js +127 -0
- package/backend/dist/modules/provider-configs/provider-config.routes.js +53 -0
- package/backend/dist/modules/public/public.routes.js +44 -0
- package/backend/dist/modules/public-api/public-api.routes.js +8 -0
- package/backend/dist/modules/s3/s3.service.js +104 -0
- package/backend/dist/modules/storage/storage.routes.js +108 -0
- package/backend/dist/modules/system/system.routes.js +663 -0
- package/backend/dist/modules/uploads/upload.routes.js +569 -0
- package/backend/dist/scripts/debug-query.js +19 -0
- package/backend/dist/scripts/seed-google-config-if-present.js +49 -0
- package/backend/dist/scripts/seed-google-config.js +40 -0
- package/backend/dist/scripts/test-api-upload.js +37 -0
- package/backend/dist/scripts/test-external-upload.js +181 -0
- package/backend/dist/scripts/test-s3-connection.js +33 -0
- package/backend/dist/server.js +7 -0
- package/backend/dist/utils/audit.js +20 -0
- package/backend/dist/utils/crypto.js +33 -0
- package/backend/dist/utils/jwt.js +15 -0
- package/backend/dist/utils/password.js +14 -0
- package/backend/prisma/generated/sqlite-client/client.d.ts +1 -0
- package/backend/prisma/generated/sqlite-client/client.js +5 -0
- package/backend/prisma/generated/sqlite-client/default.d.ts +1 -0
- package/backend/prisma/generated/sqlite-client/default.js +5 -0
- package/backend/prisma/generated/sqlite-client/edge.d.ts +1 -0
- package/backend/prisma/generated/sqlite-client/edge.js +436 -0
- package/backend/prisma/generated/sqlite-client/index-browser.js +422 -0
- package/backend/prisma/generated/sqlite-client/index.d.ts +35178 -0
- package/backend/prisma/generated/sqlite-client/index.js +457 -0
- package/backend/prisma/generated/sqlite-client/package.json +183 -0
- package/backend/prisma/generated/sqlite-client/query_engine-windows.dll.node +0 -0
- package/backend/prisma/generated/sqlite-client/query_engine_bg.js +2 -0
- package/backend/prisma/generated/sqlite-client/query_engine_bg.wasm +0 -0
- package/backend/prisma/generated/sqlite-client/runtime/edge-esm.js +35 -0
- package/backend/prisma/generated/sqlite-client/runtime/edge.js +35 -0
- package/backend/prisma/generated/sqlite-client/runtime/index-browser.d.ts +370 -0
- package/backend/prisma/generated/sqlite-client/runtime/index-browser.js +17 -0
- package/backend/prisma/generated/sqlite-client/runtime/library.d.ts +3982 -0
- package/backend/prisma/generated/sqlite-client/runtime/library.js +147 -0
- package/backend/prisma/generated/sqlite-client/runtime/react-native.js +84 -0
- package/backend/prisma/generated/sqlite-client/runtime/wasm-compiler-edge.js +85 -0
- package/backend/prisma/generated/sqlite-client/runtime/wasm-engine-edge.js +38 -0
- package/backend/prisma/generated/sqlite-client/schema.prisma +354 -0
- package/backend/prisma/generated/sqlite-client/wasm-edge-light-loader.mjs +5 -0
- package/backend/prisma/generated/sqlite-client/wasm-worker-loader.mjs +5 -0
- package/backend/prisma/generated/sqlite-client/wasm.d.ts +1 -0
- package/backend/prisma/generated/sqlite-client/wasm.js +443 -0
- package/backend/prisma/migrations/20260604084325_init/migration.sql +191 -0
- package/backend/prisma/migrations/20260604090020_global_provider_config/migration.sql +8 -0
- package/backend/prisma/migrations/20260604091957_virtual_folders/migration.sql +32 -0
- package/backend/prisma/migrations/20260604095418_nested_folders/migration.sql +8 -0
- package/backend/prisma/migrations/20260604095705_file_shares/migration.sql +22 -0
- package/backend/prisma/migrations/20260604100802_file_preview_tokens/migration.sql +20 -0
- package/backend/prisma/migrations/20260604120000_add_file_share_token/migration.sql +3 -0
- package/backend/prisma/migrations/20260604130000_workspace_invites/migration.sql +17 -0
- package/backend/prisma/migrations/20260604133000_invite_targets/migration.sql +9 -0
- package/backend/prisma/migrations/20260605090000_query_indexes/migration.sql +12 -0
- package/backend/prisma/migrations/20260605100000_folder_icons/migration.sql +1 -0
- package/backend/prisma/migrations/20260605110000_google_auth_handoff/migration.sql +22 -0
- package/backend/prisma/migrations/20260611120000_s3_storage_support/migration.sql +31 -0
- package/backend/prisma/migrations/20260611130000_upload_routing_policy/migration.sql +14 -0
- package/backend/prisma/migrations/20260611140000_api_keys/migration.sql +21 -0
- package/backend/prisma/migrations/20260630030000_upload_session_resumable_fields/migration.sql +9 -0
- package/backend/prisma/migrations/migration_lock.toml +3 -0
- package/backend/prisma/schema.prisma +353 -0
- package/backend/prisma/schema.sqlite.prisma +354 -0
- package/backend/public/assets/index-B4ifIM_G.js +135 -0
- package/backend/public/assets/index-DwOPZrmt.css +2 -0
- package/backend/public/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm +0 -0
- package/backend/public/assets/ort.bundle.min-CpXgll2e.js +2746 -0
- package/backend/public/assets/ort.bundle.min-OfoG_cy9.mjs +2817 -0
- package/backend/public/assets/ort.webgpu.bundle.min-C1vdBqT7.js +2746 -0
- package/backend/public/assets/ort.webgpu.bundle.min-CEayb2S6.mjs +2817 -0
- package/backend/public/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
- package/backend/public/favicon.ico +74 -0
- package/backend/public/favicon.svg +74 -0
- package/backend/public/icons.svg +24 -0
- package/backend/public/index.html +34 -0
- package/backend/public/manifest.webmanifest +1 -0
- package/backend/public/maskable-icon.svg +22 -0
- package/backend/public/pwa-192x192.svg +22 -0
- package/backend/public/pwa-512x512.svg +22 -0
- package/backend/public/registerSW.js +1 -0
- package/backend/public/sw.js +1 -0
- package/backend/public/workbox-9c191d2f.js +1 -0
- package/bin/9drive.js +320 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fileRouter = void 0;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const googleapis_1 = require("googleapis");
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
const prisma_js_1 = require("../../config/prisma.js");
|
|
8
|
+
const env_js_1 = require("../../config/env.js");
|
|
9
|
+
const auth_middleware_js_1 = require("../../middleware/auth.middleware.js");
|
|
10
|
+
const crypto_js_1 = require("../../utils/crypto.js");
|
|
11
|
+
const google_service_js_1 = require("../google/google.service.js");
|
|
12
|
+
const s3_service_js_1 = require("../s3/s3.service.js");
|
|
13
|
+
const stream_file_js_1 = require("./stream-file.js");
|
|
14
|
+
const stream_google_file_js_1 = require("./stream-google-file.js");
|
|
15
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
16
|
+
const node_stream_1 = require("node:stream");
|
|
17
|
+
const archiver_1 = require("archiver");
|
|
18
|
+
const audit_js_1 = require("../../utils/audit.js");
|
|
19
|
+
exports.fileRouter = (0, express_1.Router)();
|
|
20
|
+
exports.fileRouter.get('/preview/:token', async (req, res, next) => {
|
|
21
|
+
try {
|
|
22
|
+
const token = String(req.params.token);
|
|
23
|
+
const preview = await prisma_js_1.prisma.filePreviewToken.findFirst({
|
|
24
|
+
where: { tokenHash: (0, crypto_js_1.hashToken)(token), expiresAt: { gt: new Date() } },
|
|
25
|
+
include: { file: { include: { connectedAccount: true } } },
|
|
26
|
+
});
|
|
27
|
+
if (!preview || preview.file.status !== 'active')
|
|
28
|
+
return res.status(404).json({ code: 'PREVIEW_NOT_FOUND', message: 'Preview token not found.' });
|
|
29
|
+
return (0, stream_file_js_1.streamProviderFile)(preview.file, req.headers.range, res, { disposition: 'inline' });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return next(error);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
exports.fileRouter.use(auth_middleware_js_1.requireAuth);
|
|
36
|
+
exports.fileRouter.get('/suggestions', async (req, res, next) => {
|
|
37
|
+
try {
|
|
38
|
+
const query = zod_1.z.object({
|
|
39
|
+
q: zod_1.z.string().trim().max(255).optional(),
|
|
40
|
+
kind: zod_1.z.enum(['image', 'video', 'pdf', 'doc', 'archive']).optional(),
|
|
41
|
+
accountId: zod_1.z.string().optional(),
|
|
42
|
+
limit: zod_1.z.coerce.number().min(1).max(20).default(8),
|
|
43
|
+
modified: zod_1.z.enum(['today', '7d', '30d', 'year']).optional(),
|
|
44
|
+
}).parse(req.query);
|
|
45
|
+
const q = query.q || '';
|
|
46
|
+
if (!q && !query.kind && !query.modified) {
|
|
47
|
+
return res.json({ files: [], folders: [] });
|
|
48
|
+
}
|
|
49
|
+
const typeFilters = {
|
|
50
|
+
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
|
|
51
|
+
video: ['video/mp4', 'video/mpeg', 'video/ogg', 'video/quicktime', 'video/webm'],
|
|
52
|
+
pdf: ['application/pdf'],
|
|
53
|
+
doc: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain'],
|
|
54
|
+
archive: ['application/zip', 'application/x-rar-compressed', 'application/x-tar', 'application/x-7z-compressed']
|
|
55
|
+
};
|
|
56
|
+
let modifiedDateFilter;
|
|
57
|
+
if (query.modified) {
|
|
58
|
+
const now = new Date();
|
|
59
|
+
if (query.modified === 'today') {
|
|
60
|
+
modifiedDateFilter = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
61
|
+
}
|
|
62
|
+
else if (query.modified === '7d') {
|
|
63
|
+
modifiedDateFilter = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
64
|
+
}
|
|
65
|
+
else if (query.modified === '30d') {
|
|
66
|
+
modifiedDateFilter = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
67
|
+
}
|
|
68
|
+
else if (query.modified === 'year') {
|
|
69
|
+
modifiedDateFilter = new Date(now.getFullYear(), 0, 1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const fileWhere = {
|
|
73
|
+
userId: req.user.id,
|
|
74
|
+
status: 'active',
|
|
75
|
+
...(q ? { name: { contains: q } } : {}),
|
|
76
|
+
...(query.accountId && query.accountId !== 'all' ? { connectedAccountId: query.accountId } : {}),
|
|
77
|
+
...(query.kind ? { mimeType: { in: typeFilters[query.kind] || [] } } : {}),
|
|
78
|
+
...(modifiedDateFilter ? { updatedAt: { gte: modifiedDateFilter } } : {})
|
|
79
|
+
};
|
|
80
|
+
const folderWhere = {
|
|
81
|
+
userId: req.user.id,
|
|
82
|
+
deletedAt: null,
|
|
83
|
+
...(q ? { name: { contains: q } } : {}),
|
|
84
|
+
...(query.accountId && query.accountId !== 'all' ? { connectedAccountId: query.accountId } : {}),
|
|
85
|
+
...(modifiedDateFilter ? { updatedAt: { gte: modifiedDateFilter } } : {})
|
|
86
|
+
};
|
|
87
|
+
const [files, folders] = await Promise.all([
|
|
88
|
+
prisma_js_1.prisma.file.findMany({
|
|
89
|
+
where: fileWhere,
|
|
90
|
+
take: query.limit,
|
|
91
|
+
select: {
|
|
92
|
+
id: true,
|
|
93
|
+
name: true,
|
|
94
|
+
mimeType: true,
|
|
95
|
+
sizeBytes: true,
|
|
96
|
+
folderId: true,
|
|
97
|
+
provider: true,
|
|
98
|
+
providerFileId: true,
|
|
99
|
+
updatedAt: true,
|
|
100
|
+
createdAt: true,
|
|
101
|
+
connectedAccount: {
|
|
102
|
+
select: { id: true, email: true, provider: true, displayName: true }
|
|
103
|
+
},
|
|
104
|
+
folder: {
|
|
105
|
+
select: { id: true, name: true }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
orderBy: { updatedAt: 'desc' }
|
|
109
|
+
}),
|
|
110
|
+
!query.kind ? prisma_js_1.prisma.folder.findMany({
|
|
111
|
+
where: folderWhere,
|
|
112
|
+
take: 3,
|
|
113
|
+
select: {
|
|
114
|
+
id: true,
|
|
115
|
+
name: true,
|
|
116
|
+
color: true,
|
|
117
|
+
iconUrl: true,
|
|
118
|
+
updatedAt: true,
|
|
119
|
+
createdAt: true,
|
|
120
|
+
connectedAccount: {
|
|
121
|
+
select: { id: true, email: true, provider: true, displayName: true }
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
orderBy: { updatedAt: 'desc' }
|
|
125
|
+
}) : Promise.resolve([])
|
|
126
|
+
]);
|
|
127
|
+
return res.json({
|
|
128
|
+
files: files.map((file) => ({
|
|
129
|
+
...file,
|
|
130
|
+
sizeBytes: file.sizeBytes.toString(),
|
|
131
|
+
driveUrl: file.provider === 'google_drive' && file.providerFileId
|
|
132
|
+
? `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing`
|
|
133
|
+
: null,
|
|
134
|
+
})),
|
|
135
|
+
folders
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return next(error);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
exports.fileRouter.get('/', async (req, res, next) => {
|
|
143
|
+
try {
|
|
144
|
+
const query = zod_1.z.object({
|
|
145
|
+
folderId: zod_1.z.string().optional(),
|
|
146
|
+
q: zod_1.z.string().trim().max(255).optional(),
|
|
147
|
+
kind: zod_1.z.enum(['image', 'video', 'pdf', 'doc', 'archive']).optional(),
|
|
148
|
+
accountId: zod_1.z.string().optional(),
|
|
149
|
+
minSize: zod_1.z.coerce.number().optional(),
|
|
150
|
+
maxSize: zod_1.z.coerce.number().optional(),
|
|
151
|
+
startDate: zod_1.z.string().datetime().optional(),
|
|
152
|
+
endDate: zod_1.z.string().datetime().optional(),
|
|
153
|
+
modified: zod_1.z.enum(['today', '7d', '30d', 'year']).optional(),
|
|
154
|
+
limit: zod_1.z.coerce.number().min(1).max(500).optional(),
|
|
155
|
+
}).parse(req.query);
|
|
156
|
+
const typeFilters = {
|
|
157
|
+
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
|
|
158
|
+
video: ['video/mp4', 'video/mpeg', 'video/ogg', 'video/quicktime', 'video/webm'],
|
|
159
|
+
pdf: ['application/pdf'],
|
|
160
|
+
doc: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain'],
|
|
161
|
+
archive: ['application/zip', 'application/x-rar-compressed', 'application/x-tar', 'application/x-7z-compressed']
|
|
162
|
+
};
|
|
163
|
+
let modifiedDateFilter;
|
|
164
|
+
if (query.modified) {
|
|
165
|
+
const now = new Date();
|
|
166
|
+
if (query.modified === 'today') {
|
|
167
|
+
modifiedDateFilter = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
168
|
+
}
|
|
169
|
+
else if (query.modified === '7d') {
|
|
170
|
+
modifiedDateFilter = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
171
|
+
}
|
|
172
|
+
else if (query.modified === '30d') {
|
|
173
|
+
modifiedDateFilter = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
174
|
+
}
|
|
175
|
+
else if (query.modified === 'year') {
|
|
176
|
+
modifiedDateFilter = new Date(now.getFullYear(), 0, 1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const where = {
|
|
180
|
+
userId: req.user.id,
|
|
181
|
+
status: 'active',
|
|
182
|
+
...(query.folderId ? { folderId: query.folderId } : {}),
|
|
183
|
+
...(query.q ? { name: { contains: query.q } } : {}),
|
|
184
|
+
...(query.accountId && query.accountId !== 'all' ? { connectedAccountId: query.accountId } : {}),
|
|
185
|
+
...(query.kind ? { mimeType: { in: typeFilters[query.kind] || [] } } : {}),
|
|
186
|
+
...(modifiedDateFilter ? { updatedAt: { gte: modifiedDateFilter } } : {}),
|
|
187
|
+
...(query.minSize !== undefined || query.maxSize !== undefined ? {
|
|
188
|
+
sizeBytes: {
|
|
189
|
+
...(query.minSize !== undefined ? { gte: BigInt(query.minSize) } : {}),
|
|
190
|
+
...(query.maxSize !== undefined ? { lte: BigInt(query.maxSize) } : {})
|
|
191
|
+
}
|
|
192
|
+
} : {}),
|
|
193
|
+
...(query.startDate || query.endDate ? {
|
|
194
|
+
createdAt: {
|
|
195
|
+
...(query.startDate ? { gte: new Date(query.startDate) } : {}),
|
|
196
|
+
...(query.endDate ? { lte: new Date(query.endDate) } : {})
|
|
197
|
+
}
|
|
198
|
+
} : {})
|
|
199
|
+
};
|
|
200
|
+
const files = await prisma_js_1.prisma.file.findMany({
|
|
201
|
+
where,
|
|
202
|
+
take: query.limit ?? 250,
|
|
203
|
+
include: {
|
|
204
|
+
connectedAccount: { select: { id: true, email: true, provider: true } },
|
|
205
|
+
folder: { select: { id: true, name: true } }
|
|
206
|
+
},
|
|
207
|
+
orderBy: { createdAt: 'desc' }
|
|
208
|
+
});
|
|
209
|
+
return res.json({
|
|
210
|
+
files: files.map((file) => ({
|
|
211
|
+
...file,
|
|
212
|
+
sizeBytes: file.sizeBytes.toString(),
|
|
213
|
+
driveUrl: file.provider === 'google_drive' && file.providerFileId
|
|
214
|
+
? `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing`
|
|
215
|
+
: null,
|
|
216
|
+
})),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
return next(error);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
const batchFileSchema = zod_1.z.object({ fileIds: zod_1.z.array(zod_1.z.string().min(1)).min(1).max(100) });
|
|
224
|
+
exports.fileRouter.patch('/batch', async (req, res, next) => {
|
|
225
|
+
try {
|
|
226
|
+
const body = batchFileSchema.extend({ folderId: zod_1.z.string().nullable().optional() }).parse(req.body);
|
|
227
|
+
if (body.folderId)
|
|
228
|
+
await prisma_js_1.prisma.folder.findFirstOrThrow({ where: { id: body.folderId, userId: req.user.id, deletedAt: null } });
|
|
229
|
+
const result = await prisma_js_1.prisma.file.updateMany({ where: { id: { in: body.fileIds }, userId: req.user.id, status: 'active' }, data: { folderId: body.folderId ?? null } });
|
|
230
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'MOVE_FILES', 'file', undefined, { count: result.count, folderId: body.folderId });
|
|
231
|
+
return res.json({ status: 'ok', moved: result.count });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return next(error);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
exports.fileRouter.delete('/batch', async (req, res, next) => {
|
|
238
|
+
try {
|
|
239
|
+
const body = batchFileSchema.parse(req.body);
|
|
240
|
+
const files = await prisma_js_1.prisma.file.findMany({ where: { id: { in: body.fileIds }, userId: req.user.id, status: 'active' } });
|
|
241
|
+
const result = await prisma_js_1.prisma.file.updateMany({
|
|
242
|
+
where: { id: { in: body.fileIds }, userId: req.user.id, status: 'active' },
|
|
243
|
+
data: { status: 'deleted', deletedAt: new Date() }
|
|
244
|
+
});
|
|
245
|
+
for (const f of files) {
|
|
246
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'TRASH_FILE', 'file', f.id, { name: f.name });
|
|
247
|
+
}
|
|
248
|
+
return res.json({ status: 'ok', deleted: result.count });
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
return next(error);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
exports.fileRouter.get('/trash', async (req, res, next) => {
|
|
255
|
+
try {
|
|
256
|
+
const query = zod_1.z.object({
|
|
257
|
+
q: zod_1.z.string().trim().max(255).optional(),
|
|
258
|
+
accountId: zod_1.z.string().optional()
|
|
259
|
+
}).parse(req.query);
|
|
260
|
+
const files = await prisma_js_1.prisma.file.findMany({
|
|
261
|
+
where: {
|
|
262
|
+
userId: req.user.id,
|
|
263
|
+
status: 'deleted',
|
|
264
|
+
...(query.q ? { name: { contains: query.q } } : {}),
|
|
265
|
+
...(query.accountId ? { connectedAccountId: query.accountId } : {})
|
|
266
|
+
},
|
|
267
|
+
include: {
|
|
268
|
+
connectedAccount: { select: { id: true, email: true, provider: true } },
|
|
269
|
+
folder: { select: { id: true, name: true } }
|
|
270
|
+
},
|
|
271
|
+
orderBy: { deletedAt: 'desc' }
|
|
272
|
+
});
|
|
273
|
+
return res.json({ files: files.map((file) => ({ ...file, sizeBytes: file.sizeBytes.toString() })) });
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
return next(error);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
exports.fileRouter.get('/starred', async (req, res, next) => {
|
|
280
|
+
try {
|
|
281
|
+
const accountId = req.query.accountId;
|
|
282
|
+
const googleAccounts = await prisma_js_1.prisma.connectedAccount.findMany({
|
|
283
|
+
where: {
|
|
284
|
+
userId: req.user.id,
|
|
285
|
+
provider: 'google_drive',
|
|
286
|
+
status: 'connected',
|
|
287
|
+
...(accountId ? { id: accountId } : {})
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const files = [];
|
|
291
|
+
for (const account of googleAccounts) {
|
|
292
|
+
try {
|
|
293
|
+
const accountStarred = await (0, google_service_js_1.getGoogleStarredFiles)(account.id, req.user.id);
|
|
294
|
+
files.push(...accountStarred);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
console.error('Failed to get starred files for account', account.id, err.message);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return res.json({ files });
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return next(error);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
exports.fileRouter.get('/recent', async (req, res, next) => {
|
|
307
|
+
try {
|
|
308
|
+
const accountId = req.query.accountId;
|
|
309
|
+
const googleAccounts = await prisma_js_1.prisma.connectedAccount.findMany({
|
|
310
|
+
where: {
|
|
311
|
+
userId: req.user.id,
|
|
312
|
+
provider: 'google_drive',
|
|
313
|
+
status: 'connected',
|
|
314
|
+
...(accountId ? { id: accountId } : {})
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
const files = [];
|
|
318
|
+
for (const account of googleAccounts) {
|
|
319
|
+
try {
|
|
320
|
+
const accountRecent = await (0, google_service_js_1.getGoogleRecentFiles)(account.id, req.user.id);
|
|
321
|
+
files.push(...accountRecent);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
console.error('Failed to get recent files for account', account.id, err.message);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (files.length === 0) {
|
|
328
|
+
const dbFiles = await prisma_js_1.prisma.file.findMany({
|
|
329
|
+
where: {
|
|
330
|
+
userId: req.user.id,
|
|
331
|
+
status: 'active',
|
|
332
|
+
...(accountId ? { connectedAccountId: accountId } : {})
|
|
333
|
+
},
|
|
334
|
+
include: {
|
|
335
|
+
connectedAccount: { select: { id: true, email: true, provider: true } },
|
|
336
|
+
folder: { select: { id: true, name: true } },
|
|
337
|
+
},
|
|
338
|
+
orderBy: { updatedAt: 'desc' },
|
|
339
|
+
take: 50,
|
|
340
|
+
});
|
|
341
|
+
files.push(...dbFiles.map((file) => ({
|
|
342
|
+
...file,
|
|
343
|
+
sizeBytes: file.sizeBytes.toString(),
|
|
344
|
+
viewedAt: file.updatedAt.toISOString(),
|
|
345
|
+
})));
|
|
346
|
+
}
|
|
347
|
+
return res.json({ files });
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
return next(error);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
exports.fileRouter.get('/shared', async (req, res, next) => {
|
|
354
|
+
try {
|
|
355
|
+
const accountId = req.query.accountId;
|
|
356
|
+
const googleAccounts = await prisma_js_1.prisma.connectedAccount.findMany({
|
|
357
|
+
where: {
|
|
358
|
+
userId: req.user.id,
|
|
359
|
+
provider: 'google_drive',
|
|
360
|
+
status: 'connected',
|
|
361
|
+
...(accountId ? { id: accountId } : {})
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
const files = [];
|
|
365
|
+
const folders = [];
|
|
366
|
+
for (const account of googleAccounts) {
|
|
367
|
+
try {
|
|
368
|
+
const result = await (0, google_service_js_1.getGoogleSharedFiles)(account.id, req.user.id);
|
|
369
|
+
files.push(...result.files);
|
|
370
|
+
folders.push(...result.folders);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
console.error('Failed to get shared files for account', account.id, err.message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return res.json({ files, folders });
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
return next(error);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
exports.fileRouter.post('/:id/star', async (req, res, next) => {
|
|
383
|
+
try {
|
|
384
|
+
const body = zod_1.z.object({ starred: zod_1.z.boolean() }).parse(req.body);
|
|
385
|
+
const fileId = String(req.params.id);
|
|
386
|
+
const file = await prisma_js_1.prisma.file.findFirst({
|
|
387
|
+
where: { id: fileId, userId: req.user.id },
|
|
388
|
+
include: { connectedAccount: true },
|
|
389
|
+
});
|
|
390
|
+
if (!file)
|
|
391
|
+
return res.status(404).json({ message: 'File not found' });
|
|
392
|
+
if (file.provider === 'google_drive' && file.connectedAccount) {
|
|
393
|
+
await (0, google_service_js_1.setGoogleFileStarred)(file.connectedAccountId, req.user.id, file.providerFileId, body.starred);
|
|
394
|
+
}
|
|
395
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, body.starred ? 'STAR_FILE' : 'UNSTAR_FILE', 'file', file.id, { name: file.name });
|
|
396
|
+
return res.json({ status: 'ok', starred: body.starred });
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return next(error);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
exports.fileRouter.post('/batch/restore', async (req, res, next) => {
|
|
403
|
+
try {
|
|
404
|
+
const body = batchFileSchema.parse(req.body);
|
|
405
|
+
const files = await prisma_js_1.prisma.file.findMany({ where: { id: { in: body.fileIds }, userId: req.user.id, status: 'deleted' } });
|
|
406
|
+
const result = await prisma_js_1.prisma.file.updateMany({
|
|
407
|
+
where: { id: { in: body.fileIds }, userId: req.user.id, status: 'deleted' },
|
|
408
|
+
data: { status: 'active', deletedAt: null }
|
|
409
|
+
});
|
|
410
|
+
for (const f of files) {
|
|
411
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'RESTORE_FILE', 'file', f.id, { name: f.name });
|
|
412
|
+
}
|
|
413
|
+
return res.json({ status: 'ok', restored: result.count });
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return next(error);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
exports.fileRouter.delete('/batch/permanent', async (req, res, next) => {
|
|
420
|
+
try {
|
|
421
|
+
const body = batchFileSchema.parse(req.body);
|
|
422
|
+
const files = await prisma_js_1.prisma.file.findMany({
|
|
423
|
+
where: { id: { in: body.fileIds }, userId: req.user.id, status: 'deleted' },
|
|
424
|
+
include: { connectedAccount: true }
|
|
425
|
+
});
|
|
426
|
+
const deletedIds = [];
|
|
427
|
+
const syncedAccountIds = new Set();
|
|
428
|
+
const failed = [];
|
|
429
|
+
for (const file of files) {
|
|
430
|
+
try {
|
|
431
|
+
if (file.provider === 's3') {
|
|
432
|
+
await (0, s3_service_js_1.deleteS3Object)(file);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
436
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
437
|
+
await drive.files.delete({ fileId: file.providerFileId });
|
|
438
|
+
}
|
|
439
|
+
deletedIds.push(file.id);
|
|
440
|
+
syncedAccountIds.add(file.connectedAccountId);
|
|
441
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'PERMANENT_DELETE_FILE', 'file', file.id, { name: file.name });
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
failed.push({ fileId: file.id, message: error instanceof Error ? error.message : 'Delete failed' });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (deletedIds.length > 0) {
|
|
448
|
+
await prisma_js_1.prisma.file.deleteMany({
|
|
449
|
+
where: { id: { in: deletedIds }, userId: req.user.id }
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
for (const accountId of syncedAccountIds) {
|
|
453
|
+
const account = files.find((file) => file.connectedAccountId === accountId)?.connectedAccount;
|
|
454
|
+
if (account?.provider === 's3') {
|
|
455
|
+
await (0, s3_service_js_1.syncS3Quota)(accountId).catch(() => undefined);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
await (0, google_service_js_1.syncGoogleQuota)(accountId).catch(() => undefined);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (deletedIds.length === 0 && failed.length > 0) {
|
|
462
|
+
return res.status(400).json({ code: 'FILES_DELETE_FAILED', message: 'No files were permanently deleted.', deleted: 0, failed });
|
|
463
|
+
}
|
|
464
|
+
return res.json({ status: 'ok', deleted: deletedIds.length, failed });
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
return next(error);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
exports.fileRouter.get('/shared-links', async (req, res, next) => {
|
|
471
|
+
try {
|
|
472
|
+
const shares = await prisma_js_1.prisma.fileShare.findMany({
|
|
473
|
+
where: { userId: req.user.id, enabled: true, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
|
|
474
|
+
include: { file: { include: { connectedAccount: { select: { email: true, provider: true } }, folder: { select: { id: true, name: true } } } } },
|
|
475
|
+
orderBy: { createdAt: 'desc' },
|
|
476
|
+
});
|
|
477
|
+
return res.json({
|
|
478
|
+
shares: shares.filter((share) => share.file.status === 'active').map((share) => {
|
|
479
|
+
const url = share.token ? `${env_js_1.env.FRONTEND_URL}/public/files/${share.token}` : null;
|
|
480
|
+
return {
|
|
481
|
+
id: share.id,
|
|
482
|
+
url,
|
|
483
|
+
createdAt: share.createdAt.toISOString(),
|
|
484
|
+
expiresAt: share.expiresAt?.toISOString() ?? null,
|
|
485
|
+
file: { ...share.file, sizeBytes: share.file.sizeBytes.toString() },
|
|
486
|
+
};
|
|
487
|
+
})
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
return next(error);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
exports.fileRouter.post('/sync-google', async (req, res, next) => {
|
|
495
|
+
try {
|
|
496
|
+
const body = zod_1.z.object({ connectedAccountId: zod_1.z.string().min(1).optional() }).parse(req.body ?? {});
|
|
497
|
+
const accounts = await prisma_js_1.prisma.connectedAccount.findMany({
|
|
498
|
+
where: { userId: req.user.id, provider: 'google_drive', status: 'connected', ...(body.connectedAccountId ? { id: body.connectedAccountId } : {}) },
|
|
499
|
+
select: { id: true },
|
|
500
|
+
});
|
|
501
|
+
const results = [];
|
|
502
|
+
for (const account of accounts)
|
|
503
|
+
results.push(await (0, google_service_js_1.syncGoogleAppFolderFiles)(account.id, req.user.id));
|
|
504
|
+
return res.json({
|
|
505
|
+
status: 'ok',
|
|
506
|
+
results,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
return next(error);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
exports.fileRouter.get('/:id', async (req, res, next) => {
|
|
514
|
+
try {
|
|
515
|
+
const fileId = String(req.params.id);
|
|
516
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id }, include: { connectedAccount: { select: { id: true, email: true, provider: true } }, folder: { select: { id: true, name: true } } } });
|
|
517
|
+
return res.json({ file: { ...file, sizeBytes: file.sizeBytes.toString() } });
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
return next(error);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
exports.fileRouter.patch('/:id', async (req, res, next) => {
|
|
524
|
+
try {
|
|
525
|
+
const body = zod_1.z.object({ name: zod_1.z.string().min(1).max(255).optional(), folderId: zod_1.z.string().nullable().optional() }).parse(req.body);
|
|
526
|
+
const fileId = String(req.params.id);
|
|
527
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id }, include: { connectedAccount: true } });
|
|
528
|
+
const drive = file.provider === 's3' ? null : googleapis_1.google.drive({ version: 'v3', auth: await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount) });
|
|
529
|
+
if (body.folderId)
|
|
530
|
+
await prisma_js_1.prisma.folder.findFirstOrThrow({ where: { id: body.folderId, userId: req.user.id, deletedAt: null } });
|
|
531
|
+
if (body.name && drive)
|
|
532
|
+
await drive.files.update({ fileId: file.providerFileId, requestBody: { name: body.name } });
|
|
533
|
+
const updated = await prisma_js_1.prisma.file.update({ where: { id: file.id }, data: { ...(body.name ? { name: body.name } : {}), ...(body.folderId !== undefined ? { folderId: body.folderId } : {}) }, include: { connectedAccount: { select: { id: true, email: true, provider: true } }, folder: { select: { id: true, name: true } } } });
|
|
534
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'UPDATE_FILE', 'file', updated.id, { name: updated.name, updates: body });
|
|
535
|
+
return res.json({ file: { ...updated, sizeBytes: updated.sizeBytes.toString() } });
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
return next(error);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
exports.fileRouter.post('/:id/share', async (req, res, next) => {
|
|
542
|
+
try {
|
|
543
|
+
const fileId = String(req.params.id);
|
|
544
|
+
const file = await prisma_js_1.prisma.file.findFirst({
|
|
545
|
+
where: {
|
|
546
|
+
OR: [
|
|
547
|
+
{ id: fileId, userId: req.user.id },
|
|
548
|
+
{ providerFileId: fileId, userId: req.user.id },
|
|
549
|
+
{ id: fileId },
|
|
550
|
+
{ providerFileId: fileId },
|
|
551
|
+
],
|
|
552
|
+
status: 'active',
|
|
553
|
+
},
|
|
554
|
+
include: { connectedAccount: true },
|
|
555
|
+
});
|
|
556
|
+
if (!file) {
|
|
557
|
+
const folder = await prisma_js_1.prisma.folder.findFirst({
|
|
558
|
+
where: {
|
|
559
|
+
OR: [
|
|
560
|
+
{ id: fileId, userId: req.user.id },
|
|
561
|
+
{ providerFolderId: fileId, userId: req.user.id },
|
|
562
|
+
{ id: fileId },
|
|
563
|
+
{ providerFolderId: fileId },
|
|
564
|
+
],
|
|
565
|
+
deletedAt: null,
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
if (folder) {
|
|
569
|
+
const folderUrl = folder.providerFolderId ? `https://drive.google.com/drive/folders/${folder.providerFolderId}` : `${env_js_1.env.FRONTEND_URL}/all-files?folderId=${folder.id}`;
|
|
570
|
+
return res.status(200).json({ url: folderUrl, isGoogleDrive: Boolean(folder.providerFolderId) });
|
|
571
|
+
}
|
|
572
|
+
return res.status(404).json({ code: 'NOT_FOUND', message: 'File or folder not found.' });
|
|
573
|
+
}
|
|
574
|
+
if (file.provider === 'google_drive' && file.providerFileId) {
|
|
575
|
+
try {
|
|
576
|
+
if (file.connectedAccount) {
|
|
577
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
578
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
579
|
+
await drive.permissions.create({
|
|
580
|
+
fileId: file.providerFileId,
|
|
581
|
+
requestBody: { role: 'reader', type: 'anyone' },
|
|
582
|
+
supportsAllDrives: true,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
console.error('Google permission grant error:', err.message || err);
|
|
588
|
+
}
|
|
589
|
+
const gdriveUrl = `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing`;
|
|
590
|
+
return res.status(200).json({ url: gdriveUrl, isGoogleDrive: true });
|
|
591
|
+
}
|
|
592
|
+
const existingShare = await prisma_js_1.prisma.fileShare.findFirst({
|
|
593
|
+
where: { fileId: file.id, userId: req.user.id, enabled: true, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
|
|
594
|
+
orderBy: { createdAt: 'desc' },
|
|
595
|
+
});
|
|
596
|
+
let shareId = existingShare?.id;
|
|
597
|
+
let token = existingShare?.token;
|
|
598
|
+
if (!existingShare) {
|
|
599
|
+
token = (0, crypto_js_1.randomToken)(32);
|
|
600
|
+
const share = await prisma_js_1.prisma.fileShare.create({ data: { fileId: file.id, userId: req.user.id, token, tokenHash: (0, crypto_js_1.hashToken)(token) } });
|
|
601
|
+
shareId = share.id;
|
|
602
|
+
}
|
|
603
|
+
return res.status(existingShare ? 200 : 201).json({ url: `${env_js_1.env.FRONTEND_URL}/public/files/${token}`, shareId });
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
return next(error);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
exports.fileRouter.get('/:id/permissions', async (req, res, next) => {
|
|
610
|
+
try {
|
|
611
|
+
const fileId = String(req.params.id);
|
|
612
|
+
const file = await prisma_js_1.prisma.file.findFirst({
|
|
613
|
+
where: {
|
|
614
|
+
OR: [
|
|
615
|
+
{ id: fileId, userId: req.user.id },
|
|
616
|
+
{ providerFileId: fileId, userId: req.user.id },
|
|
617
|
+
{ id: fileId },
|
|
618
|
+
{ providerFileId: fileId },
|
|
619
|
+
],
|
|
620
|
+
status: 'active',
|
|
621
|
+
},
|
|
622
|
+
include: { connectedAccount: true },
|
|
623
|
+
});
|
|
624
|
+
if (!file) {
|
|
625
|
+
return res.status(404).json({ code: 'NOT_FOUND', message: 'File not found.' });
|
|
626
|
+
}
|
|
627
|
+
if (file.provider === 'google_drive' && file.providerFileId) {
|
|
628
|
+
let generalAccess = 'restricted';
|
|
629
|
+
let role = 'reader';
|
|
630
|
+
let isInherited = false;
|
|
631
|
+
let inheritedFrom = null;
|
|
632
|
+
let parentName = null;
|
|
633
|
+
if (file.connectedAccount) {
|
|
634
|
+
try {
|
|
635
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
636
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
637
|
+
const permList = await drive.permissions.list({
|
|
638
|
+
fileId: file.providerFileId,
|
|
639
|
+
fields: 'permissions(id, role, type, permissionDetails)',
|
|
640
|
+
supportsAllDrives: true,
|
|
641
|
+
});
|
|
642
|
+
const anyonePerm = permList.data.permissions?.find((p) => p.type === 'anyone');
|
|
643
|
+
if (anyonePerm) {
|
|
644
|
+
generalAccess = 'anyone';
|
|
645
|
+
role = anyonePerm.role ?? 'reader';
|
|
646
|
+
const inheritedDetail = anyonePerm?.permissionDetails?.find((d) => d.inherited);
|
|
647
|
+
isInherited = Boolean(anyonePerm?.inherited || inheritedDetail);
|
|
648
|
+
if (isInherited) {
|
|
649
|
+
const origin = await (0, google_service_js_1.findInheritedPermissionOrigin)(drive, file.providerFileId);
|
|
650
|
+
if (origin) {
|
|
651
|
+
inheritedFrom = origin.id;
|
|
652
|
+
parentName = origin.name;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
console.error('Failed to query Google Drive permissions:', err.message || err);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const driveUrl = `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing`;
|
|
662
|
+
return res.json({
|
|
663
|
+
fileId: file.id,
|
|
664
|
+
fileName: file.name,
|
|
665
|
+
provider: file.provider,
|
|
666
|
+
generalAccess,
|
|
667
|
+
role,
|
|
668
|
+
isInherited,
|
|
669
|
+
inheritedFrom,
|
|
670
|
+
parentName,
|
|
671
|
+
shareUrl: driveUrl,
|
|
672
|
+
driveUrl,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
const share = await prisma_js_1.prisma.fileShare.findFirst({
|
|
676
|
+
where: { fileId: file.id, userId: req.user.id, enabled: true, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
|
|
677
|
+
orderBy: { createdAt: 'desc' },
|
|
678
|
+
});
|
|
679
|
+
const generalAccess = share ? 'anyone' : 'restricted';
|
|
680
|
+
const shareUrl = share?.token ? `${env_js_1.env.FRONTEND_URL}/public/files/${share.token}` : null;
|
|
681
|
+
return res.json({
|
|
682
|
+
fileId: file.id,
|
|
683
|
+
fileName: file.name,
|
|
684
|
+
provider: file.provider,
|
|
685
|
+
generalAccess,
|
|
686
|
+
role: 'reader',
|
|
687
|
+
isInherited: false,
|
|
688
|
+
inheritedFrom: null,
|
|
689
|
+
parentName: null,
|
|
690
|
+
shareUrl,
|
|
691
|
+
driveUrl: null,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
return next(error);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
exports.fileRouter.put('/:id/permissions', async (req, res, next) => {
|
|
699
|
+
try {
|
|
700
|
+
const fileId = String(req.params.id);
|
|
701
|
+
const body = zod_1.z.object({
|
|
702
|
+
generalAccess: zod_1.z.enum(['restricted', 'anyone']),
|
|
703
|
+
role: zod_1.z.enum(['reader', 'commenter', 'writer']).default('reader'),
|
|
704
|
+
cascadeParent: zod_1.z.boolean().optional(),
|
|
705
|
+
}).parse(req.body);
|
|
706
|
+
const file = await prisma_js_1.prisma.file.findFirst({
|
|
707
|
+
where: {
|
|
708
|
+
OR: [
|
|
709
|
+
{ id: fileId, userId: req.user.id },
|
|
710
|
+
{ providerFileId: fileId, userId: req.user.id },
|
|
711
|
+
{ id: fileId },
|
|
712
|
+
{ providerFileId: fileId },
|
|
713
|
+
],
|
|
714
|
+
status: 'active',
|
|
715
|
+
},
|
|
716
|
+
include: { connectedAccount: true },
|
|
717
|
+
});
|
|
718
|
+
if (!file) {
|
|
719
|
+
return res.status(404).json({ code: 'NOT_FOUND', message: 'File not found.' });
|
|
720
|
+
}
|
|
721
|
+
if (file.provider === 'google_drive' && file.providerFileId) {
|
|
722
|
+
if (!file.connectedAccount) {
|
|
723
|
+
return res.status(400).json({ code: 'ACCOUNT_NOT_FOUND', message: 'Connected Google account not found.' });
|
|
724
|
+
}
|
|
725
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
726
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
727
|
+
const permList = await drive.permissions.list({
|
|
728
|
+
fileId: file.providerFileId,
|
|
729
|
+
fields: 'permissions(id, role, type, permissionDetails)',
|
|
730
|
+
supportsAllDrives: true,
|
|
731
|
+
});
|
|
732
|
+
const anyonePerm = permList.data.permissions?.find((p) => p.type === 'anyone');
|
|
733
|
+
const inheritedDetail = anyonePerm?.permissionDetails?.find((d) => d.inherited);
|
|
734
|
+
const isInherited = Boolean(anyonePerm?.inherited || inheritedDetail);
|
|
735
|
+
if (body.generalAccess === 'restricted') {
|
|
736
|
+
if (anyonePerm?.id) {
|
|
737
|
+
if (isInherited) {
|
|
738
|
+
const origin = await (0, google_service_js_1.findInheritedPermissionOrigin)(drive, file.providerFileId);
|
|
739
|
+
const targetOriginId = origin?.id;
|
|
740
|
+
if (body.cascadeParent && targetOriginId) {
|
|
741
|
+
try {
|
|
742
|
+
await drive.permissions.delete({
|
|
743
|
+
fileId: targetOriginId,
|
|
744
|
+
permissionId: origin?.permissionId ?? anyonePerm.id,
|
|
745
|
+
supportsAllDrives: true,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
catch (delParentErr) {
|
|
749
|
+
console.error('Failed to cascade delete permission on parent:', delParentErr);
|
|
750
|
+
return res.status(400).json({
|
|
751
|
+
code: 'FAILED_CASCADE_PARENT',
|
|
752
|
+
message: 'Gagal mengubah izin folder induk: ' + (delParentErr.message || delParentErr),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
return res.status(400).json({
|
|
758
|
+
code: 'PERMISSION_INHERITED',
|
|
759
|
+
inheritedFrom: targetOriginId ?? null,
|
|
760
|
+
parentName: origin?.name ?? null,
|
|
761
|
+
message: 'Hak akses publik diwarisi dari folder induk di Google Drive. Untuk mengubahnya menjadi Restricted, ubah pengaturan sharing pada folder induknya.',
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
try {
|
|
767
|
+
await drive.permissions.delete({
|
|
768
|
+
fileId: file.providerFileId,
|
|
769
|
+
permissionId: anyonePerm.id,
|
|
770
|
+
supportsAllDrives: true,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
catch (deleteErr) {
|
|
774
|
+
const msg = deleteErr?.message || String(deleteErr);
|
|
775
|
+
if (msg.includes('inherited') || msg.includes('cannotDeletePermission')) {
|
|
776
|
+
const origin = await (0, google_service_js_1.findInheritedPermissionOrigin)(drive, file.providerFileId);
|
|
777
|
+
if (body.cascadeParent && origin?.id) {
|
|
778
|
+
try {
|
|
779
|
+
await drive.permissions.delete({
|
|
780
|
+
fileId: origin.id,
|
|
781
|
+
permissionId: origin.permissionId,
|
|
782
|
+
supportsAllDrives: true,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
catch (cascadeErr) {
|
|
786
|
+
return res.status(400).json({
|
|
787
|
+
code: 'FAILED_CASCADE_PARENT',
|
|
788
|
+
message: 'Gagal mengubah izin folder induk: ' + (cascadeErr.message || cascadeErr),
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
return res.status(400).json({
|
|
794
|
+
code: 'PERMISSION_INHERITED',
|
|
795
|
+
inheritedFrom: origin?.id ?? null,
|
|
796
|
+
parentName: origin?.name ?? null,
|
|
797
|
+
message: 'Hak akses publik diwarisi dari folder induk di Google Drive. Untuk mengubahnya menjadi Restricted, ubah pengaturan sharing pada folder induknya.',
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
throw deleteErr;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
if (anyonePerm?.id) {
|
|
810
|
+
if (anyonePerm.role !== body.role) {
|
|
811
|
+
await drive.permissions.update({
|
|
812
|
+
fileId: file.providerFileId,
|
|
813
|
+
permissionId: anyonePerm.id,
|
|
814
|
+
requestBody: { role: body.role },
|
|
815
|
+
supportsAllDrives: true,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
await drive.permissions.create({
|
|
821
|
+
fileId: file.providerFileId,
|
|
822
|
+
requestBody: { role: body.role, type: 'anyone' },
|
|
823
|
+
supportsAllDrives: true,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const driveUrl = `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing`;
|
|
828
|
+
return res.json({
|
|
829
|
+
status: 'ok',
|
|
830
|
+
generalAccess: body.generalAccess,
|
|
831
|
+
role: body.role,
|
|
832
|
+
isInherited: body.generalAccess === 'restricted' ? false : isInherited,
|
|
833
|
+
shareUrl: driveUrl,
|
|
834
|
+
driveUrl,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
if (body.generalAccess === 'restricted') {
|
|
838
|
+
await prisma_js_1.prisma.fileShare.updateMany({
|
|
839
|
+
where: { fileId: file.id, userId: req.user.id, enabled: true },
|
|
840
|
+
data: { enabled: false },
|
|
841
|
+
});
|
|
842
|
+
return res.json({
|
|
843
|
+
status: 'ok',
|
|
844
|
+
generalAccess: 'restricted',
|
|
845
|
+
role: body.role,
|
|
846
|
+
shareUrl: null,
|
|
847
|
+
driveUrl: null,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
let share = await prisma_js_1.prisma.fileShare.findFirst({
|
|
852
|
+
where: { fileId: file.id, userId: req.user.id },
|
|
853
|
+
orderBy: { createdAt: 'desc' },
|
|
854
|
+
});
|
|
855
|
+
if (!share) {
|
|
856
|
+
const token = (0, crypto_js_1.randomToken)(32);
|
|
857
|
+
share = await prisma_js_1.prisma.fileShare.create({
|
|
858
|
+
data: { fileId: file.id, userId: req.user.id, token, tokenHash: (0, crypto_js_1.hashToken)(token), enabled: true },
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
else if (!share.enabled) {
|
|
862
|
+
share = await prisma_js_1.prisma.fileShare.update({
|
|
863
|
+
where: { id: share.id },
|
|
864
|
+
data: { enabled: true },
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return res.json({
|
|
868
|
+
status: 'ok',
|
|
869
|
+
generalAccess: 'anyone',
|
|
870
|
+
role: body.role,
|
|
871
|
+
shareUrl: share.token ? `${env_js_1.env.FRONTEND_URL}/public/files/${share.token}` : null,
|
|
872
|
+
driveUrl: null,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
return next(error);
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
exports.fileRouter.post('/:id/public-permission', auth_middleware_js_1.requireAuth, async (req, res, next) => {
|
|
881
|
+
try {
|
|
882
|
+
const fileId = String(req.params.id);
|
|
883
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id }, include: { connectedAccount: true } });
|
|
884
|
+
if (file.provider !== 'google_drive') {
|
|
885
|
+
return res.status(400).json({ code: 'UNSUPPORTED_PROVIDER', message: 'Only Google Drive files can be made public.' });
|
|
886
|
+
}
|
|
887
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
888
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
889
|
+
await drive.permissions.create({
|
|
890
|
+
fileId: file.providerFileId,
|
|
891
|
+
requestBody: {
|
|
892
|
+
role: 'reader',
|
|
893
|
+
type: 'anyone',
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
const metadata = await drive.files.get({ fileId: file.providerFileId, fields: 'webViewLink,webContentLink' });
|
|
897
|
+
return res.json({ status: 'ok', url: metadata.data.webViewLink ?? `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing` });
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
return res.status(500).json({ code: 'GOOGLE_API_ERROR', message: error.message || 'Failed to update Google Drive permissions.' });
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
exports.fileRouter.delete('/:id/share', async (req, res, next) => {
|
|
904
|
+
try {
|
|
905
|
+
const fileId = String(req.params.id);
|
|
906
|
+
await prisma_js_1.prisma.fileShare.updateMany({ where: { fileId, userId: req.user.id, enabled: true }, data: { enabled: false } });
|
|
907
|
+
return res.json({ status: 'ok' });
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
return next(error);
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
exports.fileRouter.post('/:id/preview-token', async (req, res, next) => {
|
|
914
|
+
try {
|
|
915
|
+
const fileId = String(req.params.id);
|
|
916
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id, status: 'active' } });
|
|
917
|
+
const token = (0, crypto_js_1.randomToken)(32);
|
|
918
|
+
await prisma_js_1.prisma.filePreviewToken.create({ data: { fileId: file.id, userId: req.user.id, tokenHash: (0, crypto_js_1.hashToken)(token), expiresAt: new Date(Date.now() + 10 * 60_000) } });
|
|
919
|
+
const path = `/files/preview/${token}`;
|
|
920
|
+
return res.status(201).json({ path, url: `${req.protocol}://${req.get('host')}${path}` });
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
return next(error);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
exports.fileRouter.get('/:id/view-url', async (req, res, next) => {
|
|
927
|
+
try {
|
|
928
|
+
const fileId = String(req.params.id);
|
|
929
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id }, include: { connectedAccount: true } });
|
|
930
|
+
if (file.provider === 'google_drive' && file.providerFileId) {
|
|
931
|
+
return res.json({ url: `https://drive.google.com/file/d/${file.providerFileId}/view?usp=sharing` });
|
|
932
|
+
}
|
|
933
|
+
return res.json({ url: null });
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
return next(error);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
exports.fileRouter.get('/:id/download', async (req, res, next) => {
|
|
940
|
+
try {
|
|
941
|
+
const fileId = String(req.params.id);
|
|
942
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id }, include: { connectedAccount: true } });
|
|
943
|
+
return (0, stream_file_js_1.streamProviderFile)(file, req.headers.range, res, { disposition: 'attachment' });
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
return next(error);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
exports.fileRouter.delete('/:id', async (req, res, next) => {
|
|
950
|
+
try {
|
|
951
|
+
const fileId = String(req.params.id);
|
|
952
|
+
const file = await prisma_js_1.prisma.file.findFirstOrThrow({ where: { id: fileId, userId: req.user.id, status: 'active' } });
|
|
953
|
+
await prisma_js_1.prisma.file.update({ where: { id: file.id }, data: { status: 'deleted', deletedAt: new Date() } });
|
|
954
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'TRASH_FILE', 'file', file.id, { name: file.name });
|
|
955
|
+
return res.json({ status: 'ok' });
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
return next(error);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
exports.fileRouter.post('/batch-download', async (req, res, next) => {
|
|
962
|
+
try {
|
|
963
|
+
const body = batchFileSchema.parse(req.body);
|
|
964
|
+
const files = await prisma_js_1.prisma.file.findMany({
|
|
965
|
+
where: { id: { in: body.fileIds }, userId: req.user.id, status: 'active' },
|
|
966
|
+
include: { connectedAccount: true }
|
|
967
|
+
});
|
|
968
|
+
if (files.length === 0)
|
|
969
|
+
return res.status(404).json({ code: 'FILES_NOT_FOUND', message: 'No files found.' });
|
|
970
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
971
|
+
res.setHeader('Content-Disposition', 'attachment; filename="9drive-download.zip"');
|
|
972
|
+
const archive = new archiver_1.ZipArchive({ zlib: { level: 9 } });
|
|
973
|
+
archive.on('error', (err) => {
|
|
974
|
+
throw err;
|
|
975
|
+
});
|
|
976
|
+
archive.pipe(res);
|
|
977
|
+
for (const file of files) {
|
|
978
|
+
try {
|
|
979
|
+
let stream;
|
|
980
|
+
let fileName = file.name;
|
|
981
|
+
if (file.provider === 's3') {
|
|
982
|
+
const config = await (0, s3_service_js_1.getS3ConfigForAccount)(file.connectedAccountId);
|
|
983
|
+
const client = (0, s3_service_js_1.createS3Client)(config);
|
|
984
|
+
const response = await client.send(new client_s3_1.GetObjectCommand({ Bucket: config.bucket, Key: file.providerFileId }));
|
|
985
|
+
stream = response.Body;
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
989
|
+
const headers = (0, stream_google_file_js_1.normalizeHeaders)(await auth.getRequestHeaders());
|
|
990
|
+
const exportTarget = stream_google_file_js_1.googleDownloadExportMimeTypes[file.mimeType];
|
|
991
|
+
if (exportTarget) {
|
|
992
|
+
fileName = (0, stream_google_file_js_1.withExtension)(file.name, exportTarget.extension);
|
|
993
|
+
}
|
|
994
|
+
const url = exportTarget
|
|
995
|
+
? `https://www.googleapis.com/drive/v3/files/${file.providerFileId}/export?mimeType=${encodeURIComponent(exportTarget.mimeType)}`
|
|
996
|
+
: `https://www.googleapis.com/drive/v3/files/${file.providerFileId}?alt=media`;
|
|
997
|
+
const response = await fetch(url, { headers });
|
|
998
|
+
if (!response.ok || !response.body)
|
|
999
|
+
continue;
|
|
1000
|
+
stream = node_stream_1.Readable.fromWeb(response.body);
|
|
1001
|
+
}
|
|
1002
|
+
archive.append(stream, { name: fileName });
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
console.error(`Failed to add file ${file.name} to zip:`, err);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
await archive.finalize();
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
return next(error);
|
|
1012
|
+
}
|
|
1013
|
+
});
|