diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index b1d2147d7..087e32721 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -89,20 +89,31 @@ export function setupEndpointsTimeline(router: Router): void { router.get<{ Params: { hashtag: string } }>( "/v1/timelines/tag/:hashtag", async (ctx, reply) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getTagTimeline( - ctx.params.hashtag, - convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), - ); - ctx.body = data.data.map((status) => convertStatus(status)); + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? undefined; + + if (!user) { + ctx.status = 401; + return; + } + + const tag = (ctx.params.hashtag ?? '').trim(); + if (tag.length < 1) { + ctx.status = 400; + ctx.body = { error: "tag cannot be empty" }; + return; + } + + const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), ['any[]', 'all[]', 'none[]']); + const cache = UserHelpers.getFreshAccountCache(); + const tl = await TimelineHelpers.getTagTimeline(user, tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote) + .then(n => NoteConverter.encodeMany(n, user, cache)); + + ctx.body = tl.map(s => convertStatus(s)); } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; + ctx.status = 400; + ctx.body = { error: e.message }; } }, ); diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index 7c032b590..ed2c7a612 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -10,8 +10,6 @@ import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note- import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; import { fetchMeta } from "@/misc/fetch-meta.js"; -import { ApiError } from "@/server/api/error.js"; -import { meta } from "@/server/api/endpoints/notes/global-timeline.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { UserList } from "@/models/entities/user-list.js"; @@ -53,17 +51,19 @@ export class TimelineHelpers { public static async getPublicTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise { if (limit > 40) limit = 40; - const m = await fetchMeta(); - if (m.disableGlobalTimeline) { - if (user == null || !(user.isAdmin || user.isModerator)) { - throw new ApiError(meta.errors.gtlDisabled); - } - } - if (local && remote) { throw new Error("local and remote are mutually exclusive options"); } + if (!local) { + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || !(user.isAdmin || user.isModerator)) { + throw new Error("global timeline is disabled"); + } + } + } + const query = PaginationHelpers.makePaginationQuery( Notes.createQueryBuilder("note"), sinceId, @@ -114,4 +114,43 @@ export class TimelineHelpers { return PaginationHelpers.execQuery(query, limit, minId !== undefined); } + + public static async getTagTimeline(user: ILocalUser, tag: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, any: string[], all: string[], none: string[], onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise { + if (limit > 40) limit = 40; + + if (local && remote) { + throw new Error("local and remote are mutually exclusive options"); + } + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .andWhere("note.visibility = 'public'") + .andWhere("note.tags @> array[:tag]::varchar[]", {tag: tag}); + + if (any.length > 0) query.andWhere("note.tags && array[:...any]::varchar[]", {any: any}); + if (all.length > 0) query.andWhere("note.tags @> array[:...all]::varchar[]", {all: all}); + if (none.length > 0) query.andWhere("NOT(note.tags @> array[:...none]::varchar[])", {none: none}); + + if (remote) query.andWhere("note.userHost IS NOT NULL"); + if (local) query.andWhere("note.userHost IS NULL"); + if (!local) query.andWhere("note.channelId IS NULL"); + + query.leftJoinAndSelect("note.renote", "renote"); + + generateRepliesQuery(query, true, user); + if (user) { + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + } + + if (onlyMedia) query.andWhere("note.fileIds != '{}'"); + + return PaginationHelpers.execQuery(query, limit, minId !== undefined); + } }