diff --git a/CHANGES.md b/CHANGES.md index 2891105..95088c6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -27,6 +27,9 @@ To be released. - Added support for RFC 8414 for OAuth Authorization Server metadata endpoint. [[#47] by Emelia Smith] + - On creating a new account, the user now can choose to follow the official + Hollo account. + - Added a favicon. - Added `LISTEN_PORT` and `ALLOW_PRIVATE_ADDRESS` environment variables. diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index 32a5e18..e97bf33 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -1,13 +1,5 @@ import { PutObjectCommand } from "@aws-sdk/client-s3"; -import { - Block, - Follow, - type Recipient, - Reject, - Undo, - isActor, - lookupObject, -} from "@fedify/fedify"; +import { Block, Undo, isActor, lookupObject } from "@fedify/fedify"; import * as vocab from "@fedify/fedify/vocab"; import { zValidator } from "@hono/zod-validator"; import { @@ -19,7 +11,6 @@ import { gte, ilike, inArray, - isNotNull, isNull, lt, lte, @@ -38,13 +29,19 @@ import { import { serializeList } from "../../entities/list"; import { getPostRelations, serializePost } from "../../entities/status"; import { federation } from "../../federation"; -import { persistAccount, persistAccountPosts } from "../../federation/account"; +import { + REMOTE_ACTOR_FETCH_POSTS, + followAccount, + persistAccount, + persistAccountPosts, + removeFollower, + unfollowAccount, +} from "../../federation/account"; import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; import { S3_BUCKET, S3_URL_BASE, s3 } from "../../s3"; import { type Account, type AccountOwner, - type NewFollow, type NewMute, accountOwners, accounts, @@ -530,13 +527,9 @@ app.get( .select({ cnt: count() }) .from(posts) .where(eq(posts.accountId, account.id)); - const fetchPosts = Number.parseInt( - // biome-ignore lint/complexity/useLiteralKeys: tsc rants about this (TS4111) - process.env["REMOTE_ACTOR_FETCH_POSTS"] ?? "10", - ); - if (cnt < fetchPosts) { + if (cnt < REMOTE_ACTOR_FETCH_POSTS) { const fedCtx = federation.createContext(c.req.raw, undefined); - await persistAccountPosts(db, account, fetchPosts, { + await persistAccountPosts(db, account, REMOTE_ACTOR_FETCH_POSTS, { documentLoader: await fedCtx.getDocumentLoader({ username: tokenOwner.handle, }), @@ -682,46 +675,19 @@ app.post( const id = c.req.param("id"); const following = await db.query.accounts.findFirst({ where: eq(accounts.id, id), - with: { owner: true, mutes: { where: eq(mutes.accountId, owner.id) } }, + with: { owner: true }, }); if (following == null) return c.json({ error: "Record not found" }, 404); - const result = await db - .insert(follows) - .values({ - iri: new URL(`#follows/${crypto.randomUUID()}`, owner.account.iri).href, - followingId: following.id, - followerId: owner.id, - shares: true, - notify: false, - languages: null, - approved: - following.owner == null || following.protected ? null : new Date(), - } satisfies NewFollow) - .onConflictDoNothing() - .returning(); - // TODO: respond with 403 if the following blocks the follower - if (result.length < 1) { + const fedCtx = federation.createContext(c.req.raw, undefined); + const follow = await followAccount( + db, + fedCtx, + { ...owner.account, owner }, + following, + ); + if (follow == null) { return c.json({ error: "The action is not allowed" }, 403); } - const follow = result[0]; - if (following.owner == null) { - const fedCtx = federation.createContext(c.req.raw, undefined); - await fedCtx.sendActivity( - { username: owner.handle }, - [ - { - id: new URL(following.iri), - inboxId: new URL(following.inboxUrl), - }, - ], - new vocab.Follow({ - id: new URL(follow.iri), - actor: new URL(owner.account.iri), - object: new URL(following.iri), - }), - { excludeBaseUris: [new URL(fedCtx.url)] }, - ); - } const account = await db.query.accounts.findFirst({ where: eq(accounts.id, following.id), with: { @@ -760,58 +726,13 @@ app.post( ); } const id = c.req.param("id"); - const result = await db - .delete(follows) - .where(and(eq(follows.followingId, id), eq(follows.followerId, owner.id))) - .returning({ iri: follows.iri }); - - if (result.length > 0) { - const fedCtx = federation.createContext(c.req.raw, undefined); - const following = await db.query.accounts.findFirst({ - where: eq(accounts.id, id), - with: { - owner: true, - mutes: { - where: eq(mutes.accountId, owner.id), - }, - }, - }); - if (following != null && following.owner == null) { - await fedCtx.sendActivity( - { username: owner.handle }, - [ - { - id: new URL(following.iri), - inboxId: new URL(following.inboxUrl), - }, - ], - new Undo({ - id: new URL(`#unfollows/${crypto.randomUUID()}`, owner.account.iri), - actor: new URL(owner.account.iri), - object: new Follow({ - id: new URL(result[0].iri), - actor: new URL(owner.account.iri), - object: new URL(following.iri), - }), - }), - { excludeBaseUris: [new URL(fedCtx.url)] }, - ); - } - await db - .update(accounts) - .set({ - followingCount: sql`${db - .select({ cnt: count() }) - .from(follows) - .where( - and( - eq(follows.followerId, owner.id), - isNotNull(follows.approved), - ), - )}`, - }) - .where(eq(accounts.id, owner.id)); - } + const following = await db.query.accounts.findFirst({ + where: eq(accounts.id, id), + with: { owner: true }, + }); + if (following == null) return c.json({ error: "Record not found" }, 404); + const fedCtx = federation.createContext(c.req.raw, undefined); + await unfollowAccount(db, fedCtx, { ...owner.account, owner }, following); const account = await db.query.accounts.findFirst({ where: eq(accounts.id, id), with: { @@ -1098,57 +1019,11 @@ app.post( }); if (acct.owner == null) { const fedCtx = federation.createContext(c.req.raw, undefined); - const recipient: Recipient = { - id: new URL(acct.iri), - inboxId: new URL(acct.inboxUrl), - }; - const following = await db - .delete(follows) - .where( - and(eq(follows.followingId, id), eq(follows.followerId, owner.id)), - ) - .returning(); - if (following.length > 0) { - await fedCtx.sendActivity( - { username: owner.handle }, - recipient, - new Undo({ - id: new URL(`#unfollows/${crypto.randomUUID()}`, owner.account.iri), - actor: new URL(owner.account.iri), - object: new Follow({ - id: new URL(following[0].iri), - actor: new URL(owner.account.iri), - object: new URL(acct.iri), - }), - }), - { excludeBaseUris: [new URL(fedCtx.url)] }, - ); - } - const follower = await db - .delete(follows) - .where( - and(eq(follows.followerId, id), eq(follows.followingId, owner.id)), - ) - .returning(); - if (follower.length > 0) { - await fedCtx.sendActivity( - { username: owner.handle }, - recipient, - new Reject({ - id: new URL(`#reject/${crypto.randomUUID()}`, owner.account.iri), - actor: new URL(owner.account.iri), - object: new Follow({ - id: new URL(follower[0].iri), - actor: new URL(acct.iri), - object: new URL(owner.account.iri), - }), - }), - { excludeBaseUris: [new URL(fedCtx.url)] }, - ); - } + await unfollowAccount(db, fedCtx, { ...owner.account, owner }, acct); + await removeFollower(db, fedCtx, { ...owner.account, owner }, acct); await fedCtx.sendActivity( { username: owner.handle }, - recipient, + { id: new URL(acct.iri), inboxId: new URL(acct.inboxUrl) }, new Block({ id: new URL(`#block/${acct.id}`, owner.account.iri), actor: new URL(owner.account.iri), diff --git a/src/components/AccountForm.tsx b/src/components/AccountForm.tsx index c17218b..a4b34ac 100644 --- a/src/components/AccountForm.tsx +++ b/src/components/AccountForm.tsx @@ -14,12 +14,14 @@ export interface AccountFormProps { protected?: boolean; language?: string; visibility?: PostVisibility; + news?: boolean; }; errors?: { username?: string; name?: string; bio?: string; }; + officialAccount: string; submitLabel: string; } @@ -132,6 +134,18 @@ export function AccountForm(props: AccountFormProps) { +
+ +
); diff --git a/src/components/AccountNewPage.tsx b/src/components/NewAccountPage.tsx similarity index 90% rename from src/components/AccountNewPage.tsx rename to src/components/NewAccountPage.tsx index afd2213..539ebf0 100644 --- a/src/components/AccountNewPage.tsx +++ b/src/components/NewAccountPage.tsx @@ -10,12 +10,14 @@ export interface NewAccountPageProps { protected?: boolean; language?: string; visibility?: PostVisibility; + news?: boolean; }; errors?: { username?: string; name?: string; bio?: string; }; + officialAccount: string; } export function NewAccountPage(props: NewAccountPageProps) { @@ -30,6 +32,7 @@ export function NewAccountPage(props: NewAccountPageProps) { values={props.values} errors={props.errors} submitLabel="Create a new account" + officialAccount={props.officialAccount} /> ); diff --git a/src/federation/account.ts b/src/federation/account.ts index bfa5bb8..bffd8f0 100644 --- a/src/federation/account.ts +++ b/src/federation/account.ts @@ -1,11 +1,15 @@ import { type Actor, Announce, + type Context, Create, type DocumentLoader, Emoji, + Follow, Link, PropertyValue, + Reject, + Undo, formatSemVer, getActorHandle, getActorTypeName, @@ -36,6 +40,11 @@ import { updatePostStats, } from "./post"; +export const REMOTE_ACTOR_FETCH_POSTS = Number.parseInt( + // biome-ignore lint/complexity/useLiteralKeys: tsc rants about this (TS4111) + process.env["REMOTE_ACTOR_FETCH_POSTS"] ?? "10", +); + export async function persistAccount( db: PgDatabase< PostgresJsQueryResultHKT, @@ -325,3 +334,157 @@ export async function updateAccountStats( ), ); } + +export async function followAccount( + db: PgDatabase< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, + ctx: Context, + follower: schema.Account & { owner: schema.AccountOwner | null }, + following: schema.Account & { owner: schema.AccountOwner | null }, + options: { + shares?: boolean; + notify?: boolean; + languages?: string[]; + } = {}, +): Promise { + if (follower.owner == null) { + throw new TypeError("Only local accounts can follow other accounts"); + } + const result = await db + .insert(schema.follows) + .values({ + iri: new URL(`#follows/${crypto.randomUUID()}`, follower.iri).href, + followingId: following.id, + followerId: follower.id, + shares: options.shares ?? true, + notify: options.notify ?? false, + languages: options.languages ?? null, + approved: + following.owner == null || following.protected ? null : new Date(), + } satisfies schema.NewFollow) + .onConflictDoNothing() + .returning(); + if (result.length < 1) return null; + await updateAccountStats(db, follower); + await updateAccountStats(db, following); + const follow = result[0]; + if (following.owner == null) { + await ctx.sendActivity( + { username: follower.owner.handle }, + [ + { + id: new URL(following.iri), + inboxId: new URL(following.inboxUrl), + }, + ], + new Follow({ + id: new URL(follow.iri), + actor: new URL(follower.iri), + object: new URL(following.iri), + }), + { excludeBaseUris: [new URL(ctx.origin)] }, + ); + } + return follow; +} + +export async function unfollowAccount( + db: PgDatabase< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, + ctx: Context, + follower: schema.Account & { owner: schema.AccountOwner | null }, + following: schema.Account & { owner: schema.AccountOwner | null }, +): Promise { + if (follower.owner == null) { + throw new TypeError("Only local accounts can unfollow other accounts"); + } + const result = await db + .delete(schema.follows) + .where( + and( + eq(schema.follows.followingId, following.id), + eq(schema.follows.followerId, follower.id), + ), + ) + .returning(); + if (result.length < 1) return null; + await updateAccountStats(db, follower); + await updateAccountStats(db, following); + if (following.owner == null) { + await ctx.sendActivity( + { username: follower.owner.handle }, + [ + { + id: new URL(following.iri), + inboxId: new URL(following.inboxUrl), + }, + ], + new Undo({ + id: new URL(`#unfollows/${crypto.randomUUID()}`, follower.iri), + actor: new URL(follower.iri), + object: new Follow({ + id: new URL(result[0].iri), + actor: new URL(follower.iri), + object: new URL(following.iri), + }), + }), + { excludeBaseUris: [new URL(ctx.origin)] }, + ); + } + return result[0]; +} + +export async function removeFollower( + db: PgDatabase< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, + ctx: Context, + following: schema.Account & { owner: schema.AccountOwner | null }, + follower: schema.Account & { owner: schema.AccountOwner | null }, +): Promise { + if (following.owner == null) { + throw new TypeError("Only local accounts can remove followers"); + } + const result = await db + .delete(schema.follows) + .where( + and( + eq(schema.follows.followingId, following.id), + eq(schema.follows.followerId, follower.id), + ), + ) + .returning(); + if (result.length < 1) return null; + await ctx.sendActivity( + { username: following.owner.handle }, + { + id: new URL(follower.iri), + inboxId: new URL(follower.inboxUrl), + endpoints: + follower.sharedInboxUrl == null + ? null + : { + sharedInbox: new URL(follower.sharedInboxUrl), + }, + }, + new Reject({ + id: new URL(`#reject/${crypto.randomUUID()}`, following.iri), + actor: new URL(following.iri), + object: new Follow({ + id: new URL(result[0].iri), + actor: new URL(follower.iri), + object: new URL(following.iri), + }), + }), + { excludeBaseUris: [new URL(ctx.origin)] }, + ); + return result[0]; +} diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 2cfda83..423f101 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -17,9 +17,9 @@ import { type Move, Note, Question, - Reject, + type Reject, type Remove, - Undo, + type Undo, type Update, isActor, } from "@fedify/fedify"; @@ -39,7 +39,12 @@ import { posts, reactions, } from "../schema"; -import { persistAccount, updateAccountStats } from "./account"; +import { + persistAccount, + removeFollower, + unfollowAccount, + updateAccountStats, +} from "./account"; import { isPost, persistPollVote, @@ -263,6 +268,7 @@ export async function onBlocked( const object = ctx.parseUri(block.objectId); if (block.objectId == null || object?.type !== "actor") return; const blocked = await db.query.accountOwners.findFirst({ + with: { account: true }, where: eq(accountOwners.handle, object.identifier), }); if (blocked == null) return; @@ -277,56 +283,18 @@ export async function onBlocked( .onConflictDoNothing() .returning(); if (result.length < 1) return; - const following = await db - .delete(follows) - .where( - and( - eq(follows.followingId, blockerAccount.id), - eq(follows.followerId, blocked.id), - ), - ) - .returning(); - if (following.length > 0) { - await ctx.sendActivity( - object, - blocker, - new Undo({ - id: new URL(`#unfollows/${crypto.randomUUID()}`, block.objectId), - actor: block.objectId, - object: new Follow({ - id: new URL(following[0].iri), - actor: block.objectId, - object: blocker.id, - }), - }), - { excludeBaseUris: [new URL(ctx.origin)] }, - ); - } - const follower = await db - .delete(follows) - .where( - and( - eq(follows.followingId, blockerAccount.id), - eq(follows.followerId, blocked.id), - ), - ) - .returning(); - if (follower.length > 0) { - await ctx.sendActivity( - object, - blocker, - new Reject({ - id: new URL(`#reject/${crypto.randomUUID()}`, block.objectId), - actor: block.objectId, - object: new Follow({ - id: new URL(follower[0].iri), - actor: blocker.id, - object: block.objectId, - }), - }), - { excludeBaseUris: [new URL(ctx.origin)] }, - ); - } + await unfollowAccount( + db, + ctx, + { ...blocked.account, owner: blocked }, + blockerAccount, + ); + await removeFollower( + db, + ctx, + { ...blocked.account, owner: blocked }, + blockerAccount, + ); } export async function onUnblocked( diff --git a/src/pages/accounts.tsx b/src/pages/accounts.tsx index f45666c..b635e34 100644 --- a/src/pages/accounts.tsx +++ b/src/pages/accounts.tsx @@ -11,19 +11,25 @@ import { isActor, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { uniq } from "es-toolkit"; import { Hono } from "hono"; import { AccountForm } from "../components/AccountForm.tsx"; import { AccountList } from "../components/AccountList.tsx"; +import { DashboardLayout } from "../components/DashboardLayout.tsx"; import { NewAccountPage, type NewAccountPageProps, -} from "../components/AccountNewPage.tsx"; -import { DashboardLayout } from "../components/DashboardLayout.tsx"; +} from "../components/NewAccountPage.tsx"; import db from "../db.ts"; import federation from "../federation"; -import { persistAccount } from "../federation/account.ts"; +import { + REMOTE_ACTOR_FETCH_POSTS, + followAccount, + persistAccount, + persistAccountPosts, + unfollowAccount, +} from "../federation/account.ts"; import { loginRequired } from "../login.ts"; import { type Account, @@ -36,6 +42,8 @@ import { } from "../schema.ts"; import { extractCustomEmojis, formatText } from "../text.ts"; +const HOLLO_OFFICIAL_ACCOUNT = "@hollo@hollo.social"; + const logger = getLogger(["hollo", "pages", "accounts"]); const accounts = new Hono(); @@ -60,6 +68,7 @@ accounts.post("/", async (c) => { .get("visibility") ?.toString() ?.trim() as PostVisibility; + const news = form.get("news") != null; if (username == null || username === "" || name == null || name === "") { return c.html( { protected: protected_, language, visibility, + news, }} errors={{ username: @@ -81,6 +91,7 @@ accounts.post("/", async (c) => { ? "Display name is required." : undefined, }} + officialAccount={HOLLO_OFFICIAL_ACCOUNT} />, 400, ); @@ -89,7 +100,7 @@ accounts.post("/", async (c) => { const bioResult = await formatText(db, bio ?? "", fedCtx); const nameEmojis = await extractCustomEmojis(db, name); const emojis = { ...nameEmojis, ...bioResult.emojis }; - await db.transaction(async (tx) => { + const [account, owner] = await db.transaction(async (tx) => { await tx .insert(instances) .values({ @@ -120,21 +131,40 @@ accounts.post("/", async (c) => { .returning(); const rsaKeyPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); const ed25519KeyPair = await generateCryptoKeyPair("Ed25519"); - await tx.insert(accountOwners).values({ - id: account[0].id, - handle: username, - rsaPrivateKeyJwk: await exportJwk(rsaKeyPair.privateKey), - rsaPublicKeyJwk: await exportJwk(rsaKeyPair.publicKey), - ed25519PrivateKeyJwk: await exportJwk(ed25519KeyPair.privateKey), - ed25519PublicKeyJwk: await exportJwk(ed25519KeyPair.publicKey), - bio: bio ?? "", - language: language ?? "en", - visibility: visibility ?? "public", - }); + const owner = await tx + .insert(accountOwners) + .values({ + id: account[0].id, + handle: username, + rsaPrivateKeyJwk: await exportJwk(rsaKeyPair.privateKey), + rsaPublicKeyJwk: await exportJwk(rsaKeyPair.publicKey), + ed25519PrivateKeyJwk: await exportJwk(ed25519KeyPair.privateKey), + ed25519PublicKeyJwk: await exportJwk(ed25519KeyPair.publicKey), + bio: bio ?? "", + language: language ?? "en", + visibility: visibility ?? "public", + }) + .returning(); + return [account[0], owner[0]]; }); const owners = await db.query.accountOwners.findMany({ with: { account: true }, }); + if (news) { + const actor = await fedCtx.lookupObject(HOLLO_OFFICIAL_ACCOUNT); + if (isActor(actor)) { + await db.transaction(async (tx) => { + const following = await persistAccount(tx, actor, fedCtx); + if (following != null) { + await followAccount(tx, fedCtx, { ...account, owner }, following); + await persistAccountPosts(tx, account, REMOTE_ACTOR_FETCH_POSTS, { + ...fedCtx, + suppressError: true, + }); + } + }); + } + } return c.html(); }); @@ -161,7 +191,12 @@ function AccountListPage({ accountOwners }: AccountListPageProps) { } accounts.get("/new", (c) => { - return c.html(); + return c.html( + , + ); }); accounts.get("/:id", async (c) => { @@ -170,11 +205,30 @@ accounts.get("/:id", async (c) => { with: { account: true }, }); if (accountOwner == null) return c.notFound(); - return c.html(); + const news = await db.query.follows.findFirst({ + where: and( + eq( + follows.followingId, + db + .select({ id: accountsTable.id }) + .from(accountsTable) + .where(eq(accountsTable.handle, HOLLO_OFFICIAL_ACCOUNT)), + ), + eq(follows.followerId, accountOwner.id), + ), + }); + return c.html( + , + ); }); interface AccountPageProps extends NewAccountPageProps { accountOwner: AccountOwner & { account: Account }; + news: boolean; } function AccountPage(props: AccountPageProps) { @@ -196,8 +250,10 @@ function AccountPage(props: AccountPageProps) { props.values?.protected ?? props.accountOwner.account.protected, language: props.values?.language ?? props.accountOwner.language, visibility: props.values?.visibility ?? props.accountOwner.visibility, + news: props.values?.news ?? props.news, }} errors={props.errors} + officialAccount={HOLLO_OFFICIAL_ACCOUNT} submitLabel="Save changes" /> @@ -219,20 +275,24 @@ accounts.post("/:id", async (c) => { .get("visibility") ?.toString() ?.trim() as PostVisibility; + const news = form.get("news") != null; if (name == null || name === "") { return c.html( , 400, ); @@ -273,6 +333,20 @@ accounts.post("/:id", async (c) => { }), { preferSharedInbox: true, excludeBaseUris: [fedCtx.url] }, ); + const account = { ...accountOwner.account, owner: accountOwner }; + const newsActor = await fedCtx.lookupObject(HOLLO_OFFICIAL_ACCOUNT); + if (isActor(newsActor)) { + const newsAccount = await persistAccount(db, newsActor, fedCtx); + if (newsAccount != null) { + if (news) { + await followAccount(db, fedCtx, account, newsAccount); + await persistAccountPosts(db, newsAccount, REMOTE_ACTOR_FETCH_POSTS, { + ...fedCtx, + suppressError: true, + }); + } else await unfollowAccount(db, fedCtx, account, newsAccount); + } + } return c.redirect("/accounts"); }); @@ -404,7 +478,7 @@ accounts.get("/:id/migrate", async (c) => {