/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import Parser from 'rss-parser'; import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { ApiError } from '../error.js'; const MAX_URL_LENGTH = 8192; const MAX_RESPONSE_SIZE = 1024 * 1024; const MAX_CONCURRENT_REQUESTS = 32; export const meta = { tags: ['meta'], requireCredential: false, allowGet: true, cacheSec: 60 * 3, limit: { duration: 60 * 1000, max: 300, }, errors: { invalidUrl: { message: 'Invalid URL.', code: 'INVALID_URL', id: '89b7ee05-ccfc-4bdd-9b13-61172fd1e06c', httpStatusCode: 400, }, fetchRssFailed: { message: 'Failed to fetch RSS.', code: 'FETCH_RSS_FAILED', id: '8db5d3d8-31d7-452f-b0cc-ca3b8925de12', kind: 'server', httpStatusCode: 422, }, fetchRssUnavailable: { message: 'RSS fetching is temporarily unavailable.', code: 'FETCH_RSS_UNAVAILABLE', id: '91e6ff44-c63f-4725-9ad0-b7a40d7f7655', kind: 'server', httpStatusCode: 503, }, }, res: { type: 'object', properties: { image: { type: 'object', optional: true, properties: { link: { type: 'string', optional: true, }, url: { type: 'string', optional: false, }, title: { type: 'string', optional: true, }, }, }, paginationLinks: { type: 'object', optional: true, properties: { self: { type: 'string', optional: true, }, first: { type: 'string', optional: true, }, next: { type: 'string', optional: true, }, last: { type: 'string', optional: true, }, prev: { type: 'string', optional: true, }, }, }, link: { type: 'string', optional: true, }, title: { type: 'string', optional: true, }, items: { type: 'array', optional: false, items: { type: 'object', properties: { link: { type: 'string', optional: true, }, guid: { type: 'string', optional: true, }, title: { type: 'string', optional: true, }, pubDate: { type: 'string', optional: true, }, creator: { type: 'string', optional: true, }, summary: { type: 'string', optional: true, }, content: { type: 'string', optional: true, }, isoDate: { type: 'string', optional: true, }, categories: { type: 'array', optional: true, items: { type: 'string', }, }, contentSnippet: { type: 'string', optional: true, }, enclosure: { type: 'object', optional: true, properties: { url: { type: 'string', optional: false, }, length: { type: 'number', optional: true, }, type: { type: 'string', optional: true, }, }, }, }, }, }, feedUrl: { type: 'string', optional: true, }, description: { type: 'string', optional: true, }, itunes: { type: 'object', optional: true, additionalProperties: true, properties: { image: { type: 'string', optional: true, }, owner: { type: 'object', optional: true, properties: { name: { type: 'string', optional: true, }, email: { type: 'string', optional: true, }, }, }, author: { type: 'string', optional: true, }, summary: { type: 'string', optional: true, }, explicit: { type: 'string', optional: true, }, categories: { type: 'array', optional: true, items: { type: 'string', }, }, keywords: { type: 'array', optional: true, items: { type: 'string', }, }, }, }, }, }, } as const; export const paramDef = { type: 'object', properties: { url: { type: 'string' }, }, required: ['url'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export private readonly inFlightRequests = new Map>>>(); private activeRequestCount = 0; constructor( private httpRequestService: HttpRequestService, ) { super(meta, paramDef, async (ps) => { const url = this.normalizeUrl(ps.url); const inFlightRequest = this.inFlightRequests.get(url); if (inFlightRequest != null) { return await inFlightRequest; } if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) { throw new ApiError(meta.errors.fetchRssUnavailable); } this.activeRequestCount++; const request = this.fetchRss(url) .catch(() => { throw new ApiError(meta.errors.fetchRssFailed); }) .finally(() => { this.inFlightRequests.delete(url); this.activeRequestCount--; }); this.inFlightRequests.set(url, request); return await request; }); } private normalizeUrl(input: string): string { if (input.length === 0 || input.length > MAX_URL_LENGTH) { throw new ApiError(meta.errors.invalidUrl); } let url: URL; try { url = new URL(input); } catch { throw new ApiError(meta.errors.invalidUrl); } if ( (url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '' ) { throw new ApiError(meta.errors.invalidUrl); } url.hash = ''; return url.href; } private async fetchRss(url: string): Promise>> { const res = await this.httpRequestService.send(url, { method: 'GET', headers: { Accept: 'application/rss+xml, */*', }, timeout: 5000, size: MAX_RESPONSE_SIZE, }); const finalUrl = new URL(res.url); if (finalUrl.protocol !== 'http:' && finalUrl.protocol !== 'https:') { throw new Error('Invalid final URL protocol'); } const text = await res.text(); const rssParser = new Parser({ xml2js: { async: true, }, }); return await rssParser.parseString(text); } }