chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Git hosting as a server extension: the platform passes the factory to
* core's `extensions` option. The extension always guards writes to repo
* and config records (branch protection) and serves the dev.pdsjs.git XRPC
* methods; file browsing and read-only smart HTTP under /git/ are the parts
* the options turn on and off.
*
* This module must stay free of node-only imports; it runs on Cloudflare
* Workers.
*/
import { cborDecode, cborDecodeJson } from '@pdsjs/core/repo';
/** @typedef {import('./hosting.js').GitSpaceAdmin} GitSpaceAdmin */
import { createGitBrowser } from './browser.js';
import { createConstellationDiscovery, noDiscovery } from './discovery.js';
import { createGitHosting } from './hosting.js';
import { createGitHttp } from './http.js';
import { GIT_IDENTITY_COLLECTION, GIT_REPO_COLLECTION } from './lexicon.js';
import { createGitMethods } from './methods.js';
import { createGitWriteGuard, GIT_CONFIG_COLLECTION } from './rules.js';
/**
* The part of the space admin port git hosting needs, built on first use
* over the host's space storage from @pdsjs/spaces, an optional peer
* imported only then.
* @param {import('@pdsjs/core/ports').SpaceStoragePort} spaceStorage
* @param {() => Promise<string|null>} getDid
* @returns {GitSpaceAdmin}
*/
function lazySpaceAdmin(spaceStorage, getDid) {
/** @type {Promise<import('@pdsjs/core/ports').SpaceAdminPort>|null} */
let admin = null;
const load = () => {
admin ??= import('@pdsjs/spaces/admin').then(({ createSpaceAdmin }) =>
createSpaceAdmin({ spaceStorage, getDid }),
);
return admin;
};
return {
createSpace: async (opts) => (await load()).createSpace(opts),
createRecord: async (space, collection, rkey, record) =>
(await load()).createRecord(space, collection, rkey, record),
putRecord: async (space, collection, rkey, record) =>
(await load()).putRecord(space, collection, rkey, record),
getRecord: async (space, collection, rkey) =>
(await load()).getRecord(space, collection, rkey),
deleteRecord: async (space, collection, rkey) =>
(await load()).deleteRecord(space, collection, rkey),
removeMember: async (space, did) => (await load()).removeMember(space, did),
};
}
/** The collections every git extension owns, guard or full. */
const GIT_COLLECTIONS = [
GIT_REPO_COLLECTION,
GIT_CONFIG_COLLECTION,
GIT_IDENTITY_COLLECTION,
'dev.pdsjs.git.checkRequest',
];
/**
* The largest bundle chain the server downloads whole to answer a read. A
* repository pushed before object indexes existed opens this way; one with
* indexes reads ranges and is never held whole.
*/
const DEFAULT_EAGER_LIMIT = 64 * 1024 * 1024;
/** Opened repositories the server keeps between reads. */
const OPEN_REPOSITORIES = 4;
/**
* The blob readers over the host's blob store, whole and ranged, that the
* guard's ancestry check and the browser's space reads share.
* @param {import('@pdsjs/core/ports').ExtensionHost} host
* @returns {{
* getBlob: (cid: string) => Promise<Uint8Array|null>,
* getBlobRange: ((cid: string, start: number, end: number) => Promise<Uint8Array|null>)|undefined,
* }}
*/
function buildBlobReaders(host) {
const { blobs } = host;
/** @param {string} cid */
const getBlob = async (cid) => {
const did = await host.getDid();
if (!did) return null;
const result = await blobs.get(did, cid);
if (!result) return null;
return result.data instanceof Uint8Array
? result.data
: new Uint8Array(/** @type {ArrayBuffer} */ (result.data));
};
// A store that reads ranges lets the guard's ancestry check and the
// browser read a repository lazily. getStream takes inclusive positions.
/** @type {((cid: string, start: number, end: number) => Promise<Uint8Array|null>)|undefined} */
const getBlobRange = blobs.getStream
? async (cid, start, end) => {
const did = await host.getDid();
if (!did) return null;
const found = await blobs.getStream?.(did, cid, {
start,
end: end - 1,
});
if (!found) return null;
return new Uint8Array(await new Response(found.body).arrayBuffer());
}
: undefined;
return { getBlob, getBlobRange };
}
/**
* The branch-protection write guard, built over the host's storage. The
* ancestry check reads the repository's own records and bundle blobs, so
* the guard needs reads, not writes.
* @param {import('@pdsjs/core/ports').ExtensionHost} host
*/
function buildWriteGuard(host) {
const { actorStorage, spaceStorage } = host;
const { getBlob, getBlobRange } = buildBlobReaders(host);
return createGitWriteGuard({
getRecord: async (space, collection, rkey) => {
if (space) {
if (!spaceStorage) return null;
const row = await spaceStorage.getSpaceRecord(space, collection, rkey);
return row ? cborDecodeJson(row.value) : null;
}
const did = await host.getDid();
if (!did) return null;
const record = await actorStorage.getRecord(
`at://${did}/${collection}/${rkey}`,
);
if (!record) return null;
return cborDecode(
record.value instanceof Uint8Array
? record.value
: new Uint8Array(record.value),
);
},
getBlob,
getBlobRange,
});
}
/**
* The branch-protection write guard alone, without the routes, the smart
* HTTP endpoint or the file browser. A server whose git hosting runs
* elsewhere mounts this and keeps the enforceable half: protected branches
* refuse force pushes and deletion at the record write, wherever the reads
* are served from.
* @returns {import('@pdsjs/core/ports').ExtensionFactory}
*/
export function createGitGuardExtension() {
return (host) => ({
name: 'git',
collections: GIT_COLLECTIONS,
writeGuard: buildWriteGuard(host),
features: { git: true, gitBrowse: false },
});
}
/**
* @param {Object} [options]
* @param {boolean} [options.http] - serve read-only git smart HTTP under /git/
* @param {boolean} [options.browse] - serve the reads of the bundle chain (default on)
* @param {number} [options.eagerLimit] - the largest bundle chain to download whole for a read, in bytes; a larger repository without indexes answers RepositoryTooLarge
* @param {import('./discovery.js').DiscoveryPort|null} [options.discovery] - what finds the pull requests, reviews and issues from accounts a repository's config does not name. By default the public Constellation index; null to read only the listed voices.
* @returns {import('@pdsjs/core/ports').ExtensionFactory}
*/
export function createGitExtension({
http = false,
browse = true,
eagerLimit = DEFAULT_EAGER_LIMIT,
discovery,
} = {}) {
return (host) => {
const { spaceStorage } = host;
// The account page is the owner's own surface, so a repository held in
// a space reads from storage rather than the authenticated space
// endpoints. A store that reads ranges lets the browser read a space
// repository lazily; getStream takes inclusive positions.
const browser = browse
? createGitBrowser({
xrpc: host.xrpc,
cacheSize: OPEN_REPOSITORIES,
maxEagerBytes: eagerLimit,
spaceReader: spaceStorage
? {
getRecord: async (space, rkey) => {
const row = await spaceStorage.getSpaceRecord(
space,
'dev.pdsjs.git.repo',
rkey,
);
return row
? { cid: row.cid, value: cborDecodeJson(row.value) }
: null;
},
...buildBlobReaders(host),
}
: undefined,
})
: null;
// Reads go through the server's own fetch, so the handler needs no
// storage access of its own.
const gitHttp = http ? createGitHttp({ xrpc: host.xrpc }) : null;
const hosting = createGitHosting({
host,
httpEnabled: http,
spaceBrowser: host.spaceBrowser ?? null,
spaceAdmin: spaceStorage
? lazySpaceAdmin(spaceStorage, host.getDid)
: null,
});
return {
name: 'git',
collections: GIT_COLLECTIONS,
writeGuard: buildWriteGuard(host),
routes: createGitMethods({
host,
browser,
hosting,
discovery:
discovery === null
? noDiscovery()
: (discovery ??
createConstellationDiscovery({
userAgent: `pds.js @pdsjs/git (${host.hostname ?? 'a pds.js server'})`,
})),
}),
features: { git: true, gitBrowse: Boolean(browser) },
stats: (count) => ({ gitRepos: count(GIT_REPO_COLLECTION) }),
...(gitHttp
? { prefixes: ['/git'], handle: (request) => gitHttp.handle(request) }
: {}),
};
};
}