mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
Add public timeline
This commit is contained in:
parent
bff170d2e2
commit
b7587f8d3f
|
|
@ -81,6 +81,7 @@ Working endpoints are:
|
||||||
- `/api/v1/accounts/familiar_followers`
|
- `/api/v1/accounts/familiar_followers`
|
||||||
- `/api/v1/statuses/:id` (`GET`, `DELETE`)
|
- `/api/v1/statuses/:id` (`GET`, `DELETE`)
|
||||||
- `/api/v1/statuses`
|
- `/api/v1/statuses`
|
||||||
|
- `/api/v1/timelines/public`
|
||||||
- `/api/v1/apps`
|
- `/api/v1/apps`
|
||||||
- `/api/v1/instance`
|
- `/api/v1/instance`
|
||||||
- `/api/v1/apps/verify_credentials`
|
- `/api/v1/apps/verify_credentials`
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
/* eslint-disable @typescript-eslint/require-await */
|
/* 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 { APActor, APImage, APOrderedCollectionPage } from "activitypub-types";
|
||||||
import { getConfig, getHost } from "@config";
|
import { getConfig, getHost } from "@config";
|
||||||
import { appendFile } from "fs/promises";
|
import { appendFile } from "fs/promises";
|
||||||
import { errorResponse } from "@response";
|
import { errorResponse } from "@response";
|
||||||
import { APIAccount } from "~types/entities/account";
|
import { APIAccount } from "~types/entities/account";
|
||||||
import { RawActivity } from "./RawActivity";
|
import { RawActivity } from "./RawActivity";
|
||||||
|
import { RawObject } from "./RawObject";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a raw actor entity in the database.
|
* Represents a raw actor entity in the database.
|
||||||
|
|
@ -30,6 +38,13 @@ export class RawActor extends BaseEntity {
|
||||||
@Column("jsonb", { default: [] })
|
@Column("jsonb", { default: [] })
|
||||||
followers!: string[];
|
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.
|
* Retrieves a RawActor entity by actor ID.
|
||||||
* @param id The ID of the actor to retrieve.
|
* @param id The ID of the actor to retrieve.
|
||||||
|
|
|
||||||
5
index.ts
5
index.ts
|
|
@ -1,4 +1,5 @@
|
||||||
import { getConfig } from "@config";
|
import { getConfig } from "@config";
|
||||||
|
import { jsonResponse } from "@response";
|
||||||
import { appendFile } from "fs/promises";
|
import { appendFile } from "fs/promises";
|
||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { AppDataSource } from "~database/datasource";
|
import { AppDataSource } from "~database/datasource";
|
||||||
|
|
@ -56,6 +57,10 @@ Bun.serve({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return jsonResponse({});
|
||||||
|
}
|
||||||
|
|
||||||
const matchedRoute = router.match(req);
|
const matchedRoute = router.match(req);
|
||||||
|
|
||||||
if (matchedRoute) {
|
if (matchedRoute) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export default async (
|
||||||
const {
|
const {
|
||||||
limit,
|
limit,
|
||||||
exclude_reblogs,
|
exclude_reblogs,
|
||||||
|
pinned,
|
||||||
}: {
|
}: {
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
|
|
@ -23,6 +24,7 @@ export default async (
|
||||||
only_media?: boolean;
|
only_media?: boolean;
|
||||||
exclude_replies?: boolean;
|
exclude_replies?: boolean;
|
||||||
exclude_reblogs?: boolean;
|
exclude_reblogs?: boolean;
|
||||||
|
// TODO: Add with_muted
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
tagged?: string;
|
tagged?: string;
|
||||||
} = matchedRoute.query;
|
} = matchedRoute.query;
|
||||||
|
|
@ -33,6 +35,10 @@ export default async (
|
||||||
|
|
||||||
if (!user) return errorResponse("User not found", 404);
|
if (!user) return errorResponse("User not found", 404);
|
||||||
|
|
||||||
|
if (pinned) {
|
||||||
|
// TODO: Add pinned statuses
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Check if status can be seen by this user
|
// TODO: Check if status can be seen by this user
|
||||||
const statuses = await Status.find({
|
const statuses = await Status.find({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
89
server/api/api/v1/timelines/public.ts
Normal file
89
server/api/api/v1/timelines/public.ts
Normal 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()))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -12,7 +12,7 @@ export default async (
|
||||||
req: Request,
|
req: Request,
|
||||||
matchedRoute: MatchedRoute
|
matchedRoute: MatchedRoute
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const scopes = (matchedRoute.query.scopes || "")
|
const scopes = (matchedRoute.query.scope || "")
|
||||||
.replaceAll("+", " ")
|
.replaceAll("+", " ")
|
||||||
.split(" ");
|
.split(" ");
|
||||||
const redirect_uri = matchedRoute.query.redirect_uri;
|
const redirect_uri = matchedRoute.query.redirect_uri;
|
||||||
|
|
@ -21,18 +21,18 @@ export default async (
|
||||||
|
|
||||||
const formData = await req.formData();
|
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;
|
const password = formData.get("password")?.toString() || null;
|
||||||
|
|
||||||
if (response_type !== "code")
|
if (response_type !== "code")
|
||||||
return errorResponse("Invalid response type (try 'code')", 400);
|
return errorResponse("Invalid response type (try 'code')", 400);
|
||||||
|
|
||||||
if (!username || !password)
|
if (!email || !password)
|
||||||
return errorResponse("Missing username or password", 400);
|
return errorResponse("Missing username or password", 400);
|
||||||
|
|
||||||
// Get user
|
// Get user
|
||||||
const user = await User.findOneBy({
|
const user = await User.findOneBy({
|
||||||
username,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !(await Bun.password.verify(password, user.password)))
|
if (!user || !(await Bun.password.verify(password, user.password)))
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default async (
|
||||||
(await html.text())
|
(await html.text())
|
||||||
.replace(
|
.replace(
|
||||||
"{{URL}}",
|
"{{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>`),
|
.replace("{{STYLES}}", `<style>${await css.text()}</style>`),
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("PATCH /api/v1/accounts/update_credentials", () => {
|
||||||
test("should update the authenticated user's display name", async () => {
|
test("should update the authenticated user's display name", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
import { APActivity, APObject } from "activitypub-types";
|
import { APActivity, APObject } from "activitypub-types";
|
||||||
import { NodeObject } from "jsonld";
|
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), {
|
return new Response(JSON.stringify(data), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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,
|
status,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue