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", "users_id"]);
|
||||
|
||||
table.unique(["collection_types_id", "articles_id", "value"]);
|
||||
table.unique(["collection_types_id", "users_id", "value"]);
|
||||
})
|
||||
.createTable("outboxes", (table) => {
|
||||
table.increments("id");
|
||||
|
|
|
@ -3,10 +3,15 @@ declare module "activitypub-express" {
|
|||
export default ActivitypubExpress;
|
||||
}
|
||||
|
||||
// Only enough here for what I need.
|
||||
declare module "activitypub-http-signatures" {
|
||||
export class Sha256Signer {
|
||||
constructor(options: { publicKeyId: string, privateKey: 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 { fillRoute } from "./router.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 PUBLIC = CONTEXT + "#Public";
|
||||
export const TYPE = "application/activity+json";
|
||||
|
||||
export const handleInboxPost = async (req: Request) => {
|
||||
export const handleInboxPost = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.body) {
|
||||
console.warn("no body");
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -23,6 +28,7 @@ export const handleInboxPost = async (req: Request) => {
|
|||
}
|
||||
catch (e) {
|
||||
console.warn("body json parse failed");
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -30,10 +36,53 @@ export const handleInboxPost = async (req: Request) => {
|
|||
const actor = await getByActor(activity.object);
|
||||
|
||||
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 {
|
||||
console.warn("follow attempt on unknown user");
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (activity.type === "Undo") {
|
||||
|
@ -41,9 +90,13 @@ export const handleInboxPost = async (req: Request) => {
|
|||
}
|
||||
else {
|
||||
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) => {
|
||||
|
@ -104,6 +157,32 @@ export const createArticleActivity = (article: Article, user: User) => {
|
|||
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[]) => {
|
||||
activity = typeof activity === "string"
|
||||
? activity
|
||||
|
@ -124,13 +203,6 @@ export const sendAll = async (keyId: string, privateKey: string, activity: Recor
|
|||
return !!!errors.length;
|
||||
};
|
||||
|
||||
export const orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[]) => ({
|
||||
type: "OrderedCollectionPage",
|
||||
context: CONTEXT,
|
||||
id,
|
||||
orderedItems
|
||||
});
|
||||
|
||||
export const userToPerson = (user: User) => {
|
||||
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 { z } from "zod";
|
||||
|
||||
|
@ -11,19 +12,44 @@ export const zCollection = z.object({
|
|||
|
||||
export type CollectionEntry = z.infer<typeof zCollection>;
|
||||
|
||||
export interface Follower {
|
||||
actor: string,
|
||||
nickname: string,
|
||||
name: string,
|
||||
inbox: string | null,
|
||||
shared_inbox: string | null
|
||||
export const orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[], paged = false) => {
|
||||
const collection: Record<string, any> = {
|
||||
type: "OrderedCollection",
|
||||
"@context": CONTEXT,
|
||||
id,
|
||||
totalItems: orderedItems.length
|
||||
};
|
||||
|
||||
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")
|
||||
;
|
||||
if (paged) {
|
||||
collection.first = {
|
||||
id: `${id}?page=1`,
|
||||
next: `${id}?page=2`,
|
||||
orderedItems,
|
||||
partOf: id,
|
||||
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 ActivitypubExpress from "activitypub-express";
|
||||
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 { getBySlug as getArticleBySlug } from "./article.js";
|
||||
import { userToPerson, TYPE as ACTIVITYPUB_TYPE } from "./activity.js";
|
||||
import { getBySlug as getArticleBySlug, getById } from "./article.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 app = express();
|
||||
|
@ -25,18 +27,51 @@ app.use(
|
|||
SITE,
|
||||
);
|
||||
|
||||
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.following, SITE.net.following.get);
|
||||
app.get(Routes.liked, SITE.net.liked.get);
|
||||
app.get(Routes.object, SITE.net.object.get);
|
||||
app.get(Routes.activity, SITE.net.activityStream.get);
|
||||
app.get(Routes.shares, SITE.net.shares.get);
|
||||
app.get(Routes.likes, SITE.net.likes.get);
|
||||
app.get("/.well-known/webfinger", SITE.net.webfinger.get);
|
||||
app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
|
||||
app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
|
||||
app.post("/proxy", SITE.net.proxy.post);
|
||||
app.post(Routes.inbox, handleInboxPost);
|
||||
app.get(Routes.followers, handleFollowerGet);
|
||||
|
||||
app.get(Routes.object, async (req, res) => {
|
||||
const article = await getById(parseInt(req.params.id));
|
||||
|
||||
if (article) {
|
||||
const nickname = await getNickname(article.users_id) as string;
|
||||
|
||||
const obj = createArticleObject(article, nickname);
|
||||
obj["@context"] = CONTEXT;
|
||||
|
||||
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) => {
|
||||
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 { hashDigest } from "./util.js";
|
||||
import { z } from "zod";
|
||||
import { CONTEXT, TYPE } from "./activity.js";
|
||||
import { hashDigest, streamToString } from "./util.js";
|
||||
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>) =>
|
||||
Object.entries(headers).map(([key, value]) => `${key}: ${value}`);
|
||||
|
@ -11,6 +17,7 @@ export interface SignedInit {
|
|||
digest?: string;
|
||||
};
|
||||
|
||||
// TODO: Handle redirects.
|
||||
export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => {
|
||||
const signedHeaders: HeadersInit = [
|
||||
["Date", new Date().toUTCString()],
|
||||
|
@ -59,3 +66,84 @@ export const signedFetch = async (url: string, init: RequestInit, signedInit: Si
|
|||
|
||||
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 { fillRoute } from "./router.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({
|
||||
|
|
|
@ -1,19 +1,11 @@
|
|||
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",
|
||||
object: "/o/:id",
|
||||
activity: "/a/:id",
|
||||
} as const;
|
||||
|
||||
export type Route = keyof typeof Routes;
|
||||
|
|
10
src/user.ts
10
src/user.ts
|
@ -1,7 +1,7 @@
|
|||
import { generateKeyPair } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import db from "./db.js";
|
||||
import { getFollowers } from "./collection.js";
|
||||
import { getFollowers } from "./follower.js";
|
||||
import { fillRoute } from "./router.js";
|
||||
|
||||
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
|
||||
|
@ -26,6 +26,12 @@ export const get = async (nickname: string) =>
|
|||
.first()
|
||||
;
|
||||
|
||||
export const getById = async (id: number) =>
|
||||
db<User>("users")
|
||||
.where("id", id)
|
||||
.first()
|
||||
;
|
||||
|
||||
const EXTRACT_NICKNAME = new RegExp("/" + fillRoute("actor", "(.+)").replaceAll("/", "\\/") + "/");
|
||||
|
||||
export const getByActor = async (actor: string) => {
|
||||
|
@ -98,3 +104,5 @@ export const getFollowerInboxes = async (userId: number): Promise<string[]> =>
|
|||
|
||||
return [...inboxes];
|
||||
});
|
||||
|
||||
export const getKeyId = (nickname: string) => fillRoute("actor", nickname) + "#main-key";
|
||||
|
|
Loading…
Reference in New Issue