diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts index 474aaefb35..d7b74bb751 100644 --- a/packages/backend/src/server/api/mastodon/MastodonClientService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -51,12 +51,14 @@ export class MastodonClientService { return new Misskey(baseUrl, accessToken, userAgent); } - /** - * Gets the base URL (origin) of the incoming request - */ - public getBaseUrl(request: FastifyRequest): string { - return `${request.protocol}://${request.host}`; - } + readonly getBaseUrl = getBaseUrl; +} + +/** + * Gets the base URL (origin) of the incoming request + */ +export function getBaseUrl(request: FastifyRequest): string { + return `${request.protocol}://${request.host}`; } /** diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 6f6999f2e1..f669b71efb 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -9,6 +9,7 @@ import { MastodonClientService } from '@/server/api/mastodon/MastodonClientServi import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js'; import type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; @@ -173,14 +174,15 @@ export class ApiAccountMastodon { reply.send(account); }); - fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/statuses', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const args = parseTimelineArgs(_request.query); - const data = await client.getAccountStatuses(_request.params.id, args); + const { client, me } = await this.clientService.getAuthClient(request); + const args = parseTimelineArgs(request.query); + const data = await client.getAccountStatuses(request.params.id, args); const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); @@ -194,29 +196,31 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/followers', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(request); const data = await client.getAccountFollowers( - _request.params.id, - parseTimelineArgs(_request.query), + request.params.id, + parseTimelineArgs(request.query), ); const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/following', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(request); const data = await client.getAccountFollowing( - _request.params.id, - parseTimelineArgs(_request.query), + request.params.id, + parseTimelineArgs(request.query), ); const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); @@ -236,7 +240,7 @@ export class ApiAccountMastodon { const client = this.clientService.getClient(_request); const data = await client.followAccount(_request.params.id); const acct = convertRelationship(data.data); - acct.following = true; + acct.following = true; // TODO this is wrong, follow may not have processed immediately reply.send(acct); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 3b2833bf86..6acb9edd6b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -25,9 +26,9 @@ export class ApiNotificationsMastodon { ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { - fastify.get('/v1/notifications', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getNotifications(parseTimelineArgs(_request.query)); + fastify.get('/v1/notifications', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const data = await client.getNotifications(parseTimelineArgs(request.query)); const response = await Promise.all(data.data.map(async n => { const converted = await this.mastoConverters.convertNotification(n, me); if (converted.type === 'reaction') { @@ -36,6 +37,7 @@ export class ApiNotificationsMastodon { return converted; })); + attachMinMaxPagination(request, reply, response); reply.send(response); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 34d82096ba..997a585077 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -5,16 +5,18 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; import { MastoConverters } from '../converters.js'; -import { parseTimelineArgs, TimelineArgs } from '../argsUtils.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; import type { FastifyInstance } from 'fastify'; interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { - type?: 'accounts' | 'hashtags' | 'statuses'; + type?: string; q?: string; + resolve?: string; } } @@ -26,66 +28,116 @@ export class ApiSearchMastodon { ) {} public register(fastify: FastifyInstance): void { - fastify.get('/v1/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); - if (!_request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); + fastify.get('/v1/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); - const query = parseTimelineArgs(_request.query); - const client = this.clientService.getClient(_request); - const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); + const type = request.query.type; + if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } - reply.send(data.data); - }); + const { client, me } = await this.clientService.getAuthClient(request); - fastify.get('/v2/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } - const query = parseTimelineArgs(_request.query); - const type = _request.query.type; - const { client, me } = await this.clientService.getAuthClient(_request); - const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null; + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const { data } = await client.search(request.query.q, { type, ...query }); const response = { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []), - hashtags: tags?.data.hashtags ?? [], + ...data, + accounts: await Promise.all(data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account))), + statuses: await Promise.all(data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me))), }; + if (type === 'hashtags') { + attachOffsetPagination(request, reply, response.hashtags); + } else { + attachMinMaxPagination(request, reply, response[type]); + } + reply.send(response); }); - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const baseUrl = this.clientService.getBaseUrl(_request); + fastify.get('/v2/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + + const type = request.query.type; + if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } + + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const acct = !type || type === 'accounts' ? await client.search(request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; + + // Pagination hack, based on "best guess" expected behavior. + // Mastodon doesn't document this part at all! + const longestResult = [response.statuses, response.hashtags] + .reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts); + + // Ignore min/max pagination because how TF would that work with multiple result sets?? + // Offset pagination is the only possible option + attachOffsetPagination(request, reply, longestResult); + + reply.send(response); + }); + + fastify.get('/v1/trends/statuses', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); const res = await fetch(`${baseUrl}/api/notes/featured`, { method: 'POST', headers: { - ..._request.headers as HeadersInit, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: '{}', }); const data = await res.json() as Status[]; - const me = await this.clientService.getAuth(_request); + const me = await this.clientService.getAuth(request); const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get('/v2/suggestions', async (_request, reply) => { - const baseUrl = this.clientService.getBaseUrl(_request); + fastify.get('/v2/suggestions', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); const res = await fetch(`${baseUrl}/api/users`, { method: 'POST', headers: { - ..._request.headers as HeadersInit, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ - limit: parseTimelineArgs(_request.query).limit ?? 20, + limit: parseTimelineArgs(request.query).limit ?? 20, origin: 'local', sort: '+follower', state: 'alive', @@ -99,6 +151,7 @@ export class ApiSearchMastodon { }; })); + attachOffsetPagination(request, reply, response); reply.send(response); }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 975aa9d04b..a333e77c3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { convertList, MastoConverters } from '../converters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; @@ -18,55 +19,60 @@ export class ApiTimelineMastodon { ) {} public register(fastify: FastifyInstance): void { - fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = toBoolean(_request.query.local) + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = toBoolean(request.query.local) ? await client.getLocalTimeline(query) : await client.getPublicTimeline(query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); const data = await client.getHomeTimeline(query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { + if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getTagTimeline(_request.params.hashtag, query); + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getTagTimeline(request.params.hashtag, query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getListTimeline(_request.params.id, query); + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getListTimeline(request.params.id, query); const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); const data = await client.getConversationTimeline(query); - const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); - reply.send(conversations); + attachMinMaxPagination(request, reply, response); + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -79,22 +85,24 @@ export class ApiTimelineMastodon { reply.send(response); }); - fastify.get('/v1/lists', async (_request, reply) => { - const client = this.clientService.getClient(_request); + fastify.get('/v1/lists', async (request, reply) => { + const client = this.clientService.getClient(request); const data = await client.getLists(); const response = data.data.map((list: Entity.List) => convertList(list)); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccountsInList(_request.params.id, _request.query); - const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(request); + const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query)); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(accounts); + attachMinMaxPagination(request, reply, response); + reply.send(response); }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { diff --git a/packages/backend/src/server/api/mastodon/pagination.ts b/packages/backend/src/server/api/mastodon/pagination.ts new file mode 100644 index 0000000000..2cf24cfb24 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/pagination.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FastifyReply, FastifyRequest } from 'fastify'; +import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; + +interface AnyEntity { + readonly id: string; +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by min_id / max_id parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachMinMaxPagination(request: FastifyRequest, reply: FastifyReply, results: AnyEntity[]): void { + // No results, nothing to do + if (!hasItems(results)) return; + + // "next" link - older results + const oldest = findOldest(results); + const nextUrl = createPaginationUrl(request, { max_id: oldest }); // Next page (older) has IDs less than the oldest of this page + const next = `<${nextUrl}>; rel="next"`; + + // "prev" link - newer results + const newest = findNewest(results); + const prevUrl = createPaginationUrl(request, { min_id: newest }); // Previous page (newer) has IDs greater than the newest of this page + const prev = `<${prevUrl}>; rel="prev"`; + + // https://docs.joinmastodon.org/api/guidelines/#pagination + const link = `${next}, ${prev}`; + reply.header('link', link); +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by limit / offset parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachOffsetPagination(request: FastifyRequest, reply: FastifyReply, results: unknown[]): void { + const links: string[] = []; + + // Find initial offset + const offset = findOffset(request); + const limit = findLimit(request); + + // "next" link - older results + if (hasItems(results)) { + const oldest = offset + results.length; + const nextUrl = createPaginationUrl(request, { offset: oldest }); // Next page (older) has entries less than the oldest of this page + links.push(`<${nextUrl}>; rel="next"`); + } + + // "prev" link - newer results + // We can only paginate backwards if a limit is specified + if (limit) { + // Make sure we don't cross below 0, as that will produce an API error + if (limit <= offset) { + const newest = offset - limit; + const prevUrl = createPaginationUrl(request, { offset: newest }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } else { + const prevUrl = createPaginationUrl(request, { offset: 0, limit: offset }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } + } + + // https://docs.joinmastodon.org/api/guidelines/#pagination + if (links.length > 0) { + const link = links.join(', '); + reply.header('link', link); + } +} + +function hasItems(items: T[]): items is [T, ...T[]] { + return items.length > 0; +} + +function findOffset(request: FastifyRequest): number { + if (typeof(request.query) !== 'object') return 0; + + const query = request.query as Record; + if (!query.offset) return 0; + + if (Array.isArray(query.offset)) { + const offsets = query.offset + .map(o => parseInt(o)) + .filter(o => !isNaN(o)); + const offset = Math.max(...offsets); + return isNaN(offset) ? 0 : offset; + } + + const offset = parseInt(query.offset); + return isNaN(offset) ? 0 : offset; +} + +function findLimit(request: FastifyRequest): number | null { + if (typeof(request.query) !== 'object') return null; + + const query = request.query as Record; + if (!query.limit) return null; + + if (Array.isArray(query.limit)) { + const limits = query.limit + .map(l => parseInt(l)) + .filter(l => !isNaN(l)); + const limit = Math.max(...limits); + return isNaN(limit) ? null : limit; + } + + const limit = parseInt(query.limit); + return isNaN(limit) ? null : limit; +} + +function findOldest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? first : last; +} + +function findNewest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? last : first; +} + +function isOlder(a: string, b: string): boolean { + if (a === b) return false; + + if (a.length !== b.length) { + return a.length < b.length; + } + + return a < b; +} + +function createPaginationUrl(request: FastifyRequest, data: { + min_id?: string; + max_id?: string; + offset?: number; + limit?: number; +}): string { + const baseUrl = getBaseUrl(request); + const requestUrl = new URL(request.url, baseUrl); + + // Remove any existing pagination + requestUrl.searchParams.delete('min_id'); + requestUrl.searchParams.delete('max_id'); + requestUrl.searchParams.delete('since_id'); + requestUrl.searchParams.delete('offset'); + + if (data.min_id) requestUrl.searchParams.set('min_id', data.min_id); + if (data.max_id) requestUrl.searchParams.set('max_id', data.max_id); + if (data.offset) requestUrl.searchParams.set('offset', String(data.offset)); + if (data.limit) requestUrl.searchParams.set('limit', String(data.limit)); + + return requestUrl.href; +}