javascript-solid-server 0.0.36 → 0.0.37
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/bin/jss.js +4 -0
- package/docs/git-support.md +283 -0
- package/package.json +1 -1
- package/src/handlers/git.js +207 -0
- package/src/server.js +53 -1
|
@@ -114,7 +114,9 @@
|
|
|
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:*)"
|
|
118
120
|
]
|
|
119
121
|
}
|
|
120
122
|
}
|
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,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
|
@@ -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
|
}
|