forked from JavaScriptSolidServer/JavaScriptSolidServer
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontainer.js
More file actions
348 lines (294 loc) · 12.9 KB
/
container.js
File metadata and controls
348 lines (294 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
import * as storage from '../storage/filesystem.js';
import { initializeQuota, checkQuota, updateQuotaUsage } from '../storage/quota.js';
import { getAllHeaders } from '../ldp/headers.js';
import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
import { createToken } from '../auth/token.js';
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
import { emitChange } from '../notifications/events.js';
/**
* Get the storage path and resource URL for a request
* In subdomain mode, storage path includes pod name, URL uses subdomain
*/
function getRequestPaths(request) {
const urlPath = request.url.split('?')[0];
// Storage path - includes pod name in subdomain mode
const storagePath = getEffectiveUrlPath(request);
// Resource URL - uses the actual request hostname (subdomain in subdomain mode)
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
return { urlPath, storagePath, resourceUrl };
}
/**
* Handle POST request to container (create new resource)
*/
export async function handlePost(request, reply) {
// Read-only mode - block all writes
if (request.config?.readOnly) {
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
}
const { urlPath, storagePath } = getRequestPaths(request);
// Ensure target is a container
if (!isContainer(urlPath)) {
return reply.code(405).send({ error: 'POST only allowed on containers' });
}
const connegEnabled = request.connegEnabled || false;
const contentType = request.headers['content-type'] || '';
// Check if we can accept this input type
if (!canAcceptInput(contentType, connegEnabled)) {
return reply.code(415).send({
error: 'Unsupported Media Type',
message: connegEnabled
? 'Supported types: application/ld+json, text/turtle, text/n3'
: 'Supported type: application/ld+json (enable conneg for Turtle support)'
});
}
// Check container exists
const stats = await storage.stat(storagePath);
if (!stats || !stats.isDirectory) {
// Create container if it doesn't exist
await storage.createContainer(storagePath);
}
// Get slug from header or generate UUID
const slug = request.headers.slug;
const linkHeader = request.headers.link || '';
// Security: validate Slug header
if (slug) {
// Maximum length check
if (slug.length > 255) {
return reply.code(400).send({ error: 'Slug header too long (max 255 characters)' });
}
// Character validation - allow alphanumeric, dots, dashes, underscores
if (!/^[a-zA-Z0-9._-]+$/.test(slug)) {
return reply.code(400).send({ error: 'Invalid Slug format. Use only alphanumeric characters, dots, dashes, and underscores.' });
}
}
// Check if creating a container (Link header contains ldp:Container or ldp:BasicContainer)
const isCreatingContainer = linkHeader.includes('Container') || linkHeader.includes('BasicContainer');
// Generate unique filename
const filename = await storage.generateUniqueFilename(storagePath, slug, isCreatingContainer);
const newUrlPath = urlPath + filename + (isCreatingContainer ? '/' : '');
const newStoragePath = storagePath + filename + (isCreatingContainer ? '/' : '');
const resourceUrl = `${request.protocol}://${request.hostname}${newUrlPath}`;
let success;
if (isCreatingContainer) {
success = await storage.createContainer(newStoragePath);
} else {
// Get content from request body
let content = request.body;
if (Buffer.isBuffer(content)) {
// Already a buffer
} else if (typeof content === 'string') {
content = Buffer.from(content);
} else if (content && typeof content === 'object') {
content = Buffer.from(JSON.stringify(content));
} else {
content = Buffer.from('');
}
// Convert Turtle/N3 to JSON-LD if conneg enabled
const inputType = contentType.split(';')[0].trim().toLowerCase();
if (connegEnabled && (inputType === RDF_TYPES.TURTLE || inputType === RDF_TYPES.N3)) {
try {
const jsonLd = await toJsonLd(content, contentType, resourceUrl, connegEnabled);
content = Buffer.from(JSON.stringify(jsonLd, null, 2));
} catch (e) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid Turtle/N3 format: ' + e.message
});
}
}
// Check storage quota before writing (skip in public mode - no pod structure)
const podName = request.config?.public ? null : getPodName(request);
if (podName) {
const { allowed, error } = await checkQuota(podName, content.length, request.defaultQuota || 0);
if (!allowed) {
return reply.code(507).send({ error: 'Insufficient Storage', message: error });
}
}
success = await storage.write(newStoragePath, content);
// Update quota usage after successful write
if (success && podName) {
await updateQuotaUsage(podName, content.length);
}
}
if (!success) {
return reply.code(500).send({ error: 'Create failed' });
}
const origin = request.headers.origin;
const headers = getAllHeaders({
isContainer: isCreatingContainer,
origin,
connegEnabled,
mashlibEnabled: request.mashlibEnabled
});
headers['Location'] = resourceUrl;
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
// Emit change notification for WebSocket subscribers
if (request.notificationsEnabled) {
emitChange(resourceUrl);
}
return reply.code(201).send();
}
/**
* Create pod directory structure (reusable for registration)
* @param {string} name - Pod name (username)
* @param {string} webId - User's WebID URI
* @param {string} podUri - Pod root URI (e.g., https://alice.example.com/ or https://example.com/alice/)
* @param {string} issuer - OIDC issuer URI
* @param {number} defaultQuota - Default storage quota in bytes (optional)
*/
export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0) {
const podPath = `/${name}/`;
// Create pod directory structure
// Pod settings directory
await storage.createContainer(podPath);
await storage.createContainer(`${podPath}inbox/`);
await storage.createContainer(`${podPath}public/`);
await storage.createContainer(`${podPath}private/`);
await storage.createContainer(`${podPath}settings/`);
await storage.createContainer(`${podPath}profile/`);
// Generate and write WebID profile at /profile/card.jsonld
const profile = generateProfile({ webId, name, podUri, issuer });
await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));
// Generate and write preferences
const prefs = generatePreferences({ webId, podUri });
await storage.write(`${podPath}settings/prefs.jsonld`, serialize(prefs));
// Generate and write type indexes
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`, { listed: true });
await storage.write(`${podPath}settings/publicTypeIndex.jsonld`, serialize(publicTypeIndex));
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false });
await storage.write(`${podPath}settings/privateTypeIndex.jsonld`, serialize(privateTypeIndex));
// Create default ACL files
// Pod root: owner full control, public read
const rootAcl = generateOwnerAcl(podUri, webId, true);
await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
// Private folder: owner only (no public)
const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
// settings folder: owner only (contains private preferences)
const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
// publicTypeIndex: public read, overrides the private default inherited from /settings/
const publicTypeIndexAcl = generateOwnerAcl(`${podUri}settings/publicTypeIndex.jsonld`, webId, false);
await storage.write(`${podPath}settings/publicTypeIndex.jsonld.acl`, serializeAcl(publicTypeIndexAcl));
// Inbox: owner full, public append
const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
// Public folder: owner full, public read (with inheritance)
const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
// Profile folder: owner full, public read (with inheritance)
// Profile documents must be publicly readable for WebID verification
const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId);
await storage.write(`${podPath}profile/.acl`, serializeAcl(profileAcl));
// Initialize storage quota if configured
if (defaultQuota > 0) {
await initializeQuota(name, defaultQuota);
}
return { podPath, podUri };
}
/**
* Create a pod (container) for a user
* POST /.pods with { "name": "alice" }
* With IdP enabled: { "name": "alice", "email": "alice@example.com", "password": "secret" }
*
* Creates the following structure:
* /{name}/
* /{name}/profile/card.jsonld - WebID profile
* /{name}/inbox/ - Notifications
* /{name}/public/ - Public files
* /{name}/private/ - Private files
* /{name}/settings/prefs.jsonld - Preferences
* /{name}/settings/publicTypeIndex.jsonld
* /{name}/settings/privateTypeIndex.jsonld
*/
export async function handleCreatePod(request, reply) {
// Read-only mode - block pod creation
if (request.config?.readOnly) {
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' });
}
const { name, email, password } = request.body || {};
const idpEnabled = request.idpEnabled;
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Pod name required' });
}
// If IdP is enabled, require email and password
if (idpEnabled) {
if (!email || typeof email !== 'string') {
return reply.code(400).send({ error: 'Email required for account creation' });
}
if (!password) {
return reply.code(400).send({ error: 'Password required' });
}
}
// Validate pod name (alphanumeric, dash, underscore)
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return reply.code(400).send({ error: 'Invalid pod name. Use alphanumeric, dash, or underscore only.' });
}
const podPath = `/${name}/`;
// Check if pod already exists
if (await storage.exists(podPath)) {
return reply.code(409).send({ error: 'Pod already exists' });
}
// Build URIs. WebID is the JSON-LD profile with an #me fragment.
const subdomainsEnabled = request.subdomainsEnabled;
const baseDomain = request.baseDomain;
let baseUri, podUri, webId;
if (subdomainsEnabled && baseDomain) {
// Subdomain mode: alice.example.com/profile/card.jsonld#me
const podHost = `${name}.${baseDomain}`;
baseUri = `${request.protocol}://${baseDomain}`;
podUri = `${request.protocol}://${podHost}/`;
webId = `${podUri}profile/card.jsonld#me`;
} else {
// Path mode: example.com/alice/profile/card.jsonld#me
baseUri = `${request.protocol}://${request.hostname}`;
podUri = `${baseUri}${podPath}`;
webId = `${podUri}profile/card.jsonld#me`;
}
// Issuer needs trailing slash for CTH compatibility
const issuer = baseUri + '/';
try {
// Use shared pod creation function
await createPodStructure(name, webId, podUri, issuer);
} catch (err) {
console.error('Pod creation error:', err);
// Cleanup on failure
await storage.remove(podPath);
return reply.code(500).send({ error: 'Failed to create pod' });
}
const origin = request.headers.origin;
const headers = getAllHeaders({ isContainer: true, origin });
headers['Location'] = podUri;
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
// If IdP is enabled, create account and return token + login URL
if (idpEnabled) {
try {
const { createAccount } = await import('../idp/accounts.js');
await createAccount({ username: name, email, password, webId, podName: name });
const token = createToken(webId);
return reply.code(201).send({
name,
webId,
podUri,
token,
idpIssuer: issuer,
loginUrl: `${baseUri}/idp/auth`,
});
} catch (err) {
console.error('Account creation error:', err);
// Rollback pod creation on account failure
await storage.remove(podPath);
return reply.code(409).send({ error: err.message });
}
}
// Generate token for the pod owner (simple auth mode)
const token = createToken(webId);
return reply.code(201).send({
name,
webId,
podUri,
token
});
}