Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

mirrors.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

mirrors.tsxBlame390 lines · 2 contributors
4a0dea1Claude1/**
2 * Block I9 — Repository mirroring.
3 *
4 * GET /:owner/:repo/settings/mirror — config form + recent runs
5 * POST /:owner/:repo/settings/mirror — save upstream URL + interval
6 * POST /:owner/:repo/settings/mirror/delete — remove mirror config
7 * POST /:owner/:repo/settings/mirror/sync — run one sync now (owner-only)
8 * POST /admin/mirrors/sync-all — site admin, run all due mirrors
9 */
10
11import { Hono } from "hono";
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader } from "../views/components";
4127ecfClaude17import { formatRelative } from "../views/ui";
4a0dea1Claude18import { softAuth, requireAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { isSiteAdmin } from "../lib/admin";
21import { audit } from "../lib/notify";
22import {
23 deleteMirror,
24 getMirrorForRepo,
25 listRecentRuns,
26 runMirrorSync,
27 safeUrlForLog,
28 syncAllDue,
29 upsertMirror,
30 validateUpstreamUrl,
31} from "../lib/mirrors";
32
33const mirrors = new Hono<AuthEnv>();
34mirrors.use("*", softAuth);
35
36async function ownerGate(c: any): Promise<
37 | Response
38 | {
39 user: any;
40 ownerName: string;
41 repoName: string;
42 repo: typeof repositories.$inferSelect;
43 }
44> {
45 const user = c.get("user");
46 if (!user) return c.redirect("/login");
47 const { owner: ownerName, repo: repoName } = c.req.param();
48 const [owner] = await db
49 .select()
50 .from(users)
51 .where(eq(users.username, ownerName))
52 .limit(1);
53 if (!owner || owner.id !== user.id) {
54 return c.html(
55 <Layout title="Forbidden" user={user}>
56 <div class="empty-state">
57 <h2>403</h2>
58 <p>Only the repository owner can configure mirroring.</p>
59 </div>
60 </Layout>,
61 403
62 );
63 }
64 const [repo] = await db
65 .select()
66 .from(repositories)
67 .where(
68 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
69 )
70 .limit(1);
71 if (!repo) return c.notFound();
72 return { user, ownerName, repoName, repo };
73}
74
75// ---------- Config page ----------
76
77mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
78 const g = await ownerGate(c);
79 if (g instanceof Response) return g;
80 const { user, ownerName, repoName, repo } = g;
81
82 const mirror = await getMirrorForRepo(repo.id);
83 const runs = mirror ? await listRecentRuns(mirror.id, 20) : [];
84
85 const success = c.req.query("success");
86 const error = c.req.query("error");
87
88 return c.html(
89 <Layout title={`Mirror — ${ownerName}/${repoName}`} user={user}>
90 <RepoHeader owner={ownerName} repo={repoName} />
91 <div class="settings-container" style="max-width:720px">
92 <h2>Mirror settings</h2>
93 <p style="color:var(--text-muted)">
94 Keep this repository in sync with an upstream URL by periodically
95 running <code>git fetch --prune</code>. Only <code>https://</code>,{" "}
96 <code>http://</code>, and <code>git://</code> URLs are accepted —
97 SSH and local paths are not supported.
98 </p>
99
100 {success && (
101 <div class="auth-success">{decodeURIComponent(success)}</div>
102 )}
103 {error && (
104 <div class="auth-error">{decodeURIComponent(error)}</div>
105 )}
106
107 <form
cce7944Claude108 method="post"
4a0dea1Claude109 action={`/${ownerName}/${repoName}/settings/mirror`}
110 class="panel"
111 style="padding:16px;margin:16px 0"
112 >
113 <div class="form-group">
114 <label for="upstream_url">Upstream URL</label>
115 <input
116 type="text"
117 id="upstream_url"
118 name="upstream_url"
119 value={mirror?.upstreamUrl || ""}
120 placeholder="https://github.com/torvalds/linux.git"
121 required
122 style="font-family:var(--font-mono)"
123 />
124 </div>
125 <div class="form-group">
126 <label for="interval_minutes">Sync interval (minutes)</label>
127 <input
128 type="number"
129 id="interval_minutes"
130 name="interval_minutes"
131 value={mirror?.intervalMinutes ?? 1440}
132 min="5"
133 max="43200"
134 style="width:160px"
135 />
136 </div>
137 <label
138 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
139 >
140 <input
141 type="checkbox"
142 name="is_enabled"
143 value="1"
144 checked={mirror ? mirror.isEnabled : true}
5db1b25copilot-swe-agent[bot]145 aria-label="Enabled"
4a0dea1Claude146 />
147 <span>Enabled</span>
148 </label>
149 <button type="submit" class="btn btn-primary">
150 {mirror ? "Update mirror" : "Enable mirror"}
151 </button>
152 </form>
153
154 {mirror && (
155 <>
156 <div style="display:flex;gap:8px;margin:12px 0">
157 <form
cce7944Claude158 method="post"
4a0dea1Claude159 action={`/${ownerName}/${repoName}/settings/mirror/sync`}
160 >
161 <button type="submit" class="btn">
162 Sync now
163 </button>
164 </form>
165 <form
cce7944Claude166 method="post"
4a0dea1Claude167 action={`/${ownerName}/${repoName}/settings/mirror/delete`}
168 onsubmit="return confirm('Remove mirror configuration?')"
169 >
170 <button type="submit" class="btn btn-danger">
171 Remove mirror
172 </button>
173 </form>
174 </div>
175
176 <h3 style="margin-top:20px">Last run</h3>
177 <div class="panel" style="padding:12px">
178 {mirror.lastSyncedAt ? (
179 <div>
180 <div
181 style="font-size:12px;color:var(--text-muted);text-transform:uppercase"
182 >
183 {mirror.lastStatus === "ok" ? "Success" : "Error"} —{" "}
4127ecfClaude184 {formatRelative(
4a0dea1Claude185 mirror.lastSyncedAt as unknown as string
4127ecfClaude186 )}
4a0dea1Claude187 </div>
188 {mirror.lastError && (
189 <pre
190 style="margin-top:8px;padding:8px;background:var(--bg-subtle);border-radius:4px;font-size:12px;overflow-x:auto;color:var(--red)"
191 >
192 {mirror.lastError}
193 </pre>
194 )}
195 </div>
196 ) : (
197 <div style="color:var(--text-muted)">Never synced.</div>
198 )}
199 </div>
200
201 <h3 style="margin-top:20px">Recent runs</h3>
202 <div class="panel">
203 {runs.length === 0 ? (
204 <div class="panel-empty">No runs yet.</div>
205 ) : (
206 runs.map((r) => (
207 <div
208 class="panel-item"
209 style="justify-content:space-between;flex-wrap:wrap;gap:6px"
210 >
211 <div>
212 <span
213 style={`font-size:11px;text-transform:uppercase;margin-right:8px;color:${
214 r.status === "ok"
215 ? "var(--green)"
216 : r.status === "error"
217 ? "var(--red)"
218 : "var(--text-muted)"
219 }`}
220 >
221 {r.status}
222 </span>
223 <span
224 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
225 >
4127ecfClaude226 {formatRelative(
4a0dea1Claude227 r.startedAt as unknown as string
4127ecfClaude228 )}
4a0dea1Claude229 </span>
230 </div>
231 {r.message && (
232 <span
233 style="font-size:12px;color:var(--text-muted);max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
234 title={r.message}
235 >
236 {r.message.split("\n")[0]}
237 </span>
238 )}
239 </div>
240 ))
241 )}
242 </div>
243 <p
244 style="font-size:12px;color:var(--text-muted);margin-top:12px"
245 >
246 Upstream (logged, credentials redacted):{" "}
247 <code>{safeUrlForLog(mirror.upstreamUrl)}</code>
248 </p>
249 </>
250 )}
251 </div>
252 </Layout>
253 );
254});
255
256// ---------- Save config ----------
257
258mirrors.post("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
259 const g = await ownerGate(c);
260 if (g instanceof Response) return g;
261 const { user, ownerName, repoName, repo } = g;
262 const body = await c.req.parseBody();
263 const upstreamUrl = String(body.upstream_url || "").trim();
264 const intervalRaw = Number(body.interval_minutes || 1440);
265 const interval = Math.max(5, Math.min(43200, Math.floor(intervalRaw || 1440)));
266 const isEnabled = String(body.is_enabled || "") === "1";
267
268 const v = validateUpstreamUrl(upstreamUrl);
269 if (!v.ok) {
270 return c.redirect(
271 `/${ownerName}/${repoName}/settings/mirror?error=${encodeURIComponent(
272 v.error || "Invalid URL"
273 )}`
274 );
275 }
276
277 const result = await upsertMirror({
278 repositoryId: repo.id,
279 upstreamUrl,
280 intervalMinutes: interval,
281 isEnabled,
282 });
283 if (!result.ok) {
284 return c.redirect(
285 `/${ownerName}/${repoName}/settings/mirror?error=${encodeURIComponent(
286 result.error
287 )}`
288 );
289 }
290
291 await audit({
292 userId: user.id,
293 repositoryId: repo.id,
294 action: "mirror.configure",
295 metadata: {
296 upstream: safeUrlForLog(upstreamUrl),
297 intervalMinutes: interval,
298 isEnabled,
299 },
300 });
301
302 return c.redirect(
303 `/${ownerName}/${repoName}/settings/mirror?success=${encodeURIComponent(
304 "Mirror configuration saved."
305 )}`
306 );
307});
308
309// ---------- Delete ----------
310
311mirrors.post("/:owner/:repo/settings/mirror/delete", requireAuth, async (c) => {
312 const g = await ownerGate(c);
313 if (g instanceof Response) return g;
314 const { user, ownerName, repoName, repo } = g;
315
316 await deleteMirror(repo.id);
317 await audit({
318 userId: user.id,
319 repositoryId: repo.id,
320 action: "mirror.delete",
321 });
322
323 return c.redirect(
324 `/${ownerName}/${repoName}/settings/mirror?success=${encodeURIComponent(
325 "Mirror removed."
326 )}`
327 );
328});
329
330// ---------- Sync now ----------
331
332mirrors.post("/:owner/:repo/settings/mirror/sync", requireAuth, async (c) => {
333 const g = await ownerGate(c);
334 if (g instanceof Response) return g;
335 const { user, ownerName, repoName, repo } = g;
336 const mirror = await getMirrorForRepo(repo.id);
337 if (!mirror) {
338 return c.redirect(
339 `/${ownerName}/${repoName}/settings/mirror?error=${encodeURIComponent(
340 "No mirror configured"
341 )}`
342 );
343 }
344
345 const result = await runMirrorSync(mirror.id);
346 await audit({
347 userId: user.id,
348 repositoryId: repo.id,
349 action: "mirror.sync",
350 metadata: { ok: result.ok, exitCode: result.exitCode },
351 });
352 const msg = result.ok
353 ? "Mirror sync completed."
354 : `Sync failed: ${result.message.split("\n")[0]}`;
355 return c.redirect(
356 `/${ownerName}/${repoName}/settings/mirror?${
357 result.ok ? "success" : "error"
358 }=${encodeURIComponent(msg)}`
359 );
360});
361
362// ---------- Admin: sync all due ----------
363
364mirrors.post("/admin/mirrors/sync-all", requireAuth, async (c) => {
365 const user = c.get("user")!;
366 if (!(await isSiteAdmin(user.id))) {
367 return c.html(
368 <Layout title="Forbidden" user={user}>
369 <div class="empty-state">
370 <h2>403</h2>
371 <p>Site admin only.</p>
372 </div>
373 </Layout>,
374 403
375 );
376 }
377 const summary = await syncAllDue();
378 await audit({
379 userId: user.id,
380 action: "admin.mirrors.sync-all",
381 metadata: summary,
382 });
383 return c.redirect(
384 `/admin?message=${encodeURIComponent(
385 `Mirror sync: ${summary.total} due, ${summary.ok} ok, ${summary.failed} failed.`
386 )}`
387 );
388});
389
390export default mirrors;