Subscriptions now declare a `verb` (create | delete | bulk-create). POST /:did/webhooks defaults to inserting both create and delete rows when no verb is given, preserving existing all-events behavior. Update events fold into the create verb. The new bulk-create verb debounces creates per DID over 400 ms and delivers a `records` array. Migration adds the verb column with default 'create' and clones every existing row for the delete verb so legacy subscriptions keep firing on both events.
165 lines
4.4 KiB
TypeScript
165 lines
4.4 KiB
TypeScript
import { Application, Router } from "@oak/oak";
|
|
import {
|
|
addWebhookSubscription,
|
|
deleteWebhooksByDid,
|
|
getNotes,
|
|
getNotesByDid,
|
|
getNotesByDids,
|
|
type WebhookVerb,
|
|
} from "./src/data/db.ts";
|
|
import { log } from "./src/log.ts";
|
|
|
|
const router = new Router();
|
|
|
|
const PAGINATION = 20;
|
|
const GITHUB_CLIENT_ID = Deno.env.get("GITHUB_CLIENT_ID") ?? "";
|
|
const GITHUB_CLIENT_SECRET = Deno.env.get("GITHUB_CLIENT_SECRET") ?? "";
|
|
|
|
router.get("/", (ctx) => {
|
|
ctx.response.body = "Hello world";
|
|
});
|
|
|
|
router.get("/auth/github", async (ctx) => {
|
|
const code = ctx.request.url.searchParams.get("code");
|
|
const type = ctx.request.url.searchParams.get("type");
|
|
|
|
if (!code) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "code is required" };
|
|
return;
|
|
}
|
|
|
|
const params: Record<string, string> = {
|
|
client_id: GITHUB_CLIENT_ID,
|
|
client_secret: GITHUB_CLIENT_SECRET,
|
|
};
|
|
|
|
if (type === "refresh") {
|
|
params.grant_type = "refresh_token";
|
|
params.refresh_token = code;
|
|
} else {
|
|
params.code = code;
|
|
}
|
|
|
|
const response = await fetch(
|
|
"https://github.com/login/oauth/access_token",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(params),
|
|
},
|
|
);
|
|
|
|
ctx.response.status = response.status;
|
|
ctx.response.body = await response.json();
|
|
});
|
|
|
|
router.get("/health", (ctx) => {
|
|
ctx.response.body = { status: "ok" };
|
|
});
|
|
|
|
router.get("/notes", (ctx) => {
|
|
const cursor = ctx.request.url.searchParams.get("cursor") ?? undefined;
|
|
const limit = Number(ctx.request.url.searchParams.get("limit")) || PAGINATION;
|
|
ctx.response.body = getNotes(cursor, limit);
|
|
});
|
|
|
|
router.get("/:did/notes", (ctx) => {
|
|
const { did } = ctx.params;
|
|
const cursor = ctx.request.url.searchParams.get("cursor") ?? undefined;
|
|
const limit = Number(ctx.request.url.searchParams.get("limit")) || PAGINATION;
|
|
ctx.response.body = getNotesByDid(did, cursor, limit);
|
|
});
|
|
|
|
router.post("/notes/feed", async (ctx) => {
|
|
const body = await ctx.request.body.json();
|
|
const { dids, cursor, limit } = body ?? {};
|
|
if (!Array.isArray(dids) || dids.length === 0) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "dids must be a non-empty array" };
|
|
return;
|
|
}
|
|
ctx.response.body = getNotesByDids(dids, cursor, Number(limit) || PAGINATION);
|
|
});
|
|
|
|
const ALLOWED_VERBS = ["create", "delete", "bulk-create"] as const;
|
|
|
|
router.post("/:did/webhooks", async (ctx) => {
|
|
const { did } = ctx.params;
|
|
const body = await ctx.request.body.json();
|
|
const { method, url, token, verb } = body ?? {};
|
|
if (!method || !url) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "method and url are required" };
|
|
return;
|
|
}
|
|
if (verb !== undefined && !ALLOWED_VERBS.includes(verb)) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = {
|
|
error: `verb must be one of ${ALLOWED_VERBS.join(", ")}`,
|
|
};
|
|
return;
|
|
}
|
|
const verbsToInsert: WebhookVerb[] = verb ? [verb] : ["create", "delete"];
|
|
const subscriptions = verbsToInsert.map((v) =>
|
|
addWebhookSubscription({ did, method, url, token, verb: v })
|
|
);
|
|
ctx.response.status = 201;
|
|
ctx.response.body = subscriptions;
|
|
});
|
|
|
|
router.delete("/:did/webhooks", (ctx) => {
|
|
const { did } = ctx.params;
|
|
deleteWebhooksByDid(did);
|
|
ctx.response.status = 204;
|
|
});
|
|
|
|
// router.delete("/:did/:rkey", async (ctx) => {
|
|
// const { did, rkey } = ctx.params;
|
|
// let verifiedDid: string;
|
|
// try {
|
|
// verifiedDid = await authenticateRequest(
|
|
// ctx.request.headers.get("Authorization"),
|
|
// );
|
|
// } catch {
|
|
// ctx.response.status = 401;
|
|
// ctx.response.body = { error: "Unauthorized" };
|
|
// return;
|
|
// }
|
|
// if (verifiedDid !== did) {
|
|
// ctx.response.status = 403;
|
|
// ctx.response.body = { error: "You can only delete your own notes" };
|
|
// return;
|
|
// }
|
|
// deleteNote({ did, rkey });
|
|
// ctx.response.status = 204;
|
|
// })
|
|
|
|
const app = new Application();
|
|
|
|
app.use(async (ctx, next) => {
|
|
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
|
ctx.response.headers.set(
|
|
"Access-Control-Allow-Methods",
|
|
"GET, POST, PUT, DELETE, OPTIONS",
|
|
);
|
|
ctx.response.headers.set(
|
|
"Access-Control-Allow-Headers",
|
|
"Content-Type, Authorization",
|
|
);
|
|
if (ctx.request.method === "OPTIONS") {
|
|
ctx.response.status = 204;
|
|
return;
|
|
}
|
|
await next();
|
|
});
|
|
|
|
app.use(router.routes());
|
|
app.use(router.allowedMethods());
|
|
|
|
log("[server] listening on port 8080");
|
|
app.listen({ port: 8080 });
|