javascript-solid-server 0.0.55 → 0.0.57
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 +6 -1
- package/README.md +94 -3
- package/bin/jss.js +191 -0
- package/package.json +1 -1
- package/src/config.js +30 -0
- package/src/handlers/container.js +23 -2
- package/src/handlers/resource.js +28 -1
- package/src/idp/index.js +3 -3
- package/src/idp/interactions.js +22 -14
- package/src/idp/invites.js +181 -0
- package/src/idp/views.js +11 -3
- package/src/server.js +7 -1
- package/src/storage/quota.js +202 -0
- package/src/utils/url.js +37 -0
|
@@ -202,7 +202,12 @@
|
|
|
202
202
|
"Bash(webledgers show:*)",
|
|
203
203
|
"Bash(webledgers set-balance:*)",
|
|
204
204
|
"Bash(ssh melvincarvalho.com \"pm2 list | grep jss\")",
|
|
205
|
-
"Bash(ssh melvincarvalho.com \"cd /home/ubuntu/jss && git pull && pm2 restart jss\")"
|
|
205
|
+
"Bash(ssh melvincarvalho.com \"cd /home/ubuntu/jss && git pull && pm2 restart jss\")",
|
|
206
|
+
"WebFetch(domain:registry.npmjs.org)",
|
|
207
|
+
"WebFetch(domain:solid-chat.com)",
|
|
208
|
+
"WebFetch(domain:developer.chrome.com)",
|
|
209
|
+
"WebFetch(domain:css-tricks.com)",
|
|
210
|
+
"Bash(node bin/jss.js:*)"
|
|
206
211
|
]
|
|
207
212
|
}
|
|
208
213
|
}
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
### Implemented (v0.0.
|
|
9
|
+
### Implemented (v0.0.57)
|
|
10
10
|
|
|
11
11
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
12
12
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -29,6 +29,8 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
29
29
|
- **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
|
|
30
30
|
- **CORS Support** - Full cross-origin resource sharing
|
|
31
31
|
- **Git HTTP Backend** - Clone and push to containers via `git` protocol
|
|
32
|
+
- **Invite-Only Registration** - CLI-managed invite codes for controlled signups
|
|
33
|
+
- **Storage Quotas** - Per-user storage limits with CLI management
|
|
32
34
|
- **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
|
|
33
35
|
|
|
34
36
|
### HTTP Methods
|
|
@@ -76,6 +78,8 @@ jss start --port 8443 --ssl-key ./key.pem --ssl-cert ./cert.pem
|
|
|
76
78
|
```bash
|
|
77
79
|
jss start [options] # Start the server
|
|
78
80
|
jss init [options] # Initialize configuration
|
|
81
|
+
jss invite <cmd> # Manage invite codes (create, list, revoke)
|
|
82
|
+
jss quota <cmd> # Manage storage quotas (set, show, reconcile)
|
|
79
83
|
jss --help # Show help
|
|
80
84
|
```
|
|
81
85
|
|
|
@@ -99,6 +103,8 @@ jss --help # Show help
|
|
|
99
103
|
| `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
|
|
100
104
|
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
|
|
101
105
|
| `--git` | Enable Git HTTP backend | false |
|
|
106
|
+
| `--invite-only` | Require invite code for registration | false |
|
|
107
|
+
| `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
|
|
102
108
|
| `-q, --quiet` | Suppress logs | false |
|
|
103
109
|
|
|
104
110
|
### Environment Variables
|
|
@@ -113,6 +119,8 @@ export JSS_CONNEG=true
|
|
|
113
119
|
export JSS_SUBDOMAINS=true
|
|
114
120
|
export JSS_BASE_DOMAIN=example.com
|
|
115
121
|
export JSS_MASHLIB=true
|
|
122
|
+
export JSS_INVITE_ONLY=true
|
|
123
|
+
export JSS_DEFAULT_QUOTA=100MB
|
|
116
124
|
jss start
|
|
117
125
|
```
|
|
118
126
|
|
|
@@ -379,6 +387,87 @@ git add .acl && git commit -m "Add ACL"
|
|
|
379
387
|
|
|
380
388
|
See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
|
|
381
389
|
|
|
390
|
+
## Invite-Only Registration
|
|
391
|
+
|
|
392
|
+
Control who can create accounts by requiring invite codes:
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
jss start --idp --invite-only
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Managing Invite Codes
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
# Create a single-use invite
|
|
402
|
+
jss invite create
|
|
403
|
+
# Created invite code: ABCD1234
|
|
404
|
+
|
|
405
|
+
# Create multi-use invite with note
|
|
406
|
+
jss invite create -u 5 -n "For team members"
|
|
407
|
+
|
|
408
|
+
# List all active invites
|
|
409
|
+
jss invite list
|
|
410
|
+
# CODE USES CREATED NOTE
|
|
411
|
+
# -------------------------------------------------------
|
|
412
|
+
# ABCD1234 0/1 2026-01-03
|
|
413
|
+
# EFGH5678 2/5 2026-01-03 For team members
|
|
414
|
+
|
|
415
|
+
# Revoke an invite
|
|
416
|
+
jss invite revoke ABCD1234
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### How It Works
|
|
420
|
+
|
|
421
|
+
| Mode | Registration | Pod Creation |
|
|
422
|
+
|------|--------------|--------------|
|
|
423
|
+
| Open (default) | Anyone can register | Anyone can create pods |
|
|
424
|
+
| Invite-only | Requires valid invite code | Via registration only |
|
|
425
|
+
|
|
426
|
+
When `--invite-only` is enabled:
|
|
427
|
+
- The registration page shows an "Invite Code" field
|
|
428
|
+
- Invalid or expired codes are rejected with an error
|
|
429
|
+
- Each use decrements the invite's remaining uses
|
|
430
|
+
- Depleted invites are automatically removed
|
|
431
|
+
|
|
432
|
+
Invite codes are stored in `.server/invites.json` in your data directory.
|
|
433
|
+
|
|
434
|
+
## Storage Quotas
|
|
435
|
+
|
|
436
|
+
Limit storage per pod to prevent abuse and manage resources:
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
jss start --default-quota 50MB
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Managing Quotas
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
# Set quota for a user (overrides default)
|
|
446
|
+
jss quota set alice 100MB
|
|
447
|
+
|
|
448
|
+
# Show quota info
|
|
449
|
+
jss quota show alice
|
|
450
|
+
# alice:
|
|
451
|
+
# Used: 12.5 MB
|
|
452
|
+
# Limit: 100 MB
|
|
453
|
+
# Free: 87.5 MB
|
|
454
|
+
# Usage: 12%
|
|
455
|
+
|
|
456
|
+
# Recalculate from actual disk usage
|
|
457
|
+
jss quota reconcile alice
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### How It Works
|
|
461
|
+
|
|
462
|
+
- Quotas are tracked incrementally on PUT, POST, and DELETE operations
|
|
463
|
+
- When quota is exceeded, the server returns HTTP 507 Insufficient Storage
|
|
464
|
+
- Each pod stores its quota in `/{pod}/.quota.json`
|
|
465
|
+
- Use `reconcile` to fix quota drift from manual file changes
|
|
466
|
+
|
|
467
|
+
### Size Formats
|
|
468
|
+
|
|
469
|
+
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
|
470
|
+
|
|
382
471
|
## Authentication
|
|
383
472
|
|
|
384
473
|
### Simple Tokens (Development)
|
|
@@ -640,7 +729,8 @@ src/
|
|
|
640
729
|
│ ├── container.js # POST, pod creation
|
|
641
730
|
│ └── git.js # Git HTTP backend
|
|
642
731
|
├── storage/
|
|
643
|
-
│
|
|
732
|
+
│ ├── filesystem.js # File operations
|
|
733
|
+
│ └── quota.js # Storage quota management
|
|
644
734
|
├── auth/
|
|
645
735
|
│ ├── middleware.js # Auth hook
|
|
646
736
|
│ ├── token.js # Simple token auth
|
|
@@ -668,7 +758,8 @@ src/
|
|
|
668
758
|
│ ├── accounts.js # User account management
|
|
669
759
|
│ ├── keys.js # JWKS key management
|
|
670
760
|
│ ├── interactions.js # Login/consent handlers
|
|
671
|
-
│
|
|
761
|
+
│ ├── views.js # HTML templates
|
|
762
|
+
│ └── invites.js # Invite code management
|
|
672
763
|
├── rdf/
|
|
673
764
|
│ ├── turtle.js # Turtle <-> JSON-LD
|
|
674
765
|
│ └── conneg.js # Content negotiation
|
package/bin/jss.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
import { Command } from 'commander';
|
|
12
12
|
import { createServer } from '../src/server.js';
|
|
13
13
|
import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js';
|
|
14
|
+
import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
|
|
15
|
+
import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
|
|
16
|
+
import { parseSize } from '../src/config.js';
|
|
14
17
|
import fs from 'fs-extra';
|
|
15
18
|
import path from 'path';
|
|
16
19
|
import { fileURLToPath } from 'url';
|
|
@@ -56,6 +59,8 @@ program
|
|
|
56
59
|
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
|
|
57
60
|
.option('--git', 'Enable Git HTTP backend (clone/push support)')
|
|
58
61
|
.option('--no-git', 'Disable Git HTTP backend')
|
|
62
|
+
.option('--invite-only', 'Require invite code for registration')
|
|
63
|
+
.option('--no-invite-only', 'Allow open registration')
|
|
59
64
|
.option('-q, --quiet', 'Suppress log output')
|
|
60
65
|
.option('--print-config', 'Print configuration and exit')
|
|
61
66
|
.action(async (options) => {
|
|
@@ -98,6 +103,7 @@ program
|
|
|
98
103
|
mashlibCdn: config.mashlibCdn,
|
|
99
104
|
mashlibVersion: config.mashlibVersion,
|
|
100
105
|
git: config.git,
|
|
106
|
+
inviteOnly: config.inviteOnly,
|
|
101
107
|
});
|
|
102
108
|
|
|
103
109
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -117,6 +123,7 @@ program
|
|
|
117
123
|
console.log(` Mashlib: local (data browser enabled)`);
|
|
118
124
|
}
|
|
119
125
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
126
|
+
if (config.inviteOnly) console.log(' Registration: invite-only');
|
|
120
127
|
console.log('\n Press Ctrl+C to stop\n');
|
|
121
128
|
}
|
|
122
129
|
|
|
@@ -204,6 +211,190 @@ program
|
|
|
204
211
|
console.log('\nRun `jss start` to start the server.\n');
|
|
205
212
|
});
|
|
206
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Invite command - manage invite codes
|
|
216
|
+
*/
|
|
217
|
+
const inviteCmd = program
|
|
218
|
+
.command('invite')
|
|
219
|
+
.description('Manage invite codes for registration');
|
|
220
|
+
|
|
221
|
+
inviteCmd
|
|
222
|
+
.command('create')
|
|
223
|
+
.description('Create a new invite code')
|
|
224
|
+
.option('-u, --uses <number>', 'Maximum uses (default: 1)', parseInt, 1)
|
|
225
|
+
.option('-n, --note <text>', 'Optional note/description')
|
|
226
|
+
.option('-r, --root <path>', 'Data directory')
|
|
227
|
+
.action(async (options) => {
|
|
228
|
+
try {
|
|
229
|
+
// Set DATA_ROOT if provided
|
|
230
|
+
if (options.root) {
|
|
231
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const { code, invite } = await createInvite({
|
|
235
|
+
maxUses: options.uses,
|
|
236
|
+
note: options.note || ''
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
console.log(`\nCreated invite code: ${code}`);
|
|
240
|
+
if (invite.maxUses > 1) {
|
|
241
|
+
console.log(`Uses: 0/${invite.maxUses}`);
|
|
242
|
+
}
|
|
243
|
+
if (invite.note) {
|
|
244
|
+
console.log(`Note: ${invite.note}`);
|
|
245
|
+
}
|
|
246
|
+
console.log('');
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error(`Error: ${err.message}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
inviteCmd
|
|
254
|
+
.command('list')
|
|
255
|
+
.description('List all invite codes')
|
|
256
|
+
.option('-r, --root <path>', 'Data directory')
|
|
257
|
+
.action(async (options) => {
|
|
258
|
+
try {
|
|
259
|
+
// Set DATA_ROOT if provided
|
|
260
|
+
if (options.root) {
|
|
261
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const invites = await listInvites();
|
|
265
|
+
|
|
266
|
+
if (invites.length === 0) {
|
|
267
|
+
console.log('\nNo invite codes found.\n');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log('\n CODE USES CREATED NOTE');
|
|
272
|
+
console.log(' ' + '-'.repeat(55));
|
|
273
|
+
|
|
274
|
+
for (const invite of invites) {
|
|
275
|
+
const uses = `${invite.uses}/${invite.maxUses}`.padEnd(8);
|
|
276
|
+
const created = invite.created.split('T')[0];
|
|
277
|
+
const note = invite.note || '';
|
|
278
|
+
console.log(` ${invite.code} ${uses} ${created} ${note}`);
|
|
279
|
+
}
|
|
280
|
+
console.log('');
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(`Error: ${err.message}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
inviteCmd
|
|
288
|
+
.command('revoke <code>')
|
|
289
|
+
.description('Revoke an invite code')
|
|
290
|
+
.option('-r, --root <path>', 'Data directory')
|
|
291
|
+
.action(async (code, options) => {
|
|
292
|
+
try {
|
|
293
|
+
// Set DATA_ROOT if provided
|
|
294
|
+
if (options.root) {
|
|
295
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const success = await revokeInvite(code);
|
|
299
|
+
|
|
300
|
+
if (success) {
|
|
301
|
+
console.log(`\nRevoked invite code: ${code.toUpperCase()}\n`);
|
|
302
|
+
} else {
|
|
303
|
+
console.log(`\nInvite code not found: ${code.toUpperCase()}\n`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error(`Error: ${err.message}`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Quota command - manage storage quotas
|
|
314
|
+
*/
|
|
315
|
+
const quotaCmd = program
|
|
316
|
+
.command('quota')
|
|
317
|
+
.description('Manage storage quotas for pods');
|
|
318
|
+
|
|
319
|
+
quotaCmd
|
|
320
|
+
.command('set <username> <size>')
|
|
321
|
+
.description('Set quota limit for a user (e.g., 50MB, 1GB)')
|
|
322
|
+
.option('-r, --root <path>', 'Data directory')
|
|
323
|
+
.action(async (username, size, options) => {
|
|
324
|
+
try {
|
|
325
|
+
if (options.root) {
|
|
326
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const bytes = parseSize(size);
|
|
330
|
+
if (bytes === 0) {
|
|
331
|
+
console.error('Invalid size format. Use e.g., 50MB, 1GB');
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const quota = await setQuotaLimit(username, bytes);
|
|
336
|
+
console.log(`\nQuota set for ${username}: ${formatBytes(quota.limit)}`);
|
|
337
|
+
console.log(`Current usage: ${formatBytes(quota.used)} (${Math.round(quota.used / quota.limit * 100)}%)\n`);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error(`Error: ${err.message}`);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
quotaCmd
|
|
345
|
+
.command('show <username>')
|
|
346
|
+
.description('Show quota info for a user')
|
|
347
|
+
.option('-r, --root <path>', 'Data directory')
|
|
348
|
+
.action(async (username, options) => {
|
|
349
|
+
try {
|
|
350
|
+
if (options.root) {
|
|
351
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const quota = await getQuotaInfo(username);
|
|
355
|
+
|
|
356
|
+
if (quota.limit === 0) {
|
|
357
|
+
console.log(`\n${username}: No quota set (unlimited)\n`);
|
|
358
|
+
} else {
|
|
359
|
+
console.log(`\n${username}:`);
|
|
360
|
+
console.log(` Used: ${formatBytes(quota.used)}`);
|
|
361
|
+
console.log(` Limit: ${formatBytes(quota.limit)}`);
|
|
362
|
+
console.log(` Free: ${formatBytes(quota.limit - quota.used)}`);
|
|
363
|
+
console.log(` Usage: ${quota.percent}%\n`);
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.error(`Error: ${err.message}`);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
quotaCmd
|
|
372
|
+
.command('reconcile <username>')
|
|
373
|
+
.description('Recalculate quota usage from actual disk usage')
|
|
374
|
+
.option('-r, --root <path>', 'Data directory')
|
|
375
|
+
.action(async (username, options) => {
|
|
376
|
+
try {
|
|
377
|
+
if (options.root) {
|
|
378
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
console.log(`Calculating actual disk usage for ${username}...`);
|
|
382
|
+
const quota = await reconcileQuota(username);
|
|
383
|
+
|
|
384
|
+
if (quota.limit === 0) {
|
|
385
|
+
console.log(`\n${username}: No quota configured\n`);
|
|
386
|
+
} else {
|
|
387
|
+
console.log(`\nReconciled ${username}:`);
|
|
388
|
+
console.log(` Used: ${formatBytes(quota.used)}`);
|
|
389
|
+
console.log(` Limit: ${formatBytes(quota.limit)}`);
|
|
390
|
+
console.log(` Usage: ${Math.round(quota.used / quota.limit * 100)}%\n`);
|
|
391
|
+
}
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error(`Error: ${err.message}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
207
398
|
/**
|
|
208
399
|
* Helper: Prompt for input
|
|
209
400
|
*/
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -42,6 +42,15 @@ export const defaults = {
|
|
|
42
42
|
mashlibCdn: false,
|
|
43
43
|
mashlibVersion: '2.0.0',
|
|
44
44
|
|
|
45
|
+
// Git HTTP backend
|
|
46
|
+
git: false,
|
|
47
|
+
|
|
48
|
+
// Invite-only registration
|
|
49
|
+
inviteOnly: false,
|
|
50
|
+
|
|
51
|
+
// Storage quota (bytes) - 50MB default
|
|
52
|
+
defaultQuota: 50 * 1024 * 1024,
|
|
53
|
+
|
|
45
54
|
// Logging
|
|
46
55
|
logger: true,
|
|
47
56
|
quiet: false,
|
|
@@ -71,8 +80,24 @@ const envMap = {
|
|
|
71
80
|
JSS_MASHLIB: 'mashlib',
|
|
72
81
|
JSS_MASHLIB_CDN: 'mashlibCdn',
|
|
73
82
|
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
83
|
+
JSS_GIT: 'git',
|
|
84
|
+
JSS_INVITE_ONLY: 'inviteOnly',
|
|
85
|
+
JSS_DEFAULT_QUOTA: 'defaultQuota',
|
|
74
86
|
};
|
|
75
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Parse a size string like "50MB" or "1GB" to bytes
|
|
90
|
+
*/
|
|
91
|
+
export function parseSize(str) {
|
|
92
|
+
const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i);
|
|
93
|
+
if (!match) return parseInt(str, 10) || 0;
|
|
94
|
+
|
|
95
|
+
const num = parseFloat(match[1]);
|
|
96
|
+
const unit = (match[2] || 'B').toUpperCase();
|
|
97
|
+
const multipliers = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 };
|
|
98
|
+
return Math.floor(num * (multipliers[unit] || 1));
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
/**
|
|
77
102
|
* Parse a value from environment variable string
|
|
78
103
|
*/
|
|
@@ -88,6 +113,11 @@ function parseEnvValue(value, key) {
|
|
|
88
113
|
return parseInt(value, 10);
|
|
89
114
|
}
|
|
90
115
|
|
|
116
|
+
// Size values (quota)
|
|
117
|
+
if (key === 'defaultQuota') {
|
|
118
|
+
return parseSize(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
return value;
|
|
92
122
|
}
|
|
93
123
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as storage from '../storage/filesystem.js';
|
|
2
|
+
import { initializeQuota, checkQuota, updateQuotaUsage } from '../storage/quota.js';
|
|
2
3
|
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
|
-
import { isContainer, getEffectiveUrlPath } from '../utils/url.js';
|
|
4
|
+
import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
|
|
4
5
|
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
|
|
5
6
|
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
|
|
6
7
|
import { createToken } from '../auth/token.js';
|
|
@@ -106,7 +107,21 @@ export async function handlePost(request, reply) {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// Check storage quota before writing
|
|
111
|
+
const podName = getPodName(request);
|
|
112
|
+
if (podName) {
|
|
113
|
+
const { allowed, error } = await checkQuota(podName, content.length, request.defaultQuota || 0);
|
|
114
|
+
if (!allowed) {
|
|
115
|
+
return reply.code(507).send({ error: 'Insufficient Storage', message: error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
success = await storage.write(newStoragePath, content);
|
|
120
|
+
|
|
121
|
+
// Update quota usage after successful write
|
|
122
|
+
if (success && podName) {
|
|
123
|
+
await updateQuotaUsage(podName, content.length);
|
|
124
|
+
}
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
if (!success) {
|
|
@@ -139,8 +154,9 @@ export async function handlePost(request, reply) {
|
|
|
139
154
|
* @param {string} webId - User's WebID URI
|
|
140
155
|
* @param {string} podUri - Pod root URI (e.g., https://alice.example.com/ or https://example.com/alice/)
|
|
141
156
|
* @param {string} issuer - OIDC issuer URI
|
|
157
|
+
* @param {number} defaultQuota - Default storage quota in bytes (optional)
|
|
142
158
|
*/
|
|
143
|
-
export async function createPodStructure(name, webId, podUri, issuer) {
|
|
159
|
+
export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0) {
|
|
144
160
|
const podPath = `/${name}/`;
|
|
145
161
|
|
|
146
162
|
// Create pod directory structure
|
|
@@ -193,6 +209,11 @@ export async function createPodStructure(name, webId, podUri, issuer) {
|
|
|
193
209
|
const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId);
|
|
194
210
|
await storage.write(`${podPath}profile/.acl`, serializeAcl(profileAcl));
|
|
195
211
|
|
|
212
|
+
// Initialize storage quota if configured
|
|
213
|
+
if (defaultQuota > 0) {
|
|
214
|
+
await initializeQuota(name, defaultQuota);
|
|
215
|
+
}
|
|
216
|
+
|
|
196
217
|
return { podPath, podUri };
|
|
197
218
|
}
|
|
198
219
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as storage from '../storage/filesystem.js';
|
|
2
|
+
import { checkQuota, updateQuotaUsage } from '../storage/quota.js';
|
|
2
3
|
import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
|
|
3
4
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
|
-
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse } from '../utils/url.js';
|
|
5
|
+
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse, getPodName } from '../utils/url.js';
|
|
5
6
|
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
6
7
|
import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
|
|
7
8
|
import {
|
|
@@ -504,11 +505,28 @@ export async function handlePut(request, reply) {
|
|
|
504
505
|
}
|
|
505
506
|
}
|
|
506
507
|
|
|
508
|
+
// Check storage quota before writing
|
|
509
|
+
const podName = getPodName(request);
|
|
510
|
+
const oldSize = stats?.size || 0;
|
|
511
|
+
const sizeDelta = content.length - oldSize;
|
|
512
|
+
|
|
513
|
+
if (podName && sizeDelta > 0) {
|
|
514
|
+
const { allowed, error } = await checkQuota(podName, sizeDelta, request.defaultQuota || 0);
|
|
515
|
+
if (!allowed) {
|
|
516
|
+
return reply.code(507).send({ error: 'Insufficient Storage', message: error });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
507
520
|
const success = await storage.write(storagePath, content);
|
|
508
521
|
if (!success) {
|
|
509
522
|
return reply.code(500).send({ error: 'Write failed' });
|
|
510
523
|
}
|
|
511
524
|
|
|
525
|
+
// Update quota usage after successful write
|
|
526
|
+
if (podName && sizeDelta !== 0) {
|
|
527
|
+
await updateQuotaUsage(podName, sizeDelta);
|
|
528
|
+
}
|
|
529
|
+
|
|
512
530
|
const origin = request.headers.origin;
|
|
513
531
|
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
|
|
514
532
|
headers['Location'] = resourceUrl;
|
|
@@ -549,11 +567,20 @@ export async function handleDelete(request, reply) {
|
|
|
549
567
|
}
|
|
550
568
|
}
|
|
551
569
|
|
|
570
|
+
// Get file size before deletion for quota update
|
|
571
|
+
const fileSize = stats.size || 0;
|
|
572
|
+
|
|
552
573
|
const success = await storage.remove(storagePath);
|
|
553
574
|
if (!success) {
|
|
554
575
|
return reply.code(500).send({ error: 'Delete failed' });
|
|
555
576
|
}
|
|
556
577
|
|
|
578
|
+
// Update quota usage (subtract deleted file size)
|
|
579
|
+
const podName = getPodName(request);
|
|
580
|
+
if (podName && fileSize > 0) {
|
|
581
|
+
await updateQuotaUsage(podName, -fileSize);
|
|
582
|
+
}
|
|
583
|
+
|
|
557
584
|
const origin = request.headers.origin;
|
|
558
585
|
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
559
586
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
package/src/idp/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
|
27
27
|
* @param {string} options.issuer - The issuer URL
|
|
28
28
|
*/
|
|
29
29
|
export async function idpPlugin(fastify, options) {
|
|
30
|
-
const { issuer } = options;
|
|
30
|
+
const { issuer, inviteOnly = false } = options;
|
|
31
31
|
|
|
32
32
|
if (!issuer) {
|
|
33
33
|
throw new Error('IdP requires issuer URL');
|
|
@@ -274,7 +274,7 @@ export async function idpPlugin(fastify, options) {
|
|
|
274
274
|
|
|
275
275
|
// Registration routes
|
|
276
276
|
fastify.get('/idp/register', async (request, reply) => {
|
|
277
|
-
return handleRegisterGet(request, reply);
|
|
277
|
+
return handleRegisterGet(request, reply, inviteOnly);
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
// Registration - rate limited to prevent spam accounts
|
|
@@ -287,7 +287,7 @@ export async function idpPlugin(fastify, options) {
|
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
289
|
}, async (request, reply) => {
|
|
290
|
-
return handleRegisterPost(request, reply, issuer);
|
|
290
|
+
return handleRegisterPost(request, reply, issuer, inviteOnly);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
293
|
fastify.log.info(`IdP initialized with issuer: ${issuer}`);
|
package/src/idp/interactions.js
CHANGED
|
@@ -7,6 +7,7 @@ import { authenticate, findById, createAccount } from './accounts.js';
|
|
|
7
7
|
import { loginPage, consentPage, errorPage, registerPage } from './views.js';
|
|
8
8
|
import * as storage from '../storage/filesystem.js';
|
|
9
9
|
import { createPodStructure } from '../handlers/container.js';
|
|
10
|
+
import { validateInvite } from './invites.js';
|
|
10
11
|
|
|
11
12
|
// Security: Maximum body size for IdP form submissions (1MB)
|
|
12
13
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
@@ -309,16 +310,16 @@ export async function handleAbort(request, reply, provider) {
|
|
|
309
310
|
* Handle GET /idp/register
|
|
310
311
|
* Shows registration page
|
|
311
312
|
*/
|
|
312
|
-
export async function handleRegisterGet(request, reply) {
|
|
313
|
+
export async function handleRegisterGet(request, reply, inviteOnly = false) {
|
|
313
314
|
const uid = request.query.uid || null;
|
|
314
|
-
return reply.type('text/html').send(registerPage(uid));
|
|
315
|
+
return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly));
|
|
315
316
|
}
|
|
316
317
|
|
|
317
318
|
/**
|
|
318
319
|
* Handle POST /idp/register
|
|
319
320
|
* Creates account and pod
|
|
320
321
|
*/
|
|
321
|
-
export async function handleRegisterPost(request, reply, issuer) {
|
|
322
|
+
export async function handleRegisterPost(request, reply, issuer, inviteOnly = false) {
|
|
322
323
|
const uid = request.query.uid || null;
|
|
323
324
|
|
|
324
325
|
// Parse body
|
|
@@ -328,7 +329,7 @@ export async function handleRegisterPost(request, reply, issuer) {
|
|
|
328
329
|
if (Buffer.isBuffer(parsedBody)) {
|
|
329
330
|
// Security: check body size
|
|
330
331
|
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
331
|
-
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.'));
|
|
332
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
|
|
332
333
|
}
|
|
333
334
|
const bodyStr = parsedBody.toString();
|
|
334
335
|
if (contentType.includes('application/json')) {
|
|
@@ -344,32 +345,39 @@ export async function handleRegisterPost(request, reply, issuer) {
|
|
|
344
345
|
} else if (typeof parsedBody === 'string') {
|
|
345
346
|
// Security: check body size
|
|
346
347
|
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
347
|
-
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.'));
|
|
348
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
|
|
348
349
|
}
|
|
349
350
|
const params = new URLSearchParams(parsedBody);
|
|
350
351
|
parsedBody = Object.fromEntries(params.entries());
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
const { username, password, confirmPassword } = parsedBody;
|
|
354
|
+
const { username, password, confirmPassword, invite } = parsedBody;
|
|
355
|
+
|
|
356
|
+
// Validate invite code if invite-only mode is enabled
|
|
357
|
+
if (inviteOnly) {
|
|
358
|
+
const inviteResult = await validateInvite(invite);
|
|
359
|
+
if (!inviteResult.valid) {
|
|
360
|
+
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
354
363
|
|
|
355
364
|
// Validate input
|
|
356
365
|
if (!username || !password) {
|
|
357
|
-
return reply.type('text/html').send(registerPage(uid, 'Username and password are required'));
|
|
366
|
+
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly));
|
|
358
367
|
}
|
|
359
368
|
|
|
360
369
|
// Validate username format
|
|
361
370
|
const usernameRegex = /^[a-z0-9]+$/;
|
|
362
371
|
if (!usernameRegex.test(username)) {
|
|
363
|
-
return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers'));
|
|
372
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers', null, inviteOnly));
|
|
364
373
|
}
|
|
365
374
|
|
|
366
375
|
if (username.length < 3) {
|
|
367
|
-
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters'));
|
|
376
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
|
|
368
377
|
}
|
|
369
378
|
|
|
370
|
-
|
|
371
379
|
if (password !== confirmPassword) {
|
|
372
|
-
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match'));
|
|
380
|
+
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
|
|
373
381
|
}
|
|
374
382
|
|
|
375
383
|
try {
|
|
@@ -393,7 +401,7 @@ export async function handleRegisterPost(request, reply, issuer) {
|
|
|
393
401
|
const podPath = `${username}/`;
|
|
394
402
|
const podExists = await storage.exists(podPath);
|
|
395
403
|
if (podExists) {
|
|
396
|
-
return reply.type('text/html').send(registerPage(uid, 'Username is already taken'));
|
|
404
|
+
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly));
|
|
397
405
|
}
|
|
398
406
|
|
|
399
407
|
// Create pod structure
|
|
@@ -413,10 +421,10 @@ export async function handleRegisterPost(request, reply, issuer) {
|
|
|
413
421
|
if (uid) {
|
|
414
422
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
415
423
|
} else {
|
|
416
|
-
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}"
|
|
424
|
+
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly));
|
|
417
425
|
}
|
|
418
426
|
} catch (err) {
|
|
419
427
|
request.log.error(err, 'Registration error');
|
|
420
|
-
return reply.type('text/html').send(registerPage(uid, err.message));
|
|
428
|
+
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
|
|
421
429
|
}
|
|
422
430
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invite code management for invite-only registration
|
|
3
|
+
* Stores codes in /data/.server/invites.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { getDataRoot } from '../utils/url.js';
|
|
10
|
+
|
|
11
|
+
const INVITES_DIR = '.server';
|
|
12
|
+
const INVITES_FILE = 'invites.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get path to invites file
|
|
16
|
+
*/
|
|
17
|
+
function getInvitesPath() {
|
|
18
|
+
return join(getDataRoot(), INVITES_DIR, INVITES_FILE);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ensure .server directory exists
|
|
23
|
+
*/
|
|
24
|
+
async function ensureServerDir() {
|
|
25
|
+
const dirPath = join(getDataRoot(), INVITES_DIR);
|
|
26
|
+
try {
|
|
27
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code !== 'EEXIST') throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load all invites from storage
|
|
35
|
+
* @returns {Promise<object>} Map of code -> invite data
|
|
36
|
+
*/
|
|
37
|
+
export async function loadInvites() {
|
|
38
|
+
try {
|
|
39
|
+
const data = await fs.readFile(getInvitesPath(), 'utf-8');
|
|
40
|
+
return JSON.parse(data);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err.code === 'ENOENT') {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Save invites to storage
|
|
51
|
+
* @param {object} invites - Map of code -> invite data
|
|
52
|
+
*/
|
|
53
|
+
async function saveInvites(invites) {
|
|
54
|
+
await ensureServerDir();
|
|
55
|
+
await fs.writeFile(getInvitesPath(), JSON.stringify(invites, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a random invite code
|
|
60
|
+
* @returns {string} 8-character uppercase alphanumeric code
|
|
61
|
+
*/
|
|
62
|
+
function generateCode() {
|
|
63
|
+
const bytes = crypto.randomBytes(6);
|
|
64
|
+
// Base64 encode and take first 8 chars, uppercase, remove ambiguous chars
|
|
65
|
+
return bytes.toString('base64')
|
|
66
|
+
.replace(/[+/=]/g, '')
|
|
67
|
+
.substring(0, 8)
|
|
68
|
+
.toUpperCase()
|
|
69
|
+
.replace(/O/g, '0')
|
|
70
|
+
.replace(/I/g, '1')
|
|
71
|
+
.replace(/L/g, '1');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a new invite code
|
|
76
|
+
* @param {object} options
|
|
77
|
+
* @param {number} options.maxUses - Maximum number of uses (default 1)
|
|
78
|
+
* @param {string} options.note - Optional note/description
|
|
79
|
+
* @returns {Promise<{code: string, invite: object}>}
|
|
80
|
+
*/
|
|
81
|
+
export async function createInvite({ maxUses = 1, note = '' } = {}) {
|
|
82
|
+
const invites = await loadInvites();
|
|
83
|
+
|
|
84
|
+
// Generate unique code
|
|
85
|
+
let code;
|
|
86
|
+
do {
|
|
87
|
+
code = generateCode();
|
|
88
|
+
} while (invites[code]);
|
|
89
|
+
|
|
90
|
+
const invite = {
|
|
91
|
+
created: new Date().toISOString(),
|
|
92
|
+
maxUses,
|
|
93
|
+
uses: 0,
|
|
94
|
+
note
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
invites[code] = invite;
|
|
98
|
+
await saveInvites(invites);
|
|
99
|
+
|
|
100
|
+
return { code, invite };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List all invite codes
|
|
105
|
+
* @returns {Promise<Array<{code: string, ...invite}>>}
|
|
106
|
+
*/
|
|
107
|
+
export async function listInvites() {
|
|
108
|
+
const invites = await loadInvites();
|
|
109
|
+
return Object.entries(invites).map(([code, invite]) => ({
|
|
110
|
+
code,
|
|
111
|
+
...invite
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Revoke (delete) an invite code
|
|
117
|
+
* @param {string} code - The invite code to revoke
|
|
118
|
+
* @returns {Promise<boolean>} True if code existed and was deleted
|
|
119
|
+
*/
|
|
120
|
+
export async function revokeInvite(code) {
|
|
121
|
+
const invites = await loadInvites();
|
|
122
|
+
const upperCode = code.toUpperCase();
|
|
123
|
+
|
|
124
|
+
if (!invites[upperCode]) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
delete invites[upperCode];
|
|
129
|
+
await saveInvites(invites);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate and consume an invite code
|
|
135
|
+
* @param {string} code - The invite code to validate
|
|
136
|
+
* @returns {Promise<{valid: boolean, error?: string}>}
|
|
137
|
+
*/
|
|
138
|
+
export async function validateInvite(code) {
|
|
139
|
+
if (!code || typeof code !== 'string') {
|
|
140
|
+
return { valid: false, error: 'Invite code required' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const invites = await loadInvites();
|
|
144
|
+
const upperCode = code.toUpperCase().trim();
|
|
145
|
+
const invite = invites[upperCode];
|
|
146
|
+
|
|
147
|
+
if (!invite) {
|
|
148
|
+
return { valid: false, error: 'Invalid invite code' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (invite.uses >= invite.maxUses) {
|
|
152
|
+
return { valid: false, error: 'Invite code has been fully used' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Consume one use
|
|
156
|
+
invite.uses += 1;
|
|
157
|
+
await saveInvites(invites);
|
|
158
|
+
|
|
159
|
+
return { valid: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check if an invite code is valid without consuming it
|
|
164
|
+
* @param {string} code - The invite code to check
|
|
165
|
+
* @returns {Promise<boolean>}
|
|
166
|
+
*/
|
|
167
|
+
export async function isValidInvite(code) {
|
|
168
|
+
if (!code || typeof code !== 'string') {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const invites = await loadInvites();
|
|
173
|
+
const upperCode = code.toUpperCase().trim();
|
|
174
|
+
const invite = invites[upperCode];
|
|
175
|
+
|
|
176
|
+
if (!invite) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return invite.uses < invite.maxUses;
|
|
181
|
+
}
|
package/src/idp/views.js
CHANGED
|
@@ -296,7 +296,13 @@ export function errorPage(title, message) {
|
|
|
296
296
|
/**
|
|
297
297
|
* Registration page HTML
|
|
298
298
|
*/
|
|
299
|
-
export function registerPage(uid = null, error = null, success = null) {
|
|
299
|
+
export function registerPage(uid = null, error = null, success = null, inviteOnly = false) {
|
|
300
|
+
const inviteField = inviteOnly ? `
|
|
301
|
+
<label for="invite">Invite Code</label>
|
|
302
|
+
<input type="text" id="invite" name="invite" required
|
|
303
|
+
placeholder="Enter your invite code" style="text-transform: uppercase;">
|
|
304
|
+
` : '';
|
|
305
|
+
|
|
300
306
|
return `
|
|
301
307
|
<!DOCTYPE html>
|
|
302
308
|
<html lang="en">
|
|
@@ -310,14 +316,16 @@ export function registerPage(uid = null, error = null, success = null) {
|
|
|
310
316
|
<div class="container">
|
|
311
317
|
<div class="logo">${solidLogo}</div>
|
|
312
318
|
<h1>Create Account</h1>
|
|
313
|
-
<p class="subtitle">Register for a new Solid Pod</p>
|
|
319
|
+
<p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
|
|
314
320
|
|
|
315
321
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
316
322
|
${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
|
|
317
323
|
|
|
318
324
|
<form method="POST" action="/idp/register${uid ? `?uid=${uid}` : ''}">
|
|
325
|
+
${inviteField}
|
|
326
|
+
|
|
319
327
|
<label for="username">Username</label>
|
|
320
|
-
<input type="text" id="username" name="username" required autofocus
|
|
328
|
+
<input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
|
|
321
329
|
placeholder="Choose a username" pattern="[a-z0-9]+"
|
|
322
330
|
title="Lowercase letters and numbers only">
|
|
323
331
|
|
package/src/server.js
CHANGED
|
@@ -46,6 +46,10 @@ export function createServer(options = {}) {
|
|
|
46
46
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
47
47
|
// Git HTTP backend is OFF by default - enables clone/push via git protocol
|
|
48
48
|
const gitEnabled = options.git ?? false;
|
|
49
|
+
// Invite-only registration is OFF by default - open registration
|
|
50
|
+
const inviteOnly = options.inviteOnly ?? false;
|
|
51
|
+
// Default storage quota per pod (50MB default, 0 = unlimited)
|
|
52
|
+
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
|
|
49
53
|
|
|
50
54
|
// Set data root via environment variable if provided
|
|
51
55
|
if (options.root) {
|
|
@@ -93,6 +97,7 @@ export function createServer(options = {}) {
|
|
|
93
97
|
fastify.decorateRequest('mashlibEnabled', null);
|
|
94
98
|
fastify.decorateRequest('mashlibCdn', null);
|
|
95
99
|
fastify.decorateRequest('mashlibVersion', null);
|
|
100
|
+
fastify.decorateRequest('defaultQuota', null);
|
|
96
101
|
fastify.addHook('onRequest', async (request) => {
|
|
97
102
|
request.connegEnabled = connegEnabled;
|
|
98
103
|
request.notificationsEnabled = notificationsEnabled;
|
|
@@ -102,6 +107,7 @@ export function createServer(options = {}) {
|
|
|
102
107
|
request.mashlibEnabled = mashlibEnabled;
|
|
103
108
|
request.mashlibCdn = mashlibCdn;
|
|
104
109
|
request.mashlibVersion = mashlibVersion;
|
|
110
|
+
request.defaultQuota = defaultQuota;
|
|
105
111
|
|
|
106
112
|
// Extract pod name from subdomain if enabled
|
|
107
113
|
if (subdomainsEnabled && baseDomain) {
|
|
@@ -125,7 +131,7 @@ export function createServer(options = {}) {
|
|
|
125
131
|
|
|
126
132
|
// Register Identity Provider plugin if enabled
|
|
127
133
|
if (idpEnabled) {
|
|
128
|
-
fastify.register(idpPlugin, { issuer: idpIssuer });
|
|
134
|
+
fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly });
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
// Register rate limiting plugin
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage quota management
|
|
3
|
+
* Tracks and enforces per-pod storage limits
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { getDataRoot } from '../utils/url.js';
|
|
9
|
+
|
|
10
|
+
const QUOTA_FILE = '.quota.json';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get quota file path for a pod
|
|
14
|
+
*/
|
|
15
|
+
function getQuotaPath(podName) {
|
|
16
|
+
return join(getDataRoot(), podName, QUOTA_FILE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load quota data for a pod
|
|
21
|
+
* @param {string} podName - The pod name
|
|
22
|
+
* @returns {Promise<{limit: number, used: number}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function loadQuota(podName) {
|
|
25
|
+
try {
|
|
26
|
+
const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
|
|
27
|
+
return JSON.parse(data);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === 'ENOENT') {
|
|
30
|
+
// No quota file - return defaults (will be initialized on first write)
|
|
31
|
+
return { limit: 0, used: 0 };
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save quota data for a pod
|
|
39
|
+
* @param {string} podName - The pod name
|
|
40
|
+
* @param {object} quota - Quota data
|
|
41
|
+
*/
|
|
42
|
+
export async function saveQuota(podName, quota) {
|
|
43
|
+
await fs.writeFile(getQuotaPath(podName), JSON.stringify(quota, null, 2));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initialize quota for a new pod
|
|
48
|
+
* @param {string} podName - The pod name
|
|
49
|
+
* @param {number} limit - Quota limit in bytes
|
|
50
|
+
*/
|
|
51
|
+
export async function initializeQuota(podName, limit) {
|
|
52
|
+
const quota = { limit, used: 0 };
|
|
53
|
+
await saveQuota(podName, quota);
|
|
54
|
+
return quota;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Calculate actual disk usage for a pod (for reconciliation)
|
|
59
|
+
* @param {string} podName - The pod name
|
|
60
|
+
* @returns {Promise<number>} Total bytes used
|
|
61
|
+
*/
|
|
62
|
+
export async function calculatePodSize(podName) {
|
|
63
|
+
const podPath = join(getDataRoot(), podName);
|
|
64
|
+
return calculateDirSize(podPath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Recursively calculate directory size
|
|
69
|
+
*/
|
|
70
|
+
async function calculateDirSize(dirPath) {
|
|
71
|
+
let total = 0;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const fullPath = join(dirPath, entry.name);
|
|
78
|
+
|
|
79
|
+
// Skip quota file itself
|
|
80
|
+
if (entry.name === QUOTA_FILE) continue;
|
|
81
|
+
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
total += await calculateDirSize(fullPath);
|
|
84
|
+
} else if (entry.isFile()) {
|
|
85
|
+
const stat = await fs.stat(fullPath);
|
|
86
|
+
total += stat.size;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// Directory might not exist or be inaccessible
|
|
91
|
+
if (err.code !== 'ENOENT') {
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if a write operation would exceed quota
|
|
101
|
+
* @param {string} podName - The pod name
|
|
102
|
+
* @param {number} additionalBytes - Bytes to be added
|
|
103
|
+
* @param {number} defaultQuota - Default quota limit
|
|
104
|
+
* @returns {Promise<{allowed: boolean, quota: object, error?: string}>}
|
|
105
|
+
*/
|
|
106
|
+
export async function checkQuota(podName, additionalBytes, defaultQuota) {
|
|
107
|
+
let quota = await loadQuota(podName);
|
|
108
|
+
|
|
109
|
+
// Initialize if no quota set
|
|
110
|
+
if (quota.limit === 0 && defaultQuota > 0) {
|
|
111
|
+
quota = await initializeQuota(podName, defaultQuota);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No quota enforcement if limit is 0
|
|
115
|
+
if (quota.limit === 0) {
|
|
116
|
+
return { allowed: true, quota };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const projectedUsage = quota.used + additionalBytes;
|
|
120
|
+
|
|
121
|
+
if (projectedUsage > quota.limit) {
|
|
122
|
+
const usedMB = (quota.used / (1024 * 1024)).toFixed(2);
|
|
123
|
+
const limitMB = (quota.limit / (1024 * 1024)).toFixed(2);
|
|
124
|
+
return {
|
|
125
|
+
allowed: false,
|
|
126
|
+
quota,
|
|
127
|
+
error: `Storage quota exceeded. Used: ${usedMB}MB / ${limitMB}MB`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { allowed: true, quota };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Update quota usage after a write
|
|
136
|
+
* @param {string} podName - The pod name
|
|
137
|
+
* @param {number} bytesChange - Bytes added (positive) or removed (negative)
|
|
138
|
+
*/
|
|
139
|
+
export async function updateQuotaUsage(podName, bytesChange) {
|
|
140
|
+
const quota = await loadQuota(podName);
|
|
141
|
+
|
|
142
|
+
// Skip if no quota initialized
|
|
143
|
+
if (quota.limit === 0) return quota;
|
|
144
|
+
|
|
145
|
+
quota.used = Math.max(0, quota.used + bytesChange);
|
|
146
|
+
await saveQuota(podName, quota);
|
|
147
|
+
return quota;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Set quota limit for a pod
|
|
152
|
+
* @param {string} podName - The pod name
|
|
153
|
+
* @param {number} limit - New limit in bytes
|
|
154
|
+
*/
|
|
155
|
+
export async function setQuotaLimit(podName, limit) {
|
|
156
|
+
let quota = await loadQuota(podName);
|
|
157
|
+
|
|
158
|
+
// If no quota exists, calculate current usage
|
|
159
|
+
if (quota.limit === 0) {
|
|
160
|
+
quota.used = await calculatePodSize(podName);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
quota.limit = limit;
|
|
164
|
+
await saveQuota(podName, quota);
|
|
165
|
+
return quota;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get quota info for a pod
|
|
170
|
+
* @param {string} podName - The pod name
|
|
171
|
+
* @returns {Promise<{limit: number, used: number, percent: number}>}
|
|
172
|
+
*/
|
|
173
|
+
export async function getQuotaInfo(podName) {
|
|
174
|
+
const quota = await loadQuota(podName);
|
|
175
|
+
const percent = quota.limit > 0 ? Math.round((quota.used / quota.limit) * 100) : 0;
|
|
176
|
+
return { ...quota, percent };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reconcile quota with actual disk usage
|
|
181
|
+
* @param {string} podName - The pod name
|
|
182
|
+
*/
|
|
183
|
+
export async function reconcileQuota(podName) {
|
|
184
|
+
const quota = await loadQuota(podName);
|
|
185
|
+
if (quota.limit === 0) return quota;
|
|
186
|
+
|
|
187
|
+
const actualUsed = await calculatePodSize(podName);
|
|
188
|
+
quota.used = actualUsed;
|
|
189
|
+
await saveQuota(podName, quota);
|
|
190
|
+
return quota;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Format bytes as human-readable string
|
|
195
|
+
*/
|
|
196
|
+
export function formatBytes(bytes) {
|
|
197
|
+
if (bytes === 0) return '0 B';
|
|
198
|
+
const k = 1024;
|
|
199
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
200
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
201
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
202
|
+
}
|
package/src/utils/url.js
CHANGED
|
@@ -143,6 +143,43 @@ export function getResourceName(urlPath) {
|
|
|
143
143
|
return parts[parts.length - 1];
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Extract pod name from URL path or request
|
|
148
|
+
* @param {string|object} pathOrRequest - URL path string or Fastify request object
|
|
149
|
+
* @returns {string|null} - Pod name or null if not found
|
|
150
|
+
*/
|
|
151
|
+
export function getPodName(pathOrRequest) {
|
|
152
|
+
// If it's a request object
|
|
153
|
+
if (typeof pathOrRequest === 'object') {
|
|
154
|
+
// Subdomain mode: pod name from hostname
|
|
155
|
+
if (pathOrRequest.subdomainsEnabled && pathOrRequest.podName) {
|
|
156
|
+
return pathOrRequest.podName;
|
|
157
|
+
}
|
|
158
|
+
// Path mode: extract from URL
|
|
159
|
+
const urlPath = pathOrRequest.url?.split('?')[0] || '';
|
|
160
|
+
return getPodNameFromPath(urlPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// If it's a string path
|
|
164
|
+
return getPodNameFromPath(pathOrRequest);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Extract pod name from URL path
|
|
169
|
+
* @param {string} urlPath - URL path (e.g., /alice/public/file.txt)
|
|
170
|
+
* @returns {string|null} - Pod name or null
|
|
171
|
+
*/
|
|
172
|
+
function getPodNameFromPath(urlPath) {
|
|
173
|
+
const parts = urlPath.split('/').filter(Boolean);
|
|
174
|
+
if (parts.length === 0) return null;
|
|
175
|
+
|
|
176
|
+
// First segment is the pod name (skip system paths)
|
|
177
|
+
const firstPart = parts[0];
|
|
178
|
+
if (firstPart.startsWith('.')) return null; // .well-known, .acl, etc.
|
|
179
|
+
|
|
180
|
+
return firstPart;
|
|
181
|
+
}
|
|
182
|
+
|
|
146
183
|
/**
|
|
147
184
|
* Determine content type from file extension
|
|
148
185
|
* @param {string} filePath
|