import path from 'path';
// Base directory for storing all pods
// Use a getter function to read env var at runtime (not import time)
// This is necessary because ES modules are loaded before the CLI sets the env var
export function getDataRoot() {
return process.env.DATA_ROOT || './data';
}
// Legacy export - kept for compatibility, but callers should use getDataRoot()
export let DATA_ROOT = './data';
// Update DATA_ROOT when env var is set (called from storage init)
export function updateDataRoot() {
DATA_ROOT = getDataRoot();
}
/**
* Convert URL path to filesystem path
* @param {string} urlPath - The URL path (e.g., /alice/profile/)
* @returns {string} - Filesystem path
* @throws {Error} - If path traversal is detected
*/
export function urlToPath(urlPath) {
// Normalize: remove leading slash, decode URI
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
normalized = decodeURIComponent(normalized);
// Security: remove path traversal attempts (multiple passes for ....// bypass)
let previous;
do {
previous = normalized;
normalized = normalized.replace(/\.\./g, '');
} while (normalized !== previous);
// Resolve to absolute path and verify it's within DATA_ROOT
const dataRoot = path.resolve(getDataRoot());
const resolved = path.resolve(dataRoot, normalized);
// Ensure resolved path is within dataRoot (prevent traversal via path.resolve tricks)
if (!resolved.startsWith(dataRoot + path.sep) && resolved !== dataRoot) {
throw new Error('Path traversal detected');
}
return resolved;
}
/**
* Convert URL path to filesystem path in subdomain mode
* In subdomain mode, the pod is determined by the hostname, not the path
* @param {string} urlPath - The URL path (e.g., /public/file.txt)
* @param {string} podName - The pod name from subdomain (e.g., "alice")
* @returns {string} - Filesystem path (e.g., DATA_ROOT/alice/public/file.txt)
* @throws {Error} - If path traversal is detected
*/
export function urlToPathWithPod(urlPath, podName) {
// Normalize: remove leading slash, decode URI
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
normalized = decodeURIComponent(normalized);
// Security: remove path traversal attempts (multiple passes for ....// bypass)
let previous;
do {
previous = normalized;
normalized = normalized.replace(/\.\./g, '');
} while (normalized !== previous);
// Also sanitize podName (multiple passes for ....// bypass)
let safePodName = podName;
let previousPod;
do {
previousPod = safePodName;
safePodName = safePodName.replace(/\.\./g, '');
} while (safePodName !== previousPod);
// Resolve to absolute path and verify it's within DATA_ROOT
const dataRoot = path.resolve(getDataRoot());
const resolved = path.resolve(dataRoot, safePodName, normalized);
// Ensure resolved path is within dataRoot (prevent traversal via path.resolve tricks)
if (!resolved.startsWith(dataRoot + path.sep) && resolved !== dataRoot) {
throw new Error('Path traversal detected');
}
return resolved;
}
/**
* Get the effective path for a request (subdomain-aware)
* @param {object} request - Fastify request object
* @returns {string} - Filesystem path
*/
export function getPathFromRequest(request) {
const urlPath = request.url.split('?')[0];
// In subdomain mode with a recognized pod subdomain
if (request.subdomainsEnabled && request.podName) {
return urlToPathWithPod(urlPath, request.podName);
}
// Path-based mode (default)
return urlToPath(urlPath);
}
/**
* Get the effective URL path for a request (with pod prefix in subdomain mode)
* @param {object} request - Fastify request object
* @returns {string} - URL path with pod prefix if needed
*/
export function getEffectiveUrlPath(request) {
const urlPath = request.url.split('?')[0];
// In subdomain mode with a recognized pod subdomain, prepend pod name
if (request.subdomainsEnabled && request.podName) {
return '/' + request.podName + urlPath;
}
return urlPath;
}
/**
* Check if URL path represents a container (ends with /)
* @param {string} urlPath
* @returns {boolean}
*/
export function isContainer(urlPath) {
return urlPath.endsWith('/');
}
/**
* Get the parent container path
* @param {string} urlPath
* @returns {string}
*/
export function getParentContainer(urlPath) {
const parts = urlPath.replace(/\/$/, '').split('/');
parts.pop();
return parts.join('/') + '/';
}
/**
* Get resource name from URL path
* @param {string} urlPath
* @returns {string}
*/
export function getResourceName(urlPath) {
const parts = urlPath.replace(/\/$/, '').split('/');
return parts[parts.length - 1];
}
/**
* Extract pod name from URL path or request
*
* Resolves to one of four shapes, by deployment mode:
*
* - Subdomain mode with a recognized subdomain `request.podName` (from hostname).
* - Subdomain mode with no recognized subdomain `null` (base-domain access;
* callers guard with `if (podName)` and skip pod-scoped side effects).
* - Single-user, root-pod (`singleUserName` empty or '/') `'.'` so
* `path.join(dataRoot, '.', QUOTA_FILE)` collapses to `/QUOTA_FILE`.
* - Single-user, named pod `singleUserName` (all requests share the one pod,
* independent of URL avoids mistaking a URL segment like `index.html`
* for a pod name).
* - Path-based multi-pod (default, no flags) first URL segment, or `null`
* for requests at `/` that aren't inside any pod.
*
* Background: before this function knew about single-user mode, a
* `PUT /index.html` on a single-user root-pod deployment produced a pod name
* of `"index.html"`, and the quota sidecar landed at
* `/index.html/.quota.json` `ENOTDIR` (index.html is a file).
*
* @param {string|object} pathOrRequest - URL path string or Fastify request object
* @returns {string|null} - Pod name, `'.'` for root-pod, or `null` when no pod applies
*/
export function getPodName(pathOrRequest) {
if (typeof pathOrRequest === 'object' && pathOrRequest !== null) {
// Subdomain mode: hostname drives it. Unrecognized host no pod.
if (pathOrRequest.subdomainsEnabled) {
return pathOrRequest.podName || null;
}
// Single-user mode: always the one pod, regardless of URL path.
if (pathOrRequest.singleUser) {
const name = pathOrRequest.singleUserName;
return (!name || name === '/') ? '.' : name;
}
// Path-based multi-pod: first URL segment.
const urlPath = pathOrRequest.url?.split('?')[0] || '';
return getPodNameFromPath(urlPath);
}
// String form: path-based pod extraction.
return getPodNameFromPath(pathOrRequest);
}
/**
* Extract pod name from URL path
* @param {string} urlPath - URL path (e.g., /alice/public/file.txt)
* @returns {string|null} - Pod name or null
*/
function getPodNameFromPath(urlPath) {
const parts = urlPath.split('/').filter(Boolean);
if (parts.length === 0) return null;
// First segment is the pod name (skip system paths)
const firstPart = parts[0];
if (firstPart.startsWith('.')) return null; // .well-known, .acl, etc.
return firstPart;
}
/**
* Determine content type from file extension
* @param {string} filePath
* @returns {string}
*/
export function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const types = {
'.jsonld': 'application/ld+json',
'.json': 'application/json',
'.html': 'text/html',
'.txt': 'text/plain',
'.css': 'text/css',
'.js': 'application/javascript',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.ttl': 'text/turtle',
'.n3': 'text/n3',
'.nt': 'application/n-triples',
'.rdf': 'application/rdf+xml',
'.nq': 'application/n-quads',
'.trig': 'application/trig',
'.md': 'text/markdown',
'.m3u': 'audio/mpegurl',
'.m3u8': 'application/vnd.apple.mpegurl',
'.pls': 'audio/x-scpls',
// Solid ACL/meta as extensions (e.g. publicTypeIndex.jsonld.acl)
'.acl': 'application/ld+json',
'.meta': 'application/ld+json'
};
// Solid convention dotfiles (.acl, .meta) are RDF resources. path.extname
// returns '' for leading-dot names, so the map lookup above misses them;
// fall back to a basename check and tag them as JSON-LD the format JSS
// writes them in via serializeAcl() / createPodStructure(). Content
// negotiation then handles Turtle-native clients (umai, Soukai-based apps,
// older Solid tooling) via handleGet's conneg branch.
const base = path.basename(filePath);
if (base === '.acl' || base === '.meta') return 'application/ld+json';
return types[ext] || 'application/octet-stream';
}
/**
* Check if content type is RDF
* @param {string} contentType
* @returns {boolean}
*/
export function isRdfContentType(contentType) {
const rdfTypes = [
'application/ld+json',
'application/json',
'text/turtle',
'text/n3',
'application/n-triples',
'application/rdf+xml',
'application/n-quads',
'application/trig'
];
return rdfTypes.includes(contentType);
}
// Security: Maximum JSON size for parsing (10MB)
const MAX_JSON_SIZE = 10 * 1024 * 1024;
/**
* Safely parse JSON with size limit to prevent DoS
* @param {string} jsonString - The JSON string to parse
* @param {number} maxSize - Maximum allowed size (default 10MB)
* @returns {object} - Parsed JSON object
* @throws {Error} - If JSON is too large or invalid
*/
export function safeJsonParse(jsonString, maxSize = MAX_JSON_SIZE) {
if (jsonString.length > maxSize) {
throw new Error(`JSON exceeds maximum size of ${maxSize} bytes`);
}
return JSON.parse(jsonString);
}