javascript-solid-server 0.0.2
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 +15 -0
- package/README.md +209 -0
- package/benchmark-report-2025-03-31T14-25-24.234Z.json +44 -0
- package/benchmark.js +286 -0
- package/package.json +21 -0
- package/src/handlers/container.js +110 -0
- package/src/handlers/resource.js +162 -0
- package/src/index.js +8 -0
- package/src/ldp/container.js +43 -0
- package/src/ldp/headers.js +78 -0
- package/src/server.js +68 -0
- package/src/storage/filesystem.js +151 -0
- package/src/utils/url.js +83 -0
- package/visualize-results.js +258 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as storage from '../storage/filesystem.js';
|
|
2
|
+
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
|
+
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
|
+
import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handle GET request
|
|
8
|
+
*/
|
|
9
|
+
export async function handleGet(request, reply) {
|
|
10
|
+
const urlPath = request.url.split('?')[0]; // Remove query string
|
|
11
|
+
const stats = await storage.stat(urlPath);
|
|
12
|
+
|
|
13
|
+
if (!stats) {
|
|
14
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const origin = request.headers.origin;
|
|
18
|
+
|
|
19
|
+
// Handle container
|
|
20
|
+
if (stats.isDirectory) {
|
|
21
|
+
const entries = await storage.listContainer(urlPath);
|
|
22
|
+
const baseUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
23
|
+
const jsonLd = generateContainerJsonLd(baseUrl, entries || []);
|
|
24
|
+
|
|
25
|
+
const headers = getAllHeaders({
|
|
26
|
+
isContainer: true,
|
|
27
|
+
etag: stats.etag,
|
|
28
|
+
contentType: 'application/ld+json',
|
|
29
|
+
origin
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
33
|
+
return reply.send(serializeJsonLd(jsonLd));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle resource
|
|
37
|
+
const content = await storage.read(urlPath);
|
|
38
|
+
if (content === null) {
|
|
39
|
+
return reply.code(500).send({ error: 'Read error' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const contentType = getContentType(urlPath);
|
|
43
|
+
const headers = getAllHeaders({
|
|
44
|
+
isContainer: false,
|
|
45
|
+
etag: stats.etag,
|
|
46
|
+
contentType,
|
|
47
|
+
origin
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
51
|
+
return reply.send(content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle HEAD request
|
|
56
|
+
*/
|
|
57
|
+
export async function handleHead(request, reply) {
|
|
58
|
+
const urlPath = request.url.split('?')[0];
|
|
59
|
+
const stats = await storage.stat(urlPath);
|
|
60
|
+
|
|
61
|
+
if (!stats) {
|
|
62
|
+
return reply.code(404).send();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const origin = request.headers.origin;
|
|
66
|
+
const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
|
|
67
|
+
|
|
68
|
+
const headers = getAllHeaders({
|
|
69
|
+
isContainer: stats.isDirectory,
|
|
70
|
+
etag: stats.etag,
|
|
71
|
+
contentType,
|
|
72
|
+
origin
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!stats.isDirectory) {
|
|
76
|
+
headers['Content-Length'] = stats.size;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
80
|
+
return reply.code(200).send();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Handle PUT request
|
|
85
|
+
*/
|
|
86
|
+
export async function handlePut(request, reply) {
|
|
87
|
+
const urlPath = request.url.split('?')[0];
|
|
88
|
+
|
|
89
|
+
// Don't allow PUT to containers
|
|
90
|
+
if (isContainer(urlPath)) {
|
|
91
|
+
return reply.code(409).send({ error: 'Cannot PUT to container. Use POST instead.' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if resource already exists
|
|
95
|
+
const existed = await storage.exists(urlPath);
|
|
96
|
+
|
|
97
|
+
// Get content from request body
|
|
98
|
+
let content = request.body;
|
|
99
|
+
|
|
100
|
+
// Handle raw body for non-JSON content types
|
|
101
|
+
if (Buffer.isBuffer(content)) {
|
|
102
|
+
// Already a buffer, use as-is
|
|
103
|
+
} else if (typeof content === 'string') {
|
|
104
|
+
content = Buffer.from(content);
|
|
105
|
+
} else if (content && typeof content === 'object') {
|
|
106
|
+
content = Buffer.from(JSON.stringify(content));
|
|
107
|
+
} else {
|
|
108
|
+
content = Buffer.from('');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const success = await storage.write(urlPath, content);
|
|
112
|
+
if (!success) {
|
|
113
|
+
return reply.code(500).send({ error: 'Write failed' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const origin = request.headers.origin;
|
|
117
|
+
const headers = getAllHeaders({ isContainer: false, origin });
|
|
118
|
+
headers['Location'] = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
119
|
+
|
|
120
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
121
|
+
return reply.code(existed ? 204 : 201).send();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle DELETE request
|
|
126
|
+
*/
|
|
127
|
+
export async function handleDelete(request, reply) {
|
|
128
|
+
const urlPath = request.url.split('?')[0];
|
|
129
|
+
|
|
130
|
+
const existed = await storage.exists(urlPath);
|
|
131
|
+
if (!existed) {
|
|
132
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const success = await storage.remove(urlPath);
|
|
136
|
+
if (!success) {
|
|
137
|
+
return reply.code(500).send({ error: 'Delete failed' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const origin = request.headers.origin;
|
|
141
|
+
const headers = getAllHeaders({ isContainer: false, origin });
|
|
142
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
143
|
+
|
|
144
|
+
return reply.code(204).send();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handle OPTIONS request
|
|
149
|
+
*/
|
|
150
|
+
export async function handleOptions(request, reply) {
|
|
151
|
+
const urlPath = request.url.split('?')[0];
|
|
152
|
+
const stats = await storage.stat(urlPath);
|
|
153
|
+
|
|
154
|
+
const origin = request.headers.origin;
|
|
155
|
+
const headers = getAllHeaders({
|
|
156
|
+
isContainer: stats?.isDirectory || isContainer(urlPath),
|
|
157
|
+
origin
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
161
|
+
return reply.code(204).send();
|
|
162
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate container representation as JSON-LD
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate JSON-LD representation of a container
|
|
9
|
+
* @param {string} containerUrl - Full URL of the container
|
|
10
|
+
* @param {Array<{name: string, isDirectory: boolean}>} entries - Container contents
|
|
11
|
+
* @returns {object} - JSON-LD representation
|
|
12
|
+
*/
|
|
13
|
+
export function generateContainerJsonLd(containerUrl, entries) {
|
|
14
|
+
// Ensure container URL ends with /
|
|
15
|
+
const baseUrl = containerUrl.endsWith('/') ? containerUrl : containerUrl + '/';
|
|
16
|
+
|
|
17
|
+
const contains = entries.map(entry => {
|
|
18
|
+
const childUrl = baseUrl + entry.name + (entry.isDirectory ? '/' : '');
|
|
19
|
+
return {
|
|
20
|
+
'@id': childUrl,
|
|
21
|
+
'@type': entry.isDirectory ? [`${LDP}Container`, `${LDP}BasicContainer`, `${LDP}Resource`] : [`${LDP}Resource`]
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
'@context': {
|
|
27
|
+
'ldp': LDP,
|
|
28
|
+
'contains': { '@id': 'ldp:contains', '@type': '@id' }
|
|
29
|
+
},
|
|
30
|
+
'@id': baseUrl,
|
|
31
|
+
'@type': ['ldp:Container', 'ldp:BasicContainer', 'ldp:Resource'],
|
|
32
|
+
'contains': contains
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert JSON-LD to string
|
|
38
|
+
* @param {object} jsonLd
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function serializeJsonLd(jsonLd) {
|
|
42
|
+
return JSON.stringify(jsonLd, null, 2);
|
|
43
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LDP (Linked Data Platform) header utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get Link headers for a resource
|
|
9
|
+
* @param {boolean} isContainer
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function getLinkHeader(isContainer) {
|
|
13
|
+
const links = [`<${LDP}Resource>; rel="type"`];
|
|
14
|
+
|
|
15
|
+
if (isContainer) {
|
|
16
|
+
links.push(`<${LDP}Container>; rel="type"`);
|
|
17
|
+
links.push(`<${LDP}BasicContainer>; rel="type"`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return links.join(', ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get standard LDP response headers
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @returns {object}
|
|
27
|
+
*/
|
|
28
|
+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null }) {
|
|
29
|
+
const headers = {
|
|
30
|
+
'Link': getLinkHeader(isContainer),
|
|
31
|
+
'WAC-Allow': 'user="read write append control", public="read write append"',
|
|
32
|
+
'Accept-Patch': 'application/sparql-update',
|
|
33
|
+
'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
34
|
+
'Vary': 'Accept, Authorization, Origin'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (isContainer) {
|
|
38
|
+
headers['Accept-Post'] = '*/*';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (etag) {
|
|
42
|
+
headers['ETag'] = etag;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (contentType) {
|
|
46
|
+
headers['Content-Type'] = contentType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return headers;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get CORS headers
|
|
54
|
+
* @param {string} origin
|
|
55
|
+
* @returns {object}
|
|
56
|
+
*/
|
|
57
|
+
export function getCorsHeaders(origin) {
|
|
58
|
+
return {
|
|
59
|
+
'Access-Control-Allow-Origin': origin || '*',
|
|
60
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
61
|
+
'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, If-Match, If-None-Match, Link, Slug, Origin',
|
|
62
|
+
'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, WAC-Allow',
|
|
63
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
64
|
+
'Access-Control-Max-Age': '86400'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get all headers combined
|
|
70
|
+
* @param {object} options
|
|
71
|
+
* @returns {object}
|
|
72
|
+
*/
|
|
73
|
+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null }) {
|
|
74
|
+
return {
|
|
75
|
+
...getResponseHeaders({ isContainer, etag, contentType }),
|
|
76
|
+
...getCorsHeaders(origin)
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
|
|
3
|
+
import { handlePost, handleCreatePod } from './handlers/container.js';
|
|
4
|
+
import { getCorsHeaders } from './ldp/headers.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create and configure Fastify server
|
|
8
|
+
*/
|
|
9
|
+
export function createServer(options = {}) {
|
|
10
|
+
const fastify = Fastify({
|
|
11
|
+
logger: options.logger ?? true,
|
|
12
|
+
trustProxy: true,
|
|
13
|
+
// Handle raw body for non-JSON content
|
|
14
|
+
bodyLimit: 10 * 1024 * 1024 // 10MB
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Add raw body parser for all content types
|
|
18
|
+
fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
|
|
19
|
+
done(null, body);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Global CORS preflight
|
|
23
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
24
|
+
// Add CORS headers to all responses
|
|
25
|
+
const corsHeaders = getCorsHeaders(request.headers.origin);
|
|
26
|
+
Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));
|
|
27
|
+
|
|
28
|
+
// Handle preflight
|
|
29
|
+
if (request.method === 'OPTIONS') {
|
|
30
|
+
reply.code(204).send();
|
|
31
|
+
return reply;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Pod creation endpoint
|
|
36
|
+
fastify.post('/.pods', handleCreatePod);
|
|
37
|
+
|
|
38
|
+
// LDP routes - using wildcard routing
|
|
39
|
+
fastify.get('/*', handleGet);
|
|
40
|
+
fastify.head('/*', handleHead);
|
|
41
|
+
fastify.put('/*', handlePut);
|
|
42
|
+
fastify.delete('/*', handleDelete);
|
|
43
|
+
fastify.post('/*', handlePost);
|
|
44
|
+
fastify.options('/*', handleOptions);
|
|
45
|
+
|
|
46
|
+
// Root route
|
|
47
|
+
fastify.get('/', handleGet);
|
|
48
|
+
fastify.head('/', handleHead);
|
|
49
|
+
fastify.options('/', handleOptions);
|
|
50
|
+
fastify.post('/', handlePost);
|
|
51
|
+
|
|
52
|
+
return fastify;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Start the server
|
|
57
|
+
*/
|
|
58
|
+
export async function startServer(port = 3000, host = '0.0.0.0') {
|
|
59
|
+
const server = createServer();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await server.listen({ port, host });
|
|
63
|
+
return server;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
server.log.error(err);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { DATA_ROOT, urlToPath, isContainer } from '../utils/url.js';
|
|
5
|
+
|
|
6
|
+
// Ensure data directory exists
|
|
7
|
+
fs.ensureDirSync(DATA_ROOT);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if resource exists
|
|
11
|
+
* @param {string} urlPath
|
|
12
|
+
* @returns {Promise<boolean>}
|
|
13
|
+
*/
|
|
14
|
+
export async function exists(urlPath) {
|
|
15
|
+
const filePath = urlToPath(urlPath);
|
|
16
|
+
return fs.pathExists(filePath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get resource stats
|
|
21
|
+
* @param {string} urlPath
|
|
22
|
+
* @returns {Promise<{isDirectory: boolean, size: number, mtime: Date, etag: string} | null>}
|
|
23
|
+
*/
|
|
24
|
+
export async function stat(urlPath) {
|
|
25
|
+
const filePath = urlToPath(urlPath);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const stats = await fs.stat(filePath);
|
|
29
|
+
return {
|
|
30
|
+
isDirectory: stats.isDirectory(),
|
|
31
|
+
size: stats.size,
|
|
32
|
+
mtime: stats.mtime,
|
|
33
|
+
etag: `"${crypto.createHash('md5').update(stats.mtime.toISOString() + stats.size).digest('hex')}"`
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read resource content
|
|
42
|
+
* @param {string} urlPath
|
|
43
|
+
* @returns {Promise<Buffer | null>}
|
|
44
|
+
*/
|
|
45
|
+
export async function read(urlPath) {
|
|
46
|
+
const filePath = urlToPath(urlPath);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return await fs.readFile(filePath);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write resource content
|
|
57
|
+
* @param {string} urlPath
|
|
58
|
+
* @param {Buffer | string} content
|
|
59
|
+
* @returns {Promise<boolean>}
|
|
60
|
+
*/
|
|
61
|
+
export async function write(urlPath, content) {
|
|
62
|
+
const filePath = urlToPath(urlPath);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Ensure parent directory exists
|
|
66
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
67
|
+
await fs.writeFile(filePath, content);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error('Write error:', err);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delete resource
|
|
77
|
+
* @param {string} urlPath
|
|
78
|
+
* @returns {Promise<boolean>}
|
|
79
|
+
*/
|
|
80
|
+
export async function remove(urlPath) {
|
|
81
|
+
const filePath = urlToPath(urlPath);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await fs.remove(filePath);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create container (directory)
|
|
93
|
+
* @param {string} urlPath
|
|
94
|
+
* @returns {Promise<boolean>}
|
|
95
|
+
*/
|
|
96
|
+
export async function createContainer(urlPath) {
|
|
97
|
+
const filePath = urlToPath(urlPath);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await fs.ensureDir(filePath);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* List container contents
|
|
109
|
+
* @param {string} urlPath
|
|
110
|
+
* @returns {Promise<Array<{name: string, isDirectory: boolean}> | null>}
|
|
111
|
+
*/
|
|
112
|
+
export async function listContainer(urlPath) {
|
|
113
|
+
const filePath = urlToPath(urlPath);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const entries = await fs.readdir(filePath, { withFileTypes: true });
|
|
117
|
+
return entries.map(entry => ({
|
|
118
|
+
name: entry.name,
|
|
119
|
+
isDirectory: entry.isDirectory()
|
|
120
|
+
}));
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate unique filename for POST
|
|
128
|
+
* @param {string} containerPath
|
|
129
|
+
* @param {string} slug
|
|
130
|
+
* @param {boolean} isDir
|
|
131
|
+
* @returns {Promise<string>}
|
|
132
|
+
*/
|
|
133
|
+
export async function generateUniqueFilename(containerPath, slug, isDir = false) {
|
|
134
|
+
const basePath = urlToPath(containerPath);
|
|
135
|
+
let name = slug || crypto.randomUUID();
|
|
136
|
+
|
|
137
|
+
// Remove any path traversal attempts
|
|
138
|
+
name = name.replace(/[/\\]/g, '-');
|
|
139
|
+
|
|
140
|
+
let candidate = path.join(basePath, name);
|
|
141
|
+
let counter = 1;
|
|
142
|
+
|
|
143
|
+
while (await fs.pathExists(candidate)) {
|
|
144
|
+
const ext = path.extname(name);
|
|
145
|
+
const base = path.basename(name, ext);
|
|
146
|
+
candidate = path.join(basePath, `${base}-${counter}${ext}`);
|
|
147
|
+
counter++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return path.basename(candidate);
|
|
151
|
+
}
|
package/src/utils/url.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
// Base directory for storing all pods
|
|
4
|
+
export const DATA_ROOT = process.env.DATA_ROOT || './data';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert URL path to filesystem path
|
|
8
|
+
* @param {string} urlPath - The URL path (e.g., /alice/profile/)
|
|
9
|
+
* @returns {string} - Filesystem path
|
|
10
|
+
*/
|
|
11
|
+
export function urlToPath(urlPath) {
|
|
12
|
+
// Normalize: remove leading slash, decode URI
|
|
13
|
+
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
|
|
14
|
+
normalized = decodeURIComponent(normalized);
|
|
15
|
+
|
|
16
|
+
// Security: prevent path traversal
|
|
17
|
+
normalized = normalized.replace(/\.\./g, '');
|
|
18
|
+
|
|
19
|
+
return path.join(DATA_ROOT, normalized);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if URL path represents a container (ends with /)
|
|
24
|
+
* @param {string} urlPath
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isContainer(urlPath) {
|
|
28
|
+
return urlPath.endsWith('/');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the parent container path
|
|
33
|
+
* @param {string} urlPath
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function getParentContainer(urlPath) {
|
|
37
|
+
const parts = urlPath.replace(/\/$/, '').split('/');
|
|
38
|
+
parts.pop();
|
|
39
|
+
return parts.join('/') + '/';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get resource name from URL path
|
|
44
|
+
* @param {string} urlPath
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function getResourceName(urlPath) {
|
|
48
|
+
const parts = urlPath.replace(/\/$/, '').split('/');
|
|
49
|
+
return parts[parts.length - 1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Determine content type from file extension
|
|
54
|
+
* @param {string} filePath
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function getContentType(filePath) {
|
|
58
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
59
|
+
const types = {
|
|
60
|
+
'.jsonld': 'application/ld+json',
|
|
61
|
+
'.json': 'application/json',
|
|
62
|
+
'.html': 'text/html',
|
|
63
|
+
'.txt': 'text/plain',
|
|
64
|
+
'.css': 'text/css',
|
|
65
|
+
'.js': 'application/javascript',
|
|
66
|
+
'.png': 'image/png',
|
|
67
|
+
'.jpg': 'image/jpeg',
|
|
68
|
+
'.jpeg': 'image/jpeg',
|
|
69
|
+
'.gif': 'image/gif',
|
|
70
|
+
'.svg': 'image/svg+xml',
|
|
71
|
+
'.pdf': 'application/pdf'
|
|
72
|
+
};
|
|
73
|
+
return types[ext] || 'application/octet-stream';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if content type is RDF
|
|
78
|
+
* @param {string} contentType
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isRdfContentType(contentType) {
|
|
82
|
+
return contentType === 'application/ld+json' || contentType === 'application/json';
|
|
83
|
+
}
|