javascript-solid-server 0.0.56 → 0.0.58
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 +3 -1
- package/README.md +76 -4
- package/bin/jss.js +88 -0
- package/package.json +1 -1
- package/src/auth/did-nostr.js +205 -0
- package/src/auth/nostr.js +10 -1
- package/src/config.js +22 -0
- package/src/handlers/container.js +23 -2
- package/src/handlers/resource.js +28 -1
- package/src/server.js +4 -0
- package/src/storage/quota.js +202 -0
- package/src/utils/url.js +37 -0
|
@@ -207,7 +207,9 @@
|
|
|
207
207
|
"WebFetch(domain:solid-chat.com)",
|
|
208
208
|
"WebFetch(domain:developer.chrome.com)",
|
|
209
209
|
"WebFetch(domain:css-tricks.com)",
|
|
210
|
-
"Bash(node bin/jss.js:*)"
|
|
210
|
+
"Bash(node bin/jss.js:*)",
|
|
211
|
+
"WebFetch(domain:nostr.social)",
|
|
212
|
+
"Bash(xargs curl -s)"
|
|
211
213
|
]
|
|
212
214
|
}
|
|
213
215
|
}
|
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
|
|
@@ -24,12 +24,13 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
24
24
|
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, RS256/ES256, dynamic registration
|
|
25
25
|
- **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
|
|
26
26
|
- **NSS-style Registration** - Username/password auth compatible with Solid apps
|
|
27
|
-
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
|
|
27
|
+
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures, did:nostr → WebID resolution
|
|
28
28
|
- **Simple Auth Tokens** - Built-in token authentication for development
|
|
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
32
|
- **Invite-Only Registration** - CLI-managed invite codes for controlled signups
|
|
33
|
+
- **Storage Quotas** - Per-user storage limits with CLI management
|
|
33
34
|
- **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
|
|
34
35
|
|
|
35
36
|
### HTTP Methods
|
|
@@ -78,6 +79,7 @@ jss start --port 8443 --ssl-key ./key.pem --ssl-cert ./cert.pem
|
|
|
78
79
|
jss start [options] # Start the server
|
|
79
80
|
jss init [options] # Initialize configuration
|
|
80
81
|
jss invite <cmd> # Manage invite codes (create, list, revoke)
|
|
82
|
+
jss quota <cmd> # Manage storage quotas (set, show, reconcile)
|
|
81
83
|
jss --help # Show help
|
|
82
84
|
```
|
|
83
85
|
|
|
@@ -102,6 +104,7 @@ jss --help # Show help
|
|
|
102
104
|
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
|
|
103
105
|
| `--git` | Enable Git HTTP backend | false |
|
|
104
106
|
| `--invite-only` | Require invite code for registration | false |
|
|
107
|
+
| `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
|
|
105
108
|
| `-q, --quiet` | Suppress logs | false |
|
|
106
109
|
|
|
107
110
|
### Environment Variables
|
|
@@ -117,6 +120,7 @@ export JSS_SUBDOMAINS=true
|
|
|
117
120
|
export JSS_BASE_DOMAIN=example.com
|
|
118
121
|
export JSS_MASHLIB=true
|
|
119
122
|
export JSS_INVITE_ONLY=true
|
|
123
|
+
export JSS_DEFAULT_QUOTA=100MB
|
|
120
124
|
jss start
|
|
121
125
|
```
|
|
122
126
|
|
|
@@ -383,6 +387,35 @@ git add .acl && git commit -m "Add ACL"
|
|
|
383
387
|
|
|
384
388
|
See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
|
|
385
389
|
|
|
390
|
+
### Linking Nostr to WebID (did:nostr)
|
|
391
|
+
|
|
392
|
+
Bridge your Nostr identity to a Solid WebID for seamless authentication:
|
|
393
|
+
|
|
394
|
+
**Step 1:** Add your WebID to your Nostr profile (kind 0 event):
|
|
395
|
+
```json
|
|
396
|
+
{
|
|
397
|
+
"name": "alice",
|
|
398
|
+
"alsoKnownAs": ["https://solid.social/alice/profile/card#me"]
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Step 2:** Add the did:nostr link to your WebID profile:
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"@id": "#me",
|
|
406
|
+
"owl:sameAs": "did:nostr:<your-64-char-hex-pubkey>"
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**How it works:**
|
|
411
|
+
1. NIP-98 signature is verified (existing flow)
|
|
412
|
+
2. DID document is fetched from `nostr.social/.well-known/did/nostr/<pubkey>.json`
|
|
413
|
+
3. `alsoKnownAs` is checked for a WebID URL
|
|
414
|
+
4. WebID profile is fetched and `owl:sameAs` verified
|
|
415
|
+
5. If bidirectional link exists → authenticated as WebID
|
|
416
|
+
|
|
417
|
+
This enables Nostr users to access their Solid pods using existing NIP-07 browser extensions.
|
|
418
|
+
|
|
386
419
|
## Invite-Only Registration
|
|
387
420
|
|
|
388
421
|
Control who can create accounts by requiring invite codes:
|
|
@@ -427,6 +460,43 @@ When `--invite-only` is enabled:
|
|
|
427
460
|
|
|
428
461
|
Invite codes are stored in `.server/invites.json` in your data directory.
|
|
429
462
|
|
|
463
|
+
## Storage Quotas
|
|
464
|
+
|
|
465
|
+
Limit storage per pod to prevent abuse and manage resources:
|
|
466
|
+
|
|
467
|
+
```bash
|
|
468
|
+
jss start --default-quota 50MB
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Managing Quotas
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
# Set quota for a user (overrides default)
|
|
475
|
+
jss quota set alice 100MB
|
|
476
|
+
|
|
477
|
+
# Show quota info
|
|
478
|
+
jss quota show alice
|
|
479
|
+
# alice:
|
|
480
|
+
# Used: 12.5 MB
|
|
481
|
+
# Limit: 100 MB
|
|
482
|
+
# Free: 87.5 MB
|
|
483
|
+
# Usage: 12%
|
|
484
|
+
|
|
485
|
+
# Recalculate from actual disk usage
|
|
486
|
+
jss quota reconcile alice
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### How It Works
|
|
490
|
+
|
|
491
|
+
- Quotas are tracked incrementally on PUT, POST, and DELETE operations
|
|
492
|
+
- When quota is exceeded, the server returns HTTP 507 Insufficient Storage
|
|
493
|
+
- Each pod stores its quota in `/{pod}/.quota.json`
|
|
494
|
+
- Use `reconcile` to fix quota drift from manual file changes
|
|
495
|
+
|
|
496
|
+
### Size Formats
|
|
497
|
+
|
|
498
|
+
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
|
499
|
+
|
|
430
500
|
## Authentication
|
|
431
501
|
|
|
432
502
|
### Simple Tokens (Development)
|
|
@@ -688,12 +758,14 @@ src/
|
|
|
688
758
|
│ ├── container.js # POST, pod creation
|
|
689
759
|
│ └── git.js # Git HTTP backend
|
|
690
760
|
├── storage/
|
|
691
|
-
│
|
|
761
|
+
│ ├── filesystem.js # File operations
|
|
762
|
+
│ └── quota.js # Storage quota management
|
|
692
763
|
├── auth/
|
|
693
764
|
│ ├── middleware.js # Auth hook
|
|
694
765
|
│ ├── token.js # Simple token auth
|
|
695
766
|
│ ├── solid-oidc.js # DPoP verification
|
|
696
|
-
│
|
|
767
|
+
│ ├── nostr.js # NIP-98 Nostr authentication
|
|
768
|
+
│ └── did-nostr.js # did:nostr → WebID resolution
|
|
697
769
|
├── wac/
|
|
698
770
|
│ ├── parser.js # ACL parsing
|
|
699
771
|
│ └── checker.js # Permission checking
|
package/bin/jss.js
CHANGED
|
@@ -12,6 +12,8 @@ import { Command } from 'commander';
|
|
|
12
12
|
import { createServer } from '../src/server.js';
|
|
13
13
|
import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js';
|
|
14
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';
|
|
15
17
|
import fs from 'fs-extra';
|
|
16
18
|
import path from 'path';
|
|
17
19
|
import { fileURLToPath } from 'url';
|
|
@@ -307,6 +309,92 @@ inviteCmd
|
|
|
307
309
|
}
|
|
308
310
|
});
|
|
309
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
|
+
|
|
310
398
|
/**
|
|
311
399
|
* Helper: Prompt for input
|
|
312
400
|
*/
|
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DID:nostr Resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves did:nostr:<pubkey> to a Solid WebID by:
|
|
5
|
+
* 1. Fetching DID document from nostr.social
|
|
6
|
+
* 2. Extracting alsoKnownAs WebID
|
|
7
|
+
* 3. Verifying bidirectional link (WebID links back to did:nostr)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Default DID resolver endpoint
|
|
11
|
+
const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr';
|
|
12
|
+
|
|
13
|
+
// Cache for resolved DIDs (pubkey -> webId or null)
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch with timeout
|
|
19
|
+
*/
|
|
20
|
+
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
25
|
+
clearTimeout(id);
|
|
26
|
+
return response;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
clearTimeout(id);
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve did:nostr pubkey to WebID via DID document
|
|
35
|
+
* @param {string} pubkey - 64-char hex Nostr pubkey
|
|
36
|
+
* @param {string} resolverUrl - DID resolver base URL
|
|
37
|
+
* @returns {Promise<string|null>} WebID URL or null
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) {
|
|
40
|
+
if (!pubkey || pubkey.length !== 64) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check cache
|
|
45
|
+
const cacheKey = pubkey.toLowerCase();
|
|
46
|
+
const cached = cache.get(cacheKey);
|
|
47
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
48
|
+
return cached.webId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Fetch DID document
|
|
53
|
+
const didUrl = `${resolverUrl}/${pubkey}.json`;
|
|
54
|
+
const didRes = await fetchWithTimeout(didUrl, {
|
|
55
|
+
headers: { 'Accept': 'application/did+json, application/json' }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!didRes.ok) {
|
|
59
|
+
cache.set(cacheKey, { webId: null, timestamp: Date.now() });
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const didDoc = await didRes.json();
|
|
64
|
+
|
|
65
|
+
// Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs
|
|
66
|
+
let webId = null;
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) {
|
|
69
|
+
// Find first HTTP(S) URL that looks like a WebID
|
|
70
|
+
webId = didDoc.alsoKnownAs.find(aka =>
|
|
71
|
+
typeof aka === 'string' && aka.startsWith('https://'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallback to profile fields
|
|
75
|
+
if (!webId && didDoc.profile) {
|
|
76
|
+
webId = didDoc.profile.webid || didDoc.profile.sameAs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!webId) {
|
|
80
|
+
cache.set(cacheKey, { webId: null, timestamp: Date.now() });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Verify bidirectional link - WebID must link back to did:nostr
|
|
85
|
+
const verified = await verifyWebIdBacklink(webId, pubkey);
|
|
86
|
+
|
|
87
|
+
if (verified) {
|
|
88
|
+
cache.set(cacheKey, { webId, timestamp: Date.now() });
|
|
89
|
+
return webId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
cache.set(cacheKey, { webId: null, timestamp: Date.now() });
|
|
93
|
+
return null;
|
|
94
|
+
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Network error or timeout - don't cache failures
|
|
97
|
+
console.error(`DID resolution error for ${pubkey}:`, err.message);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Verify WebID profile links back to did:nostr
|
|
104
|
+
* @param {string} webId - WebID URL
|
|
105
|
+
* @param {string} pubkey - Nostr pubkey
|
|
106
|
+
* @returns {Promise<boolean>}
|
|
107
|
+
*/
|
|
108
|
+
async function verifyWebIdBacklink(webId, pubkey) {
|
|
109
|
+
try {
|
|
110
|
+
const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
|
|
111
|
+
|
|
112
|
+
// Fetch WebID profile
|
|
113
|
+
const res = await fetchWithTimeout(webId, {
|
|
114
|
+
headers: { 'Accept': 'application/ld+json, application/json, text/html' }
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const contentType = res.headers.get('content-type') || '';
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
|
|
124
|
+
// Handle HTML with JSON-LD data island
|
|
125
|
+
if (contentType.includes('text/html')) {
|
|
126
|
+
const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
127
|
+
if (jsonLdMatch) {
|
|
128
|
+
try {
|
|
129
|
+
const jsonLd = JSON.parse(jsonLdMatch[1]);
|
|
130
|
+
return checkSameAsLink(jsonLd, expectedDid);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Handle JSON-LD directly
|
|
139
|
+
if (contentType.includes('json')) {
|
|
140
|
+
try {
|
|
141
|
+
const jsonLd = JSON.parse(text);
|
|
142
|
+
return checkSameAsLink(jsonLd, expectedDid);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return false;
|
|
149
|
+
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(`WebID backlink verification error for ${webId}:`, err.message);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if JSON-LD contains sameAs/owl:sameAs link to expected DID
|
|
158
|
+
* @param {object} jsonLd - Parsed JSON-LD
|
|
159
|
+
* @param {string} expectedDid - Expected did:nostr:pubkey
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
function checkSameAsLink(jsonLd, expectedDid) {
|
|
163
|
+
// Check various sameAs fields
|
|
164
|
+
const sameAsFields = [
|
|
165
|
+
jsonLd['owl:sameAs'],
|
|
166
|
+
jsonLd['sameAs'],
|
|
167
|
+
jsonLd['schema:sameAs'],
|
|
168
|
+
jsonLd['http://www.w3.org/2002/07/owl#sameAs']
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
for (const field of sameAsFields) {
|
|
172
|
+
if (!field) continue;
|
|
173
|
+
|
|
174
|
+
// Handle string value
|
|
175
|
+
if (typeof field === 'string' && field.toLowerCase() === expectedDid) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Handle object with @id
|
|
180
|
+
if (field && typeof field === 'object' && field['@id']?.toLowerCase() === expectedDid) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle array
|
|
185
|
+
if (Array.isArray(field)) {
|
|
186
|
+
for (const item of field) {
|
|
187
|
+
if (typeof item === 'string' && item.toLowerCase() === expectedDid) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (item && typeof item === 'object' && item['@id']?.toLowerCase() === expectedDid) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Clear the resolution cache (for testing)
|
|
202
|
+
*/
|
|
203
|
+
export function clearCache() {
|
|
204
|
+
cache.clear();
|
|
205
|
+
}
|
package/src/auth/nostr.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { verifyEvent } from 'nostr-tools';
|
|
15
15
|
import crypto from 'crypto';
|
|
16
|
+
import { resolveDidNostrToWebId } from './did-nostr.js';
|
|
16
17
|
|
|
17
18
|
// NIP-98 event kind (references RFC 7235)
|
|
18
19
|
const HTTP_AUTH_KIND = 27235;
|
|
@@ -223,7 +224,15 @@ export async function verifyNostrAuth(request) {
|
|
|
223
224
|
return { webId: null, error: 'Invalid Schnorr signature' };
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
//
|
|
227
|
+
// Try to resolve did:nostr to a linked WebID
|
|
228
|
+
// This checks if the pubkey has an alsoKnownAs pointing to a WebID
|
|
229
|
+
// and verifies the WebID links back to did:nostr (bidirectional)
|
|
230
|
+
const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
|
|
231
|
+
if (resolvedWebId) {
|
|
232
|
+
return { webId: resolvedWebId, error: null };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Fall back to did:nostr as the agent identifier
|
|
227
236
|
const didNostr = pubkeyToDidNostr(event.pubkey);
|
|
228
237
|
|
|
229
238
|
return { webId: didNostr, error: null };
|
package/src/config.js
CHANGED
|
@@ -48,6 +48,9 @@ export const defaults = {
|
|
|
48
48
|
// Invite-only registration
|
|
49
49
|
inviteOnly: false,
|
|
50
50
|
|
|
51
|
+
// Storage quota (bytes) - 50MB default
|
|
52
|
+
defaultQuota: 50 * 1024 * 1024,
|
|
53
|
+
|
|
51
54
|
// Logging
|
|
52
55
|
logger: true,
|
|
53
56
|
quiet: false,
|
|
@@ -79,8 +82,22 @@ const envMap = {
|
|
|
79
82
|
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
80
83
|
JSS_GIT: 'git',
|
|
81
84
|
JSS_INVITE_ONLY: 'inviteOnly',
|
|
85
|
+
JSS_DEFAULT_QUOTA: 'defaultQuota',
|
|
82
86
|
};
|
|
83
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
|
+
|
|
84
101
|
/**
|
|
85
102
|
* Parse a value from environment variable string
|
|
86
103
|
*/
|
|
@@ -96,6 +113,11 @@ function parseEnvValue(value, key) {
|
|
|
96
113
|
return parseInt(value, 10);
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
// Size values (quota)
|
|
117
|
+
if (key === 'defaultQuota') {
|
|
118
|
+
return parseSize(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
return value;
|
|
100
122
|
}
|
|
101
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/server.js
CHANGED
|
@@ -48,6 +48,8 @@ export function createServer(options = {}) {
|
|
|
48
48
|
const gitEnabled = options.git ?? false;
|
|
49
49
|
// Invite-only registration is OFF by default - open registration
|
|
50
50
|
const inviteOnly = options.inviteOnly ?? false;
|
|
51
|
+
// Default storage quota per pod (50MB default, 0 = unlimited)
|
|
52
|
+
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
|
|
51
53
|
|
|
52
54
|
// Set data root via environment variable if provided
|
|
53
55
|
if (options.root) {
|
|
@@ -95,6 +97,7 @@ export function createServer(options = {}) {
|
|
|
95
97
|
fastify.decorateRequest('mashlibEnabled', null);
|
|
96
98
|
fastify.decorateRequest('mashlibCdn', null);
|
|
97
99
|
fastify.decorateRequest('mashlibVersion', null);
|
|
100
|
+
fastify.decorateRequest('defaultQuota', null);
|
|
98
101
|
fastify.addHook('onRequest', async (request) => {
|
|
99
102
|
request.connegEnabled = connegEnabled;
|
|
100
103
|
request.notificationsEnabled = notificationsEnabled;
|
|
@@ -104,6 +107,7 @@ export function createServer(options = {}) {
|
|
|
104
107
|
request.mashlibEnabled = mashlibEnabled;
|
|
105
108
|
request.mashlibCdn = mashlibCdn;
|
|
106
109
|
request.mashlibVersion = mashlibVersion;
|
|
110
|
+
request.defaultQuota = defaultQuota;
|
|
107
111
|
|
|
108
112
|
// Extract pod name from subdomain if enabled
|
|
109
113
|
if (subdomainsEnabled && baseDomain) {
|
|
@@ -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
|