Not existing feeds are deleted from databse

main
Štěpán Škorpil 2022-04-17 14:03:53 +02:00
rodzic cefbd76066
commit 3ed37c04ce
34 zmienionych plików z 497 dodań i 313 usunięć

Wyświetl plik

@ -3469,9 +3469,9 @@
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==",
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"funding": [
{
"type": "individual",
@ -6344,9 +6344,10 @@
}
},
"node_modules/minimist": {
"version": "1.2.5",
"dev": true,
"license": "MIT"
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true
},
"node_modules/ms": {
"version": "2.1.2",
@ -11163,9 +11164,9 @@
"dev": true
},
"follow-redirects": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
},
"form-data": {
"version": "3.0.1",
@ -13249,7 +13250,9 @@
}
},
"minimist": {
"version": "1.2.5",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true
},
"ms": {

Wyświetl plik

@ -26,16 +26,16 @@
"migrate:resolve": "npx prisma migrate resolve",
"prisma:generate": "npx prisma generate",
"prisma:studio": "npx prisma studio",
"start:deploy": "npm run migrate:deploy && npm run start"
"start:deploy": "npm run migrate:deploy && npm run start"
},
"dependencies": {
"@prisma/client": "^3.6.0",
"axios": "^0.21.1",
"npmlog": "^6.0.0",
"typescript-collections": "^1.3.3",
"zod": "^3.11.6",
"rimraf": "^3.0.2",
"striptags": "^3.2.0"
"striptags": "^3.2.0",
"typescript-collections": "^1.3.3",
"zod": "^3.11.6"
},
"devDependencies": {
"@types/jest": "^27.0.2",
@ -49,11 +49,11 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.1",
"eslint-plugin-react": "^7.27.1",
"jest": "^27.3.0",
"prisma": "^3.6.0",
"standard": "*",
"typescript": "^4.3.5",
"ts-jest": "^27.0.7",
"jest": "^27.3.0"
"typescript": "^4.3.5"
},
"jest": {
"moduleFileExtensions": [

Wyświetl plik

@ -0,0 +1,193 @@
/*
Warnings:
- A unique constraint covering the columns `[name,nodeId]` on the table `Feed` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[domain]` on the table `Node` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail.
*/
-- DropForeignKey
ALTER TABLE "Email" DROP CONSTRAINT "Email_feedId_fkey";
-- DropForeignKey
ALTER TABLE "Feed" DROP CONSTRAINT "Feed_nodeId_fkey";
-- DropForeignKey
ALTER TABLE "FeedToTag" DROP CONSTRAINT "FeedToTag_feedId_fkey";
-- DropForeignKey
ALTER TABLE "FeedToTag" DROP CONSTRAINT "FeedToTag_tagId_fkey";
-- DropForeignKey
ALTER TABLE "Field" DROP CONSTRAINT "Field_feedId_fkey";
-- DropIndex
DROP INDEX "Email_address_idx";
-- DropIndex
DROP INDEX "Feed_bot_idx";
-- DropIndex
DROP INDEX "Feed_createdAt_idx";
-- DropIndex
DROP INDEX "Feed_description_idx";
-- DropIndex
DROP INDEX "Feed_displayName_idx";
-- DropIndex
DROP INDEX "Feed_fulltext_idx";
-- DropIndex
DROP INDEX "Feed_lastStatusAt_idx";
-- DropIndex
DROP INDEX "Feed_locked_idx";
-- DropIndex
DROP INDEX "Feed_name_nodeId_key";
-- DropIndex
DROP INDEX "Feed_parentFeedName_parentFeedDomain_idx";
-- DropIndex
DROP INDEX "Feed_refreshedAt_idx";
-- DropIndex
DROP INDEX "Feed_type_idx";
-- DropIndex
DROP INDEX "Field_name_idx";
-- DropIndex
DROP INDEX "Field_value_idx";
-- DropIndex
DROP INDEX "Node_domain_key";
-- DropIndex
DROP INDEX "Node_foundAt_idx";
-- DropIndex
DROP INDEX "Node_halfYearActiveUserCount_idx";
-- DropIndex
DROP INDEX "Node_monthActiveUserCount_idx";
-- DropIndex
DROP INDEX "Node_openRegistrations_idx";
-- DropIndex
DROP INDEX "Node_refreshAttemptedAt_idx";
-- DropIndex
DROP INDEX "Node_refreshedAt_idx";
-- DropIndex
DROP INDEX "Node_softwareName_idx";
-- DropIndex
DROP INDEX "Node_softwareVersion_idx";
-- DropIndex
DROP INDEX "Node_statusesCount_idx";
-- DropIndex
DROP INDEX "Node_totalUserCount_idx";
-- DropIndex
DROP INDEX "Tag_name_key";
-- CreateIndex
CREATE INDEX "Email_address_idx" ON "Email"("address");
-- CreateIndex
CREATE INDEX "Feed_displayName_idx" ON "Feed"("displayName");
-- CreateIndex
CREATE INDEX "Feed_description_idx" ON "Feed"("description");
-- CreateIndex
CREATE INDEX "Feed_bot_idx" ON "Feed"("bot");
-- CreateIndex
CREATE INDEX "Feed_locked_idx" ON "Feed"("locked");
-- CreateIndex
CREATE INDEX "Feed_lastStatusAt_idx" ON "Feed"("lastStatusAt");
-- CreateIndex
CREATE INDEX "Feed_createdAt_idx" ON "Feed"("createdAt");
-- CreateIndex
CREATE INDEX "Feed_refreshedAt_idx" ON "Feed"("refreshedAt");
-- CreateIndex
CREATE INDEX "Feed_parentFeedName_parentFeedDomain_idx" ON "Feed"("parentFeedName", "parentFeedDomain");
-- CreateIndex
CREATE INDEX "Feed_type_idx" ON "Feed"("type");
-- CreateIndex
CREATE INDEX "Feed_fulltext_idx" ON "Feed"("fulltext");
-- CreateIndex
CREATE UNIQUE INDEX "Feed_name_nodeId_key" ON "Feed"("name", "nodeId");
-- CreateIndex
CREATE INDEX "Field_name_idx" ON "Field"("name");
-- CreateIndex
CREATE INDEX "Field_value_idx" ON "Field"("value");
-- CreateIndex
CREATE UNIQUE INDEX "Node_domain_key" ON "Node"("domain");
-- CreateIndex
CREATE INDEX "Node_softwareName_idx" ON "Node"("softwareName");
-- CreateIndex
CREATE INDEX "Node_softwareVersion_idx" ON "Node"("softwareVersion");
-- CreateIndex
CREATE INDEX "Node_totalUserCount_idx" ON "Node"("totalUserCount");
-- CreateIndex
CREATE INDEX "Node_monthActiveUserCount_idx" ON "Node"("monthActiveUserCount");
-- CreateIndex
CREATE INDEX "Node_halfYearActiveUserCount_idx" ON "Node"("halfYearActiveUserCount");
-- CreateIndex
CREATE INDEX "Node_statusesCount_idx" ON "Node"("statusesCount");
-- CreateIndex
CREATE INDEX "Node_openRegistrations_idx" ON "Node"("openRegistrations");
-- CreateIndex
CREATE INDEX "Node_refreshedAt_idx" ON "Node"("refreshedAt");
-- CreateIndex
CREATE INDEX "Node_refreshAttemptedAt_idx" ON "Node"("refreshAttemptedAt");
-- CreateIndex
CREATE INDEX "Node_foundAt_idx" ON "Node"("foundAt");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- AddForeignKey
ALTER TABLE "Email" ADD CONSTRAINT "Email_feedId_fkey" FOREIGN KEY ("feedId") REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedToTag" ADD CONSTRAINT "FeedToTag_feedId_fkey" FOREIGN KEY ("feedId") REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedToTag" ADD CONSTRAINT "FeedToTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Field" ADD CONSTRAINT "Field_feedId_fkey" FOREIGN KEY ("feedId") REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Feed" ADD CONSTRAINT "Feed_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "Node"("id") ON DELETE CASCADE ON UPDATE CASCADE;

Wyświetl plik

@ -5,7 +5,7 @@ datasource db {
generator client {
provider = "prisma-client-js"
previewFeatures = ["extendedIndexes","fullTextSearch"]
previewFeatures = ["extendedIndexes","fullTextSearch","referentialActions"]
}
model Tag {
@ -17,16 +17,16 @@ model Tag {
model Email {
id String @id @default(uuid()) @db.Uuid
address String
feed Feed @relation(fields: [feedId], references: [id])
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
feedId String @db.Uuid
@@index([address])
}
model FeedToTag {
feed Feed @relation(fields: [feedId], references: [id])
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
feedId String @db.Uuid
tag Tag @relation(fields: [tagId], references: [id])
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId String @db.Uuid
@@id([feedId, tagId])
@ -36,7 +36,7 @@ model Field {
id String @id @default(uuid()) @db.Uuid
name String
value String
feed Feed @relation(fields: [feedId], references: [id])
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
feedId String @db.Uuid
@@index([name])
@ -50,7 +50,7 @@ enum FeedType{
model Feed {
id String @id @default(uuid()) @db.Uuid
node Node @relation(fields: [nodeId], references: [id])
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
nodeId String @db.Uuid
foundAt DateTime @default(now())
refreshedAt DateTime @updatedAt

Wyświetl plik

@ -0,0 +1,5 @@
export class NoSupportedLinkError extends Error {
public constructor (domain:string) {
super(`No supported link node info link for ${domain}`)
}
}

Wyświetl plik

@ -1,11 +1,12 @@
import { retrieveWellKnown } from './retrieveWellKnown'
import { retrieveNodeInfo, NodeInfo } from './retrieveNodeInfo'
import { NoSupportedLinkError } from './NoSupportedLinkError'
export const retrieveDomainNodeInfo = async (domain:string):Promise<NodeInfo> => {
const wellKnown = await retrieveWellKnown(domain)
const link = wellKnown.links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0')
if (typeof link === 'undefined') {
throw new Error(`No supported link node info link for ${domain}`)
throw new NoSupportedLinkError(domain)
}
return await retrieveNodeInfo(link.href)
}

Wyświetl plik

@ -1,6 +1,9 @@
import { Provider } from '../Provider'
import MastodonProvider from '../Mastodon'
/**
* Ecko is Mastodon's fork
*/
const EckoProvider: Provider = {
getKey: () => 'ecko',
getNodeProviders: MastodonProvider.getNodeProviders,

Wyświetl plik

@ -1,6 +1,6 @@
import { FeedData } from './FeedData'
import { FeedProviderMethod } from './FeedProviderMethod'
export interface FeedProvider {
getKey: ()=>string
retrieveFeeds: (domain:string, page:number)=> Promise<FeedData[]>
retrieveFeeds: FeedProviderMethod
}

Wyświetl plik

@ -0,0 +1,3 @@
import { FeedData } from './FeedData'
export type FeedProviderMethod = (domain: string, page: number) => Promise<FeedData[]>

Wyświetl plik

@ -1,6 +1,9 @@
import { Provider } from '../Provider'
import MastodonProvider from '../Mastodon'
/**
* Hometown is Mastodon's fork
*/
const HometownProvider: Provider = {
getKey: () => 'hometown',
getNodeProviders: MastodonProvider.getNodeProviders,

Wyświetl plik

@ -1,8 +1,9 @@
import axios from 'axios'
import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { FeedData } from '../FeedData'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { FeedProviderMethod } from '../FeedProviderMethod'
import { NoMoreFeedsError } from '../NoMoreFeedsError'
const limit = 500
@ -49,49 +50,45 @@ const replaceEmojis = (text: string, emojis: Emoji[]): string => {
return text
}
export const retrieveLocalPublicUsersPage = async (domain: string, page: number): Promise<FeedData[]> => {
try {
const response = await axios.get('https://' + domain + '/api/v1/directory', {
params: {
limit: limit,
offset: page * limit,
local: true
},
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new Error('No more users')
}
return responseData.map(
item => {
return {
name: item.username,
displayName: replaceEmojis(item.display_name, item.emojis),
description: replaceEmojis(item.note, item.emojis),
followersCount: item.followers_count,
followingCount: item.following_count,
statusesCount: item.statuses_count,
bot: item.bot,
url: item.url,
avatar: item.avatar,
locked: item.locked,
lastStatusAt: item.last_status_at !== null ? new Date(item.last_status_at) : null,
createdAt: new Date(item.created_at),
fields: item.fields.map(field => {
return {
name: replaceEmojis(field.name, item.emojis),
value: replaceEmojis(field.value, item.emojis),
verifiedAt: field.verified_at !== null ? new Date(field.verified_at) : null
}
}),
type: 'account',
parentFeed: null
}
}
)
} catch (error) {
throw new Error('Invalid response: ' + error)
export const retrieveLocalPublicUsersPage: FeedProviderMethod = async (domain, page) => {
const response = await axios.get('https://' + domain + '/api/v1/directory', {
params: {
limit: limit,
offset: page * limit,
local: true
},
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new NoMoreFeedsError('user')
}
return responseData.map(
item => {
return {
name: item.username,
displayName: replaceEmojis(item.display_name, item.emojis),
description: replaceEmojis(item.note, item.emojis),
followersCount: item.followers_count,
followingCount: item.following_count,
statusesCount: item.statuses_count,
bot: item.bot,
url: item.url,
avatar: item.avatar,
locked: item.locked,
lastStatusAt: item.last_status_at !== null ? new Date(item.last_status_at) : null,
createdAt: new Date(item.created_at),
fields: item.fields.map(field => {
return {
name: replaceEmojis(field.name, item.emojis),
value: replaceEmojis(field.value, item.emojis),
verifiedAt: field.verified_at !== null ? new Date(field.verified_at) : null
}
}),
type: 'account',
parentFeed: null
}
}
)
}

Wyświetl plik

@ -2,22 +2,20 @@ import axios from 'axios'
import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { NodeProviderMethod } from '../NodeProviderMethod'
import { NoMoreNodesError } from '../NoMoreNodesError'
const schema = z.array(
z.string()
)
export const retrievePeers = async (domain:string, page:number):Promise<string[]> => {
export const retrievePeers:NodeProviderMethod = async (domain, page) => {
if (page !== 0) {
throw new Error('No more peer pages')
}
try {
const response = await axios.get('https://' + domain + '/api/v1/instance/peers', {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
return schema.parse(response.data)
} catch (error) {
throw new Error('Invalid response')
throw new NoMoreNodesError('peer')
}
const response = await axios.get('https://' + domain + '/api/v1/instance/peers', {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
return schema.parse(response.data)
}

Wyświetl plik

@ -2,6 +2,8 @@ import axios from 'axios'
import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { NodeProviderMethod } from '../NodeProviderMethod'
import { NoMoreNodesError } from '../NoMoreNodesError'
const limit = 100
@ -11,33 +13,29 @@ const schema = z.array(
})
)
export const retrieveInstancesPage = async (domain: string, page: number): Promise<string[]> => {
try {
const response = await axios.post('https://' + domain + '/api/federation/instances', {
host: null,
blocked: null,
notResponding: null,
suspended: null,
federating: null,
subscribing: null,
publishing: null,
limit: limit,
offset: page * limit,
sort: '+id'
}, {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new Error('No more instances')
}
return responseData.map(
item => {
return item.host
}
)
} catch (error) {
throw new Error('Invalid response: ' + error)
export const retrieveInstancesPage:NodeProviderMethod = async (domain, page) => {
const response = await axios.post('https://' + domain + '/api/federation/instances', {
host: null,
blocked: null,
notResponding: null,
suspended: null,
federating: null,
subscribing: null,
publishing: null,
limit: limit,
offset: page * limit,
sort: '+id'
}, {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new NoMoreNodesError('instance')
}
return responseData.map(
item => {
return item.host
}
)
}

Wyświetl plik

@ -3,6 +3,9 @@ import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { FeedData } from '../FeedData'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { NodeProviderMethod } from '../NodeProviderMethod'
import { NoMoreFeedsError } from '../NoMoreFeedsError'
import { FeedProviderMethod } from '../FeedProviderMethod'
const limit = 100
@ -60,57 +63,53 @@ const parseDescription = (description:string|null):string => {
}).join('\n')
}
export const retrieveUsersPage = async (domain: string, page: number): Promise<FeedData[]> => {
try {
const response = await axios.post('https://' + domain + '/api/users', {
state: 'all',
origin: 'local',
sort: '+createdAt',
limit: limit,
offset: limit * page
}, {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new Error('No more users')
}
return responseData.map(
item => {
return {
name: item.username,
displayName: replaceEmojis(item.name ?? item.username, item.emojis),
description: replaceEmojis(parseDescription(item.description ?? ''), item.emojis),
followersCount: item.followersCount,
followingCount: item.followingCount,
statusesCount: item.notesCount,
bot: item.isBot,
url: `https://${domain}/@${item.username}`,
avatar: item.avatarUrl,
locked: item.isLocked,
lastStatusAt: item.updatedAt !== null ? new Date(item.updatedAt) : null,
createdAt: new Date(item.createdAt),
fields: [
...item.fields.map(field => {
return {
name: replaceEmojis(field.name, item.emojis),
value: replaceEmojis(field.value, item.emojis),
verifiedAt: null
}
}),
...[
{ name: 'Location', value: item.location, verifiedAt: null },
{ name: 'Birthday', value: item.birthday, verifiedAt: null },
{ name: 'Language', value: item.lang, verifiedAt: null }
].filter(field => field.value !== null)
],
type: 'account',
parentFeed: null
}
}
)
} catch (error) {
throw new Error('Invalid response: ' + error)
export const retrieveUsersPage:FeedProviderMethod = async (domain, page) => {
const response = await axios.post('https://' + domain + '/api/users', {
state: 'all',
origin: 'local',
sort: '+createdAt',
limit: limit,
offset: limit * page
}, {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new NoMoreFeedsError('user')
}
return responseData.map(
item => {
return {
name: item.username,
displayName: replaceEmojis(item.name ?? item.username, item.emojis),
description: replaceEmojis(parseDescription(item.description ?? ''), item.emojis),
followersCount: item.followersCount,
followingCount: item.followingCount,
statusesCount: item.notesCount,
bot: item.isBot,
url: `https://${domain}/@${item.username}`,
avatar: item.avatarUrl,
locked: item.isLocked,
lastStatusAt: item.updatedAt !== null ? new Date(item.updatedAt) : null,
createdAt: new Date(item.createdAt),
fields: [
...item.fields.map(field => {
return {
name: replaceEmojis(field.name, item.emojis),
value: replaceEmojis(field.value, item.emojis),
verifiedAt: null
}
}),
...[
{ name: 'Location', value: item.location, verifiedAt: null },
{ name: 'Birthday', value: item.birthday, verifiedAt: null },
{ name: 'Language', value: item.lang, verifiedAt: null }
].filter(field => field.value !== null)
],
type: 'account',
parentFeed: null
}
}
)
}

Wyświetl plik

@ -0,0 +1,5 @@
export class NoMoreFeedsError extends Error {
public constructor (feedType:string) {
super(`No more feeds of type ${feedType}`)
}
}

Wyświetl plik

@ -0,0 +1,5 @@
export class NoMoreNodesError extends Error {
public constructor (nodeType:string) {
super(`No more nodes of type ${nodeType}`)
}
}

Wyświetl plik

@ -1,4 +1,6 @@
import { NodeProviderMethod } from './NodeProviderMethod'
export interface NodeProvider {
getKey:()=>string,
retrieveNodes: (domain: string, page:number)=> Promise<string[]>
retrieveNodes: NodeProviderMethod
}

Wyświetl plik

@ -0,0 +1,2 @@
export type NodeProviderMethod = (domain: string, page:number)=> Promise<string[]>

Wyświetl plik

@ -6,6 +6,8 @@ import { avatarSchema } from './Avatar'
import { parseAvatarUrl } from './parseAvatarUrl'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { parseDescription } from './parseDescription'
import { NoMoreFeedsError } from '../NoMoreFeedsError'
import { FeedProviderMethod } from '../FeedProviderMethod'
const limit = 100
@ -27,7 +29,7 @@ const schema = z.object({
)
})
export const retrieveAccounts = async (domain: string, page: number): Promise<FeedData[]> => {
export const retrieveAccounts:FeedProviderMethod = async (domain, page) => {
const response = await axios.get(`https://${domain}/api/v1/accounts`, {
params: {
count: limit,
@ -39,7 +41,7 @@ export const retrieveAccounts = async (domain: string, page: number): Promise<Fe
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.data.length === 0) {
throw new Error('No more accounts')
throw new NoMoreFeedsError('account')
}
return responseData.data
.filter(item => item.host === domain)

Wyświetl plik

@ -2,6 +2,8 @@ import axios from 'axios'
import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { NodeProviderMethod } from '../NodeProviderMethod'
import { NoMoreNodesError } from '../NoMoreNodesError'
const limit = 100
@ -19,7 +21,7 @@ const schema = z.object({
)
})
export const retrieveFollowers = async (domain: string, page: number): Promise<string[]> => {
export const retrieveFollowers:NodeProviderMethod = async (domain, page) => {
const response = await axios.get(`https://${domain}/api/v1/server/followers`, {
params: {
count: limit,
@ -36,7 +38,7 @@ export const retrieveFollowers = async (domain: string, page: number): Promise<s
hosts.add(item.following.host)
})
if (hosts.size === 0) {
throw new Error('No more followers')
throw new NoMoreNodesError('follower')
}
return Array.from(hosts)
}

Wyświetl plik

@ -7,6 +7,8 @@ import { avatarSchema } from './Avatar'
import { parseAvatarUrl } from './parseAvatarUrl'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
import { parseDescription } from './parseDescription'
import { FeedProviderMethod } from '../FeedProviderMethod'
import { NoMoreFeedsError } from '../NoMoreFeedsError'
const limit = 100
@ -34,7 +36,7 @@ const schema = z.object({
)
})
export const retrieveVideoChannels = async (domain: string, page: number): Promise<FeedData[]> => {
export const retrieveVideoChannels:FeedProviderMethod = async (domain, page) => {
const response = await axios.get(`https://${domain}/api/v1/video-channels`, {
params: {
count: limit,
@ -46,7 +48,7 @@ export const retrieveVideoChannels = async (domain: string, page: number): Promi
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.data.length === 0) {
throw new Error('No more channels')
throw new NoMoreFeedsError('channel')
}
return responseData.data
.filter(item => item.host === domain)

Wyświetl plik

@ -1,19 +1,13 @@
import { Provider } from '../Provider'
import { retrievePeers } from './retrievePeers'
import { retrieveLocalPublicUsersPage } from './retrieveLocalPublicUsersPage'
import { NodeProvider } from '../NodeProvider'
import { FeedProvider } from '../FeedProvider'
import MastodonProvider from '../Mastodon'
/**
* Pleroma implements Mastodon's api
*/
const PleromaProvider: Provider = {
getKey: () => 'pleroma',
getNodeProviders: ():NodeProvider[] => [{
getKey: () => 'peers',
retrieveNodes: retrievePeers
}],
getFeedProviders: ():FeedProvider[] => [{
getKey: () => 'users',
retrieveFeeds: retrieveLocalPublicUsersPage
}]
getNodeProviders: MastodonProvider.getNodeProviders,
getFeedProviders: MastodonProvider.getFeedProviders
}
export default PleromaProvider

Wyświetl plik

@ -1,100 +0,0 @@
import axios from 'axios'
import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { FeedData } from '../FeedData'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
const limit = 500
const emojiSchema = z.object({
shortcode: z.string(),
url: z.string()
})
const schema = z.array(
z.object({
id: z.string(),
username: z.string(),
display_name: z.string(),
locked: z.boolean(),
bot: z.boolean(),
created_at: z.string(),
note: z.string(),
url: z.string(),
avatar: z.string(),
followers_count: z.number(),
following_count: z.number(),
statuses_count: z.number(),
last_status_at: z.nullable(z.string()),
emojis: z.array(emojiSchema),
fields: z.array(
z.object({
name: z.string(),
value: z.string()
})
),
pleroma: z.object({
hide_followers_count: z.boolean(),
hide_follows_count: z.boolean()
})
})
)
type Emoji = z.infer<typeof emojiSchema>
const replaceEmojis = (text: string, emojis: Emoji[]): string => {
emojis.forEach(emoji => {
text = text.replace(
RegExp(`:${emoji.shortcode}:`, 'gi'),
`<img draggable="false" class="emoji" title="${emoji.shortcode}" alt="${emoji.shortcode}" src="${emoji.url}" />`
)
})
return text
}
export const retrieveLocalPublicUsersPage = async (domain: string, page: number): Promise<FeedData[]> => {
try {
const response = await axios.get('https://' + domain + '/api/v1/directory', {
params: {
limit: limit,
offset: page * limit,
local: true
},
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
const responseData = schema.parse(response.data)
if (responseData.length === 0) {
throw new Error('No more users')
}
return responseData.map(
item => {
return {
name: item.username,
displayName: replaceEmojis(item.display_name, item.emojis),
description: replaceEmojis(item.note, item.emojis),
followersCount: item.pleroma.hide_followers_count ? null : item.followers_count,
followingCount: item.pleroma.hide_follows_count ? null : item.following_count,
statusesCount: item.statuses_count,
bot: item.bot,
url: item.url,
avatar: item.avatar,
locked: item.locked,
lastStatusAt: item.last_status_at !== null ? new Date(item.last_status_at) : null,
createdAt: new Date(item.created_at),
fields: item.fields.map(field => {
return {
name: replaceEmojis(field.name, item.emojis),
value: replaceEmojis(field.value, item.emojis),
verifiedAt: null
}
}),
type: 'account',
parentFeed: null
}
}
)
} catch (error) {
throw new Error('Invalid response: ' + error)
}
}

Wyświetl plik

@ -1,23 +0,0 @@
import axios from 'axios'
import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse'
import { z } from 'zod'
import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds'
const schema = z.array(
z.string()
)
export const retrievePeers = async (domain:string, page:number):Promise<string[]> => {
if (page !== 0) {
throw new Error('No more peer pages')
}
try {
const response = await axios.get('https://' + domain + '/api/v1/instance/peers', {
timeout: getDefaultTimeoutMilliseconds()
})
assertSuccessJsonResponse(response)
return schema.parse(response.data)
} catch (error) {
throw new Error('Invalid response')
}
}

Wyświetl plik

@ -0,0 +1,12 @@
export class ProviderKeyAlreadyRegisteredError extends Error {
private readonly _key:string
public constructor (key:string) {
super(`Provider with the key ${key} is already registered`)
this._key = key
}
public get key (): string {
return this._key
}
}

Wyświetl plik

@ -1,5 +1,6 @@
import { Provider } from './Provider'
import { Dictionary } from 'typescript-collections'
import { ProviderKeyAlreadyRegisteredError } from './ProviderKeyAlreadyRegisteredError'
export interface ProviderCallback {
(key: string, provider: Provider): void
@ -10,7 +11,7 @@ const providers: Dictionary<string, Provider> = new Dictionary<string, Provider>
const registerProvider = (provider: Provider): void => {
const key = provider.getKey()
if (providers.containsKey(key)) {
throw new Error(`Provider with the key ${key} is already registered`)
throw new ProviderKeyAlreadyRegisteredError(key)
}
providers.setValue(key, provider)
console.info('Added provider to registry', { key: key })

Wyświetl plik

@ -0,0 +1,20 @@
import { UnexpectedResponseError } from './UnexpectedResponseError'
export class UnexpectedContentTypeError extends UnexpectedResponseError {
private readonly _expectedContentType: string
private readonly _actualContentType: string
public constructor (actualContentType: string, expectedContentType:string) {
super(`Expected content type '${expectedContentType}' but got '${actualContentType}'`)
this._expectedContentType = expectedContentType
this._actualContentType = actualContentType
}
get expectedContentType (): string {
return this._expectedContentType
}
get actualContentType (): string {
return this._actualContentType
}
}

Wyświetl plik

@ -0,0 +1,3 @@
export class UnexpectedResponseError extends Error {
}

Wyświetl plik

@ -0,0 +1,20 @@
import { UnexpectedResponseError } from './UnexpectedResponseError'
export class UnexpectedResponseStatusError extends UnexpectedResponseError {
private readonly _expectedStatusCode: number
private readonly _actualStatusCode: number
public constructor (expectedStatusCode:number, actualStatusCode:number) {
super(`Expected response code ${expectedStatusCode} but got ${actualStatusCode}`)
this._actualStatusCode = actualStatusCode
this._expectedStatusCode = expectedStatusCode
}
get expectedStatusCode (): number {
return this._expectedStatusCode
}
get actualStatusCode (): number {
return this._actualStatusCode
}
}

Wyświetl plik

@ -1,10 +1,16 @@
import { AxiosResponse } from 'axios'
import { UnexpectedResponseStatusError } from './UnexpectedResponseStatusError'
import { UnexpectedContentTypeError } from './UnexpectedContentTypeError'
export const assertSuccessJsonResponse = (response:AxiosResponse<unknown>):void => {
if (response.status !== 200) {
throw new Error('Unexpected response ' + response.status)
export const assertSuccessJsonResponse = (response: AxiosResponse<unknown>): void => {
const expectedStatus = 200
const actualStatus = response.status
if (actualStatus !== expectedStatus) {
throw new UnexpectedResponseStatusError(expectedStatus, actualStatus)
}
if (!response.headers['content-type'].startsWith('application/json')) {
throw new Error('Unexpected content-type ' + response.headers['content-type'])
const expectedContentType = 'application/json'
const actualContentType = response.headers['content-type']
if (!actualContentType.startsWith(expectedContentType)) {
throw new UnexpectedContentTypeError(expectedContentType, actualContentType)
}
}

Wyświetl plik

@ -8,6 +8,7 @@ import { findNewNodes } from './Nodes/findNewNodes'
import { NodeProvider } from '../Fediverse/Providers/NodeProvider'
import { FeedProvider } from '../Fediverse/Providers/FeedProvider'
import { refreshFeeds } from './Feeds/refreshFeeds'
import { deleteOldFeeds } from '../Storage/Feeds/deleteOldFeeds'
export const processNextNode = async (prisma:PrismaClient, providerRegistry:ProviderRegistry):Promise<void> => {
console.info('#############################################')
@ -18,6 +19,7 @@ export const processNextNode = async (prisma:PrismaClient, providerRegistry:Prov
if (!providerRegistry.containsKey(node.softwareName)) {
console.warn('Unknown software', { domain: node.domain, software: node.softwareName })
await deleteOldFeeds(prisma, node)
await setNodeRefreshed(prisma, node)
return
}
@ -37,5 +39,7 @@ export const processNextNode = async (prisma:PrismaClient, providerRegistry:Prov
})
)
await deleteOldFeeds(prisma, node)
await setNodeRefreshed(prisma, node)
}

Wyświetl plik

@ -0,0 +1,18 @@
import { Node, PrismaClient } from '@prisma/client'
export const deleteOldFeeds = async (prisma: PrismaClient, node: Node): Promise<number> => {
const result = await prisma.feed.deleteMany({
where: {
nodeId: {
equals: node.id
},
refreshedAt: {
lt: node.refreshAttemptedAt
}
}
})
console.info('Deleted old feeds', {
count: result.count, olderThen: node.refreshAttemptedAt, nodeDomain: node.domain
})
return result.count
}

Wyświetl plik

@ -0,0 +1,5 @@
export class NoNodeFoundError extends Error {
public constructor () {
super('No node found')
}
}

Wyświetl plik

@ -1,4 +1,5 @@
import { Node, PrismaClient } from '@prisma/client'
import { NoNodeFoundError } from './NoNodeFoundError'
export const fetchNodeToProcess = async (prisma: PrismaClient): Promise<Node> => {
const currentTimestamp = Date.now()
@ -56,10 +57,10 @@ export const fetchNodeToProcess = async (prisma: PrismaClient): Promise<Node> =>
]
}
})
if (node) {
console.log('Found oldest node', { domain: node.domain })
} else {
throw new Error('No node found')
if (!node) {
throw new NoNodeFoundError()
}
console.log('Found oldest node', { domain: node.domain })
return node
}