microsoft-onedrive-mock 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/.ENV_EXAMPLE +16 -0
- package/.github/workflows/ci.yml +41 -0
- package/.github/workflows/release.yml +50 -0
- package/README.md +1 -0
- package/dist/batch.d.ts +2 -0
- package/dist/batch.js +93 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +115 -0
- package/dist/routes/v1.d.ts +1 -0
- package/dist/routes/v1.js +201 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +175 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.js +2 -0
- package/eslint.config.mjs +15 -0
- package/examples/device-login.ts +84 -0
- package/examples/microsoft-login.html +227 -0
- package/examples/serve-login.ts +11 -0
- package/package.json +48 -0
- package/scripts/check-token.ts +70 -0
- package/specs/odata.xml +2 -0
- package/specs/openapi.yaml +196491 -0
- package/src/batch.ts +91 -0
- package/src/index.ts +102 -0
- package/src/routes/v1.ts +227 -0
- package/src/store.ts +191 -0
- package/src/types.ts +59 -0
- package/test/basics.test.ts +119 -0
- package/test/batch.test.ts +75 -0
- package/test/config.ts +63 -0
- package/test/etag.test.ts +69 -0
- package/test/search.test.ts +57 -0
- package/test/select.test.ts +68 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +33 -0
package/src/batch.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { Request, Response } from 'express';
|
|
3
|
+
import { createApp } from './index';
|
|
4
|
+
|
|
5
|
+
export const handleBatchRequest = async (req: Request, res: Response) => {
|
|
6
|
+
try {
|
|
7
|
+
const body = req.body;
|
|
8
|
+
if (!body || !Array.isArray(body.requests)) {
|
|
9
|
+
res.status(400).json({ error: { message: "Invalid batch payload" } });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// We use an internal app instance without auth/latency middleware to process routes cleanly
|
|
14
|
+
const internalApp = createApp({ serverLagBefore: 0, serverLagAfter: 0 });
|
|
15
|
+
|
|
16
|
+
const batchResponses = await Promise.all(body.requests.map(async (part: any) => {
|
|
17
|
+
const { id, method, url, body: partBody, headers: partHeaders } = part;
|
|
18
|
+
|
|
19
|
+
// Reconstruct path. Graph batches often send relative urls like /me/drive/root
|
|
20
|
+
// We append /v1.0/ to map to our internal routes if missing
|
|
21
|
+
const requestPath = url.startsWith('/v1.0') ? url : `/v1.0${url.startsWith('/') ? '' : '/'}${url}`;
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const simulatedReq: any = {
|
|
25
|
+
method: method || 'GET',
|
|
26
|
+
url: requestPath,
|
|
27
|
+
headers: partHeaders || {},
|
|
28
|
+
body: partBody,
|
|
29
|
+
query: {}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Inject auth header from primary request to pass auth middleware
|
|
33
|
+
if (req.headers.authorization && !simulatedReq.headers.authorization) {
|
|
34
|
+
simulatedReq.headers.authorization = req.headers.authorization;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const simulatedRes: any = {
|
|
38
|
+
statusCode: 200,
|
|
39
|
+
headers: {},
|
|
40
|
+
charset: 'utf-8',
|
|
41
|
+
status: function (code: number) {
|
|
42
|
+
this.statusCode = code;
|
|
43
|
+
return this;
|
|
44
|
+
},
|
|
45
|
+
set: function (headerKey: string, headerValue: string) {
|
|
46
|
+
this.headers[headerKey] = headerValue;
|
|
47
|
+
return this;
|
|
48
|
+
},
|
|
49
|
+
setHeader: function (headerKey: string, headerValue: string) {
|
|
50
|
+
this.headers[headerKey] = headerValue;
|
|
51
|
+
return this;
|
|
52
|
+
},
|
|
53
|
+
json: function (data: any) {
|
|
54
|
+
this.data = data;
|
|
55
|
+
this.end();
|
|
56
|
+
},
|
|
57
|
+
send: function (data: any) {
|
|
58
|
+
// Normally this would be string/buffer, but let's just hold it
|
|
59
|
+
this.data = data;
|
|
60
|
+
this.end();
|
|
61
|
+
},
|
|
62
|
+
end: function () {
|
|
63
|
+
resolve({
|
|
64
|
+
id,
|
|
65
|
+
status: this.statusCode,
|
|
66
|
+
headers: this.headers,
|
|
67
|
+
body: this.data
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Bypass async handlers and directly feed to internal instance
|
|
73
|
+
internalApp(simulatedReq as Request, simulatedRes as Response, () => {
|
|
74
|
+
// Fallback next
|
|
75
|
+
resolve({
|
|
76
|
+
id,
|
|
77
|
+
status: 404,
|
|
78
|
+
body: { error: { message: "Not found within batch router" } }
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
res.json({
|
|
85
|
+
responses: batchResponses
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
} catch (error: any) {
|
|
89
|
+
res.status(500).json({ error: { message: "Internal server error during batch", details: error.message } });
|
|
90
|
+
}
|
|
91
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import express, { Request } from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { driveStore } from './store';
|
|
4
|
+
import { createV1Router } from './routes/v1';
|
|
5
|
+
import { handleBatchRequest } from './batch';
|
|
6
|
+
import { AppConfig } from './types';
|
|
7
|
+
|
|
8
|
+
export * from './types';
|
|
9
|
+
|
|
10
|
+
const createApp = (config: AppConfig = {}) => {
|
|
11
|
+
if (!config.apiEndpoint) {
|
|
12
|
+
config.apiEndpoint = "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const app = express();
|
|
16
|
+
app.use(cors({
|
|
17
|
+
// For downloads Microsoft uses specific headers sometimes, expose ETag
|
|
18
|
+
exposedHeaders: ['ETag', 'Date', 'Content-Length', 'Location']
|
|
19
|
+
}));
|
|
20
|
+
app.set('etag', false);
|
|
21
|
+
|
|
22
|
+
// Latency simulator
|
|
23
|
+
app.use(async (req, res, next) => {
|
|
24
|
+
const delay = Math.floor(Math.random() * 21);
|
|
25
|
+
if (delay > 0) {
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
27
|
+
}
|
|
28
|
+
next();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
app.use(express.json({
|
|
32
|
+
verify: (req: Request, res, buf) => {
|
|
33
|
+
req.rawBody = buf;
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
app.use(express.text({
|
|
37
|
+
type: ['multipart/mixed', 'multipart/related', 'text/*', 'application/xml', 'application/octet-stream'],
|
|
38
|
+
verify: (req: Request, res, buf) => {
|
|
39
|
+
req.rawBody = buf;
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Explicit raw body for binary uploads
|
|
44
|
+
app.use(express.raw({
|
|
45
|
+
type: '*/*',
|
|
46
|
+
limit: '50mb',
|
|
47
|
+
verify: (req: Request, res, buf) => {
|
|
48
|
+
req.rawBody = buf;
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Batch Route
|
|
53
|
+
app.post('/v1.0/$batch', handleBatchRequest);
|
|
54
|
+
|
|
55
|
+
// Debug
|
|
56
|
+
app.post('/debug/clear', (req, res) => {
|
|
57
|
+
driveStore.clear();
|
|
58
|
+
res.status(200).send('Cleared');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Health Check
|
|
62
|
+
app.get('/', (req, res) => {
|
|
63
|
+
res.status(200).send('OK');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Auth Middleware
|
|
67
|
+
const validTokens = ['valid-token', 'another-valid-token'];
|
|
68
|
+
app.use((req, res, next) => {
|
|
69
|
+
const authHeaderVal = req.headers.authorization;
|
|
70
|
+
const authHeader = Array.isArray(authHeaderVal) ? authHeaderVal[0] : authHeaderVal;
|
|
71
|
+
|
|
72
|
+
if (!authHeader) {
|
|
73
|
+
res.status(401).json({ error: { code: "unauthenticated", message: "Unauthorized: No token provided" } });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const token = authHeader.split(' ')[1];
|
|
78
|
+
if (!validTokens.includes(token)) {
|
|
79
|
+
res.status(401).json({ error: { code: "unauthenticated", message: "Unauthorized: Invalid token" } });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
next();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.use(createV1Router());
|
|
86
|
+
|
|
87
|
+
return app;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const startServer = (port: number, host: string = 'localhost', config: AppConfig = {}) => {
|
|
91
|
+
const app = createApp(config);
|
|
92
|
+
return app.listen(port, host, () => {
|
|
93
|
+
console.log(`Server is running on http://${host}:${port}`);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (require.main === module) {
|
|
98
|
+
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3006;
|
|
99
|
+
startServer(port);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { createApp, startServer, driveStore };
|
package/src/routes/v1.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import express, { Request, Response } from 'express';
|
|
2
|
+
import { driveStore } from '../store';
|
|
3
|
+
|
|
4
|
+
export const createV1Router = () => {
|
|
5
|
+
const app = express.Router();
|
|
6
|
+
|
|
7
|
+
// Helper to apply ?$select=id,name
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
const applySelect = (item: any, selectQuery?: string | string[] | qs.ParsedQs | qs.ParsedQs[]) => {
|
|
10
|
+
if (!selectQuery || typeof selectQuery !== 'string') return item;
|
|
11
|
+
const fields = selectQuery.split(',').map(f => f.trim());
|
|
12
|
+
if (fields.length === 0) return item;
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
const result: any = {};
|
|
16
|
+
for (const field of fields) {
|
|
17
|
+
if (item[field] !== undefined) {
|
|
18
|
+
result[field] = item[field];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// GET /me/drive/root
|
|
25
|
+
app.get('/v1.0/me/drive/root', (req: Request, res: Response) => {
|
|
26
|
+
const root = driveStore.getItem('root');
|
|
27
|
+
if (!root) return res.status(404).json({ error: { message: "Root not found" } });
|
|
28
|
+
res.json(applySelect(root, req.query.$select as string));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// GET /me/drive/items/{id}
|
|
32
|
+
app.get('/v1.0/me/drive/items/:itemId', (req: Request, res: Response) => {
|
|
33
|
+
let itemId = req.params.itemId as string;
|
|
34
|
+
if (itemId === 'root') itemId = 'root'; // Already handled if just mapping
|
|
35
|
+
|
|
36
|
+
const item = driveStore.getItem(itemId);
|
|
37
|
+
if (!item) {
|
|
38
|
+
res.status(404).json({ error: { code: "itemNotFound", message: "Item not found" } });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.json(applySelect(item, req.query.$select as string));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// GET /me/drive/items/{id}/children
|
|
45
|
+
app.get('/v1.0/me/drive/items/:itemId/children', (req: Request, res: Response) => {
|
|
46
|
+
const itemId = req.params.itemId as string;
|
|
47
|
+
const children = driveStore.listItems(itemId);
|
|
48
|
+
const mappedChildren = children.map(c => applySelect(c, req.query.$select as string));
|
|
49
|
+
res.json({ value: mappedChildren });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// GET /me/drive/root/search(q='query')
|
|
53
|
+
app.get('/v1.0/me/drive/root/search\\(q=\':query\'\\)', (req: Request, res: Response) => {
|
|
54
|
+
let query = (req.params.query as string) || "";
|
|
55
|
+
// Decode URI component incase it's URL encoded like %20 or %27
|
|
56
|
+
query = decodeURIComponent(query);
|
|
57
|
+
// Strip out the trailing quote matching if it caught the literal '
|
|
58
|
+
if (query.endsWith("'")) query = query.slice(0, -1);
|
|
59
|
+
query = query.toLowerCase();
|
|
60
|
+
|
|
61
|
+
// recursively find items
|
|
62
|
+
const allItems = driveStore.getAllItems();
|
|
63
|
+
const results = allItems.filter(item => item.name.toLowerCase().includes(query) && item.id !== 'root');
|
|
64
|
+
|
|
65
|
+
const mappedResults = results.map(c => applySelect(c, req.query.$select as string));
|
|
66
|
+
res.json({ value: mappedResults });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// POST /me/drive/items/{parent-id}/children (Create Metadata / Folder)
|
|
70
|
+
app.post('/v1.0/me/drive/items/:itemId/children', (req: Request, res: Response) => {
|
|
71
|
+
const parentId = req.params.itemId as string;
|
|
72
|
+
const body = req.body || {};
|
|
73
|
+
|
|
74
|
+
let parentItem = driveStore.getItem(parentId);
|
|
75
|
+
if (!parentItem && parentId === 'root') {
|
|
76
|
+
parentItem = driveStore.createItem({ id: 'root', name: 'root' }, true);
|
|
77
|
+
} else if (!parentItem) {
|
|
78
|
+
res.status(404).json({ error: { code: "itemNotFound", message: "Parent not found" } });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isFolder = !!body.folder;
|
|
83
|
+
const newItem = driveStore.createItem({
|
|
84
|
+
name: body.name || "Untitled",
|
|
85
|
+
parentReference: { id: parentId },
|
|
86
|
+
...body
|
|
87
|
+
}, isFolder);
|
|
88
|
+
|
|
89
|
+
res.status(201).json(applySelect(newItem, req.query.$select as string));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// DELETE /me/drive/items/{id}
|
|
93
|
+
app.delete('/v1.0/me/drive/items/:itemId', (req: Request, res: Response) => {
|
|
94
|
+
const fileId = req.params.itemId as string;
|
|
95
|
+
if (fileId === 'root') {
|
|
96
|
+
res.status(400).json({ error: { message: "Cannot delete root" } });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const item = driveStore.getItem(fileId);
|
|
101
|
+
if (!item) {
|
|
102
|
+
res.status(404).json({ error: { code: "itemNotFound", message: "Item not found" } });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ifMatch = req.header('If-Match');
|
|
107
|
+
if (ifMatch && ifMatch !== item.eTag) {
|
|
108
|
+
res.status(412).json({ error: { code: "PreconditionFailed", message: "ETag mismatch" } });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
driveStore.deleteItem(fileId);
|
|
113
|
+
res.status(204).send();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// GET /me/drive/items/{id}/content
|
|
117
|
+
app.get('/v1.0/me/drive/items/:itemId/content', (req: Request, res: Response) => {
|
|
118
|
+
const fileId = req.params.itemId as string;
|
|
119
|
+
const file = driveStore.getItem(fileId);
|
|
120
|
+
|
|
121
|
+
if (!file || file.folder) {
|
|
122
|
+
res.status(404).json({ error: { code: "itemNotFound", message: "File not found" } });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (file.file?.mimeType) {
|
|
127
|
+
res.setHeader('Content-Type', file.file.mimeType);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (file.content === undefined) {
|
|
131
|
+
res.send("");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (Buffer.isBuffer(file.content)) {
|
|
136
|
+
res.send(file.content);
|
|
137
|
+
} else if (typeof file.content === 'object') {
|
|
138
|
+
res.json(file.content);
|
|
139
|
+
} else {
|
|
140
|
+
res.send(file.content);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// PUT /me/drive/items/{parent-id}:/{filename}:/content
|
|
145
|
+
app.put('/v1.0/me/drive/items/:parentId\\:/:filename\\:/content', (req: Request, res: Response) => {
|
|
146
|
+
const parentId = req.params.parentId as string;
|
|
147
|
+
const filename = req.params.filename as string;
|
|
148
|
+
|
|
149
|
+
// Find existing or create
|
|
150
|
+
const children = driveStore.listItems(parentId);
|
|
151
|
+
let item = children.find(c => c.name === filename);
|
|
152
|
+
|
|
153
|
+
const content = req.rawBody !== undefined ? req.rawBody : req.body;
|
|
154
|
+
const headerMime = req.headers['content-type'];
|
|
155
|
+
const mimeType = (Array.isArray(headerMime) ? headerMime[0] : headerMime) || 'application/octet-stream';
|
|
156
|
+
|
|
157
|
+
const isNew = !item;
|
|
158
|
+
|
|
159
|
+
if (item) {
|
|
160
|
+
const ifMatch = req.header('If-Match');
|
|
161
|
+
if (ifMatch && ifMatch !== item.eTag) {
|
|
162
|
+
res.status(412).json({ error: { code: "PreconditionFailed", message: "ETag mismatch" } });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Update
|
|
167
|
+
item = driveStore.updateItem(item.id, { content, file: { mimeType } })!;
|
|
168
|
+
} else {
|
|
169
|
+
// Create
|
|
170
|
+
item = driveStore.createItem({
|
|
171
|
+
name: filename,
|
|
172
|
+
parentReference: { id: parentId },
|
|
173
|
+
content,
|
|
174
|
+
file: { mimeType }
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
res.status(isNew ? 201 : 200).json(applySelect(item, req.query.$select as string));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// PUT /me/drive/items/{id}/content
|
|
182
|
+
app.put('/v1.0/me/drive/items/:itemId/content', (req: Request, res: Response) => {
|
|
183
|
+
const itemId = req.params.itemId as string;
|
|
184
|
+
let item = driveStore.getItem(itemId);
|
|
185
|
+
|
|
186
|
+
if (!item || item.folder) {
|
|
187
|
+
res.status(404).json({ error: { code: "itemNotFound", message: "Item not found" } });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const ifMatch = req.header('If-Match');
|
|
192
|
+
if (ifMatch && ifMatch !== item.eTag) {
|
|
193
|
+
res.status(412).json({ error: { code: "PreconditionFailed", message: "ETag mismatch" } });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const content = req.rawBody !== undefined ? req.rawBody : req.body;
|
|
198
|
+
const headerMime = req.headers['content-type'];
|
|
199
|
+
const mimeType = (Array.isArray(headerMime) ? headerMime[0] : headerMime) || item.file?.mimeType || 'application/octet-stream';
|
|
200
|
+
|
|
201
|
+
item = driveStore.updateItem(item.id, { content, file: { mimeType } })!;
|
|
202
|
+
res.status(200).json(applySelect(item, req.query.$select as string));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Delta Query
|
|
206
|
+
// GET /me/drive/root/delta
|
|
207
|
+
app.get('/v1.0/me/drive/root/delta', (req: Request, res: Response) => {
|
|
208
|
+
const tokenStr = req.query.token as string;
|
|
209
|
+
|
|
210
|
+
let token: string | undefined = undefined;
|
|
211
|
+
if (tokenStr) token = tokenStr;
|
|
212
|
+
|
|
213
|
+
const result = driveStore.getDelta(token);
|
|
214
|
+
|
|
215
|
+
const host = req.headers.host || 'localhost';
|
|
216
|
+
const protocol = req.protocol || 'http';
|
|
217
|
+
const baseUrl = `${protocol}://${host}`;
|
|
218
|
+
|
|
219
|
+
res.json({
|
|
220
|
+
'@odata.context': `${baseUrl}/v1.0/$metadata#Collection(driveItem)`,
|
|
221
|
+
'@odata.deltaLink': `${baseUrl}/v1.0/me/drive/root/delta?token=${result.deltaLink}`,
|
|
222
|
+
value: result.items
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return app;
|
|
227
|
+
};
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { DriveItem } from './types';
|
|
3
|
+
|
|
4
|
+
export class DriveStore {
|
|
5
|
+
private items: Map<string, DriveItem>;
|
|
6
|
+
// To support delta we need a linear history of items created/updated/deleted
|
|
7
|
+
private deltaHistory: DriveItem[];
|
|
8
|
+
|
|
9
|
+
constructor() {
|
|
10
|
+
this.items = new Map();
|
|
11
|
+
this.deltaHistory = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private calculateStats(content: unknown): { size: number, sha1Hash: string } {
|
|
15
|
+
let buffer: Buffer;
|
|
16
|
+
if (typeof content === 'string') {
|
|
17
|
+
buffer = Buffer.from(content);
|
|
18
|
+
} else if (Buffer.isBuffer(content)) {
|
|
19
|
+
buffer = content;
|
|
20
|
+
} else if (content === undefined || content === null) {
|
|
21
|
+
buffer = Buffer.from('');
|
|
22
|
+
} else {
|
|
23
|
+
buffer = Buffer.from(JSON.stringify(content));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
size: buffer.length,
|
|
28
|
+
sha1Hash: crypto.createHash('sha1').update(buffer).digest('hex')
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
createItem(item: Partial<DriveItem> & { name: string }, isFolder = false): DriveItem {
|
|
33
|
+
if (!item.name) {
|
|
34
|
+
throw new Error("Item name is required");
|
|
35
|
+
}
|
|
36
|
+
const id = item.id || Math.random().toString(36).substring(7);
|
|
37
|
+
const now = new Date().toISOString();
|
|
38
|
+
|
|
39
|
+
const newItem: DriveItem = {
|
|
40
|
+
createdDateTime: now,
|
|
41
|
+
lastModifiedDateTime: now,
|
|
42
|
+
...item,
|
|
43
|
+
id,
|
|
44
|
+
name: item.name,
|
|
45
|
+
eTag: `W/"1"`,
|
|
46
|
+
cTag: `"cTag-1"`,
|
|
47
|
+
size: 0,
|
|
48
|
+
parentReference: item.parentReference || { driveId: "b!", id: "root" }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (isFolder) {
|
|
52
|
+
newItem.folder = { childCount: 0 };
|
|
53
|
+
} else {
|
|
54
|
+
const stats = this.calculateStats(item.content);
|
|
55
|
+
newItem.size = stats.size;
|
|
56
|
+
newItem.file = {
|
|
57
|
+
mimeType: item.file?.mimeType || "application/octet-stream",
|
|
58
|
+
hashes: { sha1Hash: stats.sha1Hash }
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.items.set(id, newItem);
|
|
63
|
+
this.addDeltaHistory(newItem);
|
|
64
|
+
|
|
65
|
+
// Update parent childCount if making a folder
|
|
66
|
+
if (newItem.parentReference && newItem.parentReference.id) {
|
|
67
|
+
this.incrementParentChildCount(newItem.parentReference.id, 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return newItem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
updateItem(id: string, updates: Partial<DriveItem>): DriveItem | null {
|
|
74
|
+
const item = this.items.get(id);
|
|
75
|
+
if (!item) return null;
|
|
76
|
+
|
|
77
|
+
// Extract internal version number from etag to increment
|
|
78
|
+
const currentVersion = parseInt(item.eTag.replace(/\D/g, '') || "1", 10);
|
|
79
|
+
const newVersion = currentVersion + 1;
|
|
80
|
+
|
|
81
|
+
const statsUpdates: Record<string, unknown> = {};
|
|
82
|
+
if (updates.content !== undefined && item.file) {
|
|
83
|
+
const stats = this.calculateStats(updates.content);
|
|
84
|
+
statsUpdates.size = stats.size;
|
|
85
|
+
statsUpdates.file = {
|
|
86
|
+
...item.file,
|
|
87
|
+
hashes: { sha1Hash: stats.sha1Hash }
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const updatedItem: DriveItem = {
|
|
92
|
+
...item,
|
|
93
|
+
...updates,
|
|
94
|
+
...statsUpdates,
|
|
95
|
+
eTag: `W/"${newVersion}"`,
|
|
96
|
+
cTag: `"cTag-${newVersion}"`,
|
|
97
|
+
lastModifiedDateTime: updates.lastModifiedDateTime || new Date().toISOString()
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.items.set(id, updatedItem);
|
|
101
|
+
this.addDeltaHistory(updatedItem);
|
|
102
|
+
return updatedItem;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getItem(id: string): DriveItem | null {
|
|
106
|
+
return this.items.get(id) || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
deleteItem(id: string): boolean {
|
|
110
|
+
const item = this.items.get(id);
|
|
111
|
+
if (!item) return false;
|
|
112
|
+
|
|
113
|
+
const deleted = this.items.delete(id);
|
|
114
|
+
if (deleted) {
|
|
115
|
+
const deletedItem = {
|
|
116
|
+
...item,
|
|
117
|
+
deleted: { state: "deleted" },
|
|
118
|
+
lastModifiedDateTime: new Date().toISOString()
|
|
119
|
+
};
|
|
120
|
+
this.addDeltaHistory(deletedItem);
|
|
121
|
+
|
|
122
|
+
// Decrement parent count
|
|
123
|
+
if (item.parentReference && item.parentReference.id) {
|
|
124
|
+
this.incrementParentChildCount(item.parentReference.id, -1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return deleted;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
listItems(parentId?: string): DriveItem[] {
|
|
131
|
+
const allItems = Array.from(this.items.values());
|
|
132
|
+
if (!parentId) return allItems;
|
|
133
|
+
|
|
134
|
+
return allItems.filter(i => i.parentReference?.id === parentId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getAllItems(): DriveItem[] {
|
|
138
|
+
return Array.from(this.items.values());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
clear(): void {
|
|
142
|
+
this.items.clear();
|
|
143
|
+
this.deltaHistory = [];
|
|
144
|
+
|
|
145
|
+
// Always recreate a standard root folder
|
|
146
|
+
this.createItem({ id: 'root', name: 'root' }, true);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Delta History (simulated changes API)
|
|
150
|
+
private addDeltaHistory(item: DriveItem) {
|
|
151
|
+
this.deltaHistory.push(JSON.parse(JSON.stringify(item)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getDeltaToken(): string {
|
|
155
|
+
return String(this.deltaHistory.length);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getDelta(token?: string): { items: DriveItem[], deltaLink: string } {
|
|
159
|
+
const tokenIndex = token ? parseInt(token, 10) : 0;
|
|
160
|
+
const start = isNaN(tokenIndex) ? 0 : Math.max(0, tokenIndex);
|
|
161
|
+
|
|
162
|
+
const items = this.deltaHistory.slice(start);
|
|
163
|
+
|
|
164
|
+
// In MS Graph, if a file transitions multiple states, delta should ideally just return the latest state
|
|
165
|
+
// but for a mock, returning the log or deduping by id to latest state is standard.
|
|
166
|
+
// Let's dedupe to match real API behavior mostly (returns latest state within the page)
|
|
167
|
+
const dedupedMap = new Map<string, DriveItem>();
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
dedupedMap.set(item.id, item);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const dedupedItems = Array.from(dedupedMap.values());
|
|
173
|
+
const newToken = String(this.deltaHistory.length);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
items: dedupedItems,
|
|
177
|
+
deltaLink: newToken
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private incrementParentChildCount(parentId: string, amount: number) {
|
|
182
|
+
const parent = this.items.get(parentId);
|
|
183
|
+
if (parent && parent.folder) {
|
|
184
|
+
parent.folder.childCount = Math.max(0, parent.folder.childCount + amount);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const driveStore = new DriveStore();
|
|
190
|
+
// Initialize root on boot
|
|
191
|
+
driveStore.clear();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface AppConfig {
|
|
2
|
+
apiEndpoint?: string;
|
|
3
|
+
serverLagBefore?: number;
|
|
4
|
+
serverLagAfter?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
9
|
+
namespace Express {
|
|
10
|
+
interface Request {
|
|
11
|
+
rawBody?: Buffer | string;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Basic representation of a Microsoft Graph DriveItem
|
|
17
|
+
export interface DriveItem {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
eTag: string;
|
|
21
|
+
cTag: string;
|
|
22
|
+
createdBy?: { user: { displayName: string } };
|
|
23
|
+
lastModifiedBy?: { user: { displayName: string } };
|
|
24
|
+
createdDateTime: string;
|
|
25
|
+
lastModifiedDateTime: string;
|
|
26
|
+
size: number;
|
|
27
|
+
parentReference?: {
|
|
28
|
+
driveId?: string;
|
|
29
|
+
driveType?: string;
|
|
30
|
+
id?: string;
|
|
31
|
+
path?: string;
|
|
32
|
+
};
|
|
33
|
+
file?: {
|
|
34
|
+
mimeType: string;
|
|
35
|
+
hashes?: {
|
|
36
|
+
quickXorHash?: string;
|
|
37
|
+
sha1Hash?: string;
|
|
38
|
+
sha256Hash?: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
folder?: {
|
|
42
|
+
childCount: number;
|
|
43
|
+
};
|
|
44
|
+
deleted?: {
|
|
45
|
+
state: string;
|
|
46
|
+
};
|
|
47
|
+
'@microsoft.graph.downloadUrl'?: string;
|
|
48
|
+
|
|
49
|
+
// Internal usage for mock state
|
|
50
|
+
content?: unknown;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DeltaResponse {
|
|
55
|
+
'@odata.context': string;
|
|
56
|
+
'@odata.nextLink'?: string;
|
|
57
|
+
'@odata.deltaLink'?: string;
|
|
58
|
+
value: DriveItem[];
|
|
59
|
+
}
|