Add public timeline

This commit is contained in:
Jesse Wierzbinski 2023-10-01 14:07:29 -10:00
parent bff170d2e2
commit b7587f8d3f
9 changed files with 158 additions and 7 deletions

View file

@ -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`

View file

@ -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.

View file

@ -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) {

View file

@ -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: {

View file

@ -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<Response> => {
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()))
);
};

View file

@ -12,7 +12,7 @@ export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
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)))

View file

@ -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}}", `<style>${await css.text()}</style>`),
{

View file

@ -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(

View file

@ -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<string, string> = {}
) => {
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,
});