diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index bdf714ae..357be760 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -102,11 +102,6 @@ export function App() { element={} />, ])} - } /> - } /> - } /> - } /> - } /> } /> } /> {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx deleted file mode 100644 index 2e614a70..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Navigate, useParams } from 'react-router'; -import type { LayoutNode } from 'officerdev'; -import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /gitea uses the Workspace/Panel system (like /command-hq): a section nav on the left and the section view -// on the right. Both talk to the officer-gitea sidecar through the /api/gitea auth proxy, which holds no -// Gitea credentials of its own — the instance URL and the access token live in the sidecar. -// -// The open section is :section in the URL, so the panels read it with useParams instead of passing state -// between themselves over a channel. This screen backs both /gitea and /gitea/:section and is the single -// place that decides what an absent or bogus section means. - -export const GiteaScreen = () => { - const { section, owner, name } = useParams(); - const workspace = useDashboardState('screens/gitea', defaultLayout); - - // Bare /gitea, or a section that doesn't exist, resolves to a canonical URL rather than rendering a default - // while the address bar says something else — the nav highlight is derived from the URL. - // - // A repository route (/gitea/repo/:owner/:name/…) has no :section at all and is exempt: it is a second, - // deeper shape on the same screen, not a malformed section. - const inRepo = !!owner && !!name; - if (!inRepo && !isGiteaSection(section)) { - return ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts deleted file mode 100644 index e94dd054..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'gitea-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'gitea-nav', appType: 'gitea-nav' }, size: 22 }, - { node: { type: 'panel', id: 'gitea-view', appType: 'gitea-view' }, size: 78 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx b/src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx deleted file mode 100644 index fe70ac93..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './GiteaScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 0abf1714..2fb814ca 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -173,7 +173,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [ { label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' }, - { label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' }, { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, { label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 37410a2a..21400910 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -14,7 +14,6 @@ export * from './Files'; export * from './Calendar'; export * from './Contacts'; export * from './CommandHQ'; -export * from './Gitea'; export * from './SystemMonitor'; export * from './Activity'; export * from './CodeEditor'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 117eec9c..8abd43f4 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -24,7 +24,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' }, { match: (p) => p.startsWith('/plugins'), title: 'Marketplace' }, { match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' }, - { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, { match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' }, { match: (p) => p.startsWith('/activity'), title: 'Activity' }, diff --git a/src/servers/api/gitea/router.ts b/src/servers/api/gitea/router.ts deleted file mode 100644 index f6a626d3..00000000 --- a/src/servers/api/gitea/router.ts +++ /dev/null @@ -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; diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index b234a150..adc13e74 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -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 diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 9f2454c7..f1138ca5 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -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 diff --git a/src/servers/hono.ts b/src/servers/hono.ts index e9cb5e1f..36e4c19c 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 ['/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 diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index cc78e969..a4414be6 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -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); }); /* diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 5aa0319f..0bf9ba13 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -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', diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 07fb2cad..fe734706 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -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', diff --git a/src/servers/sidecar/gitea/index.ts b/src/servers/sidecar/gitea/index.ts deleted file mode 100644 index 7279ad80..00000000 --- a/src/servers/sidecar/gitea/index.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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).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')); diff --git a/src/servers/sidecar/gitea/upstream.ts b/src/servers/sidecar/gitea/upstream.ts deleted file mode 100644 index 48a881e2..00000000 --- a/src/servers/sidecar/gitea/upstream.ts +++ /dev/null @@ -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(); - -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 { - 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 => - 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 { - const headers = { ...authHeaders(config), ...((init?.headers as Record) ?? {}) }; - 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 { - 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) }; - } -} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index f5f1742e..82c13537 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -10,7 +10,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; -import { appRegistryMetas as giteaMetas } from '../apps/Gitea'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as davMetas } from '../apps/Dav'; @@ -29,7 +28,6 @@ export const apps = [ ...chatHistoryMetas, ...widgetMetas, ...desktopMetas, - ...giteaMetas, ...monitorMetas, ...qrTransferMetas, ...davMetas, diff --git a/src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx b/src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx deleted file mode 100644 index 6901bd9c..00000000 --- a/src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import type { GiteaIssue, GiteaNotification } from './shared'; -import { useState } from 'react'; -import { Bell, Building2, ExternalLink, MessageSquare, Search } from 'lucide-react'; -import { DataRow, RelativeTime } from '@/components/Data'; -import { EmptyState, ErrorState, LabelChip, Loading, StateFilter, StateIcon } from './GiteaBits'; -import { RepoRow } from './RepositoriesView'; -import { giteaRepoPath } from './shared'; -import { useGiteaIssueSearch, useGiteaNotifications, useGiteaOrgs, useGiteaRepoSearch } from './useGiteaData'; - -// The cross-repository sections — the ones Gitea puts in its own header rather than inside a repository: -// your issues, your pull requests, your notifications, explore, and the orgs you belong to. - -// ── Issues / pull requests across every repo ────────────────────────────────────────────────────── - -type CrossRepoProps = { type: 'issues' | 'pulls' }; - -export const CrossRepoIssuesView = ({ type }: CrossRepoProps) => { - const [state, setState] = useState<'open' | 'closed' | 'all'>('open'); - const { data: issues, isLoading, error } = useGiteaIssueSearch({ state, type }); - const noun = type === 'pulls' ? 'pull requests' : 'issues'; - - return ( -
-
- - {!!issues?.length && ( - {issues.length} across all repositories - )} -
-
- {isLoading ? ( - - ) : error ? ( - - ) : !issues?.length ? ( - - ) : ( -
- {issues.map((issue) => ( - - ))} -
- )} -
-
- ); -}; - -/** - * The cross-repo list is the one place an issue does not already know which repository it is in from the - * URL, so the row carries `repository` — and links through it, which is why that field is read rather than - * the issue's own html_url. - */ -const CrossRepoRow = ({ issue, type }: { issue: GiteaIssue; type: 'issues' | 'pulls' }) => { - const owner = issue.repository?.owner ?? ''; - const name = issue.repository?.name ?? ''; - const to = owner && name ? giteaRepoPath(owner, name, { tab: type, item: issue.number }) : null; - - // Without a repository on the payload there is nothing to route to, so DataRow is left without a `to` - // and renders a plain div rather than a link that goes somewhere wrong. - return ( - } - title={ - <> - {issue.title} - {issue.labels?.map((label) => ( - - ))} - - } - meta={[ - // The repository leads here — it is the fact that makes a cross-repo row make sense, and it is - // the first thing you need in order to know whether the row is yours to care about. - issue.repository?.full_name ?? 'unknown', - `#${issue.number}`, - , - issue.user && `by ${issue.user.login}`, - ]} - trail={ - !!issue.comments && ( - - - {issue.comments} - - ) - } - /> - ); -}; - -// ── Notifications ───────────────────────────────────────────────────────────────────────────────── - -/** - * Turn a notification's API url into an in-app route. Gitea gives the subject as an `/api/v1/...` address - * because the field is meant for a client to re-fetch, not to navigate; pulling the owner/repo/number back - * out of it is what lets a notification open in this app instead of bouncing to the instance. - */ -const API_SUBJECT = /\/api\/v1\/repos\/([^/]+)\/([^/]+)\/(issues|pulls)\/(\d+)/; - -function subjectRoute(notification: GiteaNotification): string | null { - const match = notification.subject.url?.match(API_SUBJECT); - if (!match) return null; - const [, owner, repo, kind, index] = match; - if (!owner || !repo || !index) return null; - // Gitea reports a PR's subject under /issues/, and tells them apart with subject.type instead. - const tab = notification.subject.type === 'Pull' || kind === 'pulls' ? 'pulls' : 'issues'; - return giteaRepoPath(decodeURIComponent(owner), decodeURIComponent(repo), { tab, item: index }); -} - -export const NotificationsView = () => { - const { data: notifications, isLoading, error } = useGiteaNotifications(); - - if (isLoading) return ; - if (error) return ; - if (!notifications?.length) { - return ; - } - - return ( -
-
- {notifications.map((notification) => { - // An in-app route when the subject URL can be parsed back into owner/repo/number, and the - // instance itself when it cannot — the row stays clickable either way, and the external icon - // is the only thing that tells you which kind of click you are about to make. - const to = subjectRoute(notification); - return ( - } - title={notification.subject.title} - meta={[ - notification.repository?.full_name, - notification.subject.type, - , - ]} - trail={!to && } - /> - ); - })} -
-
- ); -}; - -// ── Explore ─────────────────────────────────────────────────────────────────────────────────────── - -export const ExploreView = () => { - const [input, setInput] = useState(''); - const [query, setQuery] = useState(''); - const { data, isLoading, error } = useGiteaRepoSearch(query); - - return ( -
-
{ - ev.preventDefault(); - setQuery(input.trim()); - }} - > -
- - setInput(ev.target.value)} - placeholder="Search repositories on this instance…" - className="w-full rounded-md border bg-background py-1.5 pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-primary" - /> -
- -
- -
- {isLoading ? ( - - ) : error ? ( - - ) : !data?.data?.length ? ( - - ) : ( -
- {data.data.map((repo) => ( - - ))} -
- )} -
-
- ); -}; - -// ── Organizations ───────────────────────────────────────────────────────────────────────────────── - -export const OrganizationsView = () => { - const { data: orgs, isLoading, error } = useGiteaOrgs(); - - if (isLoading) return ; - if (error) return ; - if (!orgs?.length) return ; - - return ( -
-
- {orgs.map((org) => ( - - ) : ( - - - - ) - } - title={org.full_name || org.username} - description={org.description} - meta={[`@${org.username}`, org.visibility, org.location]} - /> - ))} -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx b/src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx deleted file mode 100644 index b8d6d94a..00000000 --- a/src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { Tone } from '@/components/Data'; -import type { ReactNode } from 'react'; -import type { GiteaIssue, GiteaLabel, GiteaPullRequest, GiteaUser } from './shared'; -import { CircleCheck, CircleDot, GitMerge, GitPullRequest, GitPullRequestClosed } from 'lucide-react'; -import { EmptyBlock, ErrorBlock, LoadingBlock, StatusIcon, StatusPill } from '@/components/Data'; -import { serviceErrorMessage } from '../../hooks/useServiceConnection'; -import { labelTextColor } from './shared'; - -// The small pieces every Gitea view repeats. These are now thin adapters over `components/Data` — -// they translate Gitea's vocabulary (open / closed / merged) into the design language's five tones -// and otherwise get out of the way. See docs/design-language-interface.md. -// -// The export names are unchanged so the ten call sites did not all have to move at once. - -/** - * Gitea's state vocabulary, mapped once. - * - * The non-obvious choice is CLOSED ISSUE → neutral rather than a colour of its own. Most issues in - * any list are closed; giving them a tone paints most of the screen and leaves the open ones no - * quieter than the rest. Neutral is what makes "open" findable — which is the only thing anyone is - * scanning an issue list for. A closed PULL request is different: it was rejected, and that is a - * real outcome worth marking, so it keeps `danger`. - */ -function stateOf(item: GiteaIssue | GiteaPullRequest): { tone: Tone; icon: typeof CircleDot; text: string } { - const isPull = 'base' in item || !!(item as GiteaIssue).pull_request; - const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged; - - if (merged) return { tone: 'info', icon: GitMerge, text: 'Merged' }; - if (item.state === 'open') { - return { tone: 'success', icon: isPull ? GitPullRequest : CircleDot, text: 'Open' }; - } - return isPull - ? { tone: 'danger', icon: GitPullRequestClosed, text: 'Closed' } - : { tone: 'neutral', icon: CircleCheck, text: 'Closed' }; -} - -export const Avatar = ({ user, size = 20 }: { user?: GiteaUser; size?: number }) => { - const initial = (user?.full_name || user?.login || '?').charAt(0).toUpperCase(); - if (!user?.avatar_url) { - return ( - - {initial} - - ); - } - return ( - {user.login} - ); -}; - -/** - * The one place a raw colour is correct: a label's colour is data the user chose in Gitea, not a - * decision this app gets to make. Everything else on a Gitea surface goes through a tone. - */ -export const LabelChip = ({ label }: { label: GiteaLabel }) => ( - - {label.name} - -); - -export const StateBadge = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => { - const { tone, icon, text } = stateOf(item); - return ( - - {text} - - ); -}; - -/** The state marker for a dense list, where the full pill is too much furniture. */ -export const StateIcon = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => { - const { tone, icon, text } = stateOf(item); - return ; -}; - -export const Loading = ({ label = 'Loading…' }: { label?: string }) => ; - -export const ErrorState = ({ title, error }: { title: string; error: unknown }) => ( - -); - -export const EmptyState = ({ title, hint }: { title: string; hint?: ReactNode }) => ( - -); - -/** A segmented open/closed filter, the control Gitea puts above every issue and PR list. */ -type StateFilterProps = { value: 'open' | 'closed' | 'all'; onChange: (value: 'open' | 'closed' | 'all') => void }; - -export const StateFilter = ({ value, onChange }: StateFilterProps) => ( -
- {(['open', 'closed', 'all'] as const).map((state) => ( - - ))} -
-); diff --git a/src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx b/src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx deleted file mode 100644 index 10ad22e4..00000000 --- a/src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import type { ServiceConnection } from '../../hooks/useServiceConnection'; -import { useEffect, useState } from 'react'; -import { CheckCircle2, ExternalLink, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { serviceErrorMessage, useServiceConnectionActions, useServiceHealth } from '../../hooks/useServiceConnection'; -import { isUnreachableFromBrowser } from './shared'; -import { useGiteaConnection } from './useGiteaData'; - -// Connecting a Gitea instance to Officer, from the app. -// -// Both the setup wizard and the permanent settings page: GiteaView renders it in place of whatever section -// the nav asked for while nothing is connected, and the Connection section renders it for good. One -// component, so re-pointing at a different instance later goes through exactly the code path that stored -// the first one. -// -// The token is write-only — the GET it reads has no field that could carry one back, so the input is always -// blank and an empty input means "keep the stored token". That is what lets the URL be corrected without -// the token being re-pasted. -// -// TWO FORMS, not one. There is one instance and a token per person: the server owner names the instance, -// everyone else supplies only their own token against it. The sidecar enforces that — a `url` from a member -// is answered 403, not ignored — so this must not send one. The rule is upstream of the UI on purpose; what -// happens here is only that a field nobody is allowed to fill in is not drawn. - -const HINT = 'text-xs leading-relaxed text-muted-foreground'; - -/** Gitea's own default HTTP port. Officer dials it from the server, not from this browser. */ -const DEFAULT_URL = 'http://localhost:3000'; - -const URL_HINT = - "The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " + - 'localhost here means the machine Officer runs on.'; - -const MEMBER_URL_HINT = - 'The instance this server is connected to. Only the server owner can change it — your half of this is the token.'; - -const TOKEN_HINT = - 'A personal access token from the instance: Settings → Applications → Generate Token. Officer sends it ' + - 'as `Authorization: token …` on every call. Scopes read:user and read:repository are enough to browse; ' + - 'grant write only if you want Officer to change things.'; - -type SaveInput = Record & { token: string }; - -export const GiteaConnection = () => { - const { data, isLoading } = useGiteaConnection(); - const { data: health } = useServiceHealth('gitea'); - const { save, forget } = useServiceConnectionActions('gitea'); - - const connection = data?.connection ?? null; - // Defaulted to OWNER deliberately, and it is not a loading concern — the `isLoading` guard below means - // the form never renders before `data` arrives. The only way past it without data is a FAILED query - // (`retry: false`), i.e. the sidecar is down — and that is exactly when the owner needs the URL field to - // repoint a broken instance. Defaulting to false would hide it at the one moment it is needed. A member - // seeing the field in that state costs nothing: the sidecar rejects a `url` from them regardless. - const isOwner = data?.isOwner ?? true; - const instanceUrl = data?.instanceUrl ?? null; - - const [url, setUrl] = useState(''); - const [token, setToken] = useState(''); - const [error, setError] = useState(''); - - // Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the - // owner is still typing, while forgetting and re-adding does reset the form. - useEffect(() => { - setUrl(connection?.url ?? ''); - setToken(''); - }, [connection?.id]); - - const submit = async () => { - setError(''); - try { - // A member sends no `url` KEY at all — the sidecar rejects the field's presence, not just a value, - // so `url: undefined` would still have to survive JSON.stringify dropping it and is not worth relying on. - await save.mutateAsync(isOwner ? { url: url.trim(), token: token.trim() } : { token: token.trim() }); - setToken(''); - } catch (err) { - setError(serviceErrorMessage(err)); - } - }; - - const remove = async () => { - setError(''); - try { - await forget.mutateAsync(); - } catch (err) { - setError(serviceErrorMessage(err)); - } - }; - - if (isLoading) { - return ( -
- Loading… -
- ); - } - - // Where to mint a token: the URL being typed while the owner is setting the instance up, the instance - // that is already there for everyone else. - const base = (isOwner ? url.trim() : (instanceUrl ?? '')).replace(/\/+$/, ''); - const tokenUrl = base ? `${base}/user/settings/applications` : null; - // A member cannot connect before there is an instance to connect to, and saying so beats a 409. - const awaitingInstance = !isOwner && !data?.instanceConfigured; - - // Officer dials the instance from the server, but avatars and "open in Gitea" links are fetched by the - // BROWSER, straight from whatever origin the instance mints them on. A loopback or LAN address works for - // the first and not the second, and the symptom — broken avatars, dead links — looks nothing like its - // cause, so it is worth saying out loud rather than leaving to be discovered. - const dialOnly = (() => { - const stored = connection?.url ?? instanceUrl; - if (!stored) return false; - try { - return isUnreachableFromBrowser(new URL(stored).hostname); - } catch { - return false; - } - })(); - - return ( -
-
-
-
- -
-
-

{isOwner ? 'Gitea instance' : 'Your Gitea account'}

-

- {!isOwner - ? awaitingInstance - ? 'No Gitea instance has been connected on this server yet. Once the owner adds one, sign in to it here with your own access token.' - : 'Sign in to this server’s Gitea with your own access token. What you see is your account — your repositories, your notifications.' - : connection - ? 'Where Officer talks to Gitea. Saving re-checks the instance before storing anything.' - : 'Point Officer at your Gitea instance. It needs the URL and one access token.'} -

-
-
- - {connection && } - - {connection && dialOnly && ( -
- -
-
Avatars and links will not load
-

- This address only works from the server. Officer can talk to Gitea, but your browser loads avatars and - “open in Gitea” links directly from the instance, so they need an address this browser can reach too.{' '} - {isOwner - ? 'Use the instance’s public URL here instead.' - : 'Ask the server owner to use the instance’s public URL.'} -

-
-
- )} - -
- {isOwner ? ( - - ) : ( -
- Instance -
- {instanceUrl ?? 'Not connected yet'} -
- {MEMBER_URL_HINT} -
- )} - - - - {error && ( -
- - {error} -
- )} - -
- - {connection && ( - - )} -
-
-
-
- ); -}; - -type StatusRowProps = { - connection: ServiceConnection; - /** The resolved instance, which is where a member's row gets its base from — theirs stores none. */ - instanceUrl: string | null; - health: { ok: boolean; error?: string } | undefined; -}; - -const StatusRow = ({ connection, instanceUrl, health }: StatusRowProps) => ( -
- {health?.ok ? ( - - ) : ( - - )} -
-
{health?.ok ? 'Connected' : 'Not responding'}
-
- {connection.url ?? instanceUrl ?? '—'} - {connection.version ? ` · Gitea ${connection.version}` : ''} -
- {!health?.ok && health?.error &&
{health.error}
} -
-
-); diff --git a/src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx b/src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx deleted file mode 100644 index cc4d2f0b..00000000 --- a/src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import rehypeSanitize from 'rehype-sanitize'; -import { CodeRenderer } from '../FileViewer/renderers'; - -// Markdown for anything the instance hands back — a README, an issue body, a release note. -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// WHY THIS IS NOT `/api/v1/markdown` -// -// Gitea's own web UI renders markdown server-side and it does it better than this can: it resolves -// #123 issue references, @mentions and relative links against the repository, and it sanitises the -// result with bluemonday before returning it. Replicating the upstream's own rendering is the house -// rule, and the sidecar's allow-list carries `/markdown` and `/markup` so that door stays open. -// -// It is not used HERE because consuming it means putting instance-returned HTML through -// `dangerouslySetInnerHTML`, and this app has no second line of defence to put behind that: there is -// no DOMPurify, sanitize-html or xss in the tree, and installs are frozen, so adding one is a -// deliberate lockfile change and not something to slip into a feature. That would leave the owner's -// authenticated session trusting a remote server's sanitiser and nothing else — and Officer's whole -// perimeter is that one session. -// -// So: parse the markdown here instead, where `rehype-sanitize` runs on an AST that never contains raw -// HTML in the first place (note `rehype-raw` is deliberately ABSENT — with it, `