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,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.streamProviderFile = streamProviderFile;
|
|
4
|
+
const stream_google_file_js_1 = require("./stream-google-file.js");
|
|
5
|
+
const s3_service_js_1 = require("../s3/s3.service.js");
|
|
6
|
+
function streamProviderFile(file, range, res, options = {}) {
|
|
7
|
+
if (file.provider === 's3')
|
|
8
|
+
return (0, s3_service_js_1.streamS3File)(file, range, res, options);
|
|
9
|
+
return (0, stream_google_file_js_1.streamGoogleFile)(file, range, res, options);
|
|
10
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.googleDownloadExportMimeTypes = void 0;
|
|
4
|
+
exports.withExtension = withExtension;
|
|
5
|
+
exports.normalizeHeaders = normalizeHeaders;
|
|
6
|
+
exports.streamGoogleFile = streamGoogleFile;
|
|
7
|
+
const google_service_js_1 = require("../google/google.service.js");
|
|
8
|
+
exports.googleDownloadExportMimeTypes = {
|
|
9
|
+
'application/vnd.google-apps.document': { mimeType: 'application/pdf', extension: '.pdf' },
|
|
10
|
+
'application/vnd.google-apps.spreadsheet': { mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', extension: '.xlsx' },
|
|
11
|
+
'application/vnd.google-apps.presentation': { mimeType: 'application/pdf', extension: '.pdf' },
|
|
12
|
+
'application/vnd.google-apps.drawing': { mimeType: 'image/png', extension: '.png' },
|
|
13
|
+
};
|
|
14
|
+
const googlePreviewExportMimeTypes = {
|
|
15
|
+
...exports.googleDownloadExportMimeTypes,
|
|
16
|
+
'application/vnd.google-apps.spreadsheet': { mimeType: 'application/pdf', extension: '.pdf' },
|
|
17
|
+
};
|
|
18
|
+
function contentDisposition(type, fileName) {
|
|
19
|
+
return `${type}; filename="${fileName.replaceAll('"', '')}"`;
|
|
20
|
+
}
|
|
21
|
+
function withExtension(fileName, extension) {
|
|
22
|
+
return fileName.toLowerCase().endsWith(extension) ? fileName : `${fileName}${extension}`;
|
|
23
|
+
}
|
|
24
|
+
function normalizeHeaders(headers) {
|
|
25
|
+
if (headers instanceof Headers)
|
|
26
|
+
return Object.fromEntries(headers.entries());
|
|
27
|
+
return headers;
|
|
28
|
+
}
|
|
29
|
+
async function streamGoogleFile(file, range, res, options = {}) {
|
|
30
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
31
|
+
const headers = normalizeHeaders(await auth.getRequestHeaders());
|
|
32
|
+
const exportTarget = (options.disposition === 'inline' ? googlePreviewExportMimeTypes : exports.googleDownloadExportMimeTypes)[file.mimeType];
|
|
33
|
+
const responseMimeType = exportTarget?.mimeType ?? file.mimeType;
|
|
34
|
+
const responseFileName = exportTarget ? withExtension(file.name, exportTarget.extension) : file.name;
|
|
35
|
+
const url = exportTarget
|
|
36
|
+
? `https://www.googleapis.com/drive/v3/files/${file.providerFileId}/export?mimeType=${encodeURIComponent(exportTarget.mimeType)}`
|
|
37
|
+
: `https://www.googleapis.com/drive/v3/files/${file.providerFileId}?alt=media`;
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
headers: {
|
|
40
|
+
...headers,
|
|
41
|
+
...(range && !exportTarget ? { Range: range } : {}),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const message = await response.text().catch(() => response.statusText);
|
|
46
|
+
return res.status(response.status).json({ code: 'GOOGLE_FILE_STREAM_FAILED', message: message || response.statusText });
|
|
47
|
+
}
|
|
48
|
+
res.status(response.status);
|
|
49
|
+
res.setHeader('Content-Type', responseMimeType);
|
|
50
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
51
|
+
if (options.disposition)
|
|
52
|
+
res.setHeader('Content-Disposition', contentDisposition(options.disposition, responseFileName));
|
|
53
|
+
if (options.headers) {
|
|
54
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
55
|
+
res.setHeader(key, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const contentLength = response.headers.get('content-length');
|
|
59
|
+
const contentRange = response.headers.get('content-range');
|
|
60
|
+
if (contentLength)
|
|
61
|
+
res.setHeader('Content-Length', contentLength);
|
|
62
|
+
if (contentRange)
|
|
63
|
+
res.setHeader('Content-Range', contentRange);
|
|
64
|
+
if (!response.body) {
|
|
65
|
+
res.end();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const reader = response.body.getReader();
|
|
69
|
+
async function pump() {
|
|
70
|
+
const { done, value } = await reader.read();
|
|
71
|
+
if (done) {
|
|
72
|
+
res.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.write(Buffer.from(value));
|
|
76
|
+
return pump();
|
|
77
|
+
}
|
|
78
|
+
return pump();
|
|
79
|
+
}
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.folderRouter = 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 auth_middleware_js_1 = require("../../middleware/auth.middleware.js");
|
|
9
|
+
const google_service_js_1 = require("../google/google.service.js");
|
|
10
|
+
const audit_js_1 = require("../../utils/audit.js");
|
|
11
|
+
exports.folderRouter = (0, express_1.Router)();
|
|
12
|
+
exports.folderRouter.use(auth_middleware_js_1.requireAuth);
|
|
13
|
+
const defaultFolderColor = '#3b82f6';
|
|
14
|
+
const defaultFolderIconUrl = 'https://api.iconify.design/lucide:folder.svg';
|
|
15
|
+
const iconUrlSchema = zod_1.z.string().url().startsWith('https://api.iconify.design/lucide:').max(2048);
|
|
16
|
+
const colorSchema = zod_1.z.string().regex(/^(#[0-9a-fA-F]{6}|text-[a-z]+-[0-9]+)$/).max(64);
|
|
17
|
+
const createSchema = zod_1.z.object({
|
|
18
|
+
name: zod_1.z.string().min(1).max(255),
|
|
19
|
+
color: colorSchema.optional(),
|
|
20
|
+
iconUrl: iconUrlSchema.nullable().optional(),
|
|
21
|
+
parentId: zod_1.z.string().nullable().optional(),
|
|
22
|
+
accountId: zod_1.z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
function serializeFolder(folder) {
|
|
25
|
+
return {
|
|
26
|
+
...folder,
|
|
27
|
+
providerFolderId: folder.providerFolderId ?? null,
|
|
28
|
+
connectedAccountId: folder.connectedAccountId ?? null,
|
|
29
|
+
driveUrl: folder.providerFolderId ? `https://drive.google.com/drive/folders/${folder.providerFolderId}` : null,
|
|
30
|
+
createdAt: folder.createdAt.toISOString(),
|
|
31
|
+
updatedAt: folder.updatedAt.toISOString(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function ensureProviderFolderIds(folders, userId) {
|
|
35
|
+
const foldersWithoutId = folders.filter((f) => !f.providerFolderId);
|
|
36
|
+
if (foldersWithoutId.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const connectedAccount = await prisma_js_1.prisma.connectedAccount.findFirst({
|
|
39
|
+
where: { userId, provider: 'google_drive', status: 'connected' }
|
|
40
|
+
});
|
|
41
|
+
if (!connectedAccount)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(connectedAccount);
|
|
45
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
46
|
+
for (const folder of foldersWithoutId) {
|
|
47
|
+
try {
|
|
48
|
+
let parentGoogleId = undefined;
|
|
49
|
+
if (folder.parentId) {
|
|
50
|
+
const parentFolder = await prisma_js_1.prisma.folder.findFirst({
|
|
51
|
+
where: { id: folder.parentId, userId }
|
|
52
|
+
});
|
|
53
|
+
if (parentFolder?.providerFolderId) {
|
|
54
|
+
parentGoogleId = parentFolder.providerFolderId;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const driveFolder = await drive.files.create({
|
|
58
|
+
requestBody: {
|
|
59
|
+
name: folder.name,
|
|
60
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
61
|
+
...(parentGoogleId ? { parents: [parentGoogleId] } : {})
|
|
62
|
+
},
|
|
63
|
+
fields: 'id'
|
|
64
|
+
});
|
|
65
|
+
const gId = driveFolder.data.id ?? null;
|
|
66
|
+
if (gId) {
|
|
67
|
+
await prisma_js_1.prisma.folder.update({
|
|
68
|
+
where: { id: folder.id },
|
|
69
|
+
data: { providerFolderId: gId, connectedAccountId: connectedAccount.id }
|
|
70
|
+
});
|
|
71
|
+
folder.providerFolderId = gId;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.error(`Failed self-healing for folder ${folder.id}:`, error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error('Failed self-healing Google Drive auth:', error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
exports.folderRouter.get('/', async (req, res, next) => {
|
|
84
|
+
try {
|
|
85
|
+
const query = zod_1.z.object({
|
|
86
|
+
parentId: zod_1.z.string().nullable().optional(),
|
|
87
|
+
all: zod_1.z.string().optional(),
|
|
88
|
+
accountId: zod_1.z.string().optional()
|
|
89
|
+
}).parse(req.query);
|
|
90
|
+
const folders = await prisma_js_1.prisma.folder.findMany({
|
|
91
|
+
where: {
|
|
92
|
+
userId: req.user.id,
|
|
93
|
+
deletedAt: null,
|
|
94
|
+
...(query.all === '1' ? {} : { parentId: query.parentId ?? null }),
|
|
95
|
+
...(query.accountId ? { connectedAccountId: query.accountId } : {}),
|
|
96
|
+
},
|
|
97
|
+
select: { id: true, name: true, color: true, iconUrl: true, parentId: true, providerFolderId: true, connectedAccountId: true, createdAt: true, updatedAt: true },
|
|
98
|
+
orderBy: { updatedAt: 'desc' },
|
|
99
|
+
});
|
|
100
|
+
await ensureProviderFolderIds(folders, req.user.id);
|
|
101
|
+
return res.json({ folders: folders.map(serializeFolder) });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return next(error);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
exports.folderRouter.get('/recent', async (req, res, next) => {
|
|
108
|
+
try {
|
|
109
|
+
const limit = Math.min(Number(req.query.limit ?? 4), 4);
|
|
110
|
+
const accountId = req.query.accountId;
|
|
111
|
+
const folders = await prisma_js_1.prisma.folder.findMany({
|
|
112
|
+
where: {
|
|
113
|
+
userId: req.user.id,
|
|
114
|
+
deletedAt: null,
|
|
115
|
+
...(accountId ? { connectedAccountId: accountId } : {})
|
|
116
|
+
},
|
|
117
|
+
select: { id: true, name: true, color: true, iconUrl: true, parentId: true, providerFolderId: true, connectedAccountId: true, createdAt: true, updatedAt: true },
|
|
118
|
+
orderBy: { updatedAt: 'desc' },
|
|
119
|
+
take: limit,
|
|
120
|
+
});
|
|
121
|
+
await ensureProviderFolderIds(folders, req.user.id);
|
|
122
|
+
return res.json({ folders: folders.map(serializeFolder) });
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return next(error);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
exports.folderRouter.post('/', async (req, res, next) => {
|
|
129
|
+
try {
|
|
130
|
+
const body = createSchema.parse(req.body);
|
|
131
|
+
let parentFolder = null;
|
|
132
|
+
if (body.parentId) {
|
|
133
|
+
parentFolder = await prisma_js_1.prisma.folder.findFirstOrThrow({
|
|
134
|
+
where: { id: body.parentId, userId: req.user.id, deletedAt: null }
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const targetAccountId = body.accountId || parentFolder?.connectedAccountId;
|
|
138
|
+
const connectedAccount = targetAccountId
|
|
139
|
+
? await prisma_js_1.prisma.connectedAccount.findFirst({
|
|
140
|
+
where: { id: targetAccountId, userId: req.user.id, status: 'connected' }
|
|
141
|
+
})
|
|
142
|
+
: await prisma_js_1.prisma.connectedAccount.findFirst({
|
|
143
|
+
where: { userId: req.user.id, provider: 'google_drive', status: 'connected' }
|
|
144
|
+
});
|
|
145
|
+
let providerFolderId = null;
|
|
146
|
+
if (connectedAccount) {
|
|
147
|
+
try {
|
|
148
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(connectedAccount);
|
|
149
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
150
|
+
let googleParentId = undefined;
|
|
151
|
+
if (parentFolder && parentFolder.providerFolderId) {
|
|
152
|
+
googleParentId = parentFolder.providerFolderId;
|
|
153
|
+
}
|
|
154
|
+
const driveFolder = await drive.files.create({
|
|
155
|
+
requestBody: {
|
|
156
|
+
name: body.name,
|
|
157
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
158
|
+
...(googleParentId ? { parents: [googleParentId] } : {}),
|
|
159
|
+
},
|
|
160
|
+
fields: 'id'
|
|
161
|
+
});
|
|
162
|
+
providerFolderId = driveFolder.data.id ?? null;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
console.error('Failed to create folder on Google Drive:', error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const folder = await prisma_js_1.prisma.folder.create({
|
|
169
|
+
data: {
|
|
170
|
+
userId: req.user.id,
|
|
171
|
+
name: body.name,
|
|
172
|
+
color: body.color ?? defaultFolderColor,
|
|
173
|
+
iconUrl: body.iconUrl ?? defaultFolderIconUrl,
|
|
174
|
+
parentId: body.parentId ?? null,
|
|
175
|
+
providerFolderId,
|
|
176
|
+
connectedAccountId: connectedAccount?.id ?? null
|
|
177
|
+
},
|
|
178
|
+
select: { id: true, name: true, color: true, iconUrl: true, parentId: true, providerFolderId: true, connectedAccountId: true, createdAt: true, updatedAt: true },
|
|
179
|
+
});
|
|
180
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'CREATE_FOLDER', 'folder', folder.id, { name: folder.name });
|
|
181
|
+
return res.status(201).json({ folder: serializeFolder(folder) });
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return next(error);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
exports.folderRouter.patch('/:id', async (req, res, next) => {
|
|
188
|
+
try {
|
|
189
|
+
const body = createSchema.partial().parse(req.body);
|
|
190
|
+
const folderId = String(req.params.id);
|
|
191
|
+
if (body.parentId === folderId)
|
|
192
|
+
return res.status(400).json({ code: 'FOLDER_INVALID_PARENT', message: 'Folder cannot be moved into itself.' });
|
|
193
|
+
const folderRecord = await prisma_js_1.prisma.folder.findFirstOrThrow({
|
|
194
|
+
where: { id: folderId, userId: req.user.id, deletedAt: null },
|
|
195
|
+
include: { connectedAccount: true }
|
|
196
|
+
});
|
|
197
|
+
if (body.parentId) {
|
|
198
|
+
await prisma_js_1.prisma.folder.findFirstOrThrow({ where: { id: body.parentId, userId: req.user.id, deletedAt: null } });
|
|
199
|
+
const folders = await prisma_js_1.prisma.folder.findMany({ where: { userId: req.user.id, deletedAt: null }, select: { id: true, parentId: true } });
|
|
200
|
+
const descendantIds = new Set([folderId]);
|
|
201
|
+
let changed = true;
|
|
202
|
+
while (changed) {
|
|
203
|
+
changed = false;
|
|
204
|
+
for (const folder of folders) {
|
|
205
|
+
if (folder.parentId && descendantIds.has(folder.parentId) && !descendantIds.has(folder.id)) {
|
|
206
|
+
descendantIds.add(folder.id);
|
|
207
|
+
changed = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (descendantIds.has(body.parentId))
|
|
212
|
+
return res.status(400).json({ code: 'FOLDER_INVALID_PARENT', message: 'Folder cannot be moved into itself or a child folder.' });
|
|
213
|
+
}
|
|
214
|
+
// Update name on Google Drive if changed
|
|
215
|
+
if (body.name && folderRecord.providerFolderId && folderRecord.connectedAccount) {
|
|
216
|
+
try {
|
|
217
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(folderRecord.connectedAccount);
|
|
218
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
219
|
+
await drive.files.update({
|
|
220
|
+
fileId: folderRecord.providerFolderId,
|
|
221
|
+
requestBody: { name: body.name }
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
console.error('Failed to rename folder on Google Drive:', error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Update parent on Google Drive if moved
|
|
229
|
+
if (body.parentId !== undefined && folderRecord.providerFolderId && folderRecord.connectedAccount) {
|
|
230
|
+
try {
|
|
231
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(folderRecord.connectedAccount);
|
|
232
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
233
|
+
let newGoogleParentId = await (0, google_service_js_1.ensureGoogleAppFolder)(folderRecord.connectedAccount);
|
|
234
|
+
if (body.parentId) {
|
|
235
|
+
const newParent = await prisma_js_1.prisma.folder.findFirst({ where: { id: body.parentId, userId: req.user.id } });
|
|
236
|
+
if (newParent?.providerFolderId) {
|
|
237
|
+
newGoogleParentId = newParent.providerFolderId;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const fileInfo = await drive.files.get({ fileId: folderRecord.providerFolderId, fields: 'parents' });
|
|
241
|
+
const previousParents = fileInfo.data.parents?.join(',');
|
|
242
|
+
await drive.files.update({
|
|
243
|
+
fileId: folderRecord.providerFolderId,
|
|
244
|
+
addParents: newGoogleParentId,
|
|
245
|
+
removeParents: previousParents,
|
|
246
|
+
fields: 'id, parents'
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
console.error('Failed to move folder on Google Drive:', error);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const folder = await prisma_js_1.prisma.folder.updateMany({
|
|
254
|
+
where: { id: folderId, userId: req.user.id, deletedAt: null },
|
|
255
|
+
data: { ...(body.name ? { name: body.name } : {}), ...(body.color ? { color: body.color } : {}), ...(body.iconUrl !== undefined ? { iconUrl: body.iconUrl } : {}), ...(body.parentId !== undefined ? { parentId: body.parentId } : {}) },
|
|
256
|
+
});
|
|
257
|
+
if (folder.count === 0)
|
|
258
|
+
return res.status(404).json({ code: 'FOLDER_NOT_FOUND', message: 'Folder not found.' });
|
|
259
|
+
const updated = await prisma_js_1.prisma.folder.findFirstOrThrow({
|
|
260
|
+
where: { id: folderId, userId: req.user.id },
|
|
261
|
+
select: { id: true, name: true, color: true, iconUrl: true, parentId: true, providerFolderId: true, createdAt: true, updatedAt: true },
|
|
262
|
+
});
|
|
263
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'UPDATE_FOLDER', 'folder', updated.id, { name: updated.name, updates: body });
|
|
264
|
+
return res.json({ folder: serializeFolder(updated) });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
return next(error);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
exports.folderRouter.delete('/:id', async (req, res, next) => {
|
|
271
|
+
try {
|
|
272
|
+
const rootId = String(req.params.id);
|
|
273
|
+
const root = await prisma_js_1.prisma.folder.findFirstOrThrow({ where: { id: rootId, userId: req.user.id, deletedAt: null } });
|
|
274
|
+
const folders = await prisma_js_1.prisma.folder.findMany({ where: { userId: req.user.id, deletedAt: null }, select: { id: true, parentId: true } });
|
|
275
|
+
const folderIds = new Set([root.id]);
|
|
276
|
+
let changed = true;
|
|
277
|
+
while (changed) {
|
|
278
|
+
changed = false;
|
|
279
|
+
for (const folder of folders) {
|
|
280
|
+
if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) {
|
|
281
|
+
folderIds.add(folder.id);
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const files = await prisma_js_1.prisma.file.findMany({ where: { userId: req.user.id, status: 'active', folderId: { in: [...folderIds] } }, include: { connectedAccount: true } });
|
|
287
|
+
const syncedAccountIds = new Set();
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
try {
|
|
290
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(file.connectedAccount);
|
|
291
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
292
|
+
await drive.files.delete({ fileId: file.providerFileId });
|
|
293
|
+
syncedAccountIds.add(file.connectedAccountId);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Keep going so one failure does not block the whole deletion
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Delete folders on Google Drive
|
|
300
|
+
const foldersToDelete = await prisma_js_1.prisma.folder.findMany({ where: { id: { in: [...folderIds] }, userId: req.user.id }, include: { connectedAccount: true } });
|
|
301
|
+
for (const f of foldersToDelete) {
|
|
302
|
+
if (f.providerFolderId && f.connectedAccount) {
|
|
303
|
+
try {
|
|
304
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(f.connectedAccount);
|
|
305
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
306
|
+
await drive.files.delete({ fileId: f.providerFolderId });
|
|
307
|
+
if (f.connectedAccountId)
|
|
308
|
+
syncedAccountIds.add(f.connectedAccountId);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// ignore
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
await prisma_js_1.prisma.file.updateMany({ where: { id: { in: files.map((file) => file.id) } }, data: { status: 'deleted', deletedAt: new Date() } });
|
|
316
|
+
await prisma_js_1.prisma.folder.updateMany({ where: { id: { in: [...folderIds] }, userId: req.user.id }, data: { deletedAt: new Date() } });
|
|
317
|
+
for (const accountId of syncedAccountIds)
|
|
318
|
+
await (0, google_service_js_1.syncGoogleQuota)(accountId).catch(() => undefined);
|
|
319
|
+
await (0, audit_js_1.createAuditLog)(req.user.id, 'DELETE_FOLDER', 'folder', root.id, { name: root.name });
|
|
320
|
+
return res.json({ status: 'ok' });
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
return next(error);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
exports.folderRouter.get('/:id/permissions', async (req, res, next) => {
|
|
327
|
+
try {
|
|
328
|
+
const folderId = String(req.params.id);
|
|
329
|
+
const folder = await prisma_js_1.prisma.folder.findFirst({
|
|
330
|
+
where: {
|
|
331
|
+
OR: [
|
|
332
|
+
{ id: folderId, userId: req.user.id },
|
|
333
|
+
{ providerFolderId: folderId, userId: req.user.id },
|
|
334
|
+
{ id: folderId },
|
|
335
|
+
{ providerFolderId: folderId },
|
|
336
|
+
],
|
|
337
|
+
deletedAt: null,
|
|
338
|
+
},
|
|
339
|
+
include: { connectedAccount: true },
|
|
340
|
+
});
|
|
341
|
+
if (!folder) {
|
|
342
|
+
return res.status(404).json({ code: 'NOT_FOUND', message: 'Folder not found.' });
|
|
343
|
+
}
|
|
344
|
+
let generalAccess = 'restricted';
|
|
345
|
+
let role = 'reader';
|
|
346
|
+
let isInherited = false;
|
|
347
|
+
let inheritedFrom = null;
|
|
348
|
+
let parentName = null;
|
|
349
|
+
if (folder.providerFolderId) {
|
|
350
|
+
const connectedAccount = folder.connectedAccount ?? (await prisma_js_1.prisma.connectedAccount.findFirst({
|
|
351
|
+
where: { userId: req.user.id, provider: 'google_drive', status: 'connected' },
|
|
352
|
+
}));
|
|
353
|
+
if (connectedAccount) {
|
|
354
|
+
try {
|
|
355
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(connectedAccount);
|
|
356
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
357
|
+
const permList = await drive.permissions.list({
|
|
358
|
+
fileId: folder.providerFolderId,
|
|
359
|
+
fields: 'permissions(id, role, type, permissionDetails)',
|
|
360
|
+
supportsAllDrives: true,
|
|
361
|
+
});
|
|
362
|
+
const anyonePerm = permList.data.permissions?.find((p) => p.type === 'anyone');
|
|
363
|
+
if (anyonePerm) {
|
|
364
|
+
generalAccess = 'anyone';
|
|
365
|
+
role = anyonePerm.role ?? 'reader';
|
|
366
|
+
const inheritedDetail = anyonePerm?.permissionDetails?.find((d) => d.inherited);
|
|
367
|
+
isInherited = Boolean(anyonePerm?.inherited || inheritedDetail);
|
|
368
|
+
if (isInherited) {
|
|
369
|
+
const origin = await (0, google_service_js_1.findInheritedPermissionOrigin)(drive, folder.providerFolderId);
|
|
370
|
+
if (origin) {
|
|
371
|
+
inheritedFrom = origin.id;
|
|
372
|
+
parentName = origin.name;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
console.error('Failed to query folder permissions:', err.message || err);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const driveUrl = folder.providerFolderId ? `https://drive.google.com/drive/folders/${folder.providerFolderId}` : null;
|
|
383
|
+
return res.json({
|
|
384
|
+
folderId: folder.id,
|
|
385
|
+
name: folder.name,
|
|
386
|
+
generalAccess,
|
|
387
|
+
role,
|
|
388
|
+
isInherited,
|
|
389
|
+
inheritedFrom,
|
|
390
|
+
parentName,
|
|
391
|
+
shareUrl: driveUrl,
|
|
392
|
+
driveUrl,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
return next(error);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
exports.folderRouter.put('/:id/permissions', async (req, res, next) => {
|
|
400
|
+
try {
|
|
401
|
+
const folderId = String(req.params.id);
|
|
402
|
+
const body = zod_1.z.object({
|
|
403
|
+
generalAccess: zod_1.z.enum(['restricted', 'anyone']),
|
|
404
|
+
role: zod_1.z.enum(['reader', 'commenter', 'writer']).default('reader'),
|
|
405
|
+
cascadeParent: zod_1.z.boolean().optional(),
|
|
406
|
+
}).parse(req.body);
|
|
407
|
+
const folder = await prisma_js_1.prisma.folder.findFirst({
|
|
408
|
+
where: {
|
|
409
|
+
OR: [
|
|
410
|
+
{ id: folderId, userId: req.user.id },
|
|
411
|
+
{ providerFolderId: folderId, userId: req.user.id },
|
|
412
|
+
{ id: folderId },
|
|
413
|
+
{ providerFolderId: folderId },
|
|
414
|
+
],
|
|
415
|
+
deletedAt: null,
|
|
416
|
+
},
|
|
417
|
+
include: { connectedAccount: true },
|
|
418
|
+
});
|
|
419
|
+
if (!folder) {
|
|
420
|
+
return res.status(404).json({ code: 'NOT_FOUND', message: 'Folder not found.' });
|
|
421
|
+
}
|
|
422
|
+
if (!folder.providerFolderId) {
|
|
423
|
+
return res.status(400).json({ code: 'NO_PROVIDER_FOLDER_ID', message: 'Folder does not have a connected Google Drive folder.' });
|
|
424
|
+
}
|
|
425
|
+
const connectedAccount = folder.connectedAccount ?? (await prisma_js_1.prisma.connectedAccount.findFirst({
|
|
426
|
+
where: { userId: req.user.id, provider: 'google_drive', status: 'connected' },
|
|
427
|
+
}));
|
|
428
|
+
if (!connectedAccount) {
|
|
429
|
+
return res.status(400).json({ code: 'ACCOUNT_NOT_FOUND', message: 'Connected Google account not found.' });
|
|
430
|
+
}
|
|
431
|
+
const auth = await (0, google_service_js_1.getAuthedGoogleClient)(connectedAccount);
|
|
432
|
+
const drive = googleapis_1.google.drive({ version: 'v3', auth });
|
|
433
|
+
const permList = await drive.permissions.list({
|
|
434
|
+
fileId: folder.providerFolderId,
|
|
435
|
+
fields: 'permissions(id, role, type, permissionDetails)',
|
|
436
|
+
supportsAllDrives: true,
|
|
437
|
+
});
|
|
438
|
+
const anyonePerm = permList.data.permissions?.find((p) => p.type === 'anyone');
|
|
439
|
+
const inheritedDetail = anyonePerm?.permissionDetails?.find((d) => d.inherited);
|
|
440
|
+
const isInherited = Boolean(anyonePerm?.inherited || inheritedDetail);
|
|
441
|
+
if (body.generalAccess === 'restricted') {
|
|
442
|
+
if (anyonePerm?.id) {
|
|
443
|
+
if (isInherited) {
|
|
444
|
+
const origin = await (0, google_service_js_1.findInheritedPermissionOrigin)(drive, folder.providerFolderId);
|
|
445
|
+
const targetOriginId = origin?.id;
|
|
446
|
+
if (body.cascadeParent && targetOriginId) {
|
|
447
|
+
try {
|
|
448
|
+
await drive.permissions.delete({
|
|
449
|
+
fileId: targetOriginId,
|
|
450
|
+
permissionId: origin?.permissionId ?? anyonePerm.id,
|
|
451
|
+
supportsAllDrives: true,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
catch (delParentErr) {
|
|
455
|
+
console.error('Failed to cascade delete permission on parent folder:', delParentErr);
|
|
456
|
+
return res.status(400).json({
|
|
457
|
+
code: 'FAILED_CASCADE_PARENT',
|
|
458
|
+
message: 'Gagal mengubah izin folder induk: ' + (delParentErr.message || delParentErr),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
return res.status(400).json({
|
|
464
|
+
code: 'PERMISSION_INHERITED',
|
|
465
|
+
inheritedFrom: targetOriginId ?? null,
|
|
466
|
+
parentName: origin?.name ?? null,
|
|
467
|
+
message: 'Hak akses publik diwarisi dari folder induk di Google Drive. Untuk mengubahnya menjadi Restricted, ubah pengaturan sharing pada folder induknya.',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
try {
|
|
473
|
+
await drive.permissions.delete({
|
|
474
|
+
fileId: folder.providerFolderId,
|
|
475
|
+
permissionId: anyonePerm.id,
|
|
476
|
+
supportsAllDrives: true,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
catch (deleteErr) {
|
|
480
|
+
const msg = deleteErr?.message || String(deleteErr);
|
|
481
|
+
if (msg.includes('inherited') || msg.includes('cannotDeletePermission')) {
|
|
482
|
+
const origin = await (0, google_service_js_1.findInheritedPermissionOrigin)(drive, folder.providerFolderId);
|
|
483
|
+
if (body.cascadeParent && origin?.id) {
|
|
484
|
+
try {
|
|
485
|
+
await drive.permissions.delete({
|
|
486
|
+
fileId: origin.id,
|
|
487
|
+
permissionId: origin.permissionId,
|
|
488
|
+
supportsAllDrives: true,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
catch (cascadeErr) {
|
|
492
|
+
return res.status(400).json({
|
|
493
|
+
code: 'FAILED_CASCADE_PARENT',
|
|
494
|
+
message: 'Gagal mengubah izin folder induk: ' + (cascadeErr.message || cascadeErr),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
return res.status(400).json({
|
|
500
|
+
code: 'PERMISSION_INHERITED',
|
|
501
|
+
inheritedFrom: origin?.id ?? null,
|
|
502
|
+
parentName: origin?.name ?? null,
|
|
503
|
+
message: 'Hak akses publik diwarisi dari folder induk di Google Drive. Untuk mengubahnya menjadi Restricted, ubah pengaturan sharing pada folder induknya.',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
throw deleteErr;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
if (anyonePerm?.id) {
|
|
516
|
+
if (anyonePerm.role !== body.role) {
|
|
517
|
+
await drive.permissions.update({
|
|
518
|
+
fileId: folder.providerFolderId,
|
|
519
|
+
permissionId: anyonePerm.id,
|
|
520
|
+
requestBody: { role: body.role },
|
|
521
|
+
supportsAllDrives: true,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
await drive.permissions.create({
|
|
527
|
+
fileId: folder.providerFolderId,
|
|
528
|
+
requestBody: { role: body.role, type: 'anyone' },
|
|
529
|
+
supportsAllDrives: true,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const driveUrl = `https://drive.google.com/drive/folders/${folder.providerFolderId}`;
|
|
534
|
+
return res.json({
|
|
535
|
+
status: 'ok',
|
|
536
|
+
generalAccess: body.generalAccess,
|
|
537
|
+
role: body.role,
|
|
538
|
+
isInherited: body.generalAccess === 'restricted' ? false : isInherited,
|
|
539
|
+
shareUrl: driveUrl,
|
|
540
|
+
driveUrl,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
return next(error);
|
|
545
|
+
}
|
|
546
|
+
});
|