Minimale git-remote met publiek/prive-toggle

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

15.1 kB Ruw bestand
import hljs from "highlight.js";
import MarkdownIt from "markdown-it";

import { config } from "./config.ts";
import type { TreeEntry, CommitSummary } from "./git.ts";
import { fullCloneUrl, type Repo } from "./repos.ts";

export function escapeHtml(input: string): string {
  return input
    .replaceAll("&", "&")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

function segment(part: string): string {
  return encodeURIComponent(part);
}

function encodePath(filePath: string): string {
  return filePath.split("/").map(segment).join("/");
}

const md: MarkdownIt = new MarkdownIt({
  html: false, // Ruwe HTML uit README's van derden vertrouwen we niet.
  linkify: true,
  breaks: false,
  highlight(code, language) {
    if (language && hljs.getLanguage(language)) {
      try {
        return hljs.highlight(code, { language, ignoreIllegals: true }).value;
      } catch {
        /* val terug op onopgemaakte code */
      }
    }
    return escapeHtml(code);
  },
});

export function renderMarkdown(source: string): string {
  return md.render(source);
}

const EXTENSION_LANGUAGES: Record<string, string> = {
  ts: "typescript",
  tsx: "typescript",
  js: "javascript",
  jsx: "javascript",
  mjs: "javascript",
  cjs: "javascript",
  rs: "rust",
  py: "python",
  rb: "ruby",
  go: "go",
  java: "java",
  kt: "kotlin",
  c: "c",
  h: "c",
  cpp: "cpp",
  hpp: "cpp",
  cs: "csharp",
  php: "php",
  swift: "swift",
  sh: "bash",
  bash: "bash",
  zsh: "bash",
  ps1: "powershell",
  sql: "sql",
  html: "xml",
  xml: "xml",
  svg: "xml",
  css: "css",
  scss: "scss",
  json: "json",
  yml: "yaml",
  yaml: "yaml",
  toml: "ini",
  ini: "ini",
  md: "markdown",
  dockerfile: "dockerfile",
};

export function highlightSource(code: string, fileName: string): string {
  const lower = fileName.toLowerCase();
  const extension = lower.includes(".") ? lower.slice(lower.lastIndexOf(".") + 1) : lower;
  const language = EXTENSION_LANGUAGES[extension];
  if (language && hljs.getLanguage(language)) {
    try {
      return hljs.highlight(code, { language, ignoreIllegals: true }).value;
    } catch {
      /* val terug op onopgemaakte code */
    }
  }
  return escapeHtml(code);
}

export function formatBytes(bytes: number | null): string {
  if (bytes === null) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function formatDate(iso: string): string {
  const date = new Date(iso);
  if (Number.isNaN(date.getTime())) return "";
  return date.toLocaleDateString("nl-NL", { year: "numeric", month: "long", day: "numeric" });
}

interface LayoutOptions {
  title: string;
  isOwner: boolean;
  body: string;
}

export function layout({ title, isOwner, body }: LayoutOptions): string {
  return `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<header class="site-header">
  <a class="brand" href="/">${escapeHtml(config.siteName)}</a>
  <nav>
    ${
      isOwner
        ? `<a href="/admin">Beheer</a>
       <form method="post" action="/logout"><button type="submit" class="linkish">Uitloggen</button></form>`
        : `<a href="/login">Inloggen</a>`
    }
  </nav>
</header>
<main>
${body}
</main>
<footer class="site-footer">
  <span>Broncode gehost met pub-repo</span>
</footer>
</body>
</html>
`;
}

export function repoListPage(repos: Repo[], isOwner: boolean): string {
  if (repos.length === 0) {
    return `<div class="empty">
      <h1>Repositories</h1>
      <p>Er zijn nog geen ${isOwner ? "" : "publieke "}repositories.</p>
    </div>`;
  }

  const items = repos
    .map(
      (repo) => `<li class="repo-card">
      <div class="repo-card-head">
        <a class="repo-name" href="/r/${segment(repo.name)}">${escapeHtml(repo.name)}</a>
        ${repo.public ? "" : '<span class="badge badge-private">privé</span>'}
      </div>
      ${repo.description ? `<p class="repo-description">${escapeHtml(repo.description)}</p>` : ""}
      <code class="clone-url">${escapeHtml(repo.cloneUrl)}</code>
    </li>`,
    )
    .join("\n");

  return `<h1>Repositories</h1>
<ul class="repo-list">
${items}
</ul>`;
}

function breadcrumbs(repo: Repo, branch: string, filePath: string, isBlob: boolean): string {
  const parts = filePath ? filePath.split("/") : [];
  const crumbs: string[] = [
    `<a href="/r/${segment(repo.name)}/tree/${encodePath(branch)}">${escapeHtml(repo.name)}</a>`,
  ];

  let accumulated = "";
  parts.forEach((part, index) => {
    accumulated = accumulated ? `${accumulated}/${part}` : part;
    const isLast = index === parts.length - 1;
    if (isLast && isBlob) {
      crumbs.push(`<span class="crumb-current">${escapeHtml(part)}</span>`);
    } else {
      crumbs.push(
        `<a href="/r/${segment(repo.name)}/tree/${encodePath(branch)}/${encodePath(accumulated)}">${escapeHtml(part)}</a>`,
      );
    }
  });

  return `<nav class="breadcrumbs">${crumbs.join('<span class="sep">/</span>')}</nav>`;
}

function branchPicker(
  repo: Repo,
  branch: string,
  branches: string[],
  view: string,
  filePath: string,
): string {
  if (branches.length <= 1) {
    return `<span class="ref-current">${escapeHtml(branch)}</span>`;
  }

  const suffix = filePath ? `/${encodePath(filePath)}` : "";
  const options = branches
    .map(
      (name) =>
        `<li><a href="/r/${segment(repo.name)}/${view}/${encodePath(name)}${suffix}"${
          name === branch ? ' class="active"' : ""
        }>${escapeHtml(name)}<span class="kind">branch</span></a></li>`,
    )
    .join("");

  return `<details class="ref-picker">
    <summary>${escapeHtml(branch)}</summary>
    <ul>${options}</ul>
  </details>`;
}

interface TreePageOptions {
  repo: Repo;
  branch: string;
  filePath: string;
  entries: TreeEntry[];
  branches: string[];
  readmeHtml: string | null;
  snapshot: CommitSummary | null;
  isOwner: boolean;
}

export function treePage({
  repo,
  branch,
  filePath,
  entries,
  branches,
  readmeHtml,
  snapshot,
  isOwner,
}: TreePageOptions): string {
  const rows = entries
    .map((entry) => {
      const childPath = filePath ? `${filePath}/${entry.name}` : entry.name;
      const view = entry.type === "tree" ? "tree" : "blob";
      const icon = entry.type === "tree" ? "📁" : "📄";
      const href = `/r/${segment(repo.name)}/${view}/${encodePath(branch)}/${encodePath(childPath)}`;
      return `<tr>
        <td class="entry-name"><span class="icon">${icon}</span><a href="${href}">${escapeHtml(entry.name)}</a></td>
        <td class="entry-size">${entry.type === "blob" ? formatBytes(entry.size) : ""}</td>
      </tr>`;
    })
    .join("\n");

  const parentRow = filePath
    ? `<tr>
        <td class="entry-name"><span class="icon">↩</span><a href="/r/${segment(repo.name)}/tree/${encodePath(branch)}${
          filePath.includes("/") ? `/${encodePath(filePath.slice(0, filePath.lastIndexOf("/")))}` : ""
        }">..</a></td>
        <td></td>
      </tr>`
    : "";

  return `${repoHeader(repo, branch, branches, "tree", filePath, snapshot, isOwner)}
${breadcrumbs(repo, branch, filePath, false)}
<table class="tree">
  <tbody>
    ${parentRow}
    ${rows || '<tr><td colspan="2" class="muted">Lege map.</td></tr>'}
  </tbody>
</table>
${readmeHtml ? `<article class="readme markdown">${readmeHtml}</article>` : ""}`;
}

function repoHeader(
  repo: Repo,
  branch: string,
  branches: string[],
  view: string,
  filePath: string,
  snapshot: CommitSummary | null,
  isOwner: boolean,
): string {
  // Voor jezelf staat je eigen remote bovenaan en met nadruk. De publieke URL is
  // hieronder óók te zien, maar expliciet gelabeld als alleen-lezen: als je die
  // per ongeluk als remote instelt, haalt een pull een commit zonder ouders over
  // je lokale historie heen.
  const clone = isOwner
    ? `<div class="clone-box">
    <label for="clone-own">Jouw remote</label>
    <input id="clone-own" type="text" readonly value="git clone ${escapeHtml(repo.fullCloneUrl)}">
  </div>
  <div class="clone-box">
    <label for="clone-pub">Alleen lezen</label>
    <input id="clone-pub" type="text" readonly value="git clone ${escapeHtml(repo.cloneUrl)}">
  </div>
  <p class="muted snapshot-note">
    Gebruik de bovenste voor je eigen werk. De onderste is wat bezoekers clonen:
    daar staat één momentopname zonder commitgeschiedenis, en die als remote
    instellen kost je bij de eerste pull je lokale historie.
  </p>`
    : `<div class="clone-box">
    <label for="clone-pub">Clonen</label>
    <input id="clone-pub" type="text" readonly value="git clone ${escapeHtml(repo.cloneUrl)}">
  </div>
  <p class="muted snapshot-note">
    Deze repository wordt als momentopname gepubliceerd: een clone bevat de
    volledige inhoud, maar geen commitgeschiedenis.
  </p>`;

  return `<div class="repo-header">
  <div class="repo-title">
    <h1><a href="/r/${segment(repo.name)}">${escapeHtml(repo.name)}</a></h1>
    ${repo.public ? "" : '<span class="badge badge-private">privé</span>'}
  </div>
  ${repo.description ? `<p class="repo-description">${escapeHtml(repo.description)}</p>` : ""}
  <div class="repo-meta">
    ${branchPicker(repo, branch, branches, view, filePath)}
    ${snapshot ? `<span class="muted">bijgewerkt ${escapeHtml(formatDate(snapshot.date))}</span>` : ""}
  </div>
  ${clone}
</div>`;
}

interface BlobPageOptions {
  repo: Repo;
  branch: string;
  filePath: string;
  branches: string[];
  size: number;
  isOwner: boolean;
  content: { kind: "text"; html: string } | { kind: "markdown"; html: string } | { kind: "binary" } | { kind: "toolarge" };
}

export function blobPage({
  repo,
  branch,
  filePath,
  branches,
  size,
  content,
  isOwner,
}: BlobPageOptions): string {
  const rawHref = `/r/${segment(repo.name)}/raw/${encodePath(branch)}/${encodePath(filePath)}`;

  let body: string;
  switch (content.kind) {
    case "markdown":
      body = `<article class="markdown file-markdown">${content.html}</article>`;
      break;
    case "text":
      body = `<div class="file-source"><pre><code>${content.html}</code></pre></div>`;
      break;
    case "binary":
      body = `<p class="muted">Binair bestand (${formatBytes(size)}). <a href="${rawHref}">Downloaden</a>.</p>`;
      break;
    case "toolarge":
      body = `<p class="muted">Bestand te groot om te tonen (${formatBytes(size)}). <a href="${rawHref}">Downloaden</a>.</p>`;
      break;
  }

  return `${repoHeader(repo, branch, branches, "blob", filePath, null, isOwner)}
${breadcrumbs(repo, branch, filePath, true)}
<div class="file-toolbar">
  <span class="muted">${formatBytes(size)}</span>
  <a href="${rawHref}">Ruw bestand</a>
</div>
${body}`;
}

export function emptyRepoPage(repo: Repo): string {
  return `<div class="repo-header">
  <div class="repo-title"><h1>${escapeHtml(repo.name)}</h1>
  ${repo.public ? "" : '<span class="badge badge-private">privé</span>'}</div>
  ${repo.description ? `<p class="repo-description">${escapeHtml(repo.description)}</p>` : ""}
</div>
<div class="empty">
  <p>Deze repository is nog leeg.</p>
  <pre><code>git remote add origin ${escapeHtml(repo.fullCloneUrl)}
git push -u origin main</code></pre>
  <p>Dat is je eigen remote, met geschiedenis. Bezoekers clonen straks
  <code>${escapeHtml(repo.cloneUrl)}</code> en krijgen daar een momentopname.</p>
</div>`;
}

export function loginPage(next: string, error: string | null): string {
  return `<div class="narrow">
  <h1>Inloggen</h1>
  ${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
  <form method="post" action="/login" class="stack">
    <input type="hidden" name="next" value="${escapeHtml(next)}">
    <label for="user">Gebruikersnaam</label>
    <input id="user" name="user" type="text" autocomplete="username" required autofocus>
    <label for="password">Wachtwoord</label>
    <input id="password" name="password" type="password" autocomplete="current-password" required>
    <button type="submit">Inloggen</button>
  </form>
</div>`;
}

export function adminPage(repos: Repo[], message: string | null, error: string | null): string {
  const rows = repos
    .map(
      (repo) => `<tr>
      <td>
        <a href="/r/${segment(repo.name)}">${escapeHtml(repo.name)}</a>
        <code class="clone-url">${escapeHtml(repo.fullCloneUrl)}</code>
      </td>
      <td>
        <form method="post" action="/admin/repos/${segment(repo.name)}/description" class="inline">
          <input type="text" name="description" value="${escapeHtml(repo.description)}" placeholder="Korte beschrijving">
          <button type="submit">Opslaan</button>
        </form>
      </td>
      <td>
        <form method="post" action="/admin/repos/${segment(repo.name)}/visibility" class="inline">
          <input type="hidden" name="public" value="${repo.public ? "0" : "1"}">
          <button type="submit" class="${repo.public ? "toggle-on" : "toggle-off"}">
            ${repo.public ? "Publiek" : "Privé"}
          </button>
        </form>
      </td>
      <td>
        <form method="post" action="/admin/repos/${segment(repo.name)}/delete" class="inline">
          <input type="text" name="confirm" placeholder="typ ${escapeHtml(repo.name)}" required>
          <button type="submit" class="danger">Verwijderen</button>
        </form>
      </td>
    </tr>`,
    )
    .join("\n");

  // Bewust een sjabloon met <naam> erin, en niet de URL van een willekeurige repo:
  // dat laatste leest als een kant-en-klaar commando en is dan voor de verkeerde.
  const template = fullCloneUrl("<naam>");

  return `<h1>Beheer</h1>
${message ? `<p class="notice">${escapeHtml(message)}</p>` : ""}
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}

<section class="panel">
  <h2>Jouw remote</h2>
  <p class="muted">
    Gebruik voor je eigen werk altijd het volledige transport: dat pusht én pullt je
    echte geschiedenis, en vraagt altijd om je token. De publieke URL die op een
    repo-pagina staat is alleen om te lezen — daar staat de momentopname, en die
    heeft geen commitgeschiedenis. Per repository staat de juiste URL hieronder in
    de tabel.
  </p>
  <input type="text" readonly value="git remote set-url origin ${escapeHtml(template)}">
</section>

<section class="panel">
  <h2>Nieuwe repository</h2>
  <form method="post" action="/admin/repos" class="inline wrap">
    <input type="text" name="name" placeholder="naam" pattern="[a-z0-9][a-z0-9._-]*" required>
    <input type="text" name="description" placeholder="beschrijving (optioneel)">
    <button type="submit">Aanmaken</button>
  </form>
  <p class="muted">Kleine letters, cijfers, punt, streepje of underscore.</p>
</section>

<section class="panel">
  <h2>Repositories</h2>
  ${
    repos.length === 0
      ? '<p class="muted">Nog geen repositories.</p>'
      : `<table class="admin-table">
    <thead><tr><th>Naam</th><th>Beschrijving</th><th>Zichtbaarheid</th><th>Verwijderen</th></tr></thead>
    <tbody>${rows}</tbody>
  </table>`
  }
</section>`;
}

export function errorPage(status: number, message: string): string {
  return `<div class="narrow">
  <h1>${status}</h1>
  <p>${escapeHtml(message)}</p>
  <p><a href="/">Terug naar de repositories</a></p>
</div>`;
}