Vectrum
Sign In
123 lines · 4.0 KB · TypeScript
import { createHash, randomBytes } from "node:crypto";
import { db, transaction } from "./db";
import { getUserById } from "./users";

export const TOKEN_PREFIX = "vec_";
/** Prefix of tokens issued before the rename to Vectrum; still accepted. */
const LEGACY_TOKEN_PREFIX = "mng_";

export type TokenPermission = "read" | "write";

export type ApiToken = {
  id: number;
  userId: number;
  name: string;
  hint: string;
  permission: TokenPermission;
  allRepos: boolean;
  repos: { id: number; fullName: string }[];
  expiresAt: number | null;
  expired: boolean;
  createdAt: number;
  lastUsedAt: number | null;
};

type TokenRow = {
  id: number;
  user_id: number;
  name: string;
  hint: string;
  permission: TokenPermission;
  all_repos: number;
  expires_at: number | null;
  created_at: number;
  last_used_at: number | null;
};

const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");

function tokenRepos(tokenId: number) {
  return db
    .prepare(
      `SELECT r.id, u.username || '/' || r.name AS fullName FROM api_token_repos t
       JOIN repos r ON r.id = t.repo_id JOIN users u ON u.id = r.owner_id WHERE t.token_id = ? ORDER BY fullName`,
    )
    .all(tokenId) as { id: number; fullName: string }[];
}

function toToken(r: TokenRow): ApiToken {
  return {
    id: r.id,
    userId: r.user_id,
    name: r.name,
    hint: r.hint,
    permission: r.permission,
    allRepos: r.all_repos === 1,
    repos: r.all_repos === 1 ? [] : tokenRepos(r.id),
    expiresAt: r.expires_at,
    expired: r.expires_at !== null && r.expires_at < Date.now(),
    createdAt: r.created_at,
    lastUsedAt: r.last_used_at,
  };
}

export class TokenError extends Error {}

/** Creates a token and returns its secret value; only its hash is stored. */
export function createToken(
  userId: number,
  input: { name: string; permission: TokenPermission; expiresInDays: number | null; repoIds: number[] | null },
) {
  const name = input.name.trim().slice(0, 100);
  if (!name) throw new TokenError("Give your token a name so you can recognize it later.");
  if (input.repoIds && input.repoIds.length === 0) throw new TokenError("Select at least one repository.");
  const secret = `${TOKEN_PREFIX}${randomBytes(30).toString("base64url")}`;
  const now = Date.now();
  transaction(() => {
    const res = db
      .prepare(
        `INSERT INTO api_tokens (user_id, name, token_hash, hint, permission, all_repos, expires_at, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        userId,
        name,
        sha256(secret),
        secret.slice(-4),
        input.permission,
        input.repoIds ? 0 : 1,
        input.expiresInDays ? now + input.expiresInDays * 86400e3 : null,
        now,
      );
    for (const repoId of input.repoIds ?? []) {
      db.prepare("INSERT INTO api_token_repos (token_id, repo_id) VALUES (?, ?)").run(res.lastInsertRowid, repoId);
    }
  });
  return secret;
}

export function listTokens(userId: number) {
  return (db.prepare("SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC").all(userId) as TokenRow[]).map(
    toToken,
  );
}

export function deleteToken(userId: number, tokenId: number) {
  db.prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?").run(tokenId, userId);
}

/** Resolves a secret token to its owner, or null if unknown or expired. */
export function authenticateToken(secret: string) {
  if (!secret.startsWith(TOKEN_PREFIX) && !secret.startsWith(LEGACY_TOKEN_PREFIX)) return null;
  const row = db.prepare("SELECT * FROM api_tokens WHERE token_hash = ?").get(sha256(secret)) as TokenRow | undefined;
  if (!row || (row.expires_at && row.expires_at < Date.now())) return null;
  const user = getUserById(row.user_id);
  if (!user) return null;
  db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), row.id);
  return { user, token: toToken(row) };
}

export function tokenCovers(token: ApiToken, repoId: number, needWrite: boolean) {
  if (needWrite && token.permission !== "write") return false;
  return token.allRepos || token.repos.some((r) => r.id === repoId);
}