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/statuses/:id` (`GET`, `DELETE`)
|
||||
- `/api/v1/statuses`
|
||||
- `/api/v1/timelines/public`
|
||||
- `/api/v1/apps`
|
||||
- `/api/v1/instance`
|
||||
- `/api/v1/apps/verify_credentials`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
5
index.ts
5
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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
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,
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -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>`),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue