| import { Hono } from "hono" |
| import { DurableObject } from "cloudflare:workers" |
| import { randomUUID } from "node:crypto" |
| import { jwtVerify, createRemoteJWKSet } from "jose" |
| import { createAppAuth } from "@octokit/auth-app" |
| import { Octokit } from "@octokit/rest" |
| import { Resource } from "sst" |
| import { parseRepositoryClaim } from "./github" |
|
|
| type Env = { |
| SYNC_SERVER: DurableObjectNamespace<SyncServer> |
| Bucket: R2Bucket |
| WEB_DOMAIN: string |
| } |
|
|
| export class SyncServer extends DurableObject<Env> { |
| |
| constructor(ctx: DurableObjectState, env: Env) { |
| super(ctx, env) |
| } |
| async fetch() { |
| console.log("SyncServer subscribe") |
|
|
| const webSocketPair = new WebSocketPair() |
| const [client, server] = Object.values(webSocketPair) |
|
|
| this.ctx.acceptWebSocket(server) |
|
|
| const data = await this.ctx.storage.list() |
| Array.from(data.entries()) |
| .filter(([key, _]) => key.startsWith("session/")) |
| .map(([key, content]) => server.send(JSON.stringify({ key, content }))) |
|
|
| return new Response(null, { |
| status: 101, |
| webSocket: client, |
| }) |
| } |
|
|
| async webSocketMessage(_ws, _message) {} |
|
|
| async webSocketClose(ws, code, _reason, _wasClean) { |
| ws.close(code, "Durable Object is closing WebSocket") |
| } |
|
|
| async publish(key: string, content: any) { |
| const sessionID = await this.getSessionID() |
| if ( |
| !key.startsWith(`session/info/${sessionID}`) && |
| !key.startsWith(`session/message/${sessionID}/`) && |
| !key.startsWith(`session/part/${sessionID}/`) |
| ) |
| return new Response("Error: Invalid key", { status: 400 }) |
|
|
| |
| await this.env.Bucket.put(`share/${key}.json`, JSON.stringify(content), { |
| httpMetadata: { |
| contentType: "application/json", |
| }, |
| }) |
| await this.ctx.storage.put(key, content) |
| const clients = this.ctx.getWebSockets() |
| console.log("SyncServer publish", key, "to", clients.length, "subscribers") |
| for (const client of clients) { |
| client.send(JSON.stringify({ key, content })) |
| } |
| } |
|
|
| public async share(sessionID: string) { |
| let secret = await this.getSecret() |
| if (secret) return secret |
| secret = randomUUID() |
|
|
| await this.ctx.storage.put("secret", secret) |
| await this.ctx.storage.put("sessionID", sessionID) |
|
|
| return secret |
| } |
|
|
| public async getData() { |
| const data = (await this.ctx.storage.list()) as Map<string, any> |
| return Array.from(data.entries()) |
| .filter(([key, _]) => key.startsWith("session/")) |
| .map(([key, content]) => ({ key, content })) |
| } |
|
|
| public async assertSecret(secret: string) { |
| if (secret !== (await this.getSecret())) throw new Error("Invalid secret") |
| } |
|
|
| private async getSecret() { |
| return this.ctx.storage.get<string>("secret") |
| } |
|
|
| private async getSessionID() { |
| return this.ctx.storage.get<string>("sessionID") |
| } |
|
|
| async clear() { |
| const sessionID = await this.getSessionID() |
| const list = await this.env.Bucket.list({ |
| prefix: `session/message/${sessionID}/`, |
| limit: 1000, |
| }) |
| for (const item of list.objects) { |
| await this.env.Bucket.delete(item.key) |
| } |
| await this.env.Bucket.delete(`session/info/${sessionID}`) |
| await this.ctx.storage.deleteAll() |
| } |
|
|
| static shortName(id: string) { |
| return id.substring(id.length - 8) |
| } |
| } |
|
|
| export default new Hono<{ Bindings: Env }>() |
| .get("/", (c) => c.text("Hello, world!")) |
| .post("/share_create", async (c) => { |
| const body = await c.req.json<{ sessionID: string }>() |
| const sessionID = body.sessionID |
| const short = SyncServer.shortName(sessionID) |
| const id = c.env.SYNC_SERVER.idFromName(short) |
| const stub = c.env.SYNC_SERVER.get(id) |
| const secret = await stub.share(sessionID) |
| return c.json({ |
| secret, |
| url: `https://${c.env.WEB_DOMAIN}/s/${short}`, |
| }) |
| }) |
| .post("/share_delete", async (c) => { |
| const body = await c.req.json<{ sessionID: string; secret: string }>() |
| const sessionID = body.sessionID |
| const secret = body.secret |
| const id = c.env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID)) |
| const stub = c.env.SYNC_SERVER.get(id) |
| await stub.assertSecret(secret) |
| await stub.clear() |
| return c.json({}) |
| }) |
| .post("/share_delete_admin", async (c) => { |
| const body = await c.req.json<{ sessionShortName: string; adminSecret: string }>() |
| const sessionShortName = body.sessionShortName |
| const adminSecret = body.adminSecret |
| if (adminSecret !== Resource.ADMIN_SECRET.value) throw new Error("Invalid admin secret") |
| const id = c.env.SYNC_SERVER.idFromName(sessionShortName) |
| const stub = c.env.SYNC_SERVER.get(id) |
| await stub.clear() |
| return c.json({}) |
| }) |
| .post("/share_sync", async (c) => { |
| const body = await c.req.json<{ |
| sessionID: string |
| secret: string |
| key: string |
| content: any |
| }>() |
| const name = SyncServer.shortName(body.sessionID) |
| const id = c.env.SYNC_SERVER.idFromName(name) |
| const stub = c.env.SYNC_SERVER.get(id) |
| await stub.assertSecret(body.secret) |
| await stub.publish(body.key, body.content) |
| return c.json({}) |
| }) |
| .get("/share_poll", async (c) => { |
| const upgradeHeader = c.req.header("Upgrade") |
| if (!upgradeHeader || upgradeHeader !== "websocket") { |
| return c.text("Error: Upgrade header is required", { status: 426 }) |
| } |
| const id = c.req.query("id") |
| console.log("share_poll", id) |
| if (!id) return c.text("Error: Share ID is required", { status: 400 }) |
| const stub = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName(id)) |
| return stub.fetch(c.req.raw) |
| }) |
| .get("/share_data", async (c) => { |
| const id = c.req.query("id") |
| console.log("share_data", id) |
| if (!id) return c.text("Error: Share ID is required", { status: 400 }) |
| const stub = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName(id)) |
| const data = await stub.getData() |
|
|
| let info |
| const messages: Record<string, any> = {} |
| data.forEach((d) => { |
| const [root, type] = d.key.split("/") |
| if (root !== "session") return |
| if (type === "info") { |
| info = d.content |
| return |
| } |
| if (type === "message") { |
| messages[d.content.id] = { |
| parts: [], |
| ...d.content, |
| } |
| } |
| if (type === "part") { |
| messages[d.content.messageID].parts.push(d.content) |
| } |
| }) |
|
|
| return c.json({ info, messages }) |
| }) |
| .post("/feishu", async (c) => { |
| const body = (await c.req.json()) as { |
| challenge?: string |
| event?: { |
| message?: { |
| message_id?: string |
| root_id?: string |
| parent_id?: string |
| chat_id?: string |
| content?: string |
| } |
| } |
| } |
| console.log(JSON.stringify(body, null, 2)) |
| const challenge = body.challenge |
| if (challenge) return c.json({ challenge }) |
|
|
| const content = body.event?.message?.content |
| const parsed = |
| typeof content === "string" && content.trim().startsWith("{") |
| ? (JSON.parse(content) as { |
| text?: string |
| }) |
| : undefined |
| const text = typeof parsed?.text === "string" ? parsed.text : typeof content === "string" ? content : "" |
|
|
| let message = text.trim().replace(/^@_user_\d+\s*/, "") |
| message = message.replace(/^aiden,?\s*/i, "<@759257817772851260> ") |
| if (!message) return c.json({ ok: true }) |
|
|
| const threadId = body.event?.message?.root_id || body.event?.message?.message_id |
| if (threadId) message = `${message} [${threadId}]` |
|
|
| const response = await fetch( |
| `https://discord.com/api/v10/channels/${Resource.DISCORD_SUPPORT_CHANNEL_ID.value}/messages`, |
| { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| Authorization: `Bot ${Resource.DISCORD_SUPPORT_BOT_TOKEN.value}`, |
| }, |
| body: JSON.stringify({ |
| content: `${message}`, |
| }), |
| }, |
| ) |
|
|
| if (!response.ok) { |
| console.error(await response.text()) |
| return c.json({ error: "Discord bot message failed" }, { status: 502 }) |
| } |
|
|
| return c.json({ ok: true }) |
| }) |
| |
| |
| |
| .post("/exchange_github_app_token", async (c) => { |
| const EXPECTED_AUDIENCE = "opencode-github-action" |
| const GITHUB_ISSUER = "https://token.actions.githubusercontent.com" |
| const JWKS_URL = `${GITHUB_ISSUER}/.well-known/jwks` |
|
|
| |
| const token = c.req.header("Authorization")?.replace(/^Bearer /, "") |
| if (!token) return c.json({ error: "Authorization header is required" }, { status: 401 }) |
|
|
| |
| const JWKS = createRemoteJWKSet(new URL(JWKS_URL)) |
| let repository: ReturnType<typeof parseRepositoryClaim> |
| try { |
| const { payload } = await jwtVerify(token, JWKS, { |
| issuer: GITHUB_ISSUER, |
| audience: EXPECTED_AUDIENCE, |
| }) |
| repository = parseRepositoryClaim(payload) |
| } catch (err) { |
| console.error("Token verification failed:", err) |
| return c.json({ error: "Invalid or expired token" }, { status: 403 }) |
| } |
|
|
| try { |
| const auth = createAppAuth({ |
| appId: Resource.GITHUB_APP_ID.value, |
| privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, |
| }) |
| const appAuth = await auth({ type: "app" }) |
| const octokit = new Octokit({ auth: appAuth.token }) |
| const { data: installation } = await octokit.apps.getRepoInstallation({ |
| owner: repository.owner, |
| repo: repository.repo, |
| }) |
| const installationAuth = await auth({ |
| type: "installation", |
| installationId: installation.id, |
| }) |
| return c.json({ token: installationAuth.token }) |
| } catch (error) { |
| console.error("GitHub App token exchange failed:", error) |
| return c.json( |
| { error: `Failed to exchange GitHub App token for ${repository.owner}/${repository.repo}` }, |
| { status: 502 }, |
| ) |
| } |
| }) |
| |
| |
| |
| .post("/exchange_github_app_token_with_pat", async (c) => { |
| const body = await c.req.json<{ owner: string; repo: string }>() |
| const owner = body.owner |
| const repo = body.repo |
|
|
| try { |
| |
| const authHeader = c.req.header("Authorization") |
| const token = authHeader?.replace(/^Bearer /, "") |
| if (!token) throw new Error("Authorization header is required") |
|
|
| |
| const userClient = new Octokit({ auth: token }) |
| const { data: repoData } = await userClient.repos.get({ owner, repo }) |
| if (!repoData.permissions.admin && !repoData.permissions.push && !repoData.permissions.maintain) |
| throw new Error("User does not have write permissions") |
|
|
| |
| const auth = createAppAuth({ |
| appId: Resource.GITHUB_APP_ID.value, |
| privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, |
| }) |
| const appAuth = await auth({ type: "app" }) |
|
|
| |
| const appClient = new Octokit({ auth: appAuth.token }) |
| const { data: installation } = await appClient.apps.getRepoInstallation({ |
| owner, |
| repo, |
| }) |
|
|
| |
| const installationAuth = await auth({ |
| type: "installation", |
| installationId: installation.id, |
| }) |
|
|
| return c.json({ token: installationAuth.token }) |
| } catch (e: any) { |
| let error = e |
| if (e instanceof Error) { |
| error = e.message |
| } |
|
|
| return c.json({ error }, { status: 401 }) |
| } |
| }) |
| |
| |
| |
| .get("/get_github_app_installation", async (c) => { |
| const owner = c.req.query("owner") |
| const repo = c.req.query("repo") |
|
|
| const auth = createAppAuth({ |
| appId: Resource.GITHUB_APP_ID.value, |
| privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, |
| }) |
| const appAuth = await auth({ type: "app" }) |
|
|
| |
| const octokit = new Octokit({ auth: appAuth.token }) |
| let installation |
| try { |
| const ret = await octokit.apps.getRepoInstallation({ owner, repo }) |
| installation = ret.data |
| } catch (err) { |
| if (err instanceof Error && err.message.includes("Not Found")) { |
| |
| } else { |
| throw err |
| } |
| } |
|
|
| return c.json({ installation }) |
| }) |
| .all("*", (c) => c.text("Not Found")) |
|
|