@juppytt/fws 0.1.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/.claude/settings.local.json +72 -0
- package/README.md +126 -0
- package/bin/fws-cli.sh +4 -0
- package/bin/fws.ts +421 -0
- package/docs/cli-reference.md +211 -0
- package/docs/gws-support.md +276 -0
- package/package.json +28 -0
- package/src/config/rewrite-cache.ts +73 -0
- package/src/index.ts +3 -0
- package/src/proxy/mitm.ts +285 -0
- package/src/server/app.ts +26 -0
- package/src/server/middleware.ts +38 -0
- package/src/server/routes/calendar.ts +483 -0
- package/src/server/routes/control.ts +151 -0
- package/src/server/routes/drive.ts +342 -0
- package/src/server/routes/gmail.ts +758 -0
- package/src/server/routes/people.ts +239 -0
- package/src/server/routes/sheets.ts +242 -0
- package/src/server/routes/tasks.ts +191 -0
- package/src/store/index.ts +24 -0
- package/src/store/seed.ts +313 -0
- package/src/store/types.ts +225 -0
- package/src/util/id.ts +9 -0
- package/test/calendar.test.ts +227 -0
- package/test/drive.test.ts +153 -0
- package/test/gmail.test.ts +215 -0
- package/test/gws-validation.test.ts +883 -0
- package/test/helpers/harness.ts +109 -0
- package/test/snapshot.test.ts +80 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getStore } from '../../store/index.js';
|
|
3
|
+
import { generateId } from '../../util/id.js';
|
|
4
|
+
|
|
5
|
+
export function driveRoutes(): Router {
|
|
6
|
+
const r = Router();
|
|
7
|
+
const PREFIX = '/drive/v3';
|
|
8
|
+
|
|
9
|
+
// GET about
|
|
10
|
+
r.get(`${PREFIX}/about`, (_req, res) => {
|
|
11
|
+
const store = getStore();
|
|
12
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
13
|
+
res.json({
|
|
14
|
+
kind: 'drive#about',
|
|
15
|
+
user: {
|
|
16
|
+
displayName: 'Test User',
|
|
17
|
+
emailAddress: userEmail,
|
|
18
|
+
kind: 'drive#user',
|
|
19
|
+
me: true,
|
|
20
|
+
},
|
|
21
|
+
storageQuota: {
|
|
22
|
+
limit: '16106127360',
|
|
23
|
+
usage: '0',
|
|
24
|
+
usageInDrive: '0',
|
|
25
|
+
usageInDriveTrash: '0',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// LIST files
|
|
31
|
+
r.get(`${PREFIX}/files`, (req, res) => {
|
|
32
|
+
const store = getStore();
|
|
33
|
+
let files = Object.values(store.drive.files);
|
|
34
|
+
|
|
35
|
+
// Filter by q
|
|
36
|
+
const q = req.query.q as string | undefined;
|
|
37
|
+
if (q) {
|
|
38
|
+
files = filterDriveQuery(files, q);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default: hide trashed
|
|
42
|
+
if (!q || !q.includes('trashed')) {
|
|
43
|
+
files = files.filter(f => !f.trashed);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sort
|
|
47
|
+
const orderBy = req.query.orderBy as string | undefined;
|
|
48
|
+
if (orderBy) {
|
|
49
|
+
const [field, dir] = orderBy.split(' ');
|
|
50
|
+
const desc = dir === 'desc';
|
|
51
|
+
files.sort((a: any, b: any) => {
|
|
52
|
+
const av = a[field] || '';
|
|
53
|
+
const bv = b[field] || '';
|
|
54
|
+
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
|
55
|
+
return desc ? -cmp : cmp;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 100, 1000);
|
|
60
|
+
files = files.slice(0, pageSize);
|
|
61
|
+
|
|
62
|
+
res.json({
|
|
63
|
+
kind: 'drive#fileList',
|
|
64
|
+
files: files.map(f => ({
|
|
65
|
+
kind: f.kind,
|
|
66
|
+
id: f.id,
|
|
67
|
+
name: f.name,
|
|
68
|
+
mimeType: f.mimeType,
|
|
69
|
+
})),
|
|
70
|
+
incompleteSearch: false,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// CREATE file
|
|
75
|
+
r.post(`${PREFIX}/files`, (req, res) => {
|
|
76
|
+
const store = getStore();
|
|
77
|
+
const id = generateId();
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
80
|
+
|
|
81
|
+
const file = {
|
|
82
|
+
kind: 'drive#file' as const,
|
|
83
|
+
id,
|
|
84
|
+
name: req.body.name || 'Untitled',
|
|
85
|
+
mimeType: req.body.mimeType || 'application/octet-stream',
|
|
86
|
+
parents: req.body.parents || ['root'],
|
|
87
|
+
createdTime: now,
|
|
88
|
+
modifiedTime: now,
|
|
89
|
+
size: req.body.size,
|
|
90
|
+
trashed: false,
|
|
91
|
+
starred: req.body.starred || false,
|
|
92
|
+
owners: [{ emailAddress: userEmail, displayName: 'Test User' }],
|
|
93
|
+
description: req.body.description,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
store.drive.files[id] = file;
|
|
97
|
+
res.json(file);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// GET file
|
|
101
|
+
r.get(`${PREFIX}/files/:fileId`, (req, res) => {
|
|
102
|
+
const file = getStore().drive.files[req.params.fileId];
|
|
103
|
+
if (!file) {
|
|
104
|
+
return res.status(404).json({
|
|
105
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
res.json(file);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// PATCH file
|
|
112
|
+
r.patch(`${PREFIX}/files/:fileId`, (req, res) => {
|
|
113
|
+
const store = getStore();
|
|
114
|
+
const file = store.drive.files[req.params.fileId];
|
|
115
|
+
if (!file) {
|
|
116
|
+
return res.status(404).json({
|
|
117
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
Object.assign(file, req.body, { id: file.id, kind: file.kind });
|
|
121
|
+
file.modifiedTime = new Date().toISOString();
|
|
122
|
+
res.json(file);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// EMPTY TRASH (must be before DELETE /files/:fileId)
|
|
126
|
+
r.delete(`${PREFIX}/files/trash`, (_req, res) => {
|
|
127
|
+
const store = getStore();
|
|
128
|
+
for (const [id, file] of Object.entries(store.drive.files)) {
|
|
129
|
+
if (file.trashed) {
|
|
130
|
+
delete store.drive.files[id];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
res.status(204).send();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// DELETE file
|
|
137
|
+
r.delete(`${PREFIX}/files/:fileId`, (req, res) => {
|
|
138
|
+
const store = getStore();
|
|
139
|
+
if (!store.drive.files[req.params.fileId]) {
|
|
140
|
+
return res.status(404).json({
|
|
141
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
delete store.drive.files[req.params.fileId];
|
|
145
|
+
res.status(204).send();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// COPY file
|
|
149
|
+
r.post(`${PREFIX}/files/:fileId/copy`, (req, res) => {
|
|
150
|
+
const store = getStore();
|
|
151
|
+
const original = store.drive.files[req.params.fileId];
|
|
152
|
+
if (!original) {
|
|
153
|
+
return res.status(404).json({
|
|
154
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const id = generateId();
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const copy = {
|
|
161
|
+
...original,
|
|
162
|
+
id,
|
|
163
|
+
name: req.body.name || `Copy of ${original.name}`,
|
|
164
|
+
parents: req.body.parents || original.parents,
|
|
165
|
+
createdTime: now,
|
|
166
|
+
modifiedTime: now,
|
|
167
|
+
};
|
|
168
|
+
store.drive.files[id] = copy;
|
|
169
|
+
res.json(copy);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// === Permissions ===
|
|
173
|
+
|
|
174
|
+
// LIST permissions
|
|
175
|
+
r.get(`${PREFIX}/files/:fileId/permissions`, (req, res) => {
|
|
176
|
+
const file = getStore().drive.files[req.params.fileId];
|
|
177
|
+
if (!file) {
|
|
178
|
+
return res.status(404).json({
|
|
179
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Return owner as default permission
|
|
183
|
+
const userEmail = getStore().gmail.profile.emailAddress;
|
|
184
|
+
res.json({
|
|
185
|
+
kind: 'drive#permissionList',
|
|
186
|
+
permissions: [
|
|
187
|
+
{
|
|
188
|
+
kind: 'drive#permission',
|
|
189
|
+
id: 'owner',
|
|
190
|
+
type: 'user',
|
|
191
|
+
emailAddress: userEmail,
|
|
192
|
+
role: 'owner',
|
|
193
|
+
displayName: 'Test User',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// GET permission
|
|
200
|
+
r.get(`${PREFIX}/files/:fileId/permissions/:permissionId`, (req, res) => {
|
|
201
|
+
const file = getStore().drive.files[req.params.fileId];
|
|
202
|
+
if (!file) {
|
|
203
|
+
return res.status(404).json({
|
|
204
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const userEmail = getStore().gmail.profile.emailAddress;
|
|
208
|
+
if (req.params.permissionId === 'owner') {
|
|
209
|
+
return res.json({
|
|
210
|
+
kind: 'drive#permission',
|
|
211
|
+
id: 'owner',
|
|
212
|
+
type: 'user',
|
|
213
|
+
emailAddress: userEmail,
|
|
214
|
+
role: 'owner',
|
|
215
|
+
displayName: 'Test User',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
res.status(404).json({
|
|
219
|
+
error: { code: 404, message: 'Permission not found.', status: 'NOT_FOUND' },
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// CREATE permission
|
|
224
|
+
r.post(`${PREFIX}/files/:fileId/permissions`, (req, res) => {
|
|
225
|
+
const file = getStore().drive.files[req.params.fileId];
|
|
226
|
+
if (!file) {
|
|
227
|
+
return res.status(404).json({
|
|
228
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const id = generateId(8);
|
|
232
|
+
res.json({
|
|
233
|
+
kind: 'drive#permission',
|
|
234
|
+
id,
|
|
235
|
+
type: req.body.type || 'user',
|
|
236
|
+
emailAddress: req.body.emailAddress,
|
|
237
|
+
role: req.body.role || 'reader',
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// UPDATE permission
|
|
242
|
+
r.patch(`${PREFIX}/files/:fileId/permissions/:permissionId`, (req, res) => {
|
|
243
|
+
const file = getStore().drive.files[req.params.fileId];
|
|
244
|
+
if (!file) {
|
|
245
|
+
return res.status(404).json({
|
|
246
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
res.json({
|
|
250
|
+
kind: 'drive#permission',
|
|
251
|
+
id: req.params.permissionId,
|
|
252
|
+
...req.body,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// DELETE permission
|
|
257
|
+
r.delete(`${PREFIX}/files/:fileId/permissions/:permissionId`, (req, res) => {
|
|
258
|
+
const file = getStore().drive.files[req.params.fileId];
|
|
259
|
+
if (!file) {
|
|
260
|
+
return res.status(404).json({
|
|
261
|
+
error: { code: 404, message: 'File not found.', status: 'NOT_FOUND' },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
res.status(204).send();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// === Shared Drives ===
|
|
268
|
+
|
|
269
|
+
// LIST drives
|
|
270
|
+
r.get(`${PREFIX}/drives`, (_req, res) => {
|
|
271
|
+
res.json({
|
|
272
|
+
kind: 'drive#driveList',
|
|
273
|
+
drives: [],
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// CREATE drive
|
|
278
|
+
r.post(`${PREFIX}/drives`, (req, res) => {
|
|
279
|
+
const id = generateId();
|
|
280
|
+
res.json({
|
|
281
|
+
kind: 'drive#drive',
|
|
282
|
+
id,
|
|
283
|
+
name: req.body.name || 'Untitled Drive',
|
|
284
|
+
createdTime: new Date().toISOString(),
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// GET drive
|
|
289
|
+
r.get(`${PREFIX}/drives/:driveId`, (req, res) => {
|
|
290
|
+
res.status(404).json({
|
|
291
|
+
error: { code: 404, message: 'Shared drive not found.', status: 'NOT_FOUND' },
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// UPDATE drive
|
|
296
|
+
r.patch(`${PREFIX}/drives/:driveId`, (req, res) => {
|
|
297
|
+
res.status(404).json({
|
|
298
|
+
error: { code: 404, message: 'Shared drive not found.', status: 'NOT_FOUND' },
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// DELETE drive
|
|
303
|
+
r.delete(`${PREFIX}/drives/:driveId`, (req, res) => {
|
|
304
|
+
res.status(404).json({
|
|
305
|
+
error: { code: 404, message: 'Shared drive not found.', status: 'NOT_FOUND' },
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return r;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function filterDriveQuery(files: any[], q: string): any[] {
|
|
313
|
+
// Simple query parser for common Drive query patterns
|
|
314
|
+
const conditions = q.split(/\s+and\s+/i);
|
|
315
|
+
return files.filter(f => {
|
|
316
|
+
return conditions.every(cond => {
|
|
317
|
+
cond = cond.trim();
|
|
318
|
+
|
|
319
|
+
// name = 'X'
|
|
320
|
+
let match = cond.match(/^name\s*=\s*'([^']+)'$/);
|
|
321
|
+
if (match) return f.name === match[1];
|
|
322
|
+
|
|
323
|
+
// name contains 'X'
|
|
324
|
+
match = cond.match(/^name\s+contains\s+'([^']+)'$/i);
|
|
325
|
+
if (match) return f.name.toLowerCase().includes(match[1].toLowerCase());
|
|
326
|
+
|
|
327
|
+
// mimeType = 'X'
|
|
328
|
+
match = cond.match(/^mimeType\s*=\s*'([^']+)'$/);
|
|
329
|
+
if (match) return f.mimeType === match[1];
|
|
330
|
+
|
|
331
|
+
// 'parentId' in parents
|
|
332
|
+
match = cond.match(/^'([^']+)'\s+in\s+parents$/);
|
|
333
|
+
if (match) return f.parents?.includes(match[1]);
|
|
334
|
+
|
|
335
|
+
// trashed = true/false
|
|
336
|
+
match = cond.match(/^trashed\s*=\s*(true|false)$/);
|
|
337
|
+
if (match) return f.trashed === (match[1] === 'true');
|
|
338
|
+
|
|
339
|
+
return true; // unknown conditions pass through
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|