javascript-solid-server 0.0.36 → 0.0.38
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 +4 -0
- package/data/.idp/keys/jwks.json +37 -0
- package/docs/git-support.md +283 -0
- package/package.json +1 -1
- package/src/auth/nostr.js +51 -6
- package/src/handlers/git.js +207 -0
- package/src/server.js +53 -1
|
@@ -114,7 +114,12 @@
|
|
|
114
114
|
"Bash(if [ ! -d \"jose\" ])",
|
|
115
115
|
"Bash(then git clone --depth 1 --branch v0.7.0 https://github.com/solid/jose.git)",
|
|
116
116
|
"Bash(fi)",
|
|
117
|
-
"Bash(timeout 45 node:*)"
|
|
117
|
+
"Bash(timeout 45 node:*)",
|
|
118
|
+
"Bash(gh issue list:*)",
|
|
119
|
+
"Bash(DATA_ROOT=/tmp/jss-git-test JSS_PORT=4444 timeout 3 node:*)",
|
|
120
|
+
"Bash(pm2 show:*)",
|
|
121
|
+
"Bash(git config:*)",
|
|
122
|
+
"Bash(npm version:*)"
|
|
118
123
|
]
|
|
119
124
|
}
|
|
120
125
|
}
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
### Implemented (v0.0.
|
|
7
|
+
### Implemented (v0.0.37)
|
|
8
8
|
|
|
9
9
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
10
10
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -19,13 +19,15 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
19
19
|
- **Mashlib Data Browser** - Optional SolidOS UI (CDN or local hosting)
|
|
20
20
|
- **WebID Profiles** - HTML with JSON-LD data islands, rendered with mashlib-jss + solidos-lite
|
|
21
21
|
- **Web Access Control (WAC)** - `.acl` file-based authorization
|
|
22
|
-
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
|
|
22
|
+
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, RS256/ES256, dynamic registration
|
|
23
23
|
- **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
|
|
24
24
|
- **NSS-style Registration** - Username/password auth compatible with Solid apps
|
|
25
25
|
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
|
|
26
26
|
- **Simple Auth Tokens** - Built-in token authentication for development
|
|
27
27
|
- **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
|
|
28
28
|
- **CORS Support** - Full cross-origin resource sharing
|
|
29
|
+
- **Git HTTP Backend** - Clone and push to containers via `git` protocol
|
|
30
|
+
- **Security** - Blocks access to dotfiles (`.git/`, `.env`, etc.) except Solid-specific ones
|
|
29
31
|
|
|
30
32
|
### HTTP Methods
|
|
31
33
|
|
|
@@ -94,6 +96,7 @@ jss --help # Show help
|
|
|
94
96
|
| `--mashlib` | Enable Mashlib (local mode) | false |
|
|
95
97
|
| `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
|
|
96
98
|
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
|
|
99
|
+
| `--git` | Enable Git HTTP backend | false |
|
|
97
100
|
| `-q, --quiet` | Suppress logs | false |
|
|
98
101
|
|
|
99
102
|
### Environment Variables
|
|
@@ -318,6 +321,93 @@ Server: ack http://localhost:3000/alice/public/data.json
|
|
|
318
321
|
Server: pub http://localhost:3000/alice/public/data.json (on change)
|
|
319
322
|
```
|
|
320
323
|
|
|
324
|
+
## Git Support
|
|
325
|
+
|
|
326
|
+
Enable Git HTTP backend to clone and push to pod containers:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
jss start --git
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Initialize a Repository
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
# Create a git repo in a pod container
|
|
336
|
+
cd data/alice/myrepo
|
|
337
|
+
git init
|
|
338
|
+
echo "# My Project" > README.md
|
|
339
|
+
git add . && git commit -m "Initial commit"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Clone and Push
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
# Clone (public read access)
|
|
346
|
+
git clone http://localhost:3000/alice/myrepo
|
|
347
|
+
|
|
348
|
+
# Push (requires write access via WAC)
|
|
349
|
+
cd myrepo
|
|
350
|
+
echo "New content" >> README.md
|
|
351
|
+
git add . && git commit -m "Update"
|
|
352
|
+
git push
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Git operations respect WAC permissions - clone requires Read access, push requires Write access.
|
|
356
|
+
|
|
357
|
+
### Git Push with Nostr Authentication
|
|
358
|
+
|
|
359
|
+
Git push supports NIP-98 authentication via Basic Auth. Create a credential helper:
|
|
360
|
+
|
|
361
|
+
```javascript
|
|
362
|
+
// git-credential-nostr.js
|
|
363
|
+
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
|
|
364
|
+
import { hexToBytes } from '@noble/hashes/utils';
|
|
365
|
+
import fs from 'fs';
|
|
366
|
+
import readline from 'readline';
|
|
367
|
+
|
|
368
|
+
const sk = hexToBytes(fs.readFileSync('.nostr-key', 'utf8').trim());
|
|
369
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
370
|
+
const params = {};
|
|
371
|
+
for await (const line of rl) {
|
|
372
|
+
if (!line) break;
|
|
373
|
+
const [key, ...vals] = line.split('=');
|
|
374
|
+
params[key] = vals.join('=');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (params.protocol && params.host) {
|
|
378
|
+
let path = params.path || '/';
|
|
379
|
+
path = path.replace(/\/info\/refs$/, '').replace(/\/git-.*$/, '');
|
|
380
|
+
const baseUrl = `${params.protocol}://${params.host}${path}`;
|
|
381
|
+
|
|
382
|
+
const event = finalizeEvent({
|
|
383
|
+
kind: 27235,
|
|
384
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
385
|
+
tags: [['u', baseUrl], ['method', '*']],
|
|
386
|
+
content: ''
|
|
387
|
+
}, sk);
|
|
388
|
+
|
|
389
|
+
console.log('username=nostr');
|
|
390
|
+
console.log('password=' + Buffer.from(JSON.stringify(event)).toString('base64'));
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Configure git to use it:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
git config credential.helper 'node /path/to/git-credential-nostr.js'
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Add the Nostr identity to your ACL:
|
|
401
|
+
|
|
402
|
+
```turtle
|
|
403
|
+
<#nostr-writer>
|
|
404
|
+
a acl:Authorization;
|
|
405
|
+
acl:agent <did:nostr:YOUR_64_CHAR_HEX_PUBKEY>;
|
|
406
|
+
acl:accessTo <./>;
|
|
407
|
+
acl:default <./>;
|
|
408
|
+
acl:mode acl:Read, acl:Write.
|
|
409
|
+
```
|
|
410
|
+
|
|
321
411
|
## Authentication
|
|
322
412
|
|
|
323
413
|
### Simple Tokens (Development)
|
|
@@ -531,7 +621,8 @@ src/
|
|
|
531
621
|
├── server.js # Fastify setup
|
|
532
622
|
├── handlers/
|
|
533
623
|
│ ├── resource.js # GET, PUT, DELETE, HEAD, PATCH
|
|
534
|
-
│
|
|
624
|
+
│ ├── container.js # POST, pod creation
|
|
625
|
+
│ └── git.js # Git HTTP backend
|
|
535
626
|
├── storage/
|
|
536
627
|
│ └── filesystem.js # File operations
|
|
537
628
|
├── auth/
|
package/bin/jss.js
CHANGED
|
@@ -54,6 +54,8 @@ program
|
|
|
54
54
|
.option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
|
|
55
55
|
.option('--no-mashlib', 'Disable Mashlib data browser')
|
|
56
56
|
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
|
|
57
|
+
.option('--git', 'Enable Git HTTP backend (clone/push support)')
|
|
58
|
+
.option('--no-git', 'Disable Git HTTP backend')
|
|
57
59
|
.option('-q, --quiet', 'Suppress log output')
|
|
58
60
|
.option('--print-config', 'Print configuration and exit')
|
|
59
61
|
.action(async (options) => {
|
|
@@ -95,6 +97,7 @@ program
|
|
|
95
97
|
mashlib: config.mashlib || config.mashlibCdn,
|
|
96
98
|
mashlibCdn: config.mashlibCdn,
|
|
97
99
|
mashlibVersion: config.mashlibVersion,
|
|
100
|
+
git: config.git,
|
|
98
101
|
});
|
|
99
102
|
|
|
100
103
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -113,6 +116,7 @@ program
|
|
|
113
116
|
} else if (config.mashlib) {
|
|
114
117
|
console.log(` Mashlib: local (data browser enabled)`);
|
|
115
118
|
}
|
|
119
|
+
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
116
120
|
console.log('\n Press Ctrl+C to stop\n');
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jwks": {
|
|
3
|
+
"keys": [
|
|
4
|
+
{
|
|
5
|
+
"kty": "RSA",
|
|
6
|
+
"n": "zVVbuPqzcpJI4er_VpHQQd8UgRDI-PcLlwUGRG3DGqMvAWzB4UX2m5e5Uhc2vnzBC2U_1bZ5u3ovhZZhXqqj0ItBTOWjhjOZz4YpaPiwM68sqp_JUNNZPSLljhXnEhEv8q57Wcl0N8orJeuSmfjh7shsLKN-3nbWjCbQr4oAUneQ_2HNvazUTgRLKYCniS0SaI_ogTwuIJlNdFIc3FAHs4yNoSaXZDkdGWhGn49wYdB58nkjJ2ja8wc-rwNtcKEG7HZWNhoVCredav9jswWgAGodJofAxP_PCjguqn6-TzvGodXM3p0Rj1qaf27Ok39GbtOEwX4upwFKj-yJaZETQQ",
|
|
7
|
+
"e": "AQAB",
|
|
8
|
+
"d": "BAgqbUkP876bW4NMqP-jPB3kJk4k8h2QvtIIj9iraXcdlcyzyGeCKoc5ynq99pLWzBFchebnwEaLjxcXOZ-GaLKJUVgbhEe4XBa1crQaaqNkkEOjtXh28sBAC3CS6VwIyd5C-g3-gBdyTjPwXKFiV1jcZep-c-IXv6gF9kJyk-vv3aT6VabcqRBOk4PeNxL_VJt65x5JQBqyV06fv7OBrt15ez0dZL-fYMPhMhBZom0YGCkMVAC4VHAOmoVuHk3hQQCQv5x9-hq27qfgHAlkSKxTEDp2aJi4nIfzqEnFHrBqB9BwR0JEyV3ankVPzBcfEGffi5XY3kWK3EDE3oX9SQ",
|
|
9
|
+
"p": "6ZGd_spW6xSiTNwlU-to7Qr06dr1hXzWLyFbJM9_APFxE761WJtAsN50oNg-rKRQk2JdlC0N04OFYWBED-pvJ0AIEVnKJfWsxEQaBkRueZefUcf-0L1M2Q7-LQdqz9qTR3hSZ42lJhPKMdLRI2WrxTz1v1p0w0yl3bwcbBRLtTk",
|
|
10
|
+
"q": "4Q2QFyMKQUQhr4-JlQbI3VtBhfP-65oZg-13hG5Xc9kT0qo_-79YCK_4wMUX4WaSH6fG6lobTgZento3d6MXCAFpEl8WM4uRRNlzTLCC2FDZXms8i6hJHN89QFPLEPQd5bDXsw8pmA3ANIeHcwzR243VuWAYeQfeOJ-qymUjlkk",
|
|
11
|
+
"dp": "AUldDm885VSaxEOeLQUp8cxSpwseuRqD74SGhQBjmbS6w7oUM6W_SHohOFWYmsjY7Mbo7w0Ee3rI_E1UcqX-8L9oi_frpiPhTL93STuNRDwyk3e_jpTMXJG5krPswbJZh1ZBVfKwyzHmtjmMD17bAF4imGg-JmlArKUBnxLJi_k",
|
|
12
|
+
"dq": "A6Z7qtRnqy1WuolCewdUJLsBMhIGFX43YbttT9mWU4u21ZjrVsMAw4tPJplLzN0kC51mDZEOllJmIH97nNYpXnjfYmvmaUmfPpWkWB8Y0Ddnfy-QGNfO78fzL2LsjUbYYUxgA0iArTWz42Y7XTNdCAmh6NLVMslc4mA8nfHMBPk",
|
|
13
|
+
"qi": "Wikwy4ljfyLLHDO1Fpm2hn-_G7rWbwOP0ANJzY62cIEorbyyr4zAaaPflGMrDJHadlLdww_x2L1g2lNePN7mY97kFJVL0ceuwUmxDr434LqyRZWTrTP5qP6vrkjGjMImBuJEftXdOyJElcqHgtpvltydZRjlvhugnxcOKRble3Y",
|
|
14
|
+
"kid": "509868cc-c850-4708-9331-8e38273df4db",
|
|
15
|
+
"use": "sig",
|
|
16
|
+
"alg": "RS256",
|
|
17
|
+
"iat": 1767263929
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"kty": "EC",
|
|
21
|
+
"x": "KSGjhPP_QyoqHu8v891hEk_TsF5yuQUN0hFBY_qq26o",
|
|
22
|
+
"y": "aTYYEwxOR0tM82FtJoCVJ7u8xtf3y0exoWVJzJAZQ24",
|
|
23
|
+
"crv": "P-256",
|
|
24
|
+
"d": "Mkm_Oh4_emAzg2MbxoMuHcJZo3E-O_-YlK8mlSHWU8M",
|
|
25
|
+
"kid": "92009452-c551-4389-97fd-314ddd59b7d4",
|
|
26
|
+
"use": "sig",
|
|
27
|
+
"alg": "ES256",
|
|
28
|
+
"iat": 1767263929
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"cookieKeys": [
|
|
33
|
+
"IiO299-8bui4UVILu0azGvlCnNTckSGMPMPQSG2HqHw",
|
|
34
|
+
"NbeOVkfw-xN9T4J0_1uzAmDp6tYOwFW-yTJLSd7nqDU"
|
|
35
|
+
],
|
|
36
|
+
"createdAt": "2026-01-01T10:38:49.528Z"
|
|
37
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Adding Git Support to a Solid Server
|
|
2
|
+
|
|
3
|
+
This guide explains how to add Git HTTP backend support to a Solid server, enabling `git clone` and `git push` operations on pod containers.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Git HTTP protocol allows clients to clone and push to repositories over HTTP. This is implemented using Git's built-in `git http-backend` CGI program - the same one used by Apache and Nginx.
|
|
8
|
+
|
|
9
|
+
### How It Works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────┐ HTTP ┌──────────────┐ CGI ┌─────────────────┐
|
|
13
|
+
│ Git Client │ ─────────────▶│ Solid Server │ ────────────▶│ git http-backend│
|
|
14
|
+
│ │◀───────────── │ │◀──────────── │ │
|
|
15
|
+
└─────────────┘ └──────────────┘ └─────────────────┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Clone flow:**
|
|
19
|
+
1. `GET /repo/info/refs?service=git-upload-pack` - Discovery
|
|
20
|
+
2. `POST /repo/git-upload-pack` - Fetch objects
|
|
21
|
+
|
|
22
|
+
**Push flow:**
|
|
23
|
+
1. `GET /repo/info/refs?service=git-receive-pack` - Discovery
|
|
24
|
+
2. `POST /repo/git-receive-pack` - Send objects
|
|
25
|
+
|
|
26
|
+
## Implementation
|
|
27
|
+
|
|
28
|
+
### 1. Detect Git Requests
|
|
29
|
+
|
|
30
|
+
Git protocol requests are identified by URL patterns:
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
function isGitRequest(urlPath) {
|
|
34
|
+
return urlPath.includes('/info/refs') ||
|
|
35
|
+
urlPath.includes('/git-upload-pack') ||
|
|
36
|
+
urlPath.includes('/git-receive-pack');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isGitWriteOperation(urlPath) {
|
|
40
|
+
return urlPath.includes('/git-receive-pack');
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Security: Block Direct .git Access
|
|
45
|
+
|
|
46
|
+
**Important:** Git protocol requests should be allowed, but direct file access to `.git/` contents must be blocked:
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
// BLOCK: Direct access to .git contents (security risk)
|
|
50
|
+
GET /.git/config → 403 Forbidden
|
|
51
|
+
GET /.git/objects/abc123 → 403 Forbidden
|
|
52
|
+
|
|
53
|
+
// ALLOW: Git protocol (handled by git http-backend)
|
|
54
|
+
GET /repo/info/refs?service=git-upload-pack → 200 OK
|
|
55
|
+
POST /repo/git-upload-pack → 200 OK
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Authorization with WAC
|
|
59
|
+
|
|
60
|
+
Check permissions before allowing git operations:
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
// Clone/fetch requires Read access
|
|
64
|
+
// Push requires Write access
|
|
65
|
+
|
|
66
|
+
const needsWrite = isGitWriteOperation(request.url);
|
|
67
|
+
const requiredMode = needsWrite ? 'write' : 'read';
|
|
68
|
+
|
|
69
|
+
const { allowed } = await checkAccess({
|
|
70
|
+
resourceUrl,
|
|
71
|
+
resourcePath,
|
|
72
|
+
agentWebId: request.webId,
|
|
73
|
+
requiredMode
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!allowed) {
|
|
77
|
+
return reply.code(needsWrite ? 403 : 401).send({
|
|
78
|
+
error: needsWrite ? 'Write access required' : 'Read access required'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 4. Git HTTP Backend Handler
|
|
84
|
+
|
|
85
|
+
The core handler spawns `git http-backend` with CGI environment variables:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
import { spawn } from 'child_process';
|
|
89
|
+
|
|
90
|
+
async function handleGit(request, reply) {
|
|
91
|
+
const urlPath = decodeURIComponent(request.url.split('?')[0]);
|
|
92
|
+
const queryString = request.url.split('?')[1] || '';
|
|
93
|
+
|
|
94
|
+
// Build CGI environment
|
|
95
|
+
const env = {
|
|
96
|
+
...process.env,
|
|
97
|
+
GIT_PROJECT_ROOT: dataRoot, // Where repos are stored
|
|
98
|
+
GIT_HTTP_EXPORT_ALL: '', // Allow read access
|
|
99
|
+
GIT_HTTP_RECEIVE_PACK: 'true', // Enable push
|
|
100
|
+
PATH_INFO: urlPath,
|
|
101
|
+
REQUEST_METHOD: request.method,
|
|
102
|
+
CONTENT_TYPE: request.headers['content-type'] || '',
|
|
103
|
+
QUERY_STRING: queryString,
|
|
104
|
+
CONTENT_LENGTH: request.headers['content-length'] || '0',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// For non-bare repos, set GIT_DIR to .git subdirectory
|
|
108
|
+
if (isRegularRepo) {
|
|
109
|
+
env.GIT_DIR = path.join(repoPath, '.git');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Spawn git http-backend
|
|
113
|
+
const child = spawn('git', ['http-backend'], { env });
|
|
114
|
+
|
|
115
|
+
// Send request body (for POST requests)
|
|
116
|
+
if (request.body && request.body.length > 0) {
|
|
117
|
+
child.stdin.write(request.body);
|
|
118
|
+
}
|
|
119
|
+
child.stdin.end();
|
|
120
|
+
|
|
121
|
+
// Parse CGI response and send to client
|
|
122
|
+
// ... (see full implementation below)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 5. CGI Response Parsing
|
|
127
|
+
|
|
128
|
+
Git http-backend outputs CGI format (headers + body). Parse and forward:
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
let buffer = Buffer.alloc(0);
|
|
132
|
+
let headersSent = false;
|
|
133
|
+
|
|
134
|
+
child.stdout.on('data', (data) => {
|
|
135
|
+
buffer = Buffer.concat([buffer, data]);
|
|
136
|
+
|
|
137
|
+
if (!headersSent) {
|
|
138
|
+
// Find header/body separator (try both \r\n\r\n and \n\n)
|
|
139
|
+
let headerEnd = buffer.indexOf('\r\n\r\n');
|
|
140
|
+
let sep = '\r\n';
|
|
141
|
+
let sepLen = 4;
|
|
142
|
+
|
|
143
|
+
if (headerEnd === -1) {
|
|
144
|
+
headerEnd = buffer.indexOf('\n\n');
|
|
145
|
+
sep = '\n';
|
|
146
|
+
sepLen = 2;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (headerEnd !== -1) {
|
|
150
|
+
const headerSection = buffer.subarray(0, headerEnd).toString();
|
|
151
|
+
const bodySection = buffer.subarray(headerEnd + sepLen);
|
|
152
|
+
|
|
153
|
+
// Parse CGI headers
|
|
154
|
+
for (const line of headerSection.split(sep)) {
|
|
155
|
+
const colonIdx = line.indexOf(':');
|
|
156
|
+
if (colonIdx > 0) {
|
|
157
|
+
const key = line.substring(0, colonIdx).trim();
|
|
158
|
+
const value = line.substring(colonIdx + 1).trim();
|
|
159
|
+
|
|
160
|
+
if (key.toLowerCase() === 'status') {
|
|
161
|
+
statusCode = parseInt(value.split(' ')[0], 10);
|
|
162
|
+
} else {
|
|
163
|
+
reply.raw.setHeader(key, value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
reply.raw.writeHead(statusCode);
|
|
169
|
+
reply.raw.write(bodySection);
|
|
170
|
+
headersSent = true;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
reply.raw.write(buffer);
|
|
174
|
+
}
|
|
175
|
+
buffer = Buffer.alloc(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
child.stdout.on('end', () => {
|
|
179
|
+
reply.raw.end();
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Repository Setup
|
|
184
|
+
|
|
185
|
+
### Regular Repository (with working directory)
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
cd /path/to/pod/myrepo
|
|
189
|
+
git init
|
|
190
|
+
echo "# My Project" > README.md
|
|
191
|
+
git add .
|
|
192
|
+
git commit -m "Initial commit"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Bare Repository (server-only, more efficient)
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
cd /path/to/pod
|
|
199
|
+
git init --bare myrepo.git
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### ACL for Public Clone
|
|
203
|
+
|
|
204
|
+
Create `/path/to/pod/myrepo/.acl`:
|
|
205
|
+
|
|
206
|
+
```turtle
|
|
207
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
208
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
209
|
+
|
|
210
|
+
<#public>
|
|
211
|
+
a acl:Authorization;
|
|
212
|
+
acl:agentClass foaf:Agent;
|
|
213
|
+
acl:accessTo <./>;
|
|
214
|
+
acl:default <./>;
|
|
215
|
+
acl:mode acl:Read.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### ACL for Authenticated Push
|
|
219
|
+
|
|
220
|
+
```turtle
|
|
221
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
222
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
223
|
+
|
|
224
|
+
<#owner>
|
|
225
|
+
a acl:Authorization;
|
|
226
|
+
acl:agent <https://alice.example.com/#me>;
|
|
227
|
+
acl:accessTo <./>;
|
|
228
|
+
acl:default <./>;
|
|
229
|
+
acl:mode acl:Read, acl:Write, acl:Control.
|
|
230
|
+
|
|
231
|
+
<#public>
|
|
232
|
+
a acl:Authorization;
|
|
233
|
+
acl:agentClass foaf:Agent;
|
|
234
|
+
acl:accessTo <./>;
|
|
235
|
+
acl:default <./>;
|
|
236
|
+
acl:mode acl:Read.
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Usage
|
|
240
|
+
|
|
241
|
+
### Server
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# Start server with git support enabled
|
|
245
|
+
jss start --git
|
|
246
|
+
|
|
247
|
+
# Or via environment variable
|
|
248
|
+
JSS_GIT=true jss start
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Client
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# Clone
|
|
255
|
+
git clone http://localhost:3000/myrepo
|
|
256
|
+
|
|
257
|
+
# Clone with authentication (if required)
|
|
258
|
+
git clone http://localhost:3000/myrepo
|
|
259
|
+
# Git will prompt for credentials
|
|
260
|
+
|
|
261
|
+
# Push (requires write access)
|
|
262
|
+
cd myrepo
|
|
263
|
+
echo "New content" >> README.md
|
|
264
|
+
git add .
|
|
265
|
+
git commit -m "Update readme"
|
|
266
|
+
git push
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Complete Handler Code
|
|
270
|
+
|
|
271
|
+
See `src/handlers/git.js` in the JSS repository for the full implementation.
|
|
272
|
+
|
|
273
|
+
## References
|
|
274
|
+
|
|
275
|
+
- [Git HTTP Protocol](https://git-scm.com/book/en/v2/Git-on-the-Server-Smart-HTTP)
|
|
276
|
+
- [git-http-backend documentation](https://git-scm.com/docs/git-http-backend)
|
|
277
|
+
- [CGI Specification](https://www.rfc-editor.org/rfc/rfc3875)
|
|
278
|
+
- [Web Access Control (WAC)](https://solidproject.org/TR/wac)
|
|
279
|
+
|
|
280
|
+
## Prior Art
|
|
281
|
+
|
|
282
|
+
- [nosdav/server](https://github.com/nosdav/server) - Git support implementation
|
|
283
|
+
- [QuitStore](https://github.com/AKSW/QuitStore) - Git + RDF versioning
|
package/package.json
CHANGED
package/src/auth/nostr.js
CHANGED
|
@@ -22,24 +22,58 @@ const TIMESTAMP_TOLERANCE = 60;
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Check if request has Nostr authentication
|
|
25
|
+
* Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
|
|
26
|
+
* The Basic format allows git clients to authenticate via NIP-98
|
|
25
27
|
* @param {object} request - Fastify request object
|
|
26
28
|
* @returns {boolean}
|
|
27
29
|
*/
|
|
28
30
|
export function hasNostrAuth(request) {
|
|
29
31
|
const authHeader = request.headers.authorization;
|
|
30
|
-
|
|
32
|
+
if (!authHeader) return false;
|
|
33
|
+
|
|
34
|
+
// Direct Nostr header
|
|
35
|
+
if (authHeader.startsWith('Nostr ')) return true;
|
|
36
|
+
|
|
37
|
+
// Basic auth with username=nostr (for git clients)
|
|
38
|
+
if (authHeader.startsWith('Basic ')) {
|
|
39
|
+
try {
|
|
40
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
|
|
41
|
+
return decoded.startsWith('nostr:');
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
/**
|
|
34
51
|
* Extract token from Nostr authorization header
|
|
52
|
+
* Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
|
|
35
53
|
* @param {string} authHeader - Authorization header value
|
|
36
54
|
* @returns {string|null}
|
|
37
55
|
*/
|
|
38
56
|
export function extractNostrToken(authHeader) {
|
|
39
|
-
if (!authHeader
|
|
40
|
-
|
|
57
|
+
if (!authHeader) return null;
|
|
58
|
+
|
|
59
|
+
// Direct Nostr header
|
|
60
|
+
if (authHeader.startsWith('Nostr ')) {
|
|
61
|
+
return authHeader.slice(6).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Basic auth with username=nostr, password=token
|
|
65
|
+
if (authHeader.startsWith('Basic ')) {
|
|
66
|
+
try {
|
|
67
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
|
|
68
|
+
if (decoded.startsWith('nostr:')) {
|
|
69
|
+
return decoded.slice(6); // Remove "nostr:" prefix to get token
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
41
74
|
}
|
|
42
|
-
|
|
75
|
+
|
|
76
|
+
return null;
|
|
43
77
|
}
|
|
44
78
|
|
|
45
79
|
/**
|
|
@@ -125,16 +159,27 @@ export async function verifyNostrAuth(request) {
|
|
|
125
159
|
const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
|
|
126
160
|
const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
|
|
127
161
|
|
|
128
|
-
|
|
162
|
+
// Check for exact match first
|
|
163
|
+
let urlMatches = normalizedEventUrl === normalizedRequestUrl ||
|
|
164
|
+
normalizedEventUrl === normalizedRequestUrlNoQuery;
|
|
165
|
+
|
|
166
|
+
// For git clients: allow prefix matching (event URL is base of request URL)
|
|
167
|
+
// This enables git credential helpers that sign for the repo base URL
|
|
168
|
+
if (!urlMatches && normalizedRequestUrlNoQuery.startsWith(normalizedEventUrl + '/')) {
|
|
169
|
+
urlMatches = true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!urlMatches) {
|
|
129
173
|
return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
|
|
130
174
|
}
|
|
131
175
|
|
|
132
176
|
// Validate method tag matches request method
|
|
177
|
+
// For git clients: allow '*' as wildcard method
|
|
133
178
|
const eventMethod = getTagValue(event, 'method');
|
|
134
179
|
if (!eventMethod) {
|
|
135
180
|
return { webId: null, error: 'Missing method tag in event' };
|
|
136
181
|
}
|
|
137
|
-
if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
|
|
182
|
+
if (eventMethod !== '*' && eventMethod.toUpperCase() !== request.method.toUpperCase()) {
|
|
138
183
|
return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
|
|
139
184
|
}
|
|
140
185
|
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, statSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a URL path is a Git protocol request
|
|
7
|
+
* @param {string} urlPath - The URL path
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isGitRequest(urlPath) {
|
|
11
|
+
return urlPath.includes('/info/refs') ||
|
|
12
|
+
urlPath.includes('/git-upload-pack') ||
|
|
13
|
+
urlPath.includes('/git-receive-pack');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Determine if this is a write operation (push)
|
|
18
|
+
* @param {string} urlPath - The URL path
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isGitWriteOperation(urlPath) {
|
|
22
|
+
return urlPath.includes('/git-receive-pack');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract the repository path from the URL
|
|
27
|
+
* @param {string} urlPath - The URL path
|
|
28
|
+
* @returns {string|null} The repository relative path or null
|
|
29
|
+
*/
|
|
30
|
+
function extractRepoPath(urlPath) {
|
|
31
|
+
// Remove git service suffixes to get the repo path
|
|
32
|
+
const cleanPath = urlPath
|
|
33
|
+
.replace(/\/info\/refs.*$/, '')
|
|
34
|
+
.replace(/\/git-upload-pack$/, '')
|
|
35
|
+
.replace(/\/git-receive-pack$/, '');
|
|
36
|
+
|
|
37
|
+
// Remove leading slash
|
|
38
|
+
return cleanPath.replace(/^\//, '') || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find the git directory for a path
|
|
43
|
+
* @param {string} repoPath - Absolute path to check
|
|
44
|
+
* @returns {{gitDir: string, isRegular: boolean}|null}
|
|
45
|
+
*/
|
|
46
|
+
function findGitDir(repoPath) {
|
|
47
|
+
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for regular repo with .git subdirectory
|
|
52
|
+
const dotGitPath = join(repoPath, '.git');
|
|
53
|
+
if (existsSync(dotGitPath) && statSync(dotGitPath).isDirectory()) {
|
|
54
|
+
return { gitDir: dotGitPath, isRegular: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for bare repository
|
|
58
|
+
const objectsPath = join(repoPath, 'objects');
|
|
59
|
+
const refsPath = join(repoPath, 'refs');
|
|
60
|
+
if (existsSync(objectsPath) && existsSync(refsPath)) {
|
|
61
|
+
return { gitDir: repoPath, isRegular: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle Git HTTP requests using git http-backend
|
|
69
|
+
* @param {FastifyRequest} request
|
|
70
|
+
* @param {FastifyReply} reply
|
|
71
|
+
*/
|
|
72
|
+
export async function handleGit(request, reply) {
|
|
73
|
+
const urlPath = decodeURIComponent(request.url.split('?')[0]);
|
|
74
|
+
const queryString = request.url.split('?')[1] || '';
|
|
75
|
+
|
|
76
|
+
// Extract repository path
|
|
77
|
+
const repoRelative = extractRepoPath(urlPath);
|
|
78
|
+
if (!repoRelative) {
|
|
79
|
+
return reply.code(400).send({ error: 'Invalid git request' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle subdomain mode
|
|
83
|
+
let dataRoot = process.env.DATA_ROOT || './data';
|
|
84
|
+
if (request.podName) {
|
|
85
|
+
dataRoot = join(dataRoot, request.podName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const repoAbs = join(dataRoot, repoRelative);
|
|
89
|
+
|
|
90
|
+
// Find git directory
|
|
91
|
+
const gitInfo = findGitDir(repoAbs);
|
|
92
|
+
if (!gitInfo) {
|
|
93
|
+
return reply.code(404).send({ error: 'Not a git repository' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build CGI environment
|
|
97
|
+
const env = {
|
|
98
|
+
...process.env,
|
|
99
|
+
GIT_PROJECT_ROOT: dataRoot,
|
|
100
|
+
GIT_HTTP_EXPORT_ALL: '', // Allow read access
|
|
101
|
+
GIT_HTTP_RECEIVE_PACK: 'true', // Enable push
|
|
102
|
+
GIT_CONFIG_PARAMETERS: "'uploadpack.allowTipSHA1InWant=true'",
|
|
103
|
+
PATH_INFO: urlPath,
|
|
104
|
+
REQUEST_METHOD: request.method,
|
|
105
|
+
CONTENT_TYPE: request.headers['content-type'] || '',
|
|
106
|
+
QUERY_STRING: queryString,
|
|
107
|
+
REMOTE_USER: request.webId || '', // Pass authenticated user
|
|
108
|
+
CONTENT_LENGTH: request.headers['content-length'] || '0',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// For regular repositories, set GIT_DIR
|
|
112
|
+
if (gitInfo.isRegular) {
|
|
113
|
+
env.GIT_DIR = gitInfo.gitDir;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Spawn git http-backend
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const child = spawn('git', ['http-backend'], { env });
|
|
119
|
+
|
|
120
|
+
let buffer = Buffer.alloc(0);
|
|
121
|
+
let headersSent = false;
|
|
122
|
+
|
|
123
|
+
child.stdout.on('data', (data) => {
|
|
124
|
+
buffer = Buffer.concat([buffer, data]);
|
|
125
|
+
|
|
126
|
+
if (!headersSent) {
|
|
127
|
+
// Look for end of CGI headers (try both \r\n\r\n and \n\n)
|
|
128
|
+
let headerEnd = buffer.indexOf('\r\n\r\n');
|
|
129
|
+
let headerSep = '\r\n';
|
|
130
|
+
let headerEndLen = 4;
|
|
131
|
+
|
|
132
|
+
if (headerEnd === -1) {
|
|
133
|
+
headerEnd = buffer.indexOf('\n\n');
|
|
134
|
+
headerSep = '\n';
|
|
135
|
+
headerEndLen = 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (headerEnd !== -1) {
|
|
139
|
+
const headerSection = buffer.subarray(0, headerEnd).toString();
|
|
140
|
+
const bodySection = buffer.subarray(headerEnd + headerEndLen);
|
|
141
|
+
|
|
142
|
+
// Parse CGI headers and set on raw response
|
|
143
|
+
const lines = headerSection.split(headerSep);
|
|
144
|
+
let statusCode = 200;
|
|
145
|
+
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
const colonIndex = line.indexOf(':');
|
|
148
|
+
if (colonIndex > 0) {
|
|
149
|
+
const key = line.substring(0, colonIndex).trim();
|
|
150
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
151
|
+
|
|
152
|
+
// Handle Status header specially
|
|
153
|
+
if (key.toLowerCase() === 'status') {
|
|
154
|
+
statusCode = parseInt(value.split(' ')[0], 10);
|
|
155
|
+
} else {
|
|
156
|
+
reply.raw.setHeader(key, value);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
reply.raw.writeHead(statusCode);
|
|
162
|
+
headersSent = true;
|
|
163
|
+
reply.raw.write(bodySection);
|
|
164
|
+
buffer = Buffer.alloc(0);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
reply.raw.write(buffer);
|
|
168
|
+
buffer = Buffer.alloc(0);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
child.stdout.on('end', () => {
|
|
173
|
+
reply.raw.end();
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Send request body to git
|
|
178
|
+
// For POST requests, Fastify has already parsed the body into request.body
|
|
179
|
+
if (request.body && request.body.length > 0) {
|
|
180
|
+
child.stdin.write(request.body);
|
|
181
|
+
child.stdin.end();
|
|
182
|
+
} else {
|
|
183
|
+
// For GET requests or empty bodies, just close stdin
|
|
184
|
+
child.stdin.end();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Log errors
|
|
188
|
+
child.stderr.on('data', (data) => {
|
|
189
|
+
console.error('git http-backend stderr:', data.toString());
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
child.on('error', (err) => {
|
|
193
|
+
console.error('Failed to spawn git http-backend:', err);
|
|
194
|
+
if (!headersSent) {
|
|
195
|
+
reply.code(500).send({ error: 'Git backend error' });
|
|
196
|
+
}
|
|
197
|
+
resolve();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
child.on('close', (code) => {
|
|
201
|
+
if (code !== 0 && !headersSent) {
|
|
202
|
+
reply.code(500).send({ error: 'Git operation failed' });
|
|
203
|
+
}
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getCorsHeaders } from './ldp/headers.js';
|
|
|
8
8
|
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
9
9
|
import { notificationsPlugin } from './notifications/index.js';
|
|
10
10
|
import { idpPlugin } from './idp/index.js';
|
|
11
|
+
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
23
24
|
* @param {string} options.root - Data directory path (default from env or ./data)
|
|
24
25
|
* @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
|
|
25
26
|
* @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
|
|
27
|
+
* @param {boolean} options.git - Enable Git HTTP backend for clone/push (default false)
|
|
26
28
|
*/
|
|
27
29
|
export function createServer(options = {}) {
|
|
28
30
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -40,6 +42,8 @@ export function createServer(options = {}) {
|
|
|
40
42
|
const mashlibEnabled = options.mashlib ?? false;
|
|
41
43
|
const mashlibCdn = options.mashlibCdn ?? false;
|
|
42
44
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
45
|
+
// Git HTTP backend is OFF by default - enables clone/push via git protocol
|
|
46
|
+
const gitEnabled = options.git ?? false;
|
|
43
47
|
|
|
44
48
|
// Set data root via environment variable if provided
|
|
45
49
|
if (options.root) {
|
|
@@ -128,16 +132,64 @@ export function createServer(options = {}) {
|
|
|
128
132
|
// Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
// Security: Block access to dotfiles except allowed Solid-specific ones
|
|
136
|
+
// This prevents exposure of .git/, .env, .htpasswd, etc.
|
|
137
|
+
// Git protocol requests bypass this check when git is enabled
|
|
138
|
+
const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta'];
|
|
139
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
140
|
+
// Allow git protocol requests through when git is enabled
|
|
141
|
+
if (gitEnabled && isGitRequest(request.url)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
|
|
146
|
+
const hasForbiddenDotfile = segments.some(seg =>
|
|
147
|
+
seg.startsWith('.') &&
|
|
148
|
+
seg.length > 1 &&
|
|
149
|
+
!ALLOWED_DOTFILES.includes(seg)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (hasForbiddenDotfile) {
|
|
153
|
+
return reply.code(403).send({ error: 'Forbidden', message: 'Dotfile access is not allowed' });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Git HTTP backend handler - uses git http-backend CGI
|
|
158
|
+
// Authorization: Read for clone/fetch, Write for push
|
|
159
|
+
if (gitEnabled) {
|
|
160
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
161
|
+
if (!isGitRequest(request.url)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run WAC authorization - checkAccess already verifies the required mode
|
|
166
|
+
const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
|
|
167
|
+
request.webId = webId;
|
|
168
|
+
request.wacAllow = wacAllow;
|
|
169
|
+
|
|
170
|
+
if (!authorized) {
|
|
171
|
+
const needsWrite = isGitWriteOperation(request.url);
|
|
172
|
+
const message = needsWrite ? 'Write access required for push' : 'Read access required for clone';
|
|
173
|
+
reply.header('WAC-Allow', wacAllow);
|
|
174
|
+
return reply.code(webId ? 403 : 401).send({ error: message });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Handle the git request directly
|
|
178
|
+
return handleGit(request, reply);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
// Authorization hook - check WAC permissions
|
|
132
183
|
// Skip for pod creation endpoint (needs special handling)
|
|
133
184
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
134
|
-
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, and
|
|
185
|
+
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, and git
|
|
135
186
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
136
187
|
if (request.url === '/.pods' ||
|
|
137
188
|
request.url === '/.notifications' ||
|
|
138
189
|
request.method === 'OPTIONS' ||
|
|
139
190
|
request.url.startsWith('/idp/') ||
|
|
140
191
|
request.url.startsWith('/.well-known/') ||
|
|
192
|
+
(gitEnabled && isGitRequest(request.url)) ||
|
|
141
193
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
142
194
|
return;
|
|
143
195
|
}
|