Files
misskey/packages/backend/src/server/api/endpoints/fetch-rss.ts
T
おさむのひと a12ee2e5d7 Merge commit from fork
2026-07-28 11:09:37 +09:00

330 lines
6.3 KiB
TypeScript

/*
* 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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private readonly inFlightRequests = new Map<string, Promise<Awaited<ReturnType<Parser['parseString']>>>>();
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<Awaited<ReturnType<Parser['parseString']>>> {
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);
}
}