mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-02 05:16:01 +00:00
330 lines
6.3 KiB
TypeScript
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);
|
|
}
|
|
}
|