[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/bourgeoa/JavaScriptSolidServer/patchAppend/src/utils/url.js [Back]  [Original]

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);
}

Web Proxy Viewer  |  New URL  |  Original Page