tons of additions.
This commit is contained in:
parent
a3a569013e
commit
0c1d093c40
|
@ -41,6 +41,9 @@ export const up = async function (knex) {
|
||||||
|
|
||||||
table.index(["collection_types_id", "articles_id"]);
|
table.index(["collection_types_id", "articles_id"]);
|
||||||
table.index(["collection_types_id", "users_id"]);
|
table.index(["collection_types_id", "users_id"]);
|
||||||
|
|
||||||
|
table.unique(["collection_types_id", "articles_id", "value"]);
|
||||||
|
table.unique(["collection_types_id", "users_id", "value"]);
|
||||||
})
|
})
|
||||||
.createTable("outboxes", (table) => {
|
.createTable("outboxes", (table) => {
|
||||||
table.increments("id");
|
table.increments("id");
|
||||||
|
|
|
@ -3,10 +3,15 @@ declare module "activitypub-express" {
|
||||||
export default ActivitypubExpress;
|
export default ActivitypubExpress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only enough here for what I need.
|
||||||
declare module "activitypub-http-signatures" {
|
declare module "activitypub-http-signatures" {
|
||||||
export class Sha256Signer {
|
export class Sha256Signer {
|
||||||
constructor(options: { publicKeyId: string, privateKey: string });
|
constructor(options: { publicKeyId: string, privateKey: string });
|
||||||
|
|
||||||
sign: (options: { url: string, method: string, headers: any[] }) => string;
|
sign: (options: { url: string, method: string, headers: any[] }) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parse: (params: { url: string, method: string, headers: Record<string, string> }) => {
|
||||||
|
verify: (publicKey: string) => boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,16 +3,21 @@ import { Article } from "./article.js";
|
||||||
import { User, getByActor } from "./user.js";
|
import { User, getByActor } from "./user.js";
|
||||||
import { fillRoute } from "./router.js";
|
import { fillRoute } from "./router.js";
|
||||||
import { streamToString, hashDigest } from "./util.js";
|
import { streamToString, hashDigest } from "./util.js";
|
||||||
import { signedFetch } from "./net.js";
|
import { signedFetch, SignedInit, getActor} from "./net.js";
|
||||||
|
import { getById as getUserById, getKeyId } from "./user.js";
|
||||||
|
import { parse as parseSignedRequest } from "activitypub-http-signatures";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { addFollower } from "./follower.js";
|
||||||
|
|
||||||
export const CONTEXT = "https://www.w3.org/ns/activitystreams";
|
export const CONTEXT = "https://www.w3.org/ns/activitystreams";
|
||||||
export const PUBLIC = CONTEXT + "#Public";
|
export const PUBLIC = CONTEXT + "#Public";
|
||||||
export const TYPE = "application/activity+json";
|
export const TYPE = "application/activity+json";
|
||||||
|
|
||||||
export const handleInboxPost = async (req: Request) => {
|
export const handleInboxPost = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.body) {
|
if (!req.body) {
|
||||||
console.warn("no body");
|
console.warn("no body");
|
||||||
|
res.status(403).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +28,7 @@ export const handleInboxPost = async (req: Request) => {
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.warn("body json parse failed");
|
console.warn("body json parse failed");
|
||||||
|
res.status(403).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,10 +36,53 @@ export const handleInboxPost = async (req: Request) => {
|
||||||
const actor = await getByActor(activity.object);
|
const actor = await getByActor(activity.object);
|
||||||
|
|
||||||
if (actor) {
|
if (actor) {
|
||||||
const follower = activity.actor;
|
const followerUrl: string = activity.actor;
|
||||||
|
|
||||||
|
const signer = await getUserById(1);
|
||||||
|
|
||||||
|
if (!signer) {
|
||||||
|
res.status(500).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: SignedInit = {
|
||||||
|
keyId: getKeyId(signer.nickname),
|
||||||
|
privateKey: signer.private_key
|
||||||
|
};
|
||||||
|
|
||||||
|
const follower = await getActor(followerUrl, init);
|
||||||
|
|
||||||
|
if (!follower || !follower.publicKey?.publicKeyPem) {
|
||||||
|
console.warn("No public key for follow requester:", followerUrl);
|
||||||
|
res.status(403).send("no public key found for follow requester");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK validate request signature
|
||||||
|
const signature = parseSignedRequest({
|
||||||
|
url: req.originalUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: req.headers as Record<string, string>
|
||||||
|
});
|
||||||
|
|
||||||
|
const validSignature = signature.verify(follower.publicKey.publicKeyPem);
|
||||||
|
|
||||||
|
if (!validSignature) {
|
||||||
|
console.warn("Signature validation failed.");
|
||||||
|
res.status(403).send("signature validation failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addFollower(actor.id, follower.id, follower.preferredUsername, follower.name, follower.inbox, follower.endpoints?.sharedInbox);
|
||||||
|
|
||||||
|
console.log("Done handling inbox POST follow request");
|
||||||
|
res.status(200);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.warn("follow attempt on unknown user");
|
console.warn("follow attempt on unknown user");
|
||||||
|
res.status(403).end();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (activity.type === "Undo") {
|
else if (activity.type === "Undo") {
|
||||||
|
@ -41,9 +90,13 @@ export const handleInboxPost = async (req: Request) => {
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.warn("unsupported activity type");
|
console.warn("unsupported activity type");
|
||||||
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { }
|
catch(e) {
|
||||||
|
console.warn("Failed to handle inbox POST request.", e);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteArticleActivity = (article: Article, user: User) => {
|
export const deleteArticleActivity = (article: Article, user: User) => {
|
||||||
|
@ -104,6 +157,32 @@ export const createArticleActivity = (article: Article, user: User) => {
|
||||||
return activity;
|
return activity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createArticleObject = (article: Article, nickname: string) => {
|
||||||
|
const actor = fillRoute("actor", nickname);
|
||||||
|
const context = fillRoute("context", article.id);
|
||||||
|
const objectId = fillRoute("object", article.id);
|
||||||
|
const content = readFileSync(article.file as string, "utf-8");
|
||||||
|
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 followers = fillRoute("followers", nickname);
|
||||||
|
|
||||||
|
const obj: Record<string, any> = {
|
||||||
|
id: objectId,
|
||||||
|
actor,
|
||||||
|
attributedTo: actor,
|
||||||
|
type: "Article",
|
||||||
|
context,
|
||||||
|
content,
|
||||||
|
to: [PUBLIC],
|
||||||
|
cc: [followers],
|
||||||
|
url: canonicalUrl,
|
||||||
|
mediaType: "text/markdown",
|
||||||
|
published
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
export const sendAll = async (keyId: string, privateKey: string, activity: Record<string, any> | string, inboxes: string[]) => {
|
export const sendAll = async (keyId: string, privateKey: string, activity: Record<string, any> | string, inboxes: string[]) => {
|
||||||
activity = typeof activity === "string"
|
activity = typeof activity === "string"
|
||||||
? activity
|
? activity
|
||||||
|
@ -124,13 +203,6 @@ export const sendAll = async (keyId: string, privateKey: string, activity: Recor
|
||||||
return !!!errors.length;
|
return !!!errors.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[]) => ({
|
|
||||||
type: "OrderedCollectionPage",
|
|
||||||
context: CONTEXT,
|
|
||||||
id,
|
|
||||||
orderedItems
|
|
||||||
});
|
|
||||||
|
|
||||||
export const userToPerson = (user: User) => {
|
export const userToPerson = (user: User) => {
|
||||||
const id = fillRoute("actor", user.nickname);
|
const id = fillRoute("actor", user.nickname);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,308 @@
|
||||||
|
// Shamelessly lifted from: https://gitlab.com/soapbox-pub/ditto/-/blob/main/src/schemas/activitypub.ts
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const apId = z.string().url();
|
||||||
|
const recipients = z.array(z.string()).catch([]);
|
||||||
|
const published = () => z.string().datetime().catch(new Date().toISOString());
|
||||||
|
|
||||||
|
/** Validates individual items in an array, dropping any that aren"t valid. */
|
||||||
|
function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
return z.any().array()
|
||||||
|
.transform((arr) => (
|
||||||
|
arr.map((item) => {
|
||||||
|
const parsed = schema.safeParse(item);
|
||||||
|
return parsed.success ? parsed.data : undefined;
|
||||||
|
}).filter((item): item is z.infer<T> => Boolean(item))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSchema = z.object({
|
||||||
|
type: z.literal("Image").catch("Image"),
|
||||||
|
url: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentSchema = z.object({
|
||||||
|
type: z.literal("Document").catch("Document"),
|
||||||
|
mediaType: z.string().optional().catch(undefined),
|
||||||
|
url: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentionSchema = z.object({
|
||||||
|
type: z.literal("Mention"),
|
||||||
|
href: z.string().url(),
|
||||||
|
name: z.string().optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashtagSchema = z.object({
|
||||||
|
type: z.literal("Hashtag"),
|
||||||
|
href: z.string().url(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiSchema = z.object({
|
||||||
|
type: z.literal("Emoji"),
|
||||||
|
icon: imageSchema,
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagSchema = z.discriminatedUnion("type", [
|
||||||
|
mentionSchema,
|
||||||
|
hashtagSchema,
|
||||||
|
emojiSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const propertyValueSchema = z.object({
|
||||||
|
type: z.literal("PropertyValue"),
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
verified_at: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-fffd.md */
|
||||||
|
const proxySchema = z.object({
|
||||||
|
protocol: z.string().url(),
|
||||||
|
proxied: z.string(),
|
||||||
|
authoritative: z.boolean().optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const personSchema = z.object({
|
||||||
|
type: z.literal("Person"),
|
||||||
|
id: apId,
|
||||||
|
icon: imageSchema.optional().catch(undefined),
|
||||||
|
image: imageSchema.optional().catch(undefined),
|
||||||
|
name: z.string().catch(""),
|
||||||
|
preferredUsername: z.string(),
|
||||||
|
inbox: apId,
|
||||||
|
followers: apId.optional().catch(undefined),
|
||||||
|
following: apId.optional().catch(undefined),
|
||||||
|
outbox: apId.optional().catch(undefined),
|
||||||
|
summary: z.string().catch(""),
|
||||||
|
attachment: filteredArray(propertyValueSchema).catch([]),
|
||||||
|
tag: filteredArray(emojiSchema).catch([]),
|
||||||
|
endpoints: z.object({
|
||||||
|
sharedInbox: apId.optional(),
|
||||||
|
}).optional().catch({}),
|
||||||
|
publicKey: z.object({
|
||||||
|
id: apId,
|
||||||
|
owner: apId,
|
||||||
|
publicKeyPem: z.string(),
|
||||||
|
}).optional().catch(undefined),
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applicationSchema = personSchema.merge(z.object({ type: z.literal("Application") }));
|
||||||
|
const groupSchema = personSchema.merge(z.object({ type: z.literal("Group") }));
|
||||||
|
const organizationSchema = personSchema.merge(z.object({ type: z.literal("Organization") }));
|
||||||
|
const serviceSchema = personSchema.merge(z.object({ type: z.literal("Service") }));
|
||||||
|
|
||||||
|
const actorSchema = z.discriminatedUnion("type", [
|
||||||
|
personSchema,
|
||||||
|
applicationSchema,
|
||||||
|
groupSchema,
|
||||||
|
organizationSchema,
|
||||||
|
serviceSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const noteSchema = z.object({
|
||||||
|
type: z.literal("Note"),
|
||||||
|
id: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
content: z.string(),
|
||||||
|
attachment: z.array(attachmentSchema).optional().catch(undefined),
|
||||||
|
tag: filteredArray(tagSchema).catch([]),
|
||||||
|
inReplyTo: apId.optional().catch(undefined),
|
||||||
|
attributedTo: apId,
|
||||||
|
published: published(),
|
||||||
|
sensitive: z.boolean().optional().catch(undefined),
|
||||||
|
summary: z.string().nullish().catch(undefined),
|
||||||
|
quoteUrl: apId.optional().catch(undefined),
|
||||||
|
source: z.object({
|
||||||
|
content: z.string(),
|
||||||
|
mediaType: z.literal("text/markdown"),
|
||||||
|
}).optional().catch(undefined),
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexibleNoteSchema = noteSchema.extend({
|
||||||
|
quoteURL: apId.optional().catch(undefined),
|
||||||
|
quoteUri: apId.optional().catch(undefined),
|
||||||
|
_misskey_quote: apId.optional().catch(undefined),
|
||||||
|
}).transform((note) => {
|
||||||
|
const { quoteUrl, quoteUri, quoteURL, _misskey_quote, ...rest } = note;
|
||||||
|
return {
|
||||||
|
quoteUrl: quoteUrl || quoteUri || quoteURL || _misskey_quote,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://github.com/colinhacks/zod/discussions/2100#discussioncomment-5109781
|
||||||
|
const objectSchema = z.union([
|
||||||
|
flexibleNoteSchema,
|
||||||
|
personSchema,
|
||||||
|
applicationSchema,
|
||||||
|
groupSchema,
|
||||||
|
organizationSchema,
|
||||||
|
serviceSchema,
|
||||||
|
]).pipe(
|
||||||
|
z.discriminatedUnion("type", [
|
||||||
|
noteSchema,
|
||||||
|
personSchema,
|
||||||
|
applicationSchema,
|
||||||
|
groupSchema,
|
||||||
|
organizationSchema,
|
||||||
|
serviceSchema,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createNoteSchema = z.object({
|
||||||
|
type: z.literal("Create"),
|
||||||
|
id: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
actor: apId,
|
||||||
|
object: noteSchema,
|
||||||
|
published: published(),
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const announceNoteSchema = z.object({
|
||||||
|
type: z.literal("Announce"),
|
||||||
|
id: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
actor: apId,
|
||||||
|
object: apId.or(noteSchema),
|
||||||
|
published: published(),
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const followSchema = z.object({
|
||||||
|
type: z.literal("Follow"),
|
||||||
|
id: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
actor: apId,
|
||||||
|
object: apId,
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptSchema = z.object({
|
||||||
|
type: z.literal("Accept"),
|
||||||
|
id: apId,
|
||||||
|
actor: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
object: apId.or(followSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const likeSchema = z.object({
|
||||||
|
type: z.literal("Like"),
|
||||||
|
id: apId,
|
||||||
|
actor: apId,
|
||||||
|
object: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiReactSchema = z.object({
|
||||||
|
type: z.literal("EmojiReact"),
|
||||||
|
id: apId,
|
||||||
|
actor: apId,
|
||||||
|
object: apId,
|
||||||
|
content: z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)),
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSchema = z.object({
|
||||||
|
type: z.literal("Delete"),
|
||||||
|
id: apId,
|
||||||
|
actor: apId,
|
||||||
|
object: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateActorSchema = z.object({
|
||||||
|
type: z.literal("Update"),
|
||||||
|
id: apId,
|
||||||
|
actor: apId,
|
||||||
|
to: recipients,
|
||||||
|
cc: recipients,
|
||||||
|
object: actorSchema,
|
||||||
|
proxyOf: z.array(proxySchema).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activitySchema = z.discriminatedUnion("type", [
|
||||||
|
followSchema,
|
||||||
|
acceptSchema,
|
||||||
|
createNoteSchema,
|
||||||
|
announceNoteSchema,
|
||||||
|
updateActorSchema,
|
||||||
|
likeSchema,
|
||||||
|
emojiReactSchema,
|
||||||
|
deleteSchema,
|
||||||
|
]).refine((activity) => {
|
||||||
|
const ids: string[] = [activity.id];
|
||||||
|
|
||||||
|
if (activity.type === "Create") {
|
||||||
|
ids.push(
|
||||||
|
activity.object.id,
|
||||||
|
activity.object.attributedTo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.type === "Update") {
|
||||||
|
ids.push(activity.object.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origin: actorOrigin } = new URL(activity.actor);
|
||||||
|
|
||||||
|
// Object containment
|
||||||
|
return ids.every((id) => {
|
||||||
|
const { origin: idOrigin } = new URL(id);
|
||||||
|
return idOrigin === actorOrigin;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type Activity = z.infer<typeof activitySchema>;
|
||||||
|
type CreateNote = z.infer<typeof createNoteSchema>;
|
||||||
|
type Announce = z.infer<typeof announceNoteSchema>;
|
||||||
|
type Update = z.infer<typeof updateActorSchema>;
|
||||||
|
type Object = z.infer<typeof objectSchema>;
|
||||||
|
type Follow = z.infer<typeof followSchema>;
|
||||||
|
type Accept = z.infer<typeof acceptSchema>;
|
||||||
|
type Actor = z.infer<typeof actorSchema>;
|
||||||
|
type Note = z.infer<typeof noteSchema>;
|
||||||
|
type Mention = z.infer<typeof mentionSchema>;
|
||||||
|
type Hashtag = z.infer<typeof hashtagSchema>;
|
||||||
|
type Emoji = z.infer<typeof emojiSchema>;
|
||||||
|
type Like = z.infer<typeof likeSchema>;
|
||||||
|
type EmojiReact = z.infer<typeof emojiReactSchema>;
|
||||||
|
type Delete = z.infer<typeof deleteSchema>;
|
||||||
|
type Proxy = z.infer<typeof proxySchema>;
|
||||||
|
|
||||||
|
export { acceptSchema, activitySchema, actorSchema, emojiSchema, followSchema, imageSchema, noteSchema, objectSchema };
|
||||||
|
export type {
|
||||||
|
Accept,
|
||||||
|
Activity,
|
||||||
|
Actor,
|
||||||
|
Announce,
|
||||||
|
CreateNote,
|
||||||
|
Delete,
|
||||||
|
Emoji,
|
||||||
|
EmojiReact,
|
||||||
|
Follow,
|
||||||
|
Hashtag,
|
||||||
|
Like,
|
||||||
|
Mention,
|
||||||
|
Note,
|
||||||
|
Object,
|
||||||
|
Proxy,
|
||||||
|
Update,
|
||||||
|
};
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { CONTEXT } from "./activity.js";
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
@ -11,19 +12,44 @@ export const zCollection = z.object({
|
||||||
|
|
||||||
export type CollectionEntry = z.infer<typeof zCollection>;
|
export type CollectionEntry = z.infer<typeof zCollection>;
|
||||||
|
|
||||||
export interface Follower {
|
export const orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[], paged = false) => {
|
||||||
actor: string,
|
const collection: Record<string, any> = {
|
||||||
nickname: string,
|
type: "OrderedCollection",
|
||||||
name: string,
|
"@context": CONTEXT,
|
||||||
inbox: string | null,
|
id,
|
||||||
shared_inbox: string | null
|
totalItems: orderedItems.length
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFollowers = async (userId: number): Promise<Follower[]> =>
|
if (paged) {
|
||||||
db<CollectionEntry>("collections")
|
collection.first = {
|
||||||
.select("remote_users.*", "collections.value")
|
id: `${id}?page=1`,
|
||||||
.join("remote_users", "remote_users.actor", "=", "collections.value")
|
next: `${id}?page=2`,
|
||||||
.where("collection_types_id", 0)
|
orderedItems,
|
||||||
.where("users_id", userId)
|
partOf: id,
|
||||||
.orderBy("collections.created_at", "desc")
|
totalItems: orderedItems.length,
|
||||||
;
|
type: "OrderedCollectionPage"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
collection.orderedItems = orderedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderedCollectionPage = (collectionId: string, orderedItems: (string | Record<string, any>)[], page: number, pageLength: number, totalItems: number) => {
|
||||||
|
const collection: Record<string, any> = {
|
||||||
|
id: `${collectionId}?page=${page}`,
|
||||||
|
type: "OrderedCollectionPage",
|
||||||
|
"@context": CONTEXT,
|
||||||
|
partOf: collectionId,
|
||||||
|
totalItems,
|
||||||
|
orderedItems
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNext = totalItems > (((page - 1) * pageLength) + orderedItems.length);
|
||||||
|
|
||||||
|
if (hasNext) collection.next = `${collectionId}?page=${page + 1}`;
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { TYPE } from "./activity.js";
|
||||||
|
import { CollectionEntry, orderedCollection } from "./collection.js";
|
||||||
|
import db from "./db.js";
|
||||||
|
import { fillRoute } from "./router.js";
|
||||||
|
import { get as getUserByNickname } from "./user.js";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
export interface Follower {
|
||||||
|
actor: string,
|
||||||
|
nickname: string,
|
||||||
|
name: string,
|
||||||
|
inbox: string | null,
|
||||||
|
shared_inbox: string | null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFollowers = async (userId: number): Promise<Follower[]> =>
|
||||||
|
db<CollectionEntry>("collections")
|
||||||
|
.select("remote_users.*", "collections.value")
|
||||||
|
.join("remote_users", "remote_users.actor", "=", "collections.value")
|
||||||
|
.where("collection_types_id", 0)
|
||||||
|
.where("users_id", userId)
|
||||||
|
.orderBy("collections.created_at", "desc")
|
||||||
|
;
|
||||||
|
|
||||||
|
export const addFollower = async (followeeUserId: number, actor: string, nickname: string, name: string, inbox?: string, sharedInbox?: string) => {
|
||||||
|
await db("remote_users")
|
||||||
|
.insert({
|
||||||
|
actor,
|
||||||
|
nickname,
|
||||||
|
name,
|
||||||
|
inbox,
|
||||||
|
shared_inbox: sharedInbox
|
||||||
|
})
|
||||||
|
.onConflict().ignore();
|
||||||
|
;
|
||||||
|
|
||||||
|
await db("collections")
|
||||||
|
.insert({
|
||||||
|
collection_types_id: 0,
|
||||||
|
users_id: followeeUserId,
|
||||||
|
value: actor,
|
||||||
|
created_at: new Date()
|
||||||
|
})
|
||||||
|
.onConflict().ignore()
|
||||||
|
;
|
||||||
|
|
||||||
|
console.log("Follower added");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleFollowerGet = async (req: Request<{ actor: string}>, res: Response) => {
|
||||||
|
const user = await getUserByNickname(req.params.actor);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const followers = await getFollowers(user.id);
|
||||||
|
const payload = orderedCollection(fillRoute("followers", user.nickname), followers);
|
||||||
|
res.set("Content-Type", TYPE);
|
||||||
|
res.send(JSON.stringify(payload, null, 4));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(403).end();
|
||||||
|
}
|
||||||
|
};
|
65
src/index.ts
65
src/index.ts
|
@ -1,10 +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, getId as getUserId } from "./user.js";
|
import { getById as getUserById, getNickname, get as getUserByNickname, getId as getUserId, User } from "./user.js";
|
||||||
import { Routes } from "./router.js";
|
import { Routes } from "./router.js";
|
||||||
import { getBySlug as getArticleBySlug } from "./article.js";
|
import { getBySlug as getArticleBySlug, getById } from "./article.js";
|
||||||
import { userToPerson, TYPE as ACTIVITYPUB_TYPE } from "./activity.js";
|
import { userToPerson, TYPE as ACTIVITYPUB_TYPE, handleInboxPost, createArticleObject, CONTEXT, createArticleActivity } from "./activity.js";
|
||||||
|
import { handleFollowerGet } from "./follower.js";
|
||||||
|
import { handleWebfingerGet } from "./net.js";
|
||||||
|
|
||||||
const port = parseInt(process.env.port || "8080");
|
const port = parseInt(process.env.port || "8080");
|
||||||
const app = express();
|
const app = express();
|
||||||
|
@ -25,18 +27,51 @@ app.use(
|
||||||
SITE,
|
SITE,
|
||||||
);
|
);
|
||||||
|
|
||||||
app.route(Routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post);
|
app.post(Routes.inbox, handleInboxPost);
|
||||||
app.get(Routes.followers, SITE.net.followers.get);
|
app.get(Routes.followers, handleFollowerGet);
|
||||||
app.get(Routes.following, SITE.net.following.get);
|
|
||||||
app.get(Routes.liked, SITE.net.liked.get);
|
app.get(Routes.object, async (req, res) => {
|
||||||
app.get(Routes.object, SITE.net.object.get);
|
const article = await getById(parseInt(req.params.id));
|
||||||
app.get(Routes.activity, SITE.net.activityStream.get);
|
|
||||||
app.get(Routes.shares, SITE.net.shares.get);
|
if (article) {
|
||||||
app.get(Routes.likes, SITE.net.likes.get);
|
const nickname = await getNickname(article.users_id) as string;
|
||||||
app.get("/.well-known/webfinger", SITE.net.webfinger.get);
|
|
||||||
app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
|
const obj = createArticleObject(article, nickname);
|
||||||
app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
|
obj["@context"] = CONTEXT;
|
||||||
app.post("/proxy", SITE.net.proxy.post);
|
|
||||||
|
res.append("Content-Type", ACTIVITYPUB_TYPE);
|
||||||
|
res.send(JSON.stringify(obj, null, 4));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(Routes.activity, async (req, res) => {
|
||||||
|
let id = parseInt(req.params.id);
|
||||||
|
if (id >= 1_000_000_000) {
|
||||||
|
// it's a delete. TODO: implement.
|
||||||
|
res.status(501).end();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const article = await getById(id);
|
||||||
|
|
||||||
|
if (article) {
|
||||||
|
const user = await getUserById(article.users_id) as User;
|
||||||
|
const activity = createArticleActivity(article, user);
|
||||||
|
|
||||||
|
res.append("Content-Type", ACTIVITYPUB_TYPE);
|
||||||
|
res.send(JSON.stringify(activity, null, 4));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/.well-known/webfinger", handleWebfingerGet);
|
||||||
|
// app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
|
||||||
|
// app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
|
||||||
|
|
||||||
app.get(Routes.outbox, async (req, res) => {
|
app.get(Routes.outbox, async (req, res) => {
|
||||||
const userId = await getUserId(req.params.actor);
|
const userId = await getUserId(req.params.actor);
|
||||||
|
|
92
src/net.ts
92
src/net.ts
|
@ -1,6 +1,12 @@
|
||||||
import { CONTEXT } from "./activity.js";
|
import { z } from "zod";
|
||||||
import { hashDigest } from "./util.js";
|
import { CONTEXT, TYPE } from "./activity.js";
|
||||||
|
import { hashDigest, streamToString } from "./util.js";
|
||||||
import { Sha256Signer } from "activitypub-http-signatures";
|
import { Sha256Signer } from "activitypub-http-signatures";
|
||||||
|
import { actorSchema } from "./activitypub_types.js";
|
||||||
|
import type { Actor } from "./activitypub_types.js";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { fillRoute } from "./router.js";
|
||||||
|
import { get as getUserByNickname } from "./user.js";
|
||||||
|
|
||||||
export const flattenHeaders = (headers: Record<string, string>) =>
|
export const flattenHeaders = (headers: Record<string, string>) =>
|
||||||
Object.entries(headers).map(([key, value]) => `${key}: ${value}`);
|
Object.entries(headers).map(([key, value]) => `${key}: ${value}`);
|
||||||
|
@ -11,6 +17,7 @@ export interface SignedInit {
|
||||||
digest?: string;
|
digest?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Handle redirects.
|
||||||
export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => {
|
export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => {
|
||||||
const signedHeaders: HeadersInit = [
|
const signedHeaders: HeadersInit = [
|
||||||
["Date", new Date().toUTCString()],
|
["Date", new Date().toUTCString()],
|
||||||
|
@ -59,3 +66,84 @@ export const signedFetch = async (url: string, init: RequestInit, signedInit: Si
|
||||||
|
|
||||||
return fetch(url, init);
|
return fetch(url, init);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getActor = async (actorUrl: string, signedInit: SignedInit): Promise<Actor | null> => {
|
||||||
|
const init: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: [["Accept", TYPE]]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await signedFetch(actorUrl, init, signedInit);
|
||||||
|
|
||||||
|
if (res.status == 200 && res.body) {
|
||||||
|
const body = await streamToString(res.body);
|
||||||
|
const person = JSON.parse(body);
|
||||||
|
|
||||||
|
// this will throw if it's not valid
|
||||||
|
actorSchema.parse(person);
|
||||||
|
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn("Failed to get remote actor:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPublicKey = async (actorUrl: string, signedInit: SignedInit): Promise<string | null> => {
|
||||||
|
const actor = await getActor(actorUrl, signedInit);
|
||||||
|
if (actor?.type === "Person") {
|
||||||
|
return actor.publicKey?.publicKeyPem || null;
|
||||||
|
}
|
||||||
|
else return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainStripper = new RegExp("/@" + process.env.blog_url?.replace(".", "\\.") + "$/i");
|
||||||
|
|
||||||
|
export const handleWebfingerGet = async (req: Request, res: Response) => {
|
||||||
|
let nickname = req.query.resource;
|
||||||
|
|
||||||
|
if (nickname && typeof nickname === "string") {
|
||||||
|
nickname = nickname.replace(/^acct:/, "").replace(domainStripper, "");
|
||||||
|
|
||||||
|
const user = await getUserByNickname(nickname);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const actor = fillRoute("actor", nickname);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
"subject": `acct:${nickname}@${process.env.blog_host}`,
|
||||||
|
"aliases": [
|
||||||
|
actor
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
|
"type": "text/html",
|
||||||
|
"href": actor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rel": "self",
|
||||||
|
"type": "application/activity+json",
|
||||||
|
"href": actor
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
res.set("Content-Type", `application/jrd+json; charset=utf-8`);
|
||||||
|
res.send(JSON.stringify(payload, null, 4));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { z } from "zod";
|
||||||
import { get as getUserByNickname } from "./user.js";
|
import { get as getUserByNickname } from "./user.js";
|
||||||
import { fillRoute } from "./router.js";
|
import { fillRoute } from "./router.js";
|
||||||
import { getByUserId as getArticlesByUserId, getById as getArticleById, Article } from "./article.js";
|
import { getByUserId as getArticlesByUserId, getById as getArticleById, Article } from "./article.js";
|
||||||
import { createArticleActivity, deleteArticleActivity, orderedCollection } from "./activity.js";
|
import { createArticleActivity, deleteArticleActivity } from "./activity.js";
|
||||||
|
import { orderedCollection } from "./collection.js";
|
||||||
|
|
||||||
|
|
||||||
export const zRawOutboxRecord = z.object({
|
export const zRawOutboxRecord = z.object({
|
||||||
|
|
|
@ -1,19 +1,11 @@
|
||||||
export const Routes = {
|
export const Routes = {
|
||||||
actor: "/author/:actor",
|
actor: "/author/:actor",
|
||||||
object: "/o/:id",
|
|
||||||
activity: "/a/:id",
|
|
||||||
inbox: "/author/:actor/inbox",
|
inbox: "/author/:actor/inbox",
|
||||||
outbox: "/author/:actor/outbox",
|
outbox: "/author/:actor/outbox",
|
||||||
followers: "/author/:actor/followers",
|
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",
|
context: "/c/:id",
|
||||||
|
object: "/o/:id",
|
||||||
|
activity: "/a/:id",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Route = keyof typeof Routes;
|
export type Route = keyof typeof Routes;
|
||||||
|
|
10
src/user.ts
10
src/user.ts
|
@ -1,7 +1,7 @@
|
||||||
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 { getFollowers } from "./collection.js";
|
import { getFollowers } from "./follower.js";
|
||||||
import { fillRoute } from "./router.js";
|
import { fillRoute } from "./router.js";
|
||||||
|
|
||||||
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
|
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
|
||||||
|
@ -26,6 +26,12 @@ export const get = async (nickname: string) =>
|
||||||
.first()
|
.first()
|
||||||
;
|
;
|
||||||
|
|
||||||
|
export const getById = async (id: number) =>
|
||||||
|
db<User>("users")
|
||||||
|
.where("id", id)
|
||||||
|
.first()
|
||||||
|
;
|
||||||
|
|
||||||
const EXTRACT_NICKNAME = new RegExp("/" + fillRoute("actor", "(.+)").replaceAll("/", "\\/") + "/");
|
const EXTRACT_NICKNAME = new RegExp("/" + fillRoute("actor", "(.+)").replaceAll("/", "\\/") + "/");
|
||||||
|
|
||||||
export const getByActor = async (actor: string) => {
|
export const getByActor = async (actor: string) => {
|
||||||
|
@ -98,3 +104,5 @@ export const getFollowerInboxes = async (userId: number): Promise<string[]> =>
|
||||||
|
|
||||||
return [...inboxes];
|
return [...inboxes];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getKeyId = (nickname: string) => fillRoute("actor", nickname) + "#main-key";
|
||||||
|
|
Loading…
Reference in New Issue