gitea, extracted into plugins/gitea — the platform half
Eleventh extraction, and the only one that deleted its UI instead of moving it. Seventeen files under apps/Gitea/ reimplemented a fraction of an interface Gitea already has; the plugin renders the real one in an iframe over a same-origin proxy. Deleting rather than moving removed a platform change nobody now has to build: useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a plugin gets two, so an as-is extraction would have required teaching the plugin system to declare extra route patterns. Nothing inside an iframe needs a route. Nothing working was lost. The router had been commented out of hono.ts since 2026-08-13 with api: [], and officer-gitea was running nowhere — the screen rendered and every request 404'd, like soulseek, jellyfin and invoices before it. Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS, the only installable feature that was, on the reasoning that it fronts a remote instance and installs nothing locally. As a plugin its tile arrives from assets/icon.png like every other — which is also why the icon added to public/icons/ this morning was reverted rather than kept. 'gitea' stays in the ServiceName union: the connection lives in core service_connections, and gitea is the reason that table has its "member rows carry only a token and inherit the owner's URL" shape at all. registry.test.ts's grantable-permission test named gitea as "the case this was built for"; a plugin's permissions are registered at install and are never in that compile-time list, so it now names a core one. catalogue.test.ts re-anchored to vnc/notify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /api/gitea/* — auth, then forward to officer-gitea. No routes of its own and no Gitea knowledge:
|
||||
// this file must never grow app logic.
|
||||
//
|
||||
// The sidecar owns the Gitea contract and holds the personal access token. The platform knows neither.
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'gitea',
|
||||
prefix: '/api/gitea',
|
||||
});
|
||||
|
||||
export const giteaRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getGiteaServerUrl = proxy.getHttpUrl;
|
||||
@@ -111,7 +111,7 @@ describe('member provisioning is declared for every entry', () => {
|
||||
// against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until
|
||||
// 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list
|
||||
// rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing.
|
||||
for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none');
|
||||
for (const id of ['vnc', 'notify']) expect(byId(id)!.members).toBe('none');
|
||||
});
|
||||
|
||||
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
||||
|
||||
@@ -196,34 +196,6 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
|
||||
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
|
||||
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
|
||||
{
|
||||
id: 'gitea',
|
||||
ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
|
||||
process: 'officer-gitea',
|
||||
label: 'Gitea',
|
||||
summary: 'Repositories, issues and pull requests from your own Gitea account',
|
||||
// 'none' because there is nothing for the INSTALLER to provision, not because Gitea is single-tenant —
|
||||
// it is the most per-user service here. The owner's connection carries the instance URL; every member
|
||||
// adds their own access token from /gitea and acts only as themselves upstream, with Gitea's own
|
||||
// permissions as the second and real gate. A provisioner could not do that part for them without
|
||||
// holding an admin token and minting tokens on their behalf, which is more authority than this needs.
|
||||
members: 'none',
|
||||
// `existing` only, and there is no compose template. Gitea here is always something the owner already
|
||||
// runs — on this machine, on another, or hosted. Provisioning one would mean owning the migration,
|
||||
// backup and upgrade story for a service that is nobody's side feature.
|
||||
modes: ['existing'],
|
||||
permission: 'gitea',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' },
|
||||
{
|
||||
key: 'secret',
|
||||
label: 'Your access token',
|
||||
type: 'secret',
|
||||
required: true,
|
||||
help: 'A personal access token from your own Gitea account. Members add their own from /gitea.',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Headscale was here until 2026-08-14. It became a core sidecar, and then on 2026-08-15 it left the
|
||||
// repository entirely: it is the `offscale` PLUGIN now, installed from the marketplace, and neither
|
||||
// this catalogue nor `CORE_PROCESSES` mentions it. The note below is kept because the FAILURE it
|
||||
|
||||
@@ -27,7 +27,6 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
// import { giteaRouter } from './api/gitea/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
import { davSyncRouter } from './api/dav/sync-router';
|
||||
import { davRouter } from './api/dav/router';
|
||||
@@ -139,7 +138,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/terminal', terminalRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
|
||||
|
||||
@@ -73,9 +73,9 @@ describe('totality', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('signin is exempt and gitea is not', () => {
|
||||
test('signin is exempt and an ordinary route is not', () => {
|
||||
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
|
||||
expect(isExemptApiPath('/api/gitea')).toBe(false);
|
||||
expect(isExemptApiPath('/api/chat')).toBe(false);
|
||||
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -254,8 +254,11 @@ describe('kinds', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('gitea is grantable — the case this was built for', () => {
|
||||
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'gitea')).toBe(true);
|
||||
// `gitea` was this test's subject until 2026-08-19, when it became `plugins/gitea/`. A plugin's
|
||||
// permissions are registered at INSTALL from its manifest and are always `app`, so they are never in
|
||||
// this compile-time list — which is why the assertion has to name a core one to mean anything.
|
||||
test('an app permission is grantable — the case this was built for', () => {
|
||||
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'chat')).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -143,24 +143,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
},
|
||||
|
||||
// ── app: the grantable surface ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'gitea',
|
||||
label: 'Gitea',
|
||||
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
|
||||
kind: 'app',
|
||||
// api: ['/gitea'], // plugin — switched off 2026-08-13
|
||||
api: [],
|
||||
routes: ['/gitea'],
|
||||
// Every Gitea call is already scoped to the caller's own personal access token — the sidecar resolves
|
||||
// the token from the caller's row and the instance from the owner's, so a member holding `read` still
|
||||
// acts only as themselves upstream. Gitea's own permissions are the second gate and the real one:
|
||||
// a token cannot reach a repository its account cannot reach, whatever this platform thinks.
|
||||
//
|
||||
// Which is why the whole permission is `personal` rather than a list of sub-paths. Nothing under
|
||||
// /api/gitea can affect another Officer user, so withholding write here would only stop someone
|
||||
// commenting on their own issues — security theatre with a real cost and no benefit.
|
||||
personal: ['/'],
|
||||
},
|
||||
{
|
||||
key: 'memos',
|
||||
label: 'Memos',
|
||||
|
||||
@@ -234,6 +234,27 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'gitea',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Gitea',
|
||||
summary: 'Repositories, issues and pull requests — your own Gitea, rendered in Officer',
|
||||
color: '#609926',
|
||||
icon: 'GitBranch',
|
||||
source: 'https://gitea.officer.dev/plugins/gitea.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'gitea',
|
||||
label: 'Gitea',
|
||||
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
|
||||
},
|
||||
],
|
||||
// `schema: false` — the connection lives in core `service_connections`, like transmission's. The web
|
||||
// half is one iframe over a proxy of Gitea's own UI rather than panels; see its PLUGIN.md.
|
||||
has: { api: true, schema: false, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'vault',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import {
|
||||
getServiceConnection,
|
||||
saveServiceConnection,
|
||||
deleteServiceConnection,
|
||||
recordServiceProbe,
|
||||
getServiceInstanceUrl,
|
||||
getOwnerUser,
|
||||
} from 'officerdb';
|
||||
import {
|
||||
callGitea,
|
||||
getGiteaConfig,
|
||||
invalidateGiteaConfig,
|
||||
invalidateAllGiteaConfigs,
|
||||
normalizeBase,
|
||||
probe,
|
||||
} from './upstream';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
|
||||
// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health is the instance up, and does the stored token work
|
||||
// GET /_config the connection MINUS the token
|
||||
// PUT/POST /_config { url, token } — validated live before it is stored
|
||||
// DELETE /_config forget the connection
|
||||
// ANY /_api/* forwarded to the instance's own /api/*, token attached
|
||||
//
|
||||
// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so
|
||||
// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than
|
||||
// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a
|
||||
// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`.
|
||||
// Gitea genuinely is one-of: nobody runs two.
|
||||
//
|
||||
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same
|
||||
// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor
|
||||
// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to
|
||||
// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the
|
||||
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything under /api/v1 the UI legitimately needs.
|
||||
//
|
||||
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
|
||||
// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint
|
||||
// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted
|
||||
// into a connection form should not silently become a remote root shell for the instance. If an admin
|
||||
// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix.
|
||||
const ALLOWED = [
|
||||
/^\/api\/v1\/version(\/|$|\?)/,
|
||||
/^\/api\/v1\/user(\/|$|\?)/,
|
||||
// Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES
|
||||
// access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there
|
||||
// precisely to stop a token escalating its own scopes, and answers `401 auth required` to the
|
||||
// `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is
|
||||
// GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check.
|
||||
/^\/api\/v1\/users(\/|$|\?)/,
|
||||
/^\/api\/v1\/orgs(\/|$|\?)/,
|
||||
/^\/api\/v1\/org(\/|$|\?)/,
|
||||
/^\/api\/v1\/teams(\/|$|\?)/,
|
||||
/^\/api\/v1\/repos(\/|$|\?)/,
|
||||
/^\/api\/v1\/repositories(\/|$|\?)/,
|
||||
/^\/api\/v1\/notifications(\/|$|\?)/,
|
||||
/^\/api\/v1\/issues(\/|$|\?)/,
|
||||
/^\/api\/v1\/packages(\/|$|\?)/,
|
||||
// Instance metadata the UI reads to match the instance's own behaviour rather than guess it —
|
||||
// default branch name, whether the markdown renderer allows raw HTML, which features are enabled.
|
||||
/^\/api\/v1\/settings(\/|$|\?)/,
|
||||
/^\/api\/v1\/topics(\/|$|\?)/,
|
||||
/^\/api\/v1\/licenses(\/|$|\?)/,
|
||||
/^\/api\/v1\/gitignore(\/|$|\?)/,
|
||||
/^\/api\/v1\/label(\/|$|\?)/,
|
||||
// Rendering. Gitea's own web UI renders a README through these, and matching it matters: the
|
||||
// instance resolves relative links, #issue references and @mentions against the repo context, which a
|
||||
// client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown.
|
||||
/^\/api\/v1\/markdown(\/|$|\?)/,
|
||||
/^\/api\/v1\/markup(\/|$|\?)/,
|
||||
];
|
||||
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */
|
||||
async function isOwner(userId: number): Promise<boolean> {
|
||||
const owner = await getOwnerUser();
|
||||
return !!owner && owner.id === userId;
|
||||
}
|
||||
|
||||
// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs
|
||||
// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have
|
||||
// not connected yet" from "there is nothing here to connect to", which are different screens.
|
||||
//
|
||||
// `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the
|
||||
// invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could
|
||||
// not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered.
|
||||
// The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees
|
||||
// with its public host, so links have to be rebased client-side — see retargetUrls.
|
||||
async function connectionState(userId: number): Promise<Response> {
|
||||
const connection = await getServiceConnection(userId, 'gitea');
|
||||
const instanceUrl = await getServiceInstanceUrl('gitea');
|
||||
return Response.json({
|
||||
configured: !!connection,
|
||||
connection,
|
||||
instanceUrl,
|
||||
instanceConfigured: !!instanceUrl,
|
||||
isOwner: await isOwner(userId),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleConfig(req: Request, userId: number): Promise<Response> {
|
||||
if (req.method === 'GET') return connectionState(userId);
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const owner = await isOwner(userId);
|
||||
await deleteServiceConnection(userId, 'gitea');
|
||||
// The owner disconnecting removes the instance every member's row inherits, so every cached config
|
||||
// is now wrong — they must all resolve to null rather than keep talking to a server the platform no
|
||||
// longer claims. A member disconnecting only affects themselves.
|
||||
if (owner) invalidateAllGiteaConfigs();
|
||||
else invalidateGiteaConfig(userId);
|
||||
return connectionState(userId);
|
||||
}
|
||||
|
||||
if (req.method === 'PUT' || req.method === 'POST') {
|
||||
const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
|
||||
// A blank token on a save means "keep the stored one", so the URL can be corrected without the
|
||||
// token being re-pasted. There is no way to read it back out to re-send it.
|
||||
const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;
|
||||
const owner = await isOwner(userId);
|
||||
|
||||
// The instance is the owner's; the token is each person's own. Which half of the form you get is
|
||||
// decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a
|
||||
// rule: a member who could name the URL would have a per-user SSRF hop behind a settings form.
|
||||
let base: string;
|
||||
if (owner) {
|
||||
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
|
||||
if (!url) return bad('url is required');
|
||||
if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');
|
||||
base = url;
|
||||
} else {
|
||||
// Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere
|
||||
// and leave them debugging a screen that is quietly talking to a different server.
|
||||
if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403);
|
||||
const instance = await getServiceInstanceUrl('gitea');
|
||||
if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409);
|
||||
base = instance;
|
||||
}
|
||||
|
||||
const existing = await getServiceConnection(userId, 'gitea');
|
||||
if (!token && !existing?.hasSecret) return bad('an access token is required the first time');
|
||||
|
||||
// Probe with the credentials as they WILL be, so a save either stores something that works or
|
||||
// fails with the reason. Storing first and discovering later makes every downstream screen fail
|
||||
// mysteriously instead. For a member this also proves their token is valid against the shared
|
||||
// instance, which is the only thing that can be wrong on their side.
|
||||
const current = token ? null : await getGiteaConfig(userId);
|
||||
const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
|
||||
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
|
||||
|
||||
const connection = await saveServiceConnection({
|
||||
userId,
|
||||
service: 'gitea',
|
||||
// Null for anyone but the owner: their row is a credential, and inherits the instance on read.
|
||||
url: owner ? base : null,
|
||||
secret: token ?? undefined,
|
||||
version: result.version,
|
||||
});
|
||||
// An owner's save may have moved the instance, and every member's cached base was resolved from it.
|
||||
// Clearing only the caller would leave them pointed at the previous host until this process restarts.
|
||||
if (owner) invalidateAllGiteaConfigs();
|
||||
else invalidateGiteaConfig(userId);
|
||||
return Response.json({ connection, user: result.user, version: result.version });
|
||||
}
|
||||
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// Release attachments and file uploads through the contents API.
|
||||
maxRequestBodySize: 64 * 1024 * 1024,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
const userId = Number(officerUser);
|
||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (url.pathname === '/_config') return await handleConfig(req, userId);
|
||||
|
||||
const config = await getGiteaConfig(userId);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
||||
const started = Date.now();
|
||||
const result = await probe(config);
|
||||
const ms = Date.now() - started;
|
||||
if (!result.ok) {
|
||||
return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
|
||||
}
|
||||
await recordServiceProbe(userId, 'gitea', result.version);
|
||||
return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_api/')) {
|
||||
if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 });
|
||||
const subpath = url.pathname.slice('/_api'.length) + url.search;
|
||||
if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);
|
||||
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
const upstream = await callGitea(config, subpath, {
|
||||
method: req.method,
|
||||
headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
|
||||
body: hasBody ? await req.arrayBuffer() : undefined,
|
||||
});
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Path only. A repository body is the owner's private code.
|
||||
console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'gitea',
|
||||
handles: ['gitea'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'gitea:server', port });
|
||||
console.log(`[gitea] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[gitea] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -1,114 +0,0 @@
|
||||
import { getResolvedServiceCredentials } from 'officerdb';
|
||||
|
||||
// Where the Gitea instance is and what it takes to talk to it. The credential lives here and only here —
|
||||
// the platform forwards to this sidecar and holds nothing.
|
||||
//
|
||||
// Gitea authenticates API calls with a personal access token minted from the instance's own
|
||||
// Settings → Applications → Access Tokens. That is the OWNER'S to create and paste in; there is no way to
|
||||
// derive one from a platform session, and no reason to want one.
|
||||
//
|
||||
// THE HEADER IS `token `, NOT `Bearer `. Gitea's own scheme, confirmed against a live 1.27.0 instance's
|
||||
// swagger: `AuthorizationHeaderToken` is documented as "API tokens must be prepended with \"token\"
|
||||
// followed by a space." Recent Gitea also tolerates `Bearer`, but `token` is the documented spelling and
|
||||
// the one that works on every version we might be pointed at. The query-string forms (`?access_token=` /
|
||||
// `?token=`) are deprecated for removal and must not be used — they leak the credential into access logs.
|
||||
|
||||
export type GiteaConfig = { id: number; base: string; token: string | null };
|
||||
|
||||
/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */
|
||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
const cache = new Map<number, GiteaConfig | null>();
|
||||
|
||||
export function invalidateGiteaConfig(userId: number): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear every user's cached config.
|
||||
*
|
||||
* Needed because a member's entry holds the OWNER'S base, resolved at read time. Dropping only the
|
||||
* caller's entry was correct while each row was self-contained; once rows inherit, changing the instance
|
||||
* leaves every member cached against the previous host until this process restarts — pointing their
|
||||
* tokens at a server the owner has just moved away from. Call this whenever the owner's row changes.
|
||||
*/
|
||||
export function invalidateAllGiteaConfigs(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// One Gitea instance, one token per person. The base comes from whoever owns the platform; the token is
|
||||
// this caller's own, so what they see is their account — their repos, their notifications, their issues.
|
||||
//
|
||||
// getResolvedServiceCredentials does the inheriting: the owner's row carries the URL, everyone else's
|
||||
// carries only a token and resolves the base from the owner's. It returns null when the pairing is not
|
||||
// usable — no row for this user, or a row waiting on an instance the owner has not connected — and both
|
||||
// cases mean the same thing to every caller here: Gitea is not available to you yet.
|
||||
//
|
||||
// A member's URL is never read from their own row because it is never written there. That is what stops
|
||||
// a settings form from becoming a per-user SSRF hop.
|
||||
export async function getGiteaConfig(userId: number): Promise<GiteaConfig | null> {
|
||||
if (cache.has(userId)) return cache.get(userId) ?? null;
|
||||
const row = await getResolvedServiceCredentials(userId, 'gitea');
|
||||
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
|
||||
cache.set(userId, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export const authHeaders = (config: GiteaConfig): Record<string, string> =>
|
||||
config.token ? { Authorization: `token ${config.token}` } : {};
|
||||
|
||||
/**
|
||||
* One call against the instance. Returns the raw Response so callers can stream or inspect status —
|
||||
* nothing here parses a repository or an issue, because that is the owner's code and this layer has no
|
||||
* business reading it.
|
||||
*/
|
||||
export async function callGitea(config: GiteaConfig, path: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = { ...authHeaders(config), ...((init?.headers as Record<string, string>) ?? {}) };
|
||||
return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) });
|
||||
}
|
||||
|
||||
export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string };
|
||||
|
||||
/**
|
||||
* Is the instance up, and does the stored token actually work?
|
||||
*
|
||||
* Both calls carry the token, which is the one place this deliberately departs from the memos sidecar's
|
||||
* shape. Memos probes `/healthz` unauthenticated first so a bad URL is distinguishable from a bad token.
|
||||
* Gitea has no such endpoint under a common configuration: with `REQUIRE_SIGNIN_VIEW` enabled — which the
|
||||
* instance this was built against has — even `/api/v1/version` answers 403 with "Only signed in user is
|
||||
* allowed to call APIs". An unauthenticated pre-flight would therefore report "unreachable" for a
|
||||
* perfectly healthy server, which is the most misleading answer available.
|
||||
*
|
||||
* So the split is by failure mode instead, which is what the UI actually needs to tell apart:
|
||||
* throw (DNS, refused, TLS, timeout) → unreachable, the URL is wrong
|
||||
* 401/403 → reached it fine, the token is wrong
|
||||
* 200 → both good
|
||||
* Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401.
|
||||
*/
|
||||
export async function probe(config: GiteaConfig): Promise<ProbeResult> {
|
||||
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const res = await callGitea(config, '/api/v1/version', { signal: AbortSignal.timeout(8000) });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { ok: false, version: null, user: null, error: `token rejected (${res.status})` };
|
||||
}
|
||||
if (!res.ok) return { ok: false, version: null, user: null, error: `instance returned ${res.status}` };
|
||||
const body = (await res.json().catch(() => ({}))) as { version?: string };
|
||||
version = body.version ?? null;
|
||||
} catch (err) {
|
||||
return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` };
|
||||
}
|
||||
|
||||
// `/version` is satisfied by any valid token; `/user` is what proves the token is bound to an actual
|
||||
// account and yields the identity the connection screen shows back to the owner.
|
||||
try {
|
||||
const me = await callGitea(config, '/api/v1/user');
|
||||
if (!me.ok) return { ok: false, version, user: null, error: `token rejected (${me.status})` };
|
||||
const body = (await me.json().catch(() => ({}))) as { login?: string; full_name?: string; username?: string };
|
||||
return { ok: true, version, user: body.login || body.username || body.full_name || null };
|
||||
} catch (err) {
|
||||
return { ok: false, version, user: null, error: String(err) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user