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