Eleventh plugin out, and the first that is not a move. The seventeen files that reimplemented a fraction of Gitea's interface were deleted rather than carried across — each one a promise to keep pace with a project that moves faster than this plugin will. That removed a platform change nobody now has to build. useGiteaLocation read :owner/:name/:tab/:item from five route patterns and a plugin gets two, so extracting as-is would have needed the plugin system to declare extra routes. Nothing inside an iframe needs a route, so the requirement went with the screen. No regression window: gitea's 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. The proxy is what makes the iframe possible, not decoration. Officer and Gitea are different registrable domains, so a frame pointed straight at the instance gets a login page whose cookie the browser drops as third-party. Through Officer's origin it is first-party and survives; X-Frame-Options and CSP are stripped, safe only because the frame is the origin the browser already authenticated. /_web/* forwards the browser's own cookies and injects NOTHING. The sidecar holds an API token and injecting it there would show every user the owner's view of every repository. It is used only by /_api/*. Two doors, because an iframe's sub-resources carry no Authorization header: the ordinary routes go through the platform's gate, and /_web/* is declared unauthenticated and authenticates itself with a minted, path-scoped cookie — 32 random bytes against the user who asked, not dev-proxy's constant =1 which anyone knowing the cookie name can set. Verifying it also sets `user`, because createSidecarProxy fills X-Officer-User from it and the sidecar refuses a request without one. Ships an affordance: git for what git does, tea for pull requests and issues. Nothing copies it into capabilities/ yet — gitea is that mechanism's first customer.
99 lines
3.8 KiB
TypeScript
99 lines
3.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { ExternalLink, Loader2, TriangleAlert } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
// Gitea's own web interface, rendered in place.
|
|
//
|
|
// ── Why an iframe and not panels ──
|
|
//
|
|
// Gitea has a complete web interface. The version of this that shipped before was seventeen files
|
|
// reimplementing a fraction of it, and every one of those files was a promise to keep up with a project
|
|
// that moves faster than this plugin will.
|
|
//
|
|
// ── The one thing this component must do ──
|
|
//
|
|
// Get a cookie before the frame loads. Everything inside the iframe — stylesheets, scripts, avatars — is
|
|
// fetched by the browser with no Authorization header, so the frame's URL is served above the platform's
|
|
// gate and authenticates with a path-scoped cookie instead. That cookie is minted by a normal
|
|
// authenticated call, which is what this does first.
|
|
//
|
|
// Load the iframe before that call returns and every request in it is unauthenticated, which reads as
|
|
// Gitea being down.
|
|
|
|
const FRAME_SRC = '/api/gitea/_web/';
|
|
|
|
export const GiteaFrame = () => {
|
|
const { post } = useClient();
|
|
const [state, setState] = useState<'opening' | 'ready' | 'failed'>('opening');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
// Not in a dependency array: `useClient()` returns a new object every render, so depending on `post`
|
|
// would re-mint on every keystroke elsewhere in the app. Mount-only is what is meant.
|
|
void (async () => {
|
|
try {
|
|
await post('/gitea/session', {});
|
|
if (!cancelled) setState('ready');
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
setError(err instanceof Error ? err.message : 'could not open a session');
|
|
setState('failed');
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
if (state === 'opening') {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
<span className="text-sm">Opening Gitea…</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state === 'failed') {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
|
<TriangleAlert className="h-6 w-6 text-warning" />
|
|
<div>
|
|
<p className="text-sm font-medium">Could not open Gitea</p>
|
|
{/* The message, not a generic one: the interesting failure here is almost always "the sidecar
|
|
is not running" or "no instance is configured", and those want different answers. */}
|
|
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative h-full w-full">
|
|
{/* The escape hatch, and it is not an admission of defeat: the whole point of proxying an app that
|
|
already has a UI is that its own UI stays available. Anything the frame handles badly, this
|
|
opens properly. */}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="absolute right-3 top-3 z-10 opacity-60 hover:opacity-100"
|
|
onClick={() => window.open(FRAME_SRC, '_blank', 'noopener')}
|
|
title="Open Gitea in a new tab"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<iframe
|
|
src={FRAME_SRC}
|
|
title="Gitea"
|
|
className="h-full w-full border-0"
|
|
// Same origin, so `allow-same-origin` is what lets Gitea keep its session at all. Scripts and
|
|
// forms are Gitea's own — this is its interface, not untrusted content.
|
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|