deletes in outbox now.
This commit is contained in:
parent
22ed2a16d6
commit
6f2f5843c0
142
src/activity.ts
142
src/activity.ts
|
@ -1,25 +1,92 @@
|
||||||
import { Sha256Signer } from "activitypub-http-signatures";
|
import { Sha256Signer } from "activitypub-http-signatures";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { Article } from "./article.js";
|
import { Article } from "./article.js";
|
||||||
import { User, getActor } from "./user.js";
|
import { User } from "./user.js";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { routes } from "./index.js";
|
import { fillRoute } from "./router.js";
|
||||||
|
|
||||||
|
export const CONTEXT = "https://www.w3.org/ns/activitystreams";
|
||||||
|
export const PUBLIC = CONTEXT + "#Public";
|
||||||
|
|
||||||
const hasher = createHash("sha256");
|
const hasher = createHash("sha256");
|
||||||
|
|
||||||
export const send = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => {
|
const hashDigest =(payload: string | Buffer) =>
|
||||||
const headers = {
|
"sha-256="
|
||||||
|
+ hasher
|
||||||
|
.update((Buffer.isBuffer(payload) ? payload : Buffer.from(payload)).toString("hex"))
|
||||||
|
.digest("base64");
|
||||||
|
|
||||||
|
const flattenHeaders = (headers: Record<string, string>) =>
|
||||||
|
Object.entries(headers).map(([key, value]) => `${key}: ${value}`);
|
||||||
|
|
||||||
|
interface SignedInit {
|
||||||
|
keyId: string;
|
||||||
|
privateKey: string;
|
||||||
|
digest?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => {
|
||||||
|
const signedHeaders: HeadersInit = [
|
||||||
|
["Date", new Date().toUTCString()],
|
||||||
|
["Host", new URL(url).host],
|
||||||
|
["Content-Type", `application/ld+json; profile="${CONTEXT}"`]
|
||||||
|
];
|
||||||
|
|
||||||
|
if (signedInit.digest) {
|
||||||
|
signedHeaders.push(["Digest", signedInit.digest]);
|
||||||
|
}
|
||||||
|
else if (init.body) {
|
||||||
|
if (Buffer.isBuffer(init.body) || typeof init.body === "string") {
|
||||||
|
signedHeaders.push(["Digest", hashDigest(init.body)]);
|
||||||
|
}
|
||||||
|
else throw "unsupported body type";
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = new Sha256Signer({
|
||||||
|
privateKey: signedInit.privateKey,
|
||||||
|
publicKeyId: signedInit.keyId
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = signer.sign({
|
||||||
|
url,
|
||||||
|
method: init.method as string,
|
||||||
|
headers: signedHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
signedHeaders.push(["Signature", signature]);
|
||||||
|
|
||||||
|
const newHeaders = new Headers();
|
||||||
|
if (Array.isArray(init.headers)) {
|
||||||
|
for (const header of init.headers) {
|
||||||
|
if (Array.isArray(header))
|
||||||
|
newHeaders.set(header[0], header[1]);
|
||||||
|
else throw "unsupported headers type"; // Lazy.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of signedHeaders) {
|
||||||
|
newHeaders.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
init.headers = newHeaders;
|
||||||
|
}
|
||||||
|
else throw "unsupported headers type"; // Lazy.
|
||||||
|
|
||||||
|
return fetch(url, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendActivity = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
date: new Date().toUTCString(),
|
date: new Date().toUTCString(),
|
||||||
digest: hash,
|
digest: hash,
|
||||||
host: new URL(inbox).host,
|
host: new URL(inbox).host,
|
||||||
"content-type": `application/ld+json; profile="http://www.w3.org/ns/activitystreams"`
|
"content-type": `application/ld+json; profile="${CONTEXT}"`
|
||||||
} as Record<string, string>;
|
};
|
||||||
|
|
||||||
const signer = new Sha256Signer({ privateKey, publicKeyId });
|
const signer = new Sha256Signer({ privateKey, publicKeyId });
|
||||||
const signature = signer.sign({
|
const signature = signer.sign({
|
||||||
url: inbox,
|
url: inbox,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: Object.entries(headers).map((pair) => `${pair[0]}: ${pair[1]}`)
|
headers: flattenHeaders(headers)
|
||||||
});
|
});
|
||||||
|
|
||||||
headers.signature = signature;
|
headers.signature = signature;
|
||||||
|
@ -31,24 +98,46 @@ export const send = async (publicKeyId: string, privateKey: string, activity: st
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildActivity = (article: Article, user: User) => {
|
export const deleteArticleActivity = (article: Article, user: User) => {
|
||||||
const actor = getActor(user.nickname);
|
const actor = fillRoute("actor", user.nickname);
|
||||||
|
const published = typeof article.updated_at === "number" ? new Date(article.updated_at) : article.updated_at;
|
||||||
|
const context = fillRoute("context", article.id);
|
||||||
|
const objectId = fillRoute("object", article.id);
|
||||||
|
const canonicalUrl = `https://${process.env.blog_host}/${article.slug}.html`;
|
||||||
|
|
||||||
|
const activity: Record<string, any> = {
|
||||||
|
id: fillRoute("activity", article.id + 1_000_000_000),
|
||||||
|
"@context": CONTEXT,
|
||||||
|
summary: `${user.nickname} deleted article ${canonicalUrl}`,
|
||||||
|
type: "Delete",
|
||||||
|
actor,
|
||||||
|
context,
|
||||||
|
to: [PUBLIC],
|
||||||
|
published,
|
||||||
|
object: objectId
|
||||||
|
};
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createArticleActivity = (article: Article, user: User) => {
|
||||||
|
const actor = fillRoute("actor", user.nickname);
|
||||||
const published = typeof article.created_at === "number" ? new Date(article.created_at) : article.created_at;
|
const published = typeof article.created_at === "number" ? new Date(article.created_at) : article.created_at;
|
||||||
const canonicalUrl = `https://${process.env.blog_host}/${article.slug}.html`;
|
const canonicalUrl = `https://${process.env.blog_host}/${article.slug}.html`;
|
||||||
const context = `https://${process.env.blog_host}/c/${article.id}`;
|
const context = fillRoute("context", article.id);
|
||||||
const followers = `https://${process.env.blog_host}${routes.followers.replace(":actor", user.nickname)}`;
|
const followers = fillRoute("followers", user.nickname);
|
||||||
const activity: Record<string, any> = {
|
const activity: Record<string, any> = {
|
||||||
id: `https://${process.env.blog_host}${routes.activity.replace(":id", article.id.toString())}`,
|
id: fillRoute("activity", article.id),
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": CONTEXT,
|
||||||
type: "Create",
|
type: "Create",
|
||||||
actor,
|
actor,
|
||||||
context,
|
context,
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
to: [PUBLIC],
|
||||||
cc: [followers],
|
cc: [followers],
|
||||||
published
|
published
|
||||||
};
|
};
|
||||||
|
|
||||||
const objectId = `https://${process.env.blog_host}${routes.object.replace(":id", article.id.toString())}`;
|
const objectId = fillRoute("object", article.id);
|
||||||
const content = readFileSync(article.file as string, "utf-8");
|
const content = readFileSync(article.file as string, "utf-8");
|
||||||
activity.object = {
|
activity.object = {
|
||||||
id: objectId,
|
id: objectId,
|
||||||
|
@ -57,7 +146,7 @@ export const buildActivity = (article: Article, user: User) => {
|
||||||
type: "Article",
|
type: "Article",
|
||||||
context,
|
context,
|
||||||
content,
|
content,
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
to: [PUBLIC],
|
||||||
cc: [followers],
|
cc: [followers],
|
||||||
url: canonicalUrl,
|
url: canonicalUrl,
|
||||||
mediaType: "text/markdown",
|
mediaType: "text/markdown",
|
||||||
|
@ -67,10 +156,16 @@ export const buildActivity = (article: Article, user: User) => {
|
||||||
return activity;
|
return activity;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendAll = async (publicKeyId: string, privateKey: string, activity: Record<string, any>, inboxes: string[]) => {
|
export const sendAll = async (keyId: string, privateKey: string, activity: Record<string, any> | string, inboxes: string[]) => {
|
||||||
const activityStr = JSON.stringify(activity, null, 4);
|
activity = typeof activity === "string"
|
||||||
const hash = "sha-256=" + hasher.update(Buffer.from(activityStr).toString("hex")).digest("base64");
|
? activity
|
||||||
const promises = inboxes.map((inbox) => send(publicKeyId, privateKey, activityStr, hash, inbox));
|
: JSON.stringify(activity, null, 4);
|
||||||
|
|
||||||
|
const hash = hashDigest(activity);
|
||||||
|
|
||||||
|
const init: RequestInit = { method: "POST", body: activity };
|
||||||
|
const init2 = { hash, privateKey, keyId };
|
||||||
|
const promises = inboxes.map((inboxUrl) => signedFetch(inboxUrl, init, init2));
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
const results = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
@ -80,3 +175,10 @@ export const sendAll = async (publicKeyId: string, privateKey: string, activity:
|
||||||
|
|
||||||
return !!!errors.length;
|
return !!!errors.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[]) => ({
|
||||||
|
type: "OrderedCollectionPage",
|
||||||
|
context: CONTEXT,
|
||||||
|
id,
|
||||||
|
orderedItems
|
||||||
|
});
|
|
@ -30,6 +30,11 @@ export const getById = async (articleId: number): Promise<Article | null> => {
|
||||||
.then((rec) => !!rec ? rec : null)
|
.then((rec) => !!rec ? rec : null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getByUserId = async (userId: number): Promise<Article[]> =>
|
||||||
|
db<Article>("articles")
|
||||||
|
.where("users_id", userId)
|
||||||
|
.orderBy("created_at", "desc");
|
||||||
|
|
||||||
export const insert = async (userId: number, slug: string, title: string): Promise<Article> => {
|
export const insert = async (userId: number, slug: string, title: string): Promise<Article> => {
|
||||||
const data: Record<string, any> = {
|
const data: Record<string, any> = {
|
||||||
users_id: userId,
|
users_id: userId,
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import markdownit from 'markdown-it'
|
import markdownit from 'markdown-it'
|
||||||
import { slugRegex } from "./article.js";
|
import { slugRegex } from "./article.js";
|
||||||
import { newUser, get as getUserByNickname, getActor, getFollowerInboxes } from "./user.js";
|
import { newUser, get as getUserByNickname, getFollowerInboxes } from "./user.js";
|
||||||
import { insert as insertArticle } from "./article.js";
|
import { insert as insertArticle } from "./article.js";
|
||||||
import { add as addToOutbox } from "./outbox.js";
|
import { add as addToOutbox } from "./outbox.js";
|
||||||
import { buildActivity, sendAll } from "./activity.js";
|
import { createArticleActivity, sendAll } from "./activity.js";
|
||||||
import { routes } from "./index.js";
|
import { fillRoute } from "./router.js";
|
||||||
|
|
||||||
const md = markdownit();
|
const md = markdownit();
|
||||||
|
|
||||||
|
@ -54,9 +54,9 @@ else if (c === "new-article") {
|
||||||
|
|
||||||
await addToOutbox(article.id);
|
await addToOutbox(article.id);
|
||||||
|
|
||||||
const keyId = `https://${process.env.blog_host}${getActor(user.nickname)}#main-key`;
|
const keyId = fillRoute("actor", user.nickname) + "#main-key";
|
||||||
const inboxes = await getFollowerInboxes(user.id);
|
const inboxes = await getFollowerInboxes(user.id);
|
||||||
const activity = buildActivity(article, user);
|
const activity = createArticleActivity(article, user);
|
||||||
const success = await sendAll(keyId, user.private_key, activity, inboxes);
|
const success = await sendAll(keyId, user.private_key, activity, inboxes);
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
67
src/index.ts
67
src/index.ts
|
@ -1,28 +1,12 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import ActivitypubExpress from "activitypub-express";
|
import ActivitypubExpress from "activitypub-express";
|
||||||
import { get as getOutbox } from "./outbox.js";
|
import { get as getOutbox } from "./outbox.js";
|
||||||
import { get as getUserByNickname, getActor } from "./user.js";
|
import { get as getUserByNickname, getId as getUserId } from "./user.js";
|
||||||
|
import { Routes, fillRoute } from "./router.js";
|
||||||
|
|
||||||
const port = parseInt(process.env.port || "8080");
|
const port = parseInt(process.env.port || "8080");
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
export const routes = {
|
|
||||||
actor: "/author/:actor",
|
|
||||||
object: "/o/:id",
|
|
||||||
activity: "/a/:id",
|
|
||||||
inbox: "/author/:actor/inbox",
|
|
||||||
outbox: "/author/:actor/outbox",
|
|
||||||
followers: "/author/:actor/followers",
|
|
||||||
following: "/author/:actor/following",
|
|
||||||
liked: "/author/:actor/liked",
|
|
||||||
collections: "/author/:actor/c/:id",
|
|
||||||
blocked: "/author/:actor/blocked",
|
|
||||||
rejections: "/author/:actor/rejections",
|
|
||||||
rejected: "/author/:actor/rejected",
|
|
||||||
shares: "/a/:id/shares",
|
|
||||||
likes: "/a/:id/likes",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const SITE = ActivitypubExpress({
|
const SITE = ActivitypubExpress({
|
||||||
name: process.env.blog_name,
|
name: process.env.blog_name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
@ -30,7 +14,7 @@ const SITE = ActivitypubExpress({
|
||||||
actorParam: "actor",
|
actorParam: "actor",
|
||||||
objectParam: "id",
|
objectParam: "id",
|
||||||
activityParam: "id",
|
activityParam: "id",
|
||||||
routes,
|
Routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
@ -39,34 +23,39 @@ app.use(
|
||||||
SITE,
|
SITE,
|
||||||
);
|
);
|
||||||
|
|
||||||
app.route(routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post);
|
app.route(Routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post);
|
||||||
app.get(routes.followers, SITE.net.followers.get);
|
app.get(Routes.followers, SITE.net.followers.get);
|
||||||
app.get(routes.following, SITE.net.following.get);
|
app.get(Routes.following, SITE.net.following.get);
|
||||||
app.get(routes.liked, SITE.net.liked.get);
|
app.get(Routes.liked, SITE.net.liked.get);
|
||||||
app.get(routes.object, SITE.net.object.get);
|
app.get(Routes.object, SITE.net.object.get);
|
||||||
app.get(routes.activity, SITE.net.activityStream.get);
|
app.get(Routes.activity, SITE.net.activityStream.get);
|
||||||
app.get(routes.shares, SITE.net.shares.get);
|
app.get(Routes.shares, SITE.net.shares.get);
|
||||||
app.get(routes.likes, SITE.net.likes.get);
|
app.get(Routes.likes, SITE.net.likes.get);
|
||||||
app.get("/.well-known/webfinger", SITE.net.webfinger.get);
|
app.get("/.well-known/webfinger", SITE.net.webfinger.get);
|
||||||
app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
|
app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
|
||||||
app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
|
app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
|
||||||
app.post("/proxy", SITE.net.proxy.post);
|
app.post("/proxy", SITE.net.proxy.post);
|
||||||
|
|
||||||
app.get(routes.outbox, async (req, res) => {
|
app.get(Routes.outbox, async (req, res) => {
|
||||||
const nickname = req.params.actor;
|
const userId = await getUserId(req.params.actor);
|
||||||
const body = await getOutbox(nickname).then((out) => JSON.stringify(out, null, 4));
|
if (userId) {
|
||||||
res.append("Content-Type", "application/activity+json");
|
const body = await getOutbox(userId).then((out) => JSON.stringify(out, null, 4));
|
||||||
res.send(body);
|
res.append("Content-Type", "application/activity+json");
|
||||||
|
res.send(body);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(404).send("not found");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(routes.actor, async (req, res) => {
|
app.get(Routes.actor, async (req, res) => {
|
||||||
const nickname = req.params.actor;
|
const nickname = req.params.actor;
|
||||||
const actor = await getUserByNickname(nickname);
|
const actor = await getUserByNickname(nickname);
|
||||||
|
|
||||||
if (actor) {
|
if (actor) {
|
||||||
const accept = req.headers["accept"] || "";
|
const accept = req.headers["accept"] || "";
|
||||||
if (accept.startsWith("application/activity+json")) {
|
if (accept.startsWith("application/activity+json")) {
|
||||||
const id = getActor(nickname);
|
const id = fillRoute("actor", nickname);
|
||||||
const obj = {
|
const obj = {
|
||||||
id,
|
id,
|
||||||
type: "Person",
|
type: "Person",
|
||||||
|
@ -76,10 +65,10 @@ app.get(routes.actor, async (req, res) => {
|
||||||
name: actor.name,
|
name: actor.name,
|
||||||
summary: actor.bio,
|
summary: actor.bio,
|
||||||
url: id,
|
url: id,
|
||||||
inbox: `https://${process.env.blog_host}` + routes.inbox.replace(":actor", actor.nickname),
|
inbox: fillRoute("inbox", actor.nickname),
|
||||||
outbox: `https://${process.env.blog_host}` + routes.outbox.replace(":actor", actor.nickname),
|
outbox: fillRoute("outbox", actor.nickname),
|
||||||
publicKey: {
|
publicKey: {
|
||||||
id: `${id}#main-key`,
|
id: id + "#main-key",
|
||||||
owner: id,
|
owner: id,
|
||||||
publicKeyPem: actor.public_key
|
publicKeyPem: actor.public_key
|
||||||
}
|
}
|
||||||
|
@ -91,11 +80,11 @@ app.get(routes.actor, async (req, res) => {
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// TODO: html version.
|
// TODO: html version.
|
||||||
res.status(403).end();
|
res.status(403).send("todo");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
res.status(404).end();
|
res.status(404).send("actor not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getId as getUserId, getActor } from "./user.js";
|
import { get as getUserByNickname } from "./user.js";
|
||||||
import { routes } from "./index.js";
|
import { fillRoute } from "./router.js";
|
||||||
import { getById as getArticleById } from "./article.js";
|
import { getByUserId as getArticlesByUserId, getById as getArticleById, Article } from "./article.js";
|
||||||
|
import { createArticleActivity, deleteArticleActivity, orderedCollection } from "./activity.js";
|
||||||
|
|
||||||
|
|
||||||
export const zRawOutboxRecord = z.object({
|
export const zRawOutboxRecord = z.object({
|
||||||
|
@ -20,64 +20,27 @@ export const zRawOutboxRecord = z.object({
|
||||||
|
|
||||||
export type RawOutboxRecord = z.infer<typeof zRawOutboxRecord>;
|
export type RawOutboxRecord = z.infer<typeof zRawOutboxRecord>;
|
||||||
|
|
||||||
export const get = async (nickname: string): Promise<Record<string, any> | null> => {
|
export const toCollection = async (nickname: string): Promise<Record<string, any> | null> => {
|
||||||
const userId = await getUserId(nickname);
|
const user = await getUserByNickname(nickname);
|
||||||
|
|
||||||
if (userId) {
|
if (user) {
|
||||||
const actor = getActor(nickname);
|
const articles = await get(user.id)
|
||||||
|
|
||||||
const ret = db<RawOutboxRecord>("outboxes")
|
const activities = articles.map((a) => {
|
||||||
.select("outboxes.*", "slug", "file", "title")
|
if (a.verb === "Create") {
|
||||||
.join("articles", "outboxes.articles_id", "=", "articles.id")
|
return createArticleActivity(a, user);
|
||||||
.where("outboxes.users_id", userId)
|
}
|
||||||
.orderBy("created_at", "desc")
|
else if (a.verb === "Delete") {
|
||||||
.then((recs: RawOutboxRecord[]) => {
|
return deleteArticleActivity(a, user);
|
||||||
const activities: Record<string, any>[] = recs.map((rec) => {
|
}
|
||||||
const published = typeof rec.created_at === "number" ? new Date(rec.created_at) : rec.created_at;
|
else {
|
||||||
const canonicalUrl = `https://${process.env.blog_host}/${rec.slug}.html`;
|
throw "unexpected verb";
|
||||||
const context = `https://${process.env.blog_host}/c/${rec.articles_id}`;
|
}
|
||||||
const followers = `https://${process.env.blog_host}${routes.followers.replace(":actor", nickname)}`;
|
});
|
||||||
const activity: Record<string, any> = {
|
|
||||||
id: `https://${process.env.blog_host}${routes.activity.replace(":id", rec.id.toString())}`,
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: rec.verb,
|
|
||||||
actor,
|
|
||||||
context,
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [followers],
|
|
||||||
published
|
|
||||||
};
|
|
||||||
|
|
||||||
const objectId = `https://${process.env.blog_host}${routes.object.replace(":id", rec.articles_id.toString())}`;
|
const collection = orderedCollection(fillRoute("outbox", user.nickname), activities);
|
||||||
const content = readFileSync(rec.file as string, "utf-8");
|
|
||||||
activity.object = {
|
|
||||||
id: objectId,
|
|
||||||
actor,
|
|
||||||
attributedTo: actor,
|
|
||||||
type: "Article",
|
|
||||||
context,
|
|
||||||
content,
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [followers],
|
|
||||||
url: canonicalUrl,
|
|
||||||
mediaType: "text/markdown",
|
|
||||||
published
|
|
||||||
};
|
|
||||||
|
|
||||||
return activity;
|
return collection;
|
||||||
});
|
|
||||||
|
|
||||||
const collection = {
|
|
||||||
type: "OrderedCollectionPage",
|
|
||||||
context: "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: `https://${process.env.blog_host}${routes.outbox.replace(":actor", nickname)}`,
|
|
||||||
orderedItems: activities
|
|
||||||
};
|
|
||||||
|
|
||||||
return collection;
|
|
||||||
});
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return null;
|
return null;
|
||||||
|
@ -94,9 +57,23 @@ export const add = async (articleId: number): Promise<number> => {
|
||||||
articles_id: articleId,
|
articles_id: articleId,
|
||||||
created_at: article.created_at
|
created_at: article.created_at
|
||||||
})
|
})
|
||||||
.returning("id")
|
.returning("id")
|
||||||
.then(([{ id: id }]: { id: number }[]) => id)
|
.then(([{ id: id }]: { id: number }[]) => id)
|
||||||
|
|
||||||
}
|
}
|
||||||
else throw "failed to add to outbox";
|
else throw "failed to add to outbox";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ArticleActivity extends Article {
|
||||||
|
verb: string;
|
||||||
|
activity_created_at: Date | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = async (userId: number): Promise<ArticleActivity[]> =>
|
||||||
|
db<ArticleActivity>("articles")
|
||||||
|
.select("articles.*", "outboxes.verb", "outboxes.created_at as activity_created_at")
|
||||||
|
.join("outboxes", "articles.id", "outboxes.articles_id")
|
||||||
|
.where("users_id", userId)
|
||||||
|
.whereIn("outboxes.verb", ["Create", "Delete"])
|
||||||
|
.orderBy("outboxes.created_at", "desc")
|
||||||
|
;
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
export const Routes = {
|
||||||
|
actor: "/author/:actor",
|
||||||
|
object: "/o/:id",
|
||||||
|
activity: "/a/:id",
|
||||||
|
inbox: "/author/:actor/inbox",
|
||||||
|
outbox: "/author/:actor/outbox",
|
||||||
|
followers: "/author/:actor/followers",
|
||||||
|
following: "/author/:actor/following",
|
||||||
|
liked: "/author/:actor/liked",
|
||||||
|
collections: "/author/:actor/c/:id",
|
||||||
|
blocked: "/author/:actor/blocked",
|
||||||
|
rejections: "/author/:actor/rejections",
|
||||||
|
rejected: "/author/:actor/rejected",
|
||||||
|
shares: "/a/:id/shares",
|
||||||
|
likes: "/a/:id/likes",
|
||||||
|
context: "/c/:id",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Route = keyof typeof Routes;
|
||||||
|
|
||||||
|
export const fillRoute = (route: Route, value: number | string) =>
|
||||||
|
"https://"
|
||||||
|
+ process.env.blog_host
|
||||||
|
+ typeof value === "number"
|
||||||
|
? Routes[route].replace(":id", value.toString())
|
||||||
|
: Routes[route].replace(":actor", value as string)
|
||||||
|
;
|
|
@ -1,7 +1,6 @@
|
||||||
import { generateKeyPair } from "node:crypto";
|
import { generateKeyPair } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { routes } from "./index.js";
|
|
||||||
import { getFollowers } from "./collection.js";
|
import { getFollowers } from "./collection.js";
|
||||||
|
|
||||||
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
|
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
|
||||||
|
@ -26,11 +25,6 @@ export const get = async (nickname: string) =>
|
||||||
.first()
|
.first()
|
||||||
;
|
;
|
||||||
|
|
||||||
export const getActor = (nickname: string) =>
|
|
||||||
"https://"
|
|
||||||
+ process.env.blog_host
|
|
||||||
+ routes.actor.replace(":actor", nickname);
|
|
||||||
|
|
||||||
export const getNickname = async (userId: number): Promise<string | null> =>
|
export const getNickname = async (userId: number): Promise<string | null> =>
|
||||||
db("users")
|
db("users")
|
||||||
.select("nickname")
|
.select("nickname")
|
||||||
|
|
Loading…
Reference in New Issue