~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
201 lines · 6.9 KB Raw History
/**
 * Lexicons for files a person stores in their own repo.
 *
 * One record per file, one per folder. The bytes live in PDS blob storage as
 * an ordered list of parts: a file under the server's blob upload limit has
 * one part, and a larger one is split across several. Every part is
 * referenced from the record, which keeps the blobs out of orphan cleanup.
 *
 * A file names its folder by that folder's record key, not by a path. The key
 * never changes, so renaming a folder is one write and a file's identity
 * survives it. Anything that has to display a path walks the parent chain.
 */

export const DRIVE_FILE_COLLECTION = 'dev.pdsjs.drive.file';
export const DRIVE_FOLDER_COLLECTION = 'dev.pdsjs.drive.folder';

/**
 * The space type a private drive uses. A drive kept in a permissioned space
 * makes one of these rather than borrowing a space some other app made: that
 * space is the other app's data model, and its members read what it holds.
 */
export const DRIVE_SPACE_TYPE = 'dev.pdsjs.drive.space';

/** Longest file or folder name, in characters. */
export const MAX_NAME_LENGTH = 512;

/** How deep a folder chain may go before it is treated as broken. */
export const MAX_FOLDER_DEPTH = 64;

export const driveFileLexicon = {
  lexicon: 1,
  id: DRIVE_FILE_COLLECTION,
  defs: {
    main: {
      type: 'record',
      description:
        'A file stored in this repo. The bytes are an ordered list of blob parts; joining the parts in order reproduces the file.',
      key: 'tid',
      record: {
        type: 'object',
        required: ['name', 'parts', 'mimeType', 'createdAt'],
        properties: {
          name: {
            type: 'string',
            maxLength: MAX_NAME_LENGTH,
            description: 'File name as shown. Holds no slash.',
          },
          folder: {
            type: 'string',
            maxLength: MAX_NAME_LENGTH,
            description:
              'Record key of the dev.pdsjs.drive.folder this file sits in. Absent means the top level.',
          },
          parts: {
            type: 'array',
            description:
              'The file bytes, in order. One entry for a file within the blob upload limit, several for a larger one.',
            items: { type: 'blob' },
            minLength: 1,
          },
          mimeType: {
            type: 'string',
            maxLength: 256,
            description:
              'Type of the whole file. A split file stores each part as application/octet-stream, so the part mime types do not carry this.',
          },
          createdAt: { type: 'string', format: 'datetime' },
          updatedAt: { type: 'string', format: 'datetime' },
          deletedAt: {
            type: 'string',
            format: 'datetime',
            description:
              'When this file was put in the trash. Set, it is hidden from every listing, and its parts stay linked so the orphan sweep leaves the bytes alone. Clearing it restores the file; removing the record is what finally frees the bytes.',
          },
        },
      },
    },
  },
};

export const driveFolderLexicon = {
  lexicon: 1,
  id: DRIVE_FOLDER_COLLECTION,
  defs: {
    main: {
      type: 'record',
      description:
        'A folder in this repo’s drive. It exists in its own right, so a folder with nothing in it stays.',
      key: 'tid',
      record: {
        type: 'object',
        required: ['name', 'createdAt'],
        properties: {
          name: {
            type: 'string',
            maxLength: MAX_NAME_LENGTH,
            description: 'Folder name as shown. Holds no slash.',
          },
          parent: {
            type: 'string',
            maxLength: MAX_NAME_LENGTH,
            description:
              'Record key of the folder this one sits in. Absent means the top level.',
          },
          createdAt: { type: 'string', format: 'datetime' },
          updatedAt: { type: 'string', format: 'datetime' },
          deletedAt: {
            type: 'string',
            format: 'datetime',
            description:
              'When this folder was put in the trash. A folder goes with everything under it and comes back the same way.',
          },
        },
      },
    },
  },
};

/** Largest code point that is a control character, plus the delete character. */
const LAST_CONTROL_CODE = 0x1f;
const DELETE_CODE = 0x7f;

/**
 * A control character renders as nothing, so a name holding one displays as a
 * different name than it stores.
 * @param {string} value
 * @returns {boolean}
 */
function hasControlCharacter(value) {
  for (const character of value) {
    const code = character.codePointAt(0) ?? 0;
    if (code <= LAST_CONTROL_CODE || code === DELETE_CODE) return true;
  }
  return false;
}

/**
 * A file or folder name is one path segment. Slashes would make the name
 * disagree with the folder it names, and the two dot names read as a path.
 * @param {unknown} name
 * @returns {boolean}
 */
export function isValidName(name) {
  if (typeof name !== 'string') return false;
  if (name.length === 0 || name.length > MAX_NAME_LENGTH) return false;
  if (name.includes('/') || name.includes('\\')) return false;
  if (name === '.' || name === '..') return false;
  return !hasControlCharacter(name);
}

/**
 * The record key naming a folder, or '' for the top level. Anything else is
 * not a key this drive can address.
 * @param {unknown} folder
 * @returns {string|null}
 */
export function normalizeFolderKey(folder) {
  if (folder === undefined || folder === null || folder === '') return '';
  if (typeof folder !== 'string') return null;
  if (folder.length > MAX_NAME_LENGTH) return null;
  // A record key is one path segment, the same rule the protocol applies.
  if (!isValidName(folder)) return null;
  return folder;
}

/**
 * Walk a folder's parent chain, top level first. A chain that loops or runs
 * deeper than MAX_FOLDER_DEPTH is reported as broken rather than followed.
 * @param {string} rkey - the folder to walk up from, '' for the top level
 * @param {Map<string, {name: string, parent: string}>} folders
 * @returns {{rkey: string, name: string}[]|null}
 */
export function folderAncestry(rkey, folders) {
  if (!rkey) return [];
  /** @type {{rkey: string, name: string}[]} */
  const chain = [];
  const seen = new Set();
  let at = rkey;
  while (at) {
    if (seen.has(at) || chain.length >= MAX_FOLDER_DEPTH) return null;
    seen.add(at);
    const folder = folders.get(at);
    if (!folder) return null;
    chain.unshift({ rkey: at, name: folder.name });
    at = folder.parent;
  }
  return chain;
}

/**
 * A folder's display path, like `photos/2026`. Empty for the top level, null
 * for a chain that does not resolve.
 * @param {string} rkey
 * @param {Map<string, {name: string, parent: string}>} folders
 * @returns {string|null}
 */
export function folderPath(rkey, folders) {
  const chain = folderAncestry(rkey, folders);
  if (chain === null) return null;
  return chain.map((entry) => entry.name).join('/');
}