diff --git a/README.md b/README.md index 08afd971..cebdd589 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Working endpoints are: - `/api/v1/accounts/familiar_followers` - `/api/v1/statuses/:id` (`GET`, `DELETE`) - `/api/v1/statuses` +- `/api/v1/timelines/public` - `/api/v1/apps` - `/api/v1/instance` - `/api/v1/apps/verify_credentials` diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index c3e571d6..e90b7290 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -1,11 +1,19 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { + BaseEntity, + Column, + Entity, + JoinTable, + ManyToMany, + PrimaryGeneratedColumn, +} from "typeorm"; import { APActor, APImage, APOrderedCollectionPage } from "activitypub-types"; import { getConfig, getHost } from "@config"; import { appendFile } from "fs/promises"; import { errorResponse } from "@response"; import { APIAccount } from "~types/entities/account"; import { RawActivity } from "./RawActivity"; +import { RawObject } from "./RawObject"; /** * Represents a raw actor entity in the database. @@ -30,6 +38,13 @@ export class RawActor extends BaseEntity { @Column("jsonb", { default: [] }) followers!: string[]; + /** + * The list of featured objects associated with the actor. + */ + @ManyToMany(() => RawObject, { nullable: true }) + @JoinTable() + featured!: RawObject[]; + /** * Retrieves a RawActor entity by actor ID. * @param id The ID of the actor to retrieve. diff --git a/index.ts b/index.ts index 6f4185aa..ca9e7314 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,5 @@ import { getConfig } from "@config"; +import { jsonResponse } from "@response"; import { appendFile } from "fs/promises"; import "reflect-metadata"; import { AppDataSource } from "~database/datasource"; @@ -56,6 +57,10 @@ Bun.serve({ ); } + if (req.method === "OPTIONS") { + return jsonResponse({}); + } + const matchedRoute = router.match(req); if (matchedRoute) { diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 6248035f..5e5bae7b 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -15,6 +15,7 @@ export default async ( const { limit, exclude_reblogs, + pinned, }: { max_id?: string; since_id?: string; @@ -23,6 +24,7 @@ export default async ( only_media?: boolean; exclude_replies?: boolean; exclude_reblogs?: boolean; + // TODO: Add with_muted pinned?: boolean; tagged?: string; } = matchedRoute.query; @@ -33,6 +35,10 @@ export default async ( if (!user) return errorResponse("User not found", 404); + if (pinned) { + // TODO: Add pinned statuses + } + // TODO: Check if status can be seen by this user const statuses = await Status.find({ where: { diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts new file mode 100644 index 00000000..595a68c3 --- /dev/null +++ b/server/api/api/v1/timelines/public.ts @@ -0,0 +1,89 @@ +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { RawObject } from "~database/entities/RawObject"; + +/** + * Fetch public timeline statuses + */ +export default async (req: Request): Promise => { + const { + local, + limit = 20, + max_id, + min_id, + only_media, + remote, + since_id, + } = await parseRequest<{ + local?: boolean; + only_media?: boolean; + remote?: boolean; + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; + }>(req); + + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } + + let query = RawObject.createQueryBuilder("object") + .where("object.data->>'type' = 'Note'") + .andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", { + to: JSON.stringify([ + "https://www.w3.org/ns/activitystreams#Public", + ]), + }) + .orderBy("object.data->>'published'", "DESC") + .take(limit); + + if (max_id) { + const maxPost = await RawObject.findOneBy({ id: max_id }); + if (maxPost) { + query = query.andWhere("object.data->>'published' < :max_date", { + max_date: maxPost.data.published, + }); + } + } + + if (min_id) { + const minPost = await RawObject.findOneBy({ id: min_id }); + if (minPost) { + query = query.andWhere("object.data->>'published' > :min_date", { + min_date: minPost.data.published, + }); + } + } + + if (since_id) { + const sincePost = await RawObject.findOneBy({ id: since_id }); + if (sincePost) { + query = query.andWhere("object.data->>'published' >= :since_date", { + since_date: sincePost.data.published, + }); + } + } + + if (only_media) { + query = query.andWhere("object.data->'attachment' IS NOT NULL"); + } + + if (local) { + query = query.andWhere("object.data->>'actor' LIKE :actor", { + actor: `%${new URL(req.url).hostname}%`, + }); + } + + if (remote) { + query = query.andWhere("object.data->>'actor' NOT LIKE :actor", { + actor: `%${new URL(req.url).hostname}%`, + }); + } + + const objects = await query.getMany(); + + return jsonResponse( + await Promise.all(objects.map(async object => await object.toAPI())) + ); +}; diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index b150cd00..064a842c 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -12,7 +12,7 @@ export default async ( req: Request, matchedRoute: MatchedRoute ): Promise => { - const scopes = (matchedRoute.query.scopes || "") + const scopes = (matchedRoute.query.scope || "") .replaceAll("+", " ") .split(" "); const redirect_uri = matchedRoute.query.redirect_uri; @@ -21,18 +21,18 @@ export default async ( const formData = await req.formData(); - const username = formData.get("username")?.toString() || null; + const email = formData.get("email")?.toString() || null; const password = formData.get("password")?.toString() || null; if (response_type !== "code") return errorResponse("Invalid response type (try 'code')", 400); - if (!username || !password) + if (!email || !password) return errorResponse("Missing username or password", 400); // Get user const user = await User.findOneBy({ - username, + email, }); if (!user || !(await Bun.password.verify(password, user.password))) diff --git a/server/api/oauth/authorize/index.ts b/server/api/oauth/authorize/index.ts index 3f395c41..45a6d3f3 100644 --- a/server/api/oauth/authorize/index.ts +++ b/server/api/oauth/authorize/index.ts @@ -13,7 +13,7 @@ export default async ( (await html.text()) .replace( "{{URL}}", - `/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scopes=${matchedRoute.query.scopes}` + `/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scope=${matchedRoute.query.scope}` ) .replace("{{STYLES}}", ``), { diff --git a/tests/api.test.ts b/tests/api.test.ts index 15c2dfd2..6b6e8627 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -126,6 +126,27 @@ describe("POST /api/v1/statuses", () => { }); }); +describe("GET /api/v1/timelines/public", () => { + test("should return an array of APIStatus objects that includes the created status", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/timelines/public`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const statuses: APIStatus[] = await response.json(); + + expect(statuses.some(s => s.id === status?.id)).toBe(true); + }); +}); + describe("PATCH /api/v1/accounts/update_credentials", () => { test("should update the authenticated user's display name", async () => { const response = await fetch( diff --git a/utils/response.ts b/utils/response.ts index e881e93e..cddd2f94 100644 --- a/utils/response.ts +++ b/utils/response.ts @@ -1,10 +1,24 @@ import { APActivity, APObject } from "activitypub-types"; import { NodeObject } from "jsonld"; -export const jsonResponse = (data: object, status = 200) => { +export const jsonResponse = ( + data: object, + status = 200, + headers: Record = {} +) => { return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json", + "X-Frame-Options": "DENY", + "X-Permitted-Cross-Domain-Policies": "none", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": + "Authorization,Content-Type,Idempotency-Key", + "Access-Control-Allow-Methods": "POST,PUT,DELETE,GET,PATCH,OPTIONS", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": + "Link,X-RateLimit-Reset,X-RateLimit-Limit,X-RateLimit-Remaining,X-Request-Id,Idempotency-Key", + ...headers, }, status, });