⚠ feat: Mastodon API with Megalodon

This commit is contained in:
fly_mc 2024-11-20 12:45:51 +08:00
parent 75fd0bb701
commit b533f533c3
245 changed files with 25855 additions and 438 deletions

View file

@ -134,6 +134,7 @@
"json5": "2.2.3",
"jsonld": "8.3.2",
"jsrsasign": "11.1.0",
"megalodon": "workspace:*",
"meilisearch": "0.45.0",
"juice": "11.0.0",
"mfm-js": "0.24.0",

View file

@ -486,4 +486,224 @@ export class MfmService {
return serialized;
}
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
@bindThis
public async toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) {
if (nodes == null) {
return null;
}
const { happyDOM, window } = new Window();
const doc = window.document;
const body = doc.createElement('p');
async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise<void> {
if (children) {
for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x)))) targetElement.appendChild(child);
}
}
const handlers: {
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
} = {
async bold(node) {
const el = doc.createElement('span');
el.textContent = '**';
await appendChildren(node.children, el);
el.textContent += '**';
return el;
},
async small(node) {
const el = doc.createElement('small');
await appendChildren(node.children, el);
return el;
},
async strike(node) {
const el = doc.createElement('span');
el.textContent = '~~';
await appendChildren(node.children, el);
el.textContent += '~~';
return el;
},
async italic(node) {
const el = doc.createElement('span');
el.textContent = '*';
await appendChildren(node.children, el);
el.textContent += '*';
return el;
},
async fn(node) {
const el = doc.createElement('span');
el.textContent = '*';
await appendChildren(node.children, el);
el.textContent += '*';
return el;
},
blockCode(node) {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
const nodes = node.props.code
.split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
}
pre.appendChild(inner);
return pre;
},
async center(node) {
const el = doc.createElement('div');
await appendChildren(node.children, el);
return el;
},
emojiCode(node) {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji(node) {
return doc.createTextNode(node.props.emoji);
},
hashtag: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
a.setAttribute('class', 'hashtag');
return a;
},
inlineCode(node) {
const el = doc.createElement('code');
el.textContent = node.props.code;
return el;
},
mathInline(node) {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
mathBlock(node) {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
async link(node) {
const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url);
await appendChildren(node.children, a);
return a;
},
async mention(node) {
const { username, host, acct } = node.props;
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
const el = doc.createElement('span');
if (!resolved) {
el.textContent = acct;
} else {
el.setAttribute('class', 'h-card');
el.setAttribute('translate', 'no');
const a = doc.createElement('a');
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
a.className = 'u-url mention';
const span = doc.createElement('span');
span.textContent = resolved.username || username;
a.textContent = '@';
a.appendChild(span);
el.appendChild(a);
}
return el;
},
async quote(node) {
const el = doc.createElement('blockquote');
await appendChildren(node.children, el);
return el;
},
text(node) {
const el = doc.createElement('span');
const nodes = node.props.text
.split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
}
return el;
},
url(node) {
const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url);
a.textContent = node.props.url.replace(/^https?:\/\//, '');
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
return a;
},
async plain(node) {
const el = doc.createElement('span');
await appendChildren(node.children, el);
return el;
},
};
await appendChildren(nodes, body);
if (quoteUri !== null) {
const a = doc.createElement('a');
a.setAttribute('href', quoteUri);
a.textContent = quoteUri.replace(/^https?:\/\//, '');
const quote = doc.createElement('span');
quote.setAttribute('class', 'quote-inline');
quote.appendChild(doc.createElement('br'));
quote.appendChild(doc.createElement('br'));
quote.innerHTML += 'RE: ';
quote.appendChild(a);
body.appendChild(quote);
}
let result = new XMLSerializer().serializeToString(body);
if (inline) {
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
}
happyDOM.close().catch(e => {});
return result;
}
}

View file

@ -456,6 +456,8 @@ export class UserEntityService implements OnModuleInit {
}
}
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
const followingCount = profile == null ? null :
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
(profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount :
@ -558,7 +560,7 @@ export class UserEntityService implements OnModuleInit {
isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended,
description: profile!.description,
description: mastoapi ? mastoapi.description : profile ? profile.description : '',
location: profile!.location,
birthday: profile!.birthday,
lang: profile!.lang,

View file

@ -25,6 +25,7 @@ import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { MastoConverters } from './api/mastodon/converters.js';
import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
@ -43,6 +44,7 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
@ -93,7 +95,9 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ServerStatsChannelService,
UserListChannelService,
OpenApiServerService,
MastodonApiServerService,
OAuth2ProviderService,
MastoConverters,
],
exports: [
ServerService,

View file

@ -30,6 +30,7 @@ import { FileServerService } from './FileServerService.js';
import { HealthServerService } from './HealthServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { makeHstsHook } from './hsts.js';
@ -59,6 +60,7 @@ export class ServerService implements OnApplicationShutdown {
private userEntityService: UserEntityService,
private apiServerService: ApiServerService,
private openApiServerService: OpenApiServerService,
private mastodonApiServerService: MastodonApiServerService,
private streamingApiServerService: StreamingApiServerService,
private activityPubServerService: ActivityPubServerService,
private wellKnownServerService: WellKnownServerService,
@ -104,12 +106,13 @@ export class ServerService implements OnApplicationShutdown {
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
fastify.register(this.openApiServerService.createServer);
fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' });
fastify.register(this.fileServerService.createServer);
fastify.register(this.activityPubServerService.createServer);
fastify.register(this.nodeinfoServerService.createServer);
fastify.register(this.wellKnownServerService.createServer);
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
//fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
fastify.register(this.healthServerService.createServer, { prefix: '/healthz' });
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {

View file

@ -0,0 +1,911 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import megalodon, { Entity, MegalodonInterface } from 'megalodon';
import querystring from 'querystring';
import { IsNull } from 'typeorm';
import multer from 'fastify-multer';
import type { AccessTokensRepository, NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js';
import { getInstance } from './endpoints/meta.js';
import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveService } from '@/core/DriveService.js';
export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
const accessTokenArr = authorization?.split(' ') ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
return client;
}
@Injectable()
export class MastodonApiServerService {
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.noteEditRepository)
private noteEditRepository: NoteEditRepository,
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
@Inject(DI.config)
private config: Config,
private userEntityService: UserEntityService,
private driveService: DriveService,
private mastoConverter: MastoConverters,
) { }
@bindThis
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
const upload = multer({
storage: multer.diskStorage({}),
limits: {
fileSize: this.config.maxFileSize || 262144000,
files: 1,
},
});
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Access-Control-Allow-Origin', '*');
done();
});
fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
});
payload.on('end', () => {
try {
const parsed = querystring.parse(body);
done(null, parsed);
} catch (e: any) {
done(e);
}
});
payload.on('error', done);
});
fastify.register(multer.contentParser);
fastify.get('/v1/custom_emojis', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
reply.send(data.data);
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/instance', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstance();
const admin = await this.usersRepository.findOne({
where: {
host: IsNull(),
isRoot: true,
isDeleted: false,
isSuspended: false,
},
order: { id: 'ASC' },
});
const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data);
reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/announcements', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
reply.send(data.data.map((announcement) => convertAnnouncement(announcement)));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.dismissInstanceAnnouncement(
_request.body['id'],
);
reply.send(data.data);
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
},
);
fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await _request.file;
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
}
const data = await client.uploadMedia(multipartData);
reply.send(convertAttachment(data.data as Entity.Attachment));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await _request.file;
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
}
const data = await client.uploadMedia(multipartData, _request.body!);
reply.send(convertAttachment(data.data as Entity.Attachment));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/filters', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getFilters();
reply.send(data.data.map((filter) => convertFilter(filter)));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/trends', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstanceTrends();
reply.send(data.data);
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/trends/tags', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstanceTrends();
reply.send(data.data);
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/trends/links', async (_request, reply) => {
// As we do not have any system for news/links this will just return empty
reply.send([]);
});
fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await ApiAuthMastodon(_request, client);
reply.send(data);
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/preferences', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getPreferences();
reply.send(data.data);
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
//#region Accounts
fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.verifyCredentials());
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.patch('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
// Check if there is an Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it.
if (_request.files.length > 0 && accessTokens) {
const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const avatar = (_request.files as any).find((obj: any) => {
return obj.fieldname === 'avatar';
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const header = (_request.files as any).find((obj: any) => {
return obj.fieldname === 'header';
});
if (tokeninfo && avatar) {
const upload = await this.driveService.addFile({
user: { id: tokeninfo.userId, host: null },
path: avatar.path,
name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined,
sensitive: false,
});
if (upload.type.startsWith('image/')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).avatar = upload.id;
}
} else if (tokeninfo && header) {
const upload = await this.driveService.addFile({
user: { id: tokeninfo.userId, host: null },
path: header.path,
name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined,
sensitive: false,
});
if (upload.type.startsWith('image/')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).header = upload.id;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((_request.body as any).fields_attributes) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fields = (_request.body as any).fields_attributes.map((field: any) => {
if (!(field.name.trim() === '' && field.value.trim() === '')) {
if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty');
if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty');
}
return {
...field,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0);
}
const data = await client.updateCredentials(_request.body!);
reply.send(await this.mastoConverter.convertAccount(data.data));
} catch (e: any) {
//console.error(e);
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/accounts/lookup', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.search((_request.query as any).acct, { type: 'accounts' });
const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id });
data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) || [];
reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0]));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/accounts/relationships', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
let users;
try {
let ids = _request.query ? (_request.query as any)['id[]'] ?? (_request.query as any)['id'] : null;
if (typeof ids === 'string') {
ids = [ids];
}
users = ids;
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getRelationships(users));
} catch (e: any) {
/* console.error(e); */
const data = e.response.data;
data.users = users;
console.error(data);
reply.code(401).send(data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const sharkId = _request.params.id;
const data = await client.getAccount(sharkId);
const account = await this.mastoConverter.convertAccount(data.data);
reply.send(account);
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getStatuses());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFeaturedTags();
reply.send(data.data.map((tag) => convertFeaturedTag(tag)));
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getFollowers());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getFollowing());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountLists(_request.params.id);
reply.send(data.data.map((list) => convertList(list)));
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.addFollow());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.rmFollow());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.addBlock());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.rmBlock());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.addMute());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.rmMute());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/followed_tags', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowedTags();
reply.send(data.data);
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/bookmarks', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getBookmarks());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/favourites', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getFavourites());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/mutes', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getMutes());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/blocks', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.getBlocks());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/follow_requests', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit );
reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverter.convertAccount(account as Entity.Account))));
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.acceptFollow());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await account.rejectFollow());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
//#endregion
//#region Search
fastify.get('/v1/search', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await search.SearchV1());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v2/search', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await search.SearchV2());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v1/trends/statuses', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await search.getStatusTrends());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get('/v2/suggestions', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter);
reply.send(await search.getSuggestions());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
//#endregion
//#region Notifications
fastify.get('/v1/notifications', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const notify = new ApiNotifyMastodon(_request, client);
reply.send(await notify.getNotifications());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const notify = new ApiNotifyMastodon(_request, client);
reply.send(await notify.getNotification());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const notify = new ApiNotifyMastodon(_request, client);
reply.send(await notify.rmNotification());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const notify = new ApiNotifyMastodon(_request, client);
reply.send(await notify.rmNotifications());
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
reply.code(401).send(e.response.data);
}
});
//#endregion
//#region Filters
fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const filter = new ApiFilterMastodon(_request, client);
!_request.params.id ? reply.send(await filter.getFilters()) : reply.send(await filter.getFilter());
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const filter = new ApiFilterMastodon(_request, client);
reply.send(await filter.createFilter());
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
fastify.post<{ Params: { id: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const filter = new ApiFilterMastodon(_request, client);
reply.send(await filter.updateFilter());
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const filter = new ApiFilterMastodon(_request, client);
reply.send(await filter.rmFilter());
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
//#endregion
//#region Timelines
const TLEndpoint = new ApiTimelineMastodon(fastify, this.config, this.mastoConverter);
// GET Endpoints
TLEndpoint.getTL();
TLEndpoint.getHomeTl();
TLEndpoint.getListTL();
TLEndpoint.getTagTl();
TLEndpoint.getConversations();
TLEndpoint.getList();
TLEndpoint.getLists();
TLEndpoint.getListAccounts();
// POST Endpoints
TLEndpoint.createList();
TLEndpoint.addListAccount();
// PUT Endpoint
TLEndpoint.updateList();
// DELETE Endpoints
TLEndpoint.deleteList();
TLEndpoint.rmListAccount();
//#endregion
//#region Status
const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter);
// GET Endpoints
NoteEndpoint.getStatus();
NoteEndpoint.getStatusSource();
NoteEndpoint.getContext();
NoteEndpoint.getHistory();
NoteEndpoint.getReblogged();
NoteEndpoint.getFavourites();
NoteEndpoint.getMedia();
NoteEndpoint.getPoll();
//POST Endpoints
NoteEndpoint.postStatus();
NoteEndpoint.addFavourite();
NoteEndpoint.rmFavourite();
NoteEndpoint.reblogStatus();
NoteEndpoint.unreblogStatus();
NoteEndpoint.bookmarkStatus();
NoteEndpoint.unbookmarkStatus();
NoteEndpoint.pinStatus();
NoteEndpoint.unpinStatus();
NoteEndpoint.reactStatus();
NoteEndpoint.unreactStatus();
NoteEndpoint.votePoll();
// PUT Endpoint
fastify.put<{ Params: { id: string } }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateMedia(_request.params.id, _request.body!);
reply.send(convertAttachment(data.data));
} catch (e: any) {
/* console.error(e); */
reply.code(401).send(e.response.data);
}
});
NoteEndpoint.updateStatus();
// DELETE Endpoint
NoteEndpoint.deleteStatus();
//#endregion
done();
}
}

View file

@ -0,0 +1,359 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Entity } from 'megalodon';
import mfm from '@transfem-org/sfm-js';
import { DI } from '@/di-symbols.js';
import { MfmService } from '@/core/MfmService.js';
import type { Config } from '@/config.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js';
import type { NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { IdService } from '@/core/IdService.js';
import { GetterService } from '../GetterService.js';
export enum IdConvertType {
MastodonId,
SharkeyId,
}
export const escapeMFM = (text: string): string => text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#x60;')
.replace(/\r?\n/g, '<br>');
@Injectable()
export class MastoConverters {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.noteEditRepository)
private noteEditRepository: NoteEditRepository,
private mfmService: MfmService,
private getterService: GetterService,
private customEmojiService: CustomEmojiService,
private idService: IdService,
private driveFileEntityService: DriveFileEntityService,
) {
}
private encode(u: MiUser, m: IMentionedRemoteUsers): Entity.Mention {
let acct = u.username;
let acctUrl = `https://${u.host || this.config.host}/@${u.username}`;
let url: string | null = null;
if (u.host) {
const info = m.find(r => r.username === u.username && r.host === u.host);
acct = `${u.username}@${u.host}`;
acctUrl = `https://${u.host}/@${u.username}`;
if (info) url = info.url ?? info.uri;
}
return {
id: u.id,
username: u.username,
acct: acct,
url: url ?? acctUrl,
};
}
public fileType(s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' {
if (s === 'image/gif') {
return 'gifv';
}
if (s.includes('image')) {
return 'image';
}
if (s.includes('video')) {
return 'video';
}
if (s.includes('audio')) {
return 'audio';
}
return 'unknown';
}
public encodeFile(f: any): Entity.Attachment {
return {
id: f.id,
type: this.fileType(f.type),
url: f.url,
remote_url: f.url,
preview_url: f.thumbnailUrl,
text_url: f.url,
meta: {
width: f.properties.width,
height: f.properties.height,
},
description: f.comment ? f.comment : null,
blurhash: f.blurhash ? f.blurhash : null,
};
}
public async getUser(id: string): Promise<MiUser> {
return this.getterService.getUser(id).then(p => {
return p;
});
}
private async encodeField(f: Entity.Field): Promise<Entity.Field> {
return {
name: f.name,
value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value),
verified_at: null,
};
}
public async convertAccount(account: Entity.Account | MiUser) {
const user = await this.getUser(account.id);
const profile = await this.userProfilesRepository.findOneBy({ userId: user.id });
const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host);
const emoji: Entity.Emoji[] = [];
Object.entries(emojis).forEach(entry => {
const [key, value] = entry;
emoji.push({
shortcode: key,
static_url: value,
url: value,
visible_in_picker: true,
category: undefined,
});
});
const fqn = `${user.username}@${user.host ?? this.config.hostname}`;
let acct = user.username;
let acctUrl = `https://${user.host || this.config.host}/@${user.username}`;
const acctUri = `https://${this.config.host}/users/${user.id}`;
if (user.host) {
acct = `${user.username}@${user.host}`;
acctUrl = `https://${user.host}/@${user.username}`;
}
return awaitAll({
id: account.id,
username: user.username,
acct: acct,
fqn: fqn,
display_name: user.name ?? user.username,
locked: user.isLocked,
created_at: this.idService.parse(user.id).date.toISOString(),
followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0,
following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0,
statuses_count: user.notesCount,
note: profile?.description ?? '',
url: user.uri ?? acctUrl,
uri: user.uri ?? acctUri,
avatar: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
avatar_static: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
header: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
header_static: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
emojis: emoji,
moved: null, //FIXME
fields: Promise.all(profile?.fields.map(async p => this.encodeField(p)) ?? []),
bot: user.isBot,
discoverable: user.isExplorable,
});
}
public async getEdits(id: string) {
const note = await this.getterService.getNote(id);
if (!note) {
return {};
}
const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p));
const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } });
const history: Promise<any>[] = [];
let lastDate = this.idService.parse(note.id).date;
for (const edit of edits) {
const files = this.driveFileEntityService.packManyByIds(edit.fileIds);
const item = {
account: noteUser,
content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''),
created_at: lastDate.toISOString(),
emojis: [],
sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false),
spoiler_text: edit.cw ?? '',
poll: null,
media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []),
};
lastDate = edit.updatedAt;
history.push(awaitAll(item));
}
return await Promise.all(history);
}
private async convertReblog(status: Entity.Status | null): Promise<any> {
if (!status) return null;
return await this.convertStatus(status);
}
public async convertStatus(status: Entity.Status) {
const convertedAccount = this.convertAccount(status.account);
const note = await this.getterService.getNote(status.id);
const noteUser = await this.getUser(status.account.id);
const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host);
const emoji: Entity.Emoji[] = [];
Object.entries(emojis).forEach(entry => {
const [key, value] = entry;
emoji.push({
shortcode: key,
static_url: value,
url: value,
visible_in_picker: true,
category: undefined,
});
});
const mentions = Promise.all(note.mentions.map(p =>
this.getUser(p)
.then(u => this.encode(u, JSON.parse(note.mentionedRemoteUsers)))
.catch(() => null)))
.then(p => p.filter(m => m)) as Promise<Entity.Mention[]>;
const tags = note.tags.map(tag => {
return {
name: tag,
url: `${this.config.url}/tags/${tag}`,
} as Entity.Tag;
});
const isQuote = note.renoteId && note.text ? true : false;
const renote = note.renoteId ? this.getterService.getNote(note.renoteId) : null;
const quoteUri = Promise.resolve(renote).then(renote => {
if (!renote || !isQuote) return null;
return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`;
});
const content = note.text !== null
? quoteUri.then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri))
.then(p => p ?? escapeMFM(note.text!))
: '';
// noinspection ES6MissingAwait
return await awaitAll({
id: note.id,
uri: note.uri ?? `https://${this.config.host}/notes/${note.id}`,
url: note.url ?? note.uri ?? `https://${this.config.host}/notes/${note.id}`,
account: convertedAccount,
in_reply_to_id: note.replyId,
in_reply_to_account_id: note.replyUserId,
reblog: !isQuote ? await this.convertReblog(status.reblog) : null,
content: content,
content_type: 'text/x.misskeymarkdown',
text: note.text,
created_at: status.created_at,
emojis: emoji,
replies_count: note.repliesCount,
reblogs_count: note.renoteCount,
favourites_count: status.favourites_count,
reblogged: false,
favourited: status.favourited,
muted: status.muted,
sensitive: status.sensitive,
spoiler_text: note.cw ? note.cw : '',
visibility: status.visibility,
media_attachments: status.media_attachments,
mentions: mentions,
tags: tags,
card: null, //FIXME
poll: status.poll ?? null,
application: null, //FIXME
language: null, //FIXME
pinned: false,
reactions: status.emoji_reactions,
emoji_reactions: status.emoji_reactions,
bookmarked: false,
quote: isQuote ? await this.convertReblog(status.reblog) : null,
// optional chaining cannot be used, as it evaluates to undefined, not null
edited_at: note.updatedAt ? note.updatedAt.toISOString() : null,
});
}
}
function simpleConvert(data: any) {
// copy the object to bypass weird pass by reference bugs
const result = Object.assign({}, data);
return result;
}
export function convertAccount(account: Entity.Account) {
return simpleConvert(account);
}
export function convertAnnouncement(announcement: Entity.Announcement) {
return simpleConvert(announcement);
}
export function convertAttachment(attachment: Entity.Attachment) {
return simpleConvert(attachment);
}
export function convertFilter(filter: Entity.Filter) {
return simpleConvert(filter);
}
export function convertList(list: Entity.List) {
return simpleConvert(list);
}
export function convertFeaturedTag(tag: Entity.FeaturedTag) {
return simpleConvert(tag);
}
export function convertNotification(notification: Entity.Notification) {
notification.account = convertAccount(notification.account);
if (notification.status) notification.status = convertStatus(notification.status);
return notification;
}
export function convertPoll(poll: Entity.Poll) {
return simpleConvert(poll);
}
export function convertReaction(reaction: Entity.Reaction) {
if (reaction.accounts) {
reaction.accounts = reaction.accounts.map(convertAccount);
}
return reaction;
}
export function convertRelationship(relationship: Entity.Relationship) {
return simpleConvert(relationship);
}
export function convertStatus(status: Entity.Status) {
status.account = convertAccount(status.account);
status.media_attachments = status.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
if (status.poll) status.poll = convertPoll(status.poll);
if (status.reblog) status.reblog = convertStatus(status.reblog);
return status;
}
export function convertStatusSource(status: Entity.StatusSource) {
return simpleConvert(status);
}
export function convertConversation(conversation: Entity.Conversation) {
conversation.accounts = conversation.accounts.map(convertAccount);
if (conversation.last_status) {
conversation.last_status = convertStatus(conversation.last_status);
}
return conversation;
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiAuthMastodon } from './endpoints/auth.js';
import { ApiAccountMastodon } from './endpoints/account.js';
import { ApiSearchMastodon } from './endpoints/search.js';
import { ApiNotifyMastodon } from './endpoints/notifications.js';
import { ApiFilterMastodon } from './endpoints/filter.js';
import { ApiTimelineMastodon } from './endpoints/timeline.js';
import { ApiStatusMastodon } from './endpoints/status.js';
export {
ApiAccountMastodon,
ApiAuthMastodon,
ApiSearchMastodon,
ApiNotifyMastodon,
ApiFilterMastodon,
ApiTimelineMastodon,
ApiStatusMastodon,
};

View file

@ -0,0 +1,275 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MastoConverters, convertRelationship } from '../converters.js';
import { argsToBools, limitToInt } from './timeline.js';
import type { MegalodonInterface } from 'megalodon';
import type { FastifyRequest } from 'fastify';
import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Config } from '@/config.js';
import { Injectable } from '@nestjs/common';
const relationshipModel = {
id: '',
following: false,
followed_by: false,
delivery_following: false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
showing_reblogs: false,
endorsed: false,
notifying: false,
note: '',
};
@Injectable()
export class ApiAccountMastodon {
private request: FastifyRequest;
private client: MegalodonInterface;
private BASE_URL: string;
constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoconverter: MastoConverters) {
this.request = request;
this.client = client;
this.BASE_URL = BASE_URL;
}
public async verifyCredentials() {
try {
const data = await this.client.verifyAccountCredentials();
const acct = await this.mastoconverter.convertAccount(data.data);
const newAcct = Object.assign({}, acct, {
source: {
note: acct.note,
fields: acct.fields,
privacy: '',
sensitive: false,
language: '',
},
});
return newAcct;
} catch (e: any) {
/* console.error(e);
console.error(e.response.data); */
return e.response;
}
}
public async lookup() {
try {
const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' });
return this.mastoconverter.convertAccount(data.data.accounts[0]);
} catch (e: any) {
/* console.error(e)
console.error(e.response.data); */
return e.response;
}
}
public async getRelationships(users: [string]) {
try {
relationshipModel.id = users.toString() || '1';
if (!(users.length > 0)) {
return [relationshipModel];
}
const reqIds = [];
for (let i = 0; i < users.length; i++) {
reqIds.push(users[i]);
}
const data = await this.client.getRelationships(reqIds);
return data.data.map((relationship) => convertRelationship(relationship));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getStatuses() {
try {
const data = await this.client.getAccountStatuses((this.request.params as any).id, argsToBools(limitToInt(this.request.query as any)));
return await Promise.all(data.data.map(async (status) => await this.mastoconverter.convertStatus(status)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getFollowers() {
try {
const data = await this.client.getAccountFollowers(
(this.request.params as any).id,
limitToInt(this.request.query as any),
);
return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getFollowing() {
try {
const data = await this.client.getAccountFollowing(
(this.request.params as any).id,
limitToInt(this.request.query as any),
);
return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async addFollow() {
try {
const data = await this.client.followAccount( (this.request.params as any).id );
const acct = convertRelationship(data.data);
acct.following = true;
return acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async rmFollow() {
try {
const data = await this.client.unfollowAccount( (this.request.params as any).id );
const acct = convertRelationship(data.data);
acct.following = false;
return acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async addBlock() {
try {
const data = await this.client.blockAccount( (this.request.params as any).id );
return convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async rmBlock() {
try {
const data = await this.client.unblockAccount( (this.request.params as any).id );
return convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async addMute() {
try {
const data = await this.client.muteAccount(
(this.request.params as any).id,
this.request.body as any,
);
return convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async rmMute() {
try {
const data = await this.client.unmuteAccount( (this.request.params as any).id );
return convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getBookmarks() {
try {
const data = await this.client.getBookmarks( limitToInt(this.request.query as any) );
return data.data.map((status) => this.mastoconverter.convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getFavourites() {
try {
const data = await this.client.getFavourites( limitToInt(this.request.query as any) );
return data.data.map((status) => this.mastoconverter.convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getMutes() {
try {
const data = await this.client.getMutes( limitToInt(this.request.query as any) );
return data.data.map((account) => this.mastoconverter.convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async getBlocks() {
try {
const data = await this.client.getBlocks( limitToInt(this.request.query as any) );
return data.data.map((account) => this.mastoconverter.convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async acceptFollow() {
try {
const data = await this.client.acceptFollowRequest( (this.request.params as any).id );
return convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
public async rejectFollow() {
try {
const data = await this.client.rejectFollowRequest( (this.request.params as any).id );
return convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
return e.response.data;
}
}
}

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MegalodonInterface } from 'megalodon';
import type { FastifyRequest } from 'fastify';
const readScope = [
'read:account',
'read:drive',
'read:blocks',
'read:favorites',
'read:following',
'read:messaging',
'read:mutes',
'read:notifications',
'read:reactions',
'read:pages',
'read:page-likes',
'read:user-groups',
'read:channels',
'read:gallery',
'read:gallery-likes',
];
const writeScope = [
'write:account',
'write:drive',
'write:blocks',
'write:favorites',
'write:following',
'write:messaging',
'write:mutes',
'write:notes',
'write:notifications',
'write:reactions',
'write:votes',
'write:pages',
'write:page-likes',
'write:user-groups',
'write:channels',
'write:gallery',
'write:gallery-likes',
];
export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) {
const body: any = request.body || request.query;
try {
let scope = body.scopes;
if (typeof scope === 'string') scope = scope.split(' ') || scope.split('+');
const pushScope = new Set<string>();
for (const s of scope) {
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
}
const scopeArr = Array.from(pushScope);
const red = body.redirect_uris;
const appData = await client.registerApp(body.client_name, {
scopes: scopeArr,
redirect_uris: red,
website: body.website,
});
const returns = {
id: Math.floor(Math.random() * 100).toString(),
name: appData.name,
website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url || '').toString('base64'),
client_secret: appData.clientSecret,
};
return returns;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { convertFilter } from '../converters.js';
import type { MegalodonInterface } from 'megalodon';
import type { FastifyRequest } from 'fastify';
export class ApiFilterMastodon {
private request: FastifyRequest;
private client: MegalodonInterface;
constructor(request: FastifyRequest, client: MegalodonInterface) {
this.request = request;
this.client = client;
}
public async getFilters() {
try {
const data = await this.client.getFilters();
return data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async getFilter() {
try {
const data = await this.client.getFilter( (this.request.params as any).id );
return convertFilter(data.data);
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async createFilter() {
try {
const body: any = this.request.body;
const data = await this.client.createFilter(body.pharse, body.context, body);
return convertFilter(data.data);
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async updateFilter() {
try {
const body: any = this.request.body;
const data = await this.client.updateFilter((this.request.params as any).id, body.pharse, body.context);
return convertFilter(data.data);
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async rmFilter() {
try {
const data = await this.client.deleteFilter( (this.request.params as any).id );
return data.data;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
}

View file

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity } from 'megalodon';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import type { Config } from '@/config.js';
import type { MiMeta } from '@/models/Meta.js';
export async function getInstance(
response: Entity.Instance,
contact: Entity.Account,
config: Config,
meta: MiMeta,
) {
return {
uri: config.url,
title: meta.name || 'Sharkey',
short_description:
meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
description:
meta.description ||
'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
email: response.email || '',
version: `3.0.0 (compatible; Sharkey ${config.version})`,
urls: response.urls,
stats: {
user_count: response.stats.user_count,
status_count: response.stats.status_count,
domain_count: response.stats.domain_count,
},
thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png',
languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations,
approval_required: meta.approvalRequiredForSignup,
invites_enabled: response.registrations,
configuration: {
accounts: {
max_featured_tags: 20,
},
statuses: {
max_characters: config.maxNoteLength,
max_media_attachments: 16,
characters_reserved_per_url: response.uri.length,
},
media_attachments: {
supported_mime_types: FILE_TYPE_BROWSERSAFE,
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_limit: 60,
video_matrix_limit: 2304000,
},
polls: {
max_options: 10,
max_characters_per_option: 150,
min_expiration: 50,
max_expiration: 2629746,
},
reactions: {
max_reactions: 1,
},
},
contact_account: contact,
rules: [],
};
}

View file

@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { convertNotification } from '../converters.js';
import type { MegalodonInterface, Entity } from 'megalodon';
import type { FastifyRequest } from 'fastify';
function toLimitToInt(q: any) {
if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10);
return q;
}
export class ApiNotifyMastodon {
private request: FastifyRequest;
private client: MegalodonInterface;
constructor(request: FastifyRequest, client: MegalodonInterface) {
this.request = request;
this.client = client;
}
public async getNotifications() {
try {
const data = await this.client.getNotifications( toLimitToInt(this.request.query) );
const notifs = data.data;
const processed = notifs.map((n: Entity.Notification) => {
const convertedn = convertNotification(n);
if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') {
if (convertedn.type === 'reaction') convertedn.type = 'favourite';
return convertedn;
} else {
return convertedn;
}
});
return processed;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async getNotification() {
try {
const data = await this.client.getNotification( (this.request.params as any).id );
const notif = convertNotification(data.data);
if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite';
return notif;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async rmNotification() {
try {
const data = await this.client.dismissNotification( (this.request.params as any).id );
return data.data;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async rmNotifications() {
try {
const data = await this.client.dismissNotifications();
return data.data;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
}

View file

@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MastoConverters } from '../converters.js';
import { limitToInt } from './timeline.js';
import type { MegalodonInterface } from 'megalodon';
import type { FastifyRequest } from 'fastify';
export class ApiSearchMastodon {
private request: FastifyRequest;
private client: MegalodonInterface;
private BASE_URL: string;
constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoConverter: MastoConverters) {
this.request = request;
this.client = client;
this.BASE_URL = BASE_URL;
}
public async SearchV1() {
try {
const query: any = limitToInt(this.request.query as any);
const type = query.type || '';
const data = await this.client.search(query.q, { type: type, ...query });
return data.data;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async SearchV2() {
try {
const query: any = limitToInt(this.request.query as any);
const type = query.type;
const acct = !type || type === 'accounts' ? await this.client.search(query.q, { type: 'accounts', ...query }) : null;
const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null;
const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null;
const data = {
accounts: await Promise.all(acct?.data.accounts.map(async (account: any) => await this.mastoConverter.convertAccount(account)) ?? []),
statuses: await Promise.all(stat?.data.statuses.map(async (status: any) => await this.mastoConverter.convertStatus(status)) ?? []),
hashtags: tags?.data.hashtags ?? [],
};
return data;
} catch (e: any) {
console.error(e);
return e.response.data;
}
}
public async getStatusTrends() {
try {
const data = await fetch(`${this.BASE_URL}/api/notes/featured`,
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
})
.then(res => res.json())
.then(data => data.map((status: any) => this.mastoConverter.convertStatus(status)));
return data;
} catch (e: any) {
console.error(e);
return [];
}
}
public async getSuggestions() {
try {
const data = await fetch(`${this.BASE_URL}/api/users`,
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ i: this.request.headers.authorization?.replace('Bearer ', ''), limit: parseInt((this.request.query as any).limit) || 20, origin: 'local', sort: '+follower', state: 'alive' }),
}).then((res) => res.json()).then(data => data.map(((entry: any) => { return { source: 'global', account: entry }; })));
return Promise.all(data.map(async (suggestion: any) => { suggestion.account = await this.mastoConverter.convertAccount(suggestion.account); return suggestion; }));
} catch (e: any) {
console.error(e);
return [];
}
}
}

View file

@ -0,0 +1,415 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js';
import { convertAttachment, convertPoll, convertStatusSource, MastoConverters } from '../converters.js';
import { getClient } from '../MastodonApiServerService.js';
import { limitToInt } from './timeline.js';
import type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
import type { Config } from '@/config.js';
import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
function normalizeQuery(data: any) {
const str = querystring.stringify(data);
return querystring.parse(str);
}
export class ApiStatusMastodon {
private fastify: FastifyInstance;
private mastoconverter: MastoConverters;
constructor(fastify: FastifyInstance, mastoconverter: MastoConverters) {
this.fastify = fastify;
this.mastoconverter = mastoconverter;
}
public async getStatus() {
this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(_request.is404 ? 404 : 401).send(e.response.data);
}
});
}
public async getStatusSource() {
this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/source', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusSource(_request.params.id);
reply.send(data.data);
} catch (e: any) {
console.error(e);
reply.code(_request.is404 ? 404 : 401).send(e.response.data);
}
});
}
public async getContext() {
this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const query: any = _request.query;
try {
const data = await client.getStatusContext(_request.params.id, limitToInt(query));
data.data.ancestors = await Promise.all(data.data.ancestors.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)));
data.data.descendants = await Promise.all(data.data.descendants.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)));
reply.send(data.data);
} catch (e: any) {
console.error(e);
reply.code(_request.is404 ? 404 : 401).send(e.response.data);
}
});
}
public async getHistory() {
this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
try {
const edits = await this.mastoconverter.getEdits(_request.params.id);
reply.send(edits);
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async getReblogged() {
this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusRebloggedBy(_request.params.id);
reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoconverter.convertAccount(account))));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async getFavourites() {
this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusFavouritedBy(_request.params.id);
reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoconverter.convertAccount(account))));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async getMedia() {
this.fastify.get<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMedia(_request.params.id);
reply.send(convertAttachment(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async getPoll() {
this.fastify.get<{ Params: { id: string } }>('/v1/polls/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getPoll(_request.params.id);
reply.send(convertPoll(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async votePoll() {
this.fastify.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = _request.body;
try {
const data = await client.votePoll(_request.params.id, body.choices);
reply.send(convertPoll(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async postStatus() {
this.fastify.post('/v1/statuses', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
let body: any = _request.body;
try {
if (
(!body.poll && body['poll[options][]']) ||
(!body.media_ids && body['media_ids[]'])
) {
body = normalizeQuery(body);
}
const text = body.status ? body.status : ' ';
const removed = text.replace(/@\S+/g, '').replace(/\s|/g, '');
const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) {
const a = await client.createEmojiReaction(
body.in_reply_to_id,
removed,
);
reply.send(a.data);
}
if (body.in_reply_to_id && removed === '/unreact') {
try {
const id = body.in_reply_to_id;
const post = await client.getStatus(id);
const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name;
const data = await client.deleteEmojiReaction(id, react);
reply.send(data.data);
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
}
if (!body.media_ids) body.media_ids = undefined;
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
const { sensitive } = body;
body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive;
if (body.poll) {
if (
body.poll.expires_in != null &&
typeof body.poll.expires_in === 'string'
) body.poll.expires_in = parseInt(body.poll.expires_in);
if (
body.poll.multiple != null &&
typeof body.poll.multiple === 'string'
) body.poll.multiple = body.poll.multiple === 'true';
if (
body.poll.hide_totals != null &&
typeof body.poll.hide_totals === 'string'
) body.poll.hide_totals = body.poll.hide_totals === 'true';
}
const data = await client.postStatus(text, body);
reply.send(await this.mastoconverter.convertStatus(data.data as Entity.Status));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async updateStatus() {
this.fastify.put<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = _request.body;
try {
if (!body.media_ids) body.media_ids = undefined;
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
const data = await client.editStatus(_request.params.id, body);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(_request.is404 ? 404 : 401).send(e.response.data);
}
});
}
public async addFavourite() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = (await client.createEmojiReaction(_request.params.id, '❤')) as any;
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async rmFavourite() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteEmojiReaction(_request.params.id, '❤');
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async reblogStatus() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.reblogStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async unreblogStatus() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unreblogStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async bookmarkStatus() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.bookmarkStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async unbookmarkStatus() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unbookmarkStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async pinStatus() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.pinStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async unpinStatus() {
this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unpinStatus(_request.params.id);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async reactStatus() {
this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createEmojiReaction(_request.params.id, _request.params.name);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async unreactStatus() {
this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name);
reply.send(await this.mastoconverter.convertStatus(data.data));
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
public async deleteStatus() {
this.fastify.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteStatus(_request.params.id);
reply.send(data.data);
} catch (e: any) {
console.error(e);
reply.code(401).send(e.response.data);
}
});
}
}

View file

@ -0,0 +1,273 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ParsedUrlQuery } from 'querystring';
import { convertConversation, convertList, MastoConverters } from '../converters.js';
import { getClient } from '../MastodonApiServerService.js';
import type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
import type { Config } from '@/config.js';
import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
export function limitToInt(q: ParsedUrlQuery) {
const object: any = q;
if (q.limit) if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10);
if (q.offset) if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10);
return object;
}
export function argsToBools(q: ParsedUrlQuery) {
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
const toBoolean = (value: string) =>
!['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
// Keys taken from:
// - https://docs.joinmastodon.org/methods/accounts/#statuses
// - https://docs.joinmastodon.org/methods/timelines/#public
// - https://docs.joinmastodon.org/methods/timelines/#tag
const object: any = q;
if (q.only_media) if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media);
if (q.exclude_replies) if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies);
if (q.exclude_reblogs) if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs);
if (q.pinned) if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned);
if (q.local) if (typeof q.local === 'string') object.local = toBoolean(q.local);
return q;
}
export class ApiTimelineMastodon {
private fastify: FastifyInstance;
constructor(fastify: FastifyInstance, config: Config, private mastoconverter: MastoConverters) {
this.fastify = fastify;
}
public async getTL() {
this.fastify.get('/v1/timelines/public', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = _request.query;
const data = query.local === 'true'
? await client.getLocalTimeline(argsToBools(limitToInt(query)))
: await client.getPublicTimeline(argsToBools(limitToInt(query)));
reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async getHomeTl() {
this.fastify.get('/v1/timelines/home', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = _request.query;
const data = await client.getHomeTimeline(limitToInt(query));
reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async getTagTl() {
this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = _request.query;
const params: any = _request.params;
const data = await client.getTagTimeline(params.hashtag, limitToInt(query));
reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async getListTL() {
this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = _request.query;
const params: any = _request.params;
const data = await client.getListTimeline(params.id, limitToInt(query));
reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async getConversations() {
this.fastify.get('/v1/conversations', async (_request, reply) => {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = _request.query;
const data = await client.getConversationTimeline(limitToInt(query));
reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async getList() {
this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const params: any = _request.params;
const data = await client.getList(params.id);
reply.send(convertList(data.data));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async getLists() {
this.fastify.get('/v1/lists', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const data = await client.getLists();
reply.send(data.data.map((list: Entity.List) => convertList(list)));
} catch (e: any) {
console.error(e);
return e.response.data;
}
});
}
public async getListAccounts() {
this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const params: any = _request.params;
const query: any = _request.query;
const data = await client.getAccountsInList(params.id, query);
reply.send(data.data.map((account: Entity.Account) => this.mastoconverter.convertAccount(account)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async addListAccount() {
this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const params: any = _request.params;
const query: any = _request.query;
const data = await client.addAccountsToList(params.id, query.accounts_id);
reply.send(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async rmListAccount() {
this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const params: any = _request.params;
const query: any = _request.query;
const data = await client.deleteAccountsFromList(params.id, query.accounts_id);
reply.send(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async createList() {
this.fastify.post('/v1/lists', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = _request.body;
const data = await client.createList(body.title);
reply.send(convertList(data.data));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async updateList() {
this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = _request.body;
const params: any = _request.params;
const data = await client.updateList(params.id, body.title);
reply.send(convertList(data.data));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
public async deleteList() {
this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
try {
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
const accessTokens = _request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const params: any = _request.params;
const data = await client.deleteList(params.id);
reply.send({});
} catch (e: any) {
console.error(e);
console.error(e.response.data);
reply.code(401).send(e.response.data);
}
});
}
}

View file

@ -3,491 +3,313 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import dns from 'node:dns/promises';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import megalodon, { MegalodonInterface } from 'megalodon';
import { v4 as uuid } from 'uuid';
import httpLinkHeader from 'http-link-header';
import ipaddr from 'ipaddr.js';
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize';
import oauth2orize, { type OAuth2, AuthorizationError } from 'oauth2orize';
import oauth2Pkce from 'oauth2orize-pkce';
import { JSDOM } from 'jsdom';
import { mf2 } from 'microformats-parser';
import fastifyCors from '@fastify/cors';
import fastifyView from '@fastify/view';
import pug from 'pug';
import bodyParser from 'body-parser';
import fastifyExpress from '@fastify/express';
import { verifyChallenge } from 'pkce-challenge';
import { mf2 } from 'microformats-parser';
import { permissions as kinds } from 'misskey-js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import multer from 'fastify-multer';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { AccessTokensRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import type { MiLocalUser } from '@/models/User.js';
import type { FastifyInstance } from 'fastify';
import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import { StatusError } from '@/misc/status-error.js';
import type { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify';
// TODO: Consider migrating to @node-oauth/oauth2-server once
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
// Upstream the various validations and RFC9207 implementation in that case.
const kinds = [
'read:account',
'write:account',
'read:blocks',
'write:blocks',
'read:drive',
'write:drive',
'read:favorites',
'write:favorites',
'read:following',
'write:following',
'read:messaging',
'write:messaging',
'read:mutes',
'write:mutes',
'write:notes',
'read:notifications',
'write:notifications',
'read:reactions',
'write:reactions',
'write:votes',
'read:pages',
'write:pages',
'write:page-likes',
'read:page-likes',
'read:user-groups',
'write:user-groups',
'read:channels',
'write:channels',
'read:gallery',
'write:gallery',
'read:gallery-likes',
'write:gallery-likes',
];
// Follows https://indieauth.spec.indieweb.org/#client-identifier
// This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation
// although Google has stricter rule.
function validateClientId(raw: string): URL {
// "Clients are identified by a [URL]."
const url = ((): URL => {
try {
return new URL(raw);
} catch { throw new AuthorizationError('client_id must be a valid URL', 'invalid_request'); }
})();
try {
const url = new URL(raw);
// "Client identifier URLs MUST have either an https or http scheme"
// But then again:
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1
// 'The redirection endpoint SHOULD require the use of TLS as described
// in Section 1.6 when the requested response type is "code" or "token"'
const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:'];
if (!allowedProtocols.includes(url.protocol)) {
throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request');
}
const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:'];
if (!allowedProtocols.includes(url.protocol)) {
throw new AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request');
}
// "MUST contain a path component (new URL() implicitly adds one)"
const segments = url.pathname.split('/');
if (segments.includes('.') || segments.includes('..')) {
throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request');
}
// "MUST NOT contain single-dot or double-dot path segments,"
const segments = url.pathname.split('/');
if (segments.includes('.') || segments.includes('..')) {
throw new AuthorizationError('client_id must not contain dot path segments', 'invalid_request');
}
if (url.hash) {
throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request');
}
// ("MAY contain a query string component")
if (url.username || url.password) {
throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
}
// "MUST NOT contain a fragment component"
if (url.hash) {
throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request');
}
if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request');
}
// "MUST NOT contain a username or password component"
if (url.username || url.password) {
throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
}
// ("MAY contain a port")
// "host names MUST be domain names or a loopback interface and MUST NOT be
// IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]."
if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
throw new AuthorizationError('client_id must have a domain name as a host name', 'invalid_request');
}
return url;
return url;
} catch (e) {
throw new AuthorizationError('client_id must be a valid URL', 'invalid_request');
}
}
interface ClientInformation {
id: string;
redirectUris: string[];
name: string;
}
// https://indieauth.spec.indieweb.org/#client-information-discovery
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
// and if there is an [h-app] with a url property matching the client_id URL,
// then it should use the name and icon and display them on the authorization prompt."
// (But we don't display any icon for now)
// https://indieauth.spec.indieweb.org/#redirect-url
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
// of redirect_uri at the client_id URL.
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
// look for an exact match of the given redirect_uri in the request against the list of
// redirect_uris discovered after resolving any relative URLs."
async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
try {
const res = await httpRequestService.send(id);
const redirectUris: string[] = [];
try {
const res = await httpRequestService.send(id);
const redirectUris: string[] = [];
const linkHeader = res.headers.get('link');
if (linkHeader) {
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
}
const linkHeader = res.headers.get('link');
if (linkHeader) {
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
}
const text = await res.text();
const fragment = JSDOM.fragment(text);
const text = await res.text();
const fragment = JSDOM.fragment(text);
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href));
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href));
let name = id;
if (text) {
const microformats = mf2(text, { baseUrl: res.url });
const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0];
if (typeof nameProperty === 'string') {
name = nameProperty;
}
}
let name = id;
if (text) {
const microformats = mf2(text, { baseUrl: res.url });
const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0];
if (typeof nameProperty === 'string') {
name = nameProperty;
}
}
return {
id,
redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
name: typeof name === 'string' ? name : id,
};
} catch (err) {
console.error(err);
logger.error('Error while fetching client information', { err });
if (err instanceof StatusError) {
throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
} else {
throw new AuthorizationError('Failed to parse client information', 'server_error');
}
}
return {
id,
redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
name: typeof name === 'string' ? name : id,
};
} catch (err) {
logger.error('Error while fetching client information', { err });
throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
}
}
type OmitFirstElement<T extends unknown[]> = T extends [unknown, ...(infer R)]
? R
: [];
interface OAuthParsedRequest extends OAuth2Req {
codeChallenge: string;
codeChallengeMethod: string;
}
interface OAuthHttpResponse extends ServerResponse {
redirect(location: string): void;
}
interface OAuth2DecisionRequest extends MiddlewareRequest {
body: {
transaction_id: string;
cancel: boolean;
login_token: string;
}
}
function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] {
return {
query: (txn, res, params): void => {
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
// "In authorization responses to the client, including error responses,
// an authorization server supporting this specification MUST indicate its
// identity by including the iss parameter in the response."
params.iss = issuerUrl;
const parsed = new URL(txn.redirectURI);
for (const [key, value] of Object.entries(params)) {
parsed.searchParams.append(key, value as string);
}
return (res as OAuthHttpResponse).redirect(parsed.toString());
},
};
}
/**
* Maps the transaction ID and the oauth/authorize parameters.
*
* Flow:
* 1. oauth/authorize endpoint will call store() to store the parameters
* and puts the generated transaction ID to the dialog page
* 2. oauth/decision will call load() to retrieve the parameters and then remove()
*/
class OAuth2Store {
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // expires after 5min
load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void {
const { transaction_id } = req.body;
if (!transaction_id) {
cb(new AuthorizationError('Missing transaction ID', 'invalid_request'));
return;
}
const loaded = this.#cache.get(transaction_id);
if (!loaded) {
cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied'));
return;
}
cb(null, loaded);
}
store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
const transactionId = secureRndstr(128);
this.#cache.set(transactionId, oauth2);
cb(null, transactionId);
}
remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void {
this.#cache.delete(tid);
cb();
}
function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
const accessTokenArr = authorization?.split(' ') ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
return client;
}
@Injectable()
export class OAuth2ProviderService {
#server = oauth2orize.createServer({
store: new OAuth2Store(),
});
#logger: Logger;
#logger: Logger;
#server = oauth2orize.createServer();
constructor(
@Inject(DI.config)
private config: Config,
private httpRequestService: HttpRequestService,
@Inject(DI.accessTokensRepository)
accessTokensRepository: AccessTokensRepository,
idService: IdService,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private cacheService: CacheService,
loggerService: LoggerService,
) {
this.#logger = loggerService.getLogger('oauth');
constructor(
@Inject(DI.config)
private config: Config,
private httpRequestService: HttpRequestService,
loggerService: LoggerService,
) {
this.#logger = loggerService.getLogger('oauth');
}
const grantCodeCache = new MemoryKVCache<{
clientId: string,
userId: string,
redirectUri: string,
codeChallenge: string,
scopes: string[],
// https://datatracker.ietf.org/doc/html/rfc8414.html
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
public generateRFC8414() {
return {
issuer: this.config.url,
authorization_endpoint: new URL('/oauth/authorize', this.config.url),
token_endpoint: new URL('/oauth/token', this.config.url),
scopes_supported: kinds,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
service_documentation: 'https://misskey-hub.net',
code_challenge_methods_supported: ['S256'],
authorization_response_iss_parameter_supported: true,
};
}
// fields to prevent multiple code use
grantedToken?: string,
revoked?: boolean,
used?: boolean,
}>(1000 * 60 * 5); // expires after 5m
@bindThis
public async createServer(fastify: FastifyInstance): Promise<void> {
const upload = multer({
storage: multer.diskStorage({}),
limits: {
fileSize: this.config.maxFileSize || 262144000,
files: 1,
},
});
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
// "Authorization servers MUST support PKCE [RFC7636]."
this.#server.grant(oauth2Pkce.extensions());
this.#server.grant(oauth2orize.grant.code({
modes: getQueryMode(config.url),
}, (client, redirectUri, token, ares, areq, locals, done) => {
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
this.#logger.info(`Checking the user before sending authorization code to ${client.id}`);
fastify.register(fastifyCors);
if (!token) {
throw new AuthorizationError('No user', 'invalid_request');
}
const user = await this.cacheService.localUserByNativeTokenCache.fetch(token,
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>);
if (!user) {
throw new AuthorizationError('No such user', 'invalid_request');
}
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`);
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Access-Control-Allow-Origin', '*');
done();
});
const code = secureRndstr(128);
grantCodeCache.set(code, {
clientId: client.id,
userId: user.id,
redirectUri,
codeChallenge: (areq as OAuthParsedRequest).codeChallenge,
scopes: areq.scope,
});
return [code];
})().then(args => done(null, ...args), err => done(err));
}));
this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => {
(async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => {
this.#logger.info('Checking the received authorization code for the exchange');
const granted = grantCodeCache.get(code);
if (!granted) {
return;
}
fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
});
payload.on('end', () => {
try {
const parsed = querystring.parse(body);
done(null, parsed);
} catch (e: any) {
done(e);
}
});
payload.on('error', done);
});
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
// "If an authorization code is used more than once, the authorization server
// MUST deny the request and SHOULD revoke (when possible) all tokens
// previously issued based on that authorization code."
if (granted.used) {
this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`);
grantCodeCache.delete(code);
granted.revoked = true;
if (granted.grantedToken) {
await accessTokensRepository.delete({ token: granted.grantedToken });
}
return;
}
granted.used = true;
fastify.register(multer.contentParser);
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3
if (body.client_id !== granted.clientId) return;
if (redirectUri !== granted.redirectUri) return;
fastify.get('/authorize', async (request, reply) => {
const query: any = request.query;
let param = "mastodon=true";
if (query.state) param += `&state=${query.state}`;
if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`;
const client = query.client_id ? query.client_id : "";
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
if (!body.code_verifier) return;
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
if (client) {
try {
const clientUrl = validateClientId(Buffer.from(client.toString(), 'base64').toString());
if (process.env.NODE_ENV !== 'test') {
const lookup = await dns.lookup(clientUrl.hostname);
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
}
}
await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
} catch (err) {
reply.code(400).send({ error: err.message });
return;
}
}
const accessToken = secureRndstr(128);
const now = new Date();
reply.redirect(
`${Buffer.from(client.toString(), 'base64').toString()}?${param}`,
);
});
// NOTE: we don't have a setup for automatic token expiration
await accessTokensRepository.insert({
id: idService.gen(now.getTime()),
lastUsedAt: now,
userId: granted.userId,
token: accessToken,
hash: accessToken,
name: granted.clientId,
permission: granted.scopes,
});
fastify.get('/authorize/', async (request, reply) => {
const query: any = request.query;
let param = "mastodon=true";
if (query.state) param += `&state=${query.state}`;
if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`;
const client = query.client_id ? query.client_id : "";
reply.redirect(
`${Buffer.from(client.toString(), 'base64').toString()}?${param}`,
);
});
if (granted.revoked) {
this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.');
await accessTokensRepository.delete({ token: accessToken });
return;
}
fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => {
const body: any = request.body || request.query;
if (body.grant_type === "client_credentials") {
const ret = {
access_token: uuid(),
token_type: "Bearer",
scope: "read",
created_at: Math.floor(new Date().getTime() / 1000),
};
reply.send(ret);
}
let client_id: any = body.client_id;
const BASE_URL = `${request.protocol}://${request.hostname}`;
const client = getClient(BASE_URL, '');
let token = null;
if (body.code) {
token = body.code;
}
if (client_id instanceof Array) {
client_id = client_id.toString();
} else if (!client_id) {
client_id = null;
}
granted.grantedToken = accessToken;
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
if (body.grant_type === 'authorization_code' && !body.code_verifier) {
reply.code(400).send({
error: 'invalid_request',
error_description: 'code_verifier is required for authorization_code grant type'
});
return;
}
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
})().then(args => done(null, ...args ?? []), err => done(err));
}));
}
try {
const atData = await client.fetchAccessToken(
client_id,
body.client_secret,
token ? token : "",
);
const ret = {
access_token: atData.accessToken,
token_type: "Bearer",
scope: body.scope || "read write follow push",
created_at: Math.floor(new Date().getTime() / 1000),
};
reply.send(ret);
} catch (err: any) {
reply.code(401).send(err.response.data);
}
});
// https://datatracker.ietf.org/doc/html/rfc8414.html
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
public generateRFC8414() {
return {
issuer: this.config.url,
authorization_endpoint: new URL('/oauth/authorize', this.config.url),
token_endpoint: new URL('/oauth/token', this.config.url),
scopes_supported: kinds,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
service_documentation: 'https://misskey-hub.net',
code_challenge_methods_supported: ['S256'],
authorization_response_iss_parameter_supported: true,
};
}
@bindThis
public async createServer(fastify: FastifyInstance): Promise<void> {
fastify.get('/authorize', async (request, reply) => {
const oauth2 = (request.raw as MiddlewareRequest).oauth2;
if (!oauth2) {
throw new Error('Unexpected lack of authorization information');
}
this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
reply.header('Cache-Control', 'no-store');
return await reply.view('oauth', {
transactionId: oauth2.transactionID,
clientName: oauth2.client.name,
scope: oauth2.req.scope.join(' '),
});
});
fastify.post('/decision', async () => { });
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
await fastify.register(fastifyExpress);
fastify.use('/authorize', this.#server.authorize(((areq, done) => {
(async (): Promise<Parameters<typeof done>> => {
// This should return client/redirectURI AND the error, or
// the handler can't send error to the redirection URI
const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest;
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
const clientUrl = validateClientId(clientID);
// https://indieauth.spec.indieweb.org/#client-information-discovery
// "the server may want to resolve the domain name first and avoid fetching the document
// if the IP address is within the loopback range defined by [RFC5735]
// or any other implementation-specific internal IP address."
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
const lookup = await dns.lookup(clientUrl.hostname);
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
}
}
// Find client information from the remote.
const clientInfo = await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
// Require the redirect URI to be included in an explicit list, per
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
if (!clientInfo.redirectUris.includes(redirectURI)) {
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
}
try {
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
if (!scopes.length) {
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
}
areq.scope = scopes;
// Require PKCE parameters.
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
if (typeof codeChallenge !== 'string') {
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
}
if (codeChallengeMethod !== 'S256') {
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
}
} catch (err) {
return [err as Error, clientInfo, redirectURI];
}
return [null, clientInfo, redirectURI];
})().then(args => done(...args), err => done(err));
}) as ValidateFunctionArity2));
fastify.use('/authorize', this.#server.errorHandler({
mode: 'indirect',
modes: getQueryMode(this.config.url),
}));
fastify.use('/authorize', this.#server.errorHandler());
fastify.use('/decision', bodyParser.urlencoded({ extended: false }));
fastify.use('/decision', this.#server.decision((req, done) => {
const { body } = req as OAuth2DecisionRequest;
this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
req.user = body.login_token;
done(null, undefined);
}));
fastify.use('/decision', this.#server.errorHandler());
// Return 404 for any unknown paths under /oauth so that clients can know
// whether a certain endpoint is supported or not.
fastify.all('/*', async (_request, reply) => {
reply.code(404);
reply.send({
error: {
message: 'Unknown OAuth endpoint.',
code: 'UNKNOWN_OAUTH_ENDPOINT',
id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147',
kind: 'client',
},
});
});
}
@bindThis
public async createTokenServer(fastify: FastifyInstance): Promise<void> {
fastify.register(fastifyCors);
fastify.post('', async () => { });
await fastify.register(fastifyExpress);
// Clients may use JSON or urlencoded
fastify.use('', bodyParser.urlencoded({ extended: false }));
fastify.use('', bodyParser.json({ strict: true }));
fastify.use('', this.#server.token());
fastify.use('', this.#server.errorHandler());
}
fastify.all('/*', async (_request, reply) => {
reply.code(404).send({
error: {
message: 'Unknown OAuth endpoint.',
code: 'UNKNOWN_OAUTH_ENDPOINT',
id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147',
kind: 'client',
},
});
});
}
}

View file

@ -55,14 +55,35 @@ const props = defineProps<{
token: string;
}>();
const getUrlParams = () =>
window.location.search
.substring(1)
.split('&')
.reduce((result, query) => {
const [k, v] = query.split('=');
result[k] = decodeURI(v);
return result;
}, {});
const state = ref<'waiting' | 'accepted' | 'fetch-session-error' | 'denied' | null>(null);
const session = ref<Misskey.entities.AuthSessionShowResponse | null>(null);
function accepted() {
state.value = 'accepted';
if (session.value && session.value.app.callbackUrl) {
const isMastodon = !!getUrlParams().mastodon;
if (session.value && session.value.app.callbackUrl && isMastodon) {
const redirectUri = decodeURIComponent(getUrlParams().redirect_uri);
if (!session.value.app.callbackUrl.includes('elk.zone') && !session.value.app.callbackUrl.split('\n').includes(redirectUri)) {
state.value = 'fetch-session-error';
throw new Error('Callback URI doesn\'t match registered app');
}
const callbackUrl = session.value.app.callbackUrl.includes('elk.zone') ? new URL(session.value.app.callbackUrl) : new URL(redirectUri);
callbackUrl.searchParams.append('code', session.value.token);
if (getUrlParams().state) callbackUrl.searchParams.append('state', getUrlParams().state);
location.href = callbackUrl.toString();
} else if (session.value && session.value.app.callbackUrl) {
const url = new URL(session.value.app.callbackUrl);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(url.protocol)) throw new Error('invalid url');
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url');
location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`;
}
}

View file

@ -10,7 +10,7 @@ import { $i, iAmModerator } from '@/account.js';
import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue';
export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
export const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
loader: loader,
loadingComponent: MkLoading,
errorComponent: MkError,

View file

@ -0,0 +1,3 @@
node_modules
./src
tsconfig.json

View file

@ -0,0 +1,89 @@
{
"name": "megalodon",
"version": "7.0.1",
"description": "Mastodon API client for node.js and browser",
"main": "./lib/src/index.js",
"typings": "./lib/src/index.d.ts",
"scripts": {
"build": "tsc -p ./",
"doc": "typedoc --out ../docs ./src",
"test": "NODE_ENV=test jest -u --maxWorkers=3"
},
"engines": {
"node": ">=15.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/h3poteto/megalodon.git"
},
"keywords": [
"mastodon",
"client",
"api",
"streaming",
"rest",
"proxy"
],
"author": "h3poteto",
"license": "MIT",
"bugs": {
"url": "https://github.com/h3poteto/megalodon/issues"
},
"jest": {
"moduleFileExtensions": [
"ts",
"js"
],
"moduleNameMapper": {
"^@/(.+)": "<rootDir>/src/$1",
"^~/(.+)": "<rootDir>/$1"
},
"testMatch": [
"**/test/**/*.spec.ts"
],
"preset": "ts-jest/presets/default",
"transform": {
"^.+\\.(ts|tsx)$": [
"ts-jest",
{
"tsconfig": "tsconfig.json"
}
]
},
"testEnvironment": "node"
},
"homepage": "https://github.com/h3poteto/megalodon#readme",
"dependencies": {
"@types/core-js": "^2.5.8",
"@types/form-data": "^2.5.0",
"@types/jest": "^29.5.10",
"@types/oauth": "^0.9.4",
"@types/object-assign-deep": "^0.4.3",
"@types/parse-link-header": "^2.0.3",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.10",
"axios": "1.7.4",
"dayjs": "^1.11.10",
"form-data": "^4.0.0",
"https-proxy-agent": "^7.0.2",
"oauth": "^0.10.0",
"object-assign-deep": "^0.4.0",
"parse-link-header": "^2.0.0",
"socks-proxy-agent": "^8.0.2",
"typescript": "5.1.6",
"uuid": "^9.0.1",
"ws": "8.17.1"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"jest": "^29.7.0",
"jest-worker": "^29.7.0",
"lodash": "4.17.21",
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
"typedoc": "^0.25.3"
}
}

1
packages/megalodon/src/axios.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'axios/lib/adapters/http'

View file

@ -0,0 +1,13 @@
export class RequestCanceledError extends Error {
public isCancel: boolean
constructor(msg: string) {
super(msg)
this.isCancel = true
Object.setPrototypeOf(this, RequestCanceledError)
}
}
export const isCancel = (value: any): boolean => {
return value && value.isCancel
}

View file

@ -0,0 +1,3 @@
import MisskeyAPI from "./misskey/api_client";
export default MisskeyAPI.Converter;

View file

@ -0,0 +1,3 @@
export const NO_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob'
export const DEFAULT_SCOPE = ['read', 'write', 'follow']
export const DEFAULT_UA = 'megalodon'

View file

@ -0,0 +1,137 @@
import axios, { AxiosRequestConfig } from 'axios'
import proxyAgent, { ProxyConfig } from './proxy_config'
import { NodeinfoError } from './megalodon'
const NODEINFO_10 = 'http://nodeinfo.diaspora.software/ns/schema/1.0'
const NODEINFO_20 = 'http://nodeinfo.diaspora.software/ns/schema/2.0'
const NODEINFO_21 = 'http://nodeinfo.diaspora.software/ns/schema/2.1'
type Links = {
links: Array<Link>
}
type Link = {
href: string
rel: string
}
type Nodeinfo10 = {
software: Software
metadata: Metadata
}
type Nodeinfo20 = {
software: Software
metadata: Metadata
}
type Nodeinfo21 = {
software: Software
metadata: Metadata
}
type Software = {
name: string
}
type Metadata = {
upstream?: {
name: string
}
}
/**
* Detect SNS type.
* Now support Mastodon, Pleroma and Pixelfed. Throws an error when no known platform can be detected.
*
* @param url Base URL of SNS.
* @param proxyConfig Proxy setting, or set false if don't use proxy.
* @return SNS name.
*/
export const detector = async (
url: string,
proxyConfig: ProxyConfig | false = false
): Promise<'mastodon' | 'pleroma' | 'misskey' | 'friendica'> => {
let options: AxiosRequestConfig = {
timeout: 20000
}
if (proxyConfig) {
options = Object.assign(options, {
httpsAgent: proxyAgent(proxyConfig)
})
}
const res = await axios.get<Links>(url + '/.well-known/nodeinfo', options)
const link = res.data.links.find(l => l.rel === NODEINFO_20 || l.rel === NODEINFO_21)
if (!link) throw new NodeinfoError('Could not find nodeinfo')
switch (link.rel) {
case NODEINFO_10: {
const res = await axios.get<Nodeinfo10>(link.href, options)
switch (res.data.software.name) {
case 'pleroma':
return 'pleroma'
case 'akkoma':
return 'pleroma'
case 'mastodon':
return 'mastodon'
case "wildebeest":
return "mastodon"
case 'misskey':
return 'misskey'
case 'friendica':
return 'friendica'
default:
if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') {
return 'mastodon'
}
throw new NodeinfoError('Unknown SNS')
}
}
case NODEINFO_20: {
const res = await axios.get<Nodeinfo20>(link.href, options)
switch (res.data.software.name) {
case 'pleroma':
return 'pleroma'
case 'akkoma':
return 'pleroma'
case 'mastodon':
return 'mastodon'
case "wildebeest":
return "mastodon"
case 'misskey':
return 'misskey'
case 'friendica':
return 'friendica'
default:
if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') {
return 'mastodon'
}
throw new NodeinfoError('Unknown SNS')
}
}
case NODEINFO_21: {
const res = await axios.get<Nodeinfo21>(link.href, options)
switch (res.data.software.name) {
case 'pleroma':
return 'pleroma'
case 'akkoma':
return 'pleroma'
case 'mastodon':
return 'mastodon'
case "wildebeest":
return "mastodon"
case 'misskey':
return 'misskey'
case 'friendica':
return 'friendica'
default:
if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') {
return 'mastodon'
}
throw new NodeinfoError('Unknown SNS')
}
}
default:
throw new NodeinfoError('Could not find nodeinfo')
}
}

View file

@ -0,0 +1,36 @@
/// <reference path="emoji.ts" />
/// <reference path="source.ts" />
/// <reference path="field.ts" />
/// <reference path="role.ts" />
namespace Entity {
export type Account = {
id: string
fqn?: string
username: string
acct: string
display_name: string
locked: boolean
discoverable?: boolean
group?: boolean | null
noindex?: boolean | null
suspended?: boolean | null
limited?: boolean | null
created_at: string
followers_count: number
following_count: number
statuses_count: number
note: string
url: string
avatar: string
avatar_static: string
header: string
header_static: string
emojis: Array<Emoji>
moved: Account | null
fields: Array<Field>
bot: boolean | null
source?: Source
role?: Role
mute_expires_at?: string
}
}

View file

@ -0,0 +1,8 @@
namespace Entity {
export type Activity = {
week: string
statuses: string
logins: string
registrations: string
}
}

View file

@ -0,0 +1,40 @@
/// <reference path="emoji.ts" />
namespace Entity {
export type Announcement = {
id: string
content: string
starts_at: string | null
ends_at: string | null
published: boolean
all_day: boolean
published_at: string
updated_at: string | null
read: boolean | null
mentions: Array<AnnouncementAccount>
statuses: Array<AnnouncementStatus>
tags: Array<StatusTag>
emojis: Array<Emoji>
reactions: Array<AnnouncementReaction>
}
export type AnnouncementAccount = {
id: string
username: string
url: string
acct: string
}
export type AnnouncementStatus = {
id: string
url: string
}
export type AnnouncementReaction = {
name: string
count: number
me: boolean | null
url: string | null
static_url: string | null
}
}

View file

@ -0,0 +1,7 @@
namespace Entity {
export type Application = {
name: string
website?: string | null
vapid_key?: string | null
}
}

View file

@ -0,0 +1,14 @@
/// <reference path="attachment.ts" />
namespace Entity {
export type AsyncAttachment = {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
url: string | null
remote_url: string | null
preview_url: string
text_url: string | null
meta: Meta | null
description: string | null
blurhash: string | null
}
}

View file

@ -0,0 +1,49 @@
namespace Entity {
export type Sub = {
// For Image, Gifv, and Video
width?: number
height?: number
size?: string
aspect?: number
// For Gifv and Video
frame_rate?: string
// For Audio, Gifv, and Video
duration?: number
bitrate?: number
}
export type Focus = {
x: number
y: number
}
export type Meta = {
original?: Sub
small?: Sub
focus?: Focus
length?: string
duration?: number
fps?: number
size?: string
width?: number
height?: number
aspect?: number
audio_encode?: string
audio_bitrate?: string
audio_channel?: string
}
export type Attachment = {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
url: string
remote_url: string | null
preview_url: string | null
text_url: string | null
meta: Meta | null
description: string | null
blurhash: string | null
}
}

View file

@ -0,0 +1,18 @@
namespace Entity {
export type Card = {
url: string
title: string
description: string
type: 'link' | 'photo' | 'video' | 'rich'
image: string | null
author_name: string | null
author_url: string | null
provider_name: string | null
provider_url: string | null
html: string | null
width: number | null
height: number | null
embed_url: string | null
blurhash: string | null
}
}

View file

@ -0,0 +1,8 @@
/// <reference path="status.ts" />
namespace Entity {
export type Context = {
ancestors: Array<Status>
descendants: Array<Status>
}
}

View file

@ -0,0 +1,11 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
namespace Entity {
export type Conversation = {
id: string
accounts: Array<Account>
last_status: Status | null
unread: boolean
}
}

View file

@ -0,0 +1,9 @@
namespace Entity {
export type Emoji = {
shortcode: string
static_url: string
url: string
visible_in_picker: boolean
category?: string
}
}

View file

@ -0,0 +1,8 @@
namespace Entity {
export type FeaturedTag = {
id: string
name: string
statuses_count: number
last_status_at: string
}
}

View file

@ -0,0 +1,7 @@
namespace Entity {
export type Field = {
name: string
value: string
verified_at?: string | null
}
}

View file

@ -0,0 +1,12 @@
namespace Entity {
export type Filter = {
id: string
phrase: string
context: Array<FilterContext>
expires_at: string | null
irreversible: boolean
whole_word: boolean
}
export type FilterContext = string
}

View file

@ -0,0 +1,27 @@
/// <reference path="emoji.ts" />
/// <reference path="field.ts" />
namespace Entity {
export type FollowRequest = {
id: number
username: string
acct: string
display_name: string
locked: boolean
bot: boolean
discoverable?: boolean
group: boolean
created_at: string
note: string
url: string
avatar: string
avatar_static: string
header: string
header_static: string
followers_count: number
following_count: number
statuses_count: number
emojis: Array<Emoji>
fields: Array<Field>
}
}

View file

@ -0,0 +1,7 @@
namespace Entity {
export type History = {
day: string
uses: number
accounts: number
}
}

View file

@ -0,0 +1,9 @@
namespace Entity {
export type IdentityProof = {
provider: string
provider_username: string
updated_at: string
proof_url: string
profile_url: string
}
}

View file

@ -0,0 +1,40 @@
/// <reference path="account.ts" />
/// <reference path="urls.ts" />
/// <reference path="stats.ts" />
namespace Entity {
export type Instance = {
uri: string
title: string
description: string
email: string
version: string
thumbnail: string | null
urls: URLs | null
stats: Stats
languages: Array<string>
registrations: boolean
approval_required: boolean
invites_enabled?: boolean
configuration: {
statuses: {
max_characters: number
max_media_attachments?: number
characters_reserved_per_url?: number
}
polls?: {
max_options: number
max_characters_per_option: number
min_expiration: number
max_expiration: number
}
}
contact_account?: Account
rules?: Array<InstanceRule>
}
export type InstanceRule = {
id: string
text: string
}
}

View file

@ -0,0 +1,10 @@
namespace Entity {
export type List = {
id: string
title: string
replies_policy?: RepliesPolicy | null
exclusive?: RepliesPolicy | null
}
export type RepliesPolicy = 'followed' | 'list' | 'none'
}

View file

@ -0,0 +1,15 @@
namespace Entity {
export type Marker = {
home?: {
last_read_id: string
version: number
updated_at: string
}
notifications?: {
last_read_id: string
version: number
updated_at: string
unread_count?: number
}
}
}

View file

@ -0,0 +1,8 @@
namespace Entity {
export type Mention = {
id: string
username: string
url: string
acct: string
}
}

View file

@ -0,0 +1,16 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
namespace Entity {
export type Notification = {
account: Account
created_at: string
id: string
status?: Status
emoji?: string
type: NotificationType
target?: Account
}
export type NotificationType = string
}

View file

@ -0,0 +1,15 @@
/// <reference path="poll_option.ts" />
namespace Entity {
export type Poll = {
id: string
expires_at: string | null
expired: boolean
multiple: boolean
votes_count: number
options: Array<PollOption>
voted: boolean
emojis?: []
own_votes?: Array<number>
}
}

View file

@ -0,0 +1,6 @@
namespace Entity {
export type PollOption = {
title: string
votes_count: number | null
}
}

View file

@ -0,0 +1,9 @@
namespace Entity {
export type Preferences = {
'posting:default:visibility': 'public' | 'unlisted' | 'private' | 'direct'
'posting:default:sensitive': boolean
'posting:default:language': string | null
'reading:expand:media': 'default' | 'show_all' | 'hide_all'
'reading:expand:spoilers': boolean
}
}

View file

@ -0,0 +1,16 @@
namespace Entity {
export type Alerts = {
follow: boolean
favourite: boolean
mention: boolean
reblog: boolean
poll: boolean
}
export type PushSubscription = {
id: string
endpoint: string
server_key: string
alerts: Alerts
}
}

View file

@ -0,0 +1,10 @@
/// <reference path="account.ts" />
namespace Entity {
export type Reaction = {
count: number
me: boolean
name: string
accounts?: Array<Account>
}
}

View file

@ -0,0 +1,17 @@
namespace Entity {
export type Relationship = {
id: string
following: boolean
followed_by: boolean
blocking: boolean
blocked_by: boolean
muting: boolean
muting_notifications: boolean
requested: boolean
domain_blocking: boolean
showing_reblogs: boolean
endorsed: boolean
notifying: boolean
note: string | null
}
}

View file

@ -0,0 +1,18 @@
/// <reference path="account.ts" />
namespace Entity {
export type Report = {
id: string
action_taken: boolean
action_taken_at: string | null
status_ids: Array<string> | null
rule_ids: Array<string> | null
// These parameters don't exist in Pleroma
category: Category | null
comment: string | null
forwarded: boolean | null
target_account?: Account | null
}
export type Category = 'spam' | 'violation' | 'other'
}

View file

@ -0,0 +1,11 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
/// <reference path="tag.ts" />
namespace Entity {
export type Results = {
accounts: Array<Account>
statuses: Array<Status>
hashtags: Array<Tag>
}
}

View file

@ -0,0 +1,5 @@
namespace Entity {
export type Role = {
name: string
}
}

View file

@ -0,0 +1,10 @@
/// <reference path="attachment.ts" />
/// <reference path="status_params.ts" />
namespace Entity {
export type ScheduledStatus = {
id: string
scheduled_at: string
params: StatusParams
media_attachments: Array<Attachment> | null
}
}

View file

@ -0,0 +1,10 @@
/// <reference path="field.ts" />
namespace Entity {
export type Source = {
privacy: string | null
sensitive: boolean | null
language: string | null
note: string
fields: Array<Field>
}
}

View file

@ -0,0 +1,7 @@
namespace Entity {
export type Stats = {
user_count: number
status_count: number
domain_count: number
}
}

View file

@ -0,0 +1,50 @@
/// <reference path="account.ts" />
/// <reference path="application.ts" />
/// <reference path="mention.ts" />
/// <reference path="attachment.ts" />
/// <reference path="emoji.ts" />
/// <reference path="card.ts" />
/// <reference path="poll.ts" />
/// <reference path="reaction.ts" />
namespace Entity {
export type Status = {
id: string
uri: string
url: string
account: Account
in_reply_to_id: string | null
in_reply_to_account_id: string | null
reblog: Status | null
content: string
plain_content?: string | null
created_at: string
edited_at: string | null
emojis: Emoji[]
replies_count: number
reblogs_count: number
favourites_count: number
reblogged: boolean | null
favourited: boolean | null
muted: boolean | null
sensitive: boolean
spoiler_text: string
visibility: 'public' | 'unlisted' | 'private' | 'direct'
media_attachments: Array<Attachment>
mentions: Array<Mention>
tags: Array<StatusTag>
card: Card | null
poll: Poll | null
application: Application | null
language: string | null
pinned: boolean | null
emoji_reactions: Array<Reaction>
quote: Status | boolean | null
bookmarked: boolean
}
export type StatusTag = {
name: string
url: string
}
}

View file

@ -0,0 +1,23 @@
/// <reference path="account.ts" />
/// <reference path="application.ts" />
/// <reference path="mention.ts" />
/// <reference path="tag.ts" />
/// <reference path="attachment.ts" />
/// <reference path="emoji.ts" />
/// <reference path="card.ts" />
/// <reference path="poll.ts" />
/// <reference path="reaction.ts" />
namespace Entity {
export type StatusEdit = {
account: Account;
content: string;
plain_content: string | null;
created_at: string;
emojis: Emoji[];
sensitive: boolean;
spoiler_text: string;
media_attachments: Array<Attachment>;
poll: Poll | null;
};
}

View file

@ -0,0 +1,12 @@
namespace Entity {
export type StatusParams = {
text: string
in_reply_to_id: string | null
media_ids: Array<string> | null
sensitive: boolean | null
spoiler_text: string | null
visibility: 'public' | 'unlisted' | 'private' | 'direct' | null
scheduled_at: string | null
application_id: number | null
}
}

View file

@ -0,0 +1,7 @@
namespace Entity {
export type StatusSource = {
id: string
text: string
spoiler_text: string
}
}

View file

@ -0,0 +1,10 @@
/// <reference path="history.ts" />
namespace Entity {
export type Tag = {
name: string
url: string
history: Array<History>
following?: boolean
}
}

View file

@ -0,0 +1,8 @@
namespace Entity {
export type Token = {
access_token: string
token_type: string
scope: string
created_at: number
}
}

View file

@ -0,0 +1,5 @@
namespace Entity {
export type URLs = {
streaming_api: string
}
}

View file

@ -0,0 +1,40 @@
/// <reference path="./entities/account.ts" />
/// <reference path="./entities/activity.ts" />
/// <reference path="./entities/announcement.ts" />
/// <reference path="./entities/application.ts" />
/// <reference path="./entities/async_attachment.ts" />
/// <reference path="./entities/attachment.ts" />
/// <reference path="./entities/card.ts" />
/// <reference path="./entities/context.ts" />
/// <reference path="./entities/conversation.ts" />
/// <reference path="./entities/emoji.ts" />
/// <reference path="./entities/featured_tag.ts" />
/// <reference path="./entities/field.ts" />
/// <reference path="./entities/filter.ts" />
/// <reference path="./entities/follow_request.ts" />
/// <reference path="./entities/history.ts" />
/// <reference path="./entities/identity_proof.ts" />
/// <reference path="./entities/instance.ts" />
/// <reference path="./entities/list.ts" />
/// <reference path="./entities/marker.ts" />
/// <reference path="./entities/mention.ts" />
/// <reference path="./entities/notification.ts" />
/// <reference path="./entities/poll.ts" />
/// <reference path="./entities/poll_option.ts" />
/// <reference path="./entities/preferences.ts" />
/// <reference path="./entities/push_subscription.ts" />
/// <reference path="./entities/reaction.ts" />
/// <reference path="./entities/relationship.ts" />
/// <reference path="./entities/report.ts" />
/// <reference path="./entities/results.ts" />
/// <reference path="./entities/scheduled_status.ts" />
/// <reference path="./entities/source.ts" />
/// <reference path="./entities/stats.ts" />
/// <reference path="./entities/status.ts" />
/// <reference path="./entities/status_params.ts" />
/// <reference path="./entities/status_source.ts" />
/// <reference path="./entities/tag.ts" />
/// <reference path="./entities/token.ts" />
/// <reference path="./entities/urls.ts" />
export default Entity

View file

@ -0,0 +1,11 @@
import Entity from './entity'
namespace FilterContext {
export const Home: Entity.FilterContext = 'home'
export const Notifications: Entity.FilterContext = 'notifications'
export const Public: Entity.FilterContext = 'public'
export const Thread: Entity.FilterContext = 'thread'
export const Account: Entity.FilterContext = 'account'
}
export default FilterContext

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,769 @@
import axios, { AxiosResponse, AxiosRequestConfig } from 'axios'
import objectAssignDeep from 'object-assign-deep'
import WebSocket from './web_socket'
import Response from '../response'
import { RequestCanceledError } from '../cancel'
import proxyAgent, { ProxyConfig } from '../proxy_config'
import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from '../default'
import FriendicaEntity from './entity'
import MegalodonEntity from '../entity'
import NotificationType, { UnknownNotificationTypeError } from '../notification'
import FriendicaNotificationType from './notification'
namespace FriendicaAPI {
/**
* Interface
*/
export interface Interface {
get<T = any>(path: string, params?: any, headers?: { [key: string]: string }, pathIsFullyQualified?: boolean): Promise<Response<T>>
put<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
putForm<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
patch<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
patchForm<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
post<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
postForm<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
del<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
cancel(): void
socket(path: string, stream: string, params?: string): WebSocket
}
/**
* Friendica API client.
*
* Using axios for request, you will handle promises.
*/
export class Client implements Interface {
static DEFAULT_SCOPE = DEFAULT_SCOPE
static DEFAULT_URL = 'https://mastodon.social'
static NO_REDIRECT = NO_REDIRECT
private accessToken: string | null
private baseUrl: string
private userAgent: string
private abortController: AbortController
private proxyConfig: ProxyConfig | false = false
/**
* @param baseUrl hostname or base URL
* @param accessToken access token from OAuth2 authorization
* @param userAgent UserAgent is specified in header on request.
* @param proxyConfig Proxy setting, or set false if don't use proxy.
*/
constructor(
baseUrl: string,
accessToken: string | null = null,
userAgent: string = DEFAULT_UA,
proxyConfig: ProxyConfig | false = false
) {
this.accessToken = accessToken
this.baseUrl = baseUrl
this.userAgent = userAgent
this.proxyConfig = proxyConfig
this.abortController = new AbortController()
axios.defaults.signal = this.abortController.signal
}
/**
* GET request to mastodon REST API.
* @param path relative path from baseUrl
* @param params Query parameters
* @param headers Request header object
*/
public async get<T>(
path: string,
params = {},
headers: { [key: string]: string } = {},
pathIsFullyQualified = false
): Promise<Response<T>> {
let options: AxiosRequestConfig = {
params: params,
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios
.get<T>((pathIsFullyQualified ? '' : this.baseUrl) + path, options)
.catch((err: Error) => {
if (axios.isCancel(err)) {
throw new RequestCanceledError(err.message)
} else {
throw err
}
})
.then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* PUT request to mastodon REST API.
* @param path relative path from baseUrl
* @param params Form data. If you want to post file, please use FormData()
* @param headers Request header object
*/
public async put<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios
.put<T>(this.baseUrl + path, params, options)
.catch((err: Error) => {
if (axios.isCancel(err)) {
throw new RequestCanceledError(err.message)
} else {
throw err
}
})
.then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* PUT request to mastodon REST API for multipart.
* @param path relative path from baseUrl
* @param params Form data. If you want to post file, please use FormData()
* @param headers Request header object
*/
public async putForm<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios
.putForm<T>(this.baseUrl + path, params, options)
.catch((err: Error) => {
if (axios.isCancel(err)) {
throw new RequestCanceledError(err.message)
} else {
throw err
}
})
.then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* PATCH request to mastodon REST API.
* @param path relative path from baseUrl
* @param params Form data. If you want to post file, please use FormData()
* @param headers Request header object
*/
public async patch<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios
.patch<T>(this.baseUrl + path, params, options)
.catch((err: Error) => {
if (axios.isCancel(err)) {
throw new RequestCanceledError(err.message)
} else {
throw err
}
})
.then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* PATCH request to mastodon REST API for multipart.
* @param path relative path from baseUrl
* @param params Form data. If you want to post file, please use FormData()
* @param headers Request header object
*/
public async patchForm<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios
.patchForm<T>(this.baseUrl + path, params, options)
.catch((err: Error) => {
if (axios.isCancel(err)) {
throw new RequestCanceledError(err.message)
} else {
throw err
}
})
.then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* POST request to mastodon REST API.
* @param path relative path from baseUrl
* @param params Form data
* @param headers Request header object
*/
public async post<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios.post<T>(this.baseUrl + path, params, options).then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* POST request to mastodon REST API for multipart.
* @param path relative path from baseUrl
* @param params Form data
* @param headers Request header object
*/
public async postForm<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios.postForm<T>(this.baseUrl + path, params, options).then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* DELETE request to mastodon REST API.
* @param path relative path from baseUrl
* @param params Form data
* @param headers Request header object
*/
public async del<T>(path: string, params = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
data: params,
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.accessToken) {
options = objectAssignDeep({}, options, {
headers: {
Authorization: `Bearer ${this.accessToken}`
}
})
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios
.delete(this.baseUrl + path, options)
.catch((err: Error) => {
if (axios.isCancel(err)) {
throw new RequestCanceledError(err.message)
} else {
throw err
}
})
.then((resp: AxiosResponse) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* Cancel all requests in this instance.
* @returns void
*/
public cancel(): void {
return this.abortController.abort()
}
/**
* Get connection and receive websocket connection for Pleroma API.
*
* @param path relative path from baseUrl: normally it is `/streaming`.
* @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28
* @returns WebSocket, which inherits from EventEmitter
*/
public socket(path: string, stream: string, params?: string): WebSocket {
if (!this.accessToken) {
throw new Error('accessToken is required')
}
const url = this.baseUrl + path
const streaming = new WebSocket(url, stream, params, this.accessToken, this.userAgent, this.proxyConfig)
process.nextTick(() => {
streaming.start()
})
return streaming
}
}
export namespace Entity {
export type Account = FriendicaEntity.Account
export type Activity = FriendicaEntity.Activity
export type Application = FriendicaEntity.Application
export type AsyncAttachment = FriendicaEntity.AsyncAttachment
export type Attachment = FriendicaEntity.Attachment
export type Card = FriendicaEntity.Card
export type Context = FriendicaEntity.Context
export type Conversation = FriendicaEntity.Conversation
export type Emoji = FriendicaEntity.Emoji
export type FeaturedTag = FriendicaEntity.FeaturedTag
export type Field = FriendicaEntity.Field
export type Filter = FriendicaEntity.Filter
export type FollowRequest = FriendicaEntity.FollowRequest
export type History = FriendicaEntity.History
export type IdentityProof = FriendicaEntity.IdentityProof
export type Instance = FriendicaEntity.Instance
export type List = FriendicaEntity.List
export type Marker = FriendicaEntity.Marker
export type Mention = FriendicaEntity.Mention
export type Notification = FriendicaEntity.Notification
export type Poll = FriendicaEntity.Poll
export type PollOption = FriendicaEntity.PollOption
export type Preferences = FriendicaEntity.Preferences
export type PushSubscription = FriendicaEntity.PushSubscription
export type Relationship = FriendicaEntity.Relationship
export type Report = FriendicaEntity.Report
export type Results = FriendicaEntity.Results
export type ScheduledStatus = FriendicaEntity.ScheduledStatus
export type Source = FriendicaEntity.Source
export type Stats = FriendicaEntity.Stats
export type Status = FriendicaEntity.Status
export type StatusParams = FriendicaEntity.StatusParams
export type StatusSource = FriendicaEntity.StatusSource
export type Tag = FriendicaEntity.Tag
export type Token = FriendicaEntity.Token
export type URLs = FriendicaEntity.URLs
}
export namespace Converter {
export const encodeNotificationType = (
t: MegalodonEntity.NotificationType
): FriendicaEntity.NotificationType | UnknownNotificationTypeError => {
switch (t) {
case NotificationType.Follow:
return FriendicaNotificationType.Follow
case NotificationType.Favourite:
return FriendicaNotificationType.Favourite
case NotificationType.Reblog:
return FriendicaNotificationType.Reblog
case NotificationType.Mention:
return FriendicaNotificationType.Mention
case NotificationType.FollowRequest:
return FriendicaNotificationType.FollowRequest
case NotificationType.Status:
return FriendicaNotificationType.Status
case NotificationType.PollExpired:
return FriendicaNotificationType.Poll
case NotificationType.Update:
return FriendicaNotificationType.Update
default:
return new UnknownNotificationTypeError()
}
}
export const decodeNotificationType = (
t: FriendicaEntity.NotificationType
): MegalodonEntity.NotificationType | UnknownNotificationTypeError => {
switch (t) {
case FriendicaNotificationType.Follow:
return NotificationType.Follow
case FriendicaNotificationType.Favourite:
return NotificationType.Favourite
case FriendicaNotificationType.Mention:
return NotificationType.Mention
case FriendicaNotificationType.Reblog:
return NotificationType.Reblog
case FriendicaNotificationType.FollowRequest:
return NotificationType.FollowRequest
case FriendicaNotificationType.Status:
return NotificationType.Status
case FriendicaNotificationType.Poll:
return NotificationType.PollExpired
case FriendicaNotificationType.Update:
return NotificationType.Update
default:
return new UnknownNotificationTypeError()
}
}
export const account = (a: Entity.Account): MegalodonEntity.Account => ({
id: a.id,
username: a.username,
acct: a.acct,
display_name: a.display_name,
locked: a.locked,
discoverable: a.discoverable,
group: a.group,
noindex: null,
suspended: null,
limited: null,
created_at: a.created_at,
followers_count: a.followers_count,
following_count: a.following_count,
statuses_count: a.statuses_count,
note: a.note,
url: a.url,
avatar: a.avatar,
avatar_static: a.avatar_static,
header: a.header,
header_static: a.header_static,
emojis: a.emojis.map(e => emoji(e)),
moved: a.moved ? account(a.moved) : null,
fields: a.fields.map(f => field(f)),
bot: a.bot,
source: a.source ? source(a.source) : undefined
})
export const activity = (a: Entity.Activity): MegalodonEntity.Activity => a
export const application = (a: Entity.Application): MegalodonEntity.Application => a
export const attachment = (a: Entity.Attachment): MegalodonEntity.Attachment => a
export const async_attachment = (a: Entity.AsyncAttachment) => {
if (a.url) {
return {
id: a.id,
type: a.type,
url: a.url,
remote_url: a.remote_url,
preview_url: a.preview_url,
text_url: a.text_url,
meta: a.meta,
description: a.description,
blurhash: a.blurhash
} as MegalodonEntity.Attachment
} else {
return a as MegalodonEntity.AsyncAttachment
}
}
export const card = (c: Entity.Card): MegalodonEntity.Card => ({
url: c.url,
title: c.title,
description: c.description,
type: c.type,
image: c.image,
author_name: c.author_name,
author_url: c.author_url,
provider_name: c.provider_name,
provider_url: c.provider_url,
html: c.html,
width: c.width,
height: c.height,
embed_url: null,
blurhash: c.blurhash
})
export const context = (c: Entity.Context): MegalodonEntity.Context => ({
ancestors: Array.isArray(c.ancestors) ? c.ancestors.map(a => status(a)) : [],
descendants: Array.isArray(c.descendants) ? c.descendants.map(d => status(d)) : []
})
export const conversation = (c: Entity.Conversation): MegalodonEntity.Conversation => ({
id: c.id,
accounts: Array.isArray(c.accounts) ? c.accounts.map(a => account(a)) : [],
last_status: c.last_status ? status(c.last_status) : null,
unread: c.unread
})
export const emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => ({
shortcode: e.shortcode,
static_url: e.static_url,
url: e.url,
visible_in_picker: e.visible_in_picker
})
export const featured_tag = (e: Entity.FeaturedTag): MegalodonEntity.FeaturedTag => e
export const field = (f: Entity.Field): MegalodonEntity.Field => f
export const filter = (f: Entity.Filter): MegalodonEntity.Filter => f
export const follow_request = (f: Entity.FollowRequest): MegalodonEntity.FollowRequest => ({
id: f.id,
username: f.username,
acct: f.acct,
display_name: f.display_name,
locked: f.locked,
bot: f.bot,
discoverable: f.discoverable,
group: f.group,
created_at: f.created_at,
note: f.note,
url: f.url,
avatar: f.avatar,
avatar_static: f.avatar_static,
header: f.header,
header_static: f.header_static,
followers_count: f.followers_count,
following_count: f.following_count,
statuses_count: f.statuses_count,
emojis: f.emojis.map(e => emoji(e)),
fields: f.fields.map(f => field(f))
})
export const history = (h: Entity.History): MegalodonEntity.History => h
export const identity_proof = (i: Entity.IdentityProof): MegalodonEntity.IdentityProof => i
export const instance = (i: Entity.Instance): MegalodonEntity.Instance => {
return {
uri: i.uri,
title: i.title,
description: i.description,
email: i.email,
version: i.version,
thumbnail: i.thumbnail,
urls: i.urls ? urls(i.urls) : null,
stats: stats(i.stats),
languages: i.languages,
registrations: i.registrations,
approval_required: i.approval_required,
invites_enabled: i.invites_enabled,
configuration: {
statuses: {
max_characters: i.max_toot_chars
}
},
contact_account: account(i.contact_account),
rules: i.rules
}
}
export const list = (l: Entity.List): MegalodonEntity.List => l
export const marker = (m: Entity.Marker): MegalodonEntity.Marker => m
export const mention = (m: Entity.Mention): MegalodonEntity.Mention => m
export const notification = (n: Entity.Notification): MegalodonEntity.Notification | UnknownNotificationTypeError => {
const notificationType = decodeNotificationType(n.type)
if (notificationType instanceof UnknownNotificationTypeError) return notificationType
if (n.status) {
return {
account: account(n.account),
created_at: n.created_at,
id: n.id,
status: status(n.status),
type: notificationType
}
} else {
return {
account: account(n.account),
created_at: n.created_at,
id: n.id,
type: notificationType
}
}
}
export const poll = (p: Entity.Poll): MegalodonEntity.Poll => p
export const poll_option = (p: Entity.PollOption): MegalodonEntity.PollOption => p
export const preferences = (p: Entity.Preferences): MegalodonEntity.Preferences => p
export const push_subscription = (p: Entity.PushSubscription): MegalodonEntity.PushSubscription => p
export const relationship = (r: Entity.Relationship): MegalodonEntity.Relationship => r
export const report = (r: Entity.Report): MegalodonEntity.Report => ({
id: r.id,
action_taken: r.action_taken,
action_taken_at: null,
category: r.category,
comment: r.comment,
forwarded: r.forwarded,
status_ids: r.status_ids,
rule_ids: r.rule_ids,
target_account: account(r.target_account)
})
export const results = (r: Entity.Results): MegalodonEntity.Results => ({
accounts: Array.isArray(r.accounts) ? r.accounts.map(a => account(a)) : [],
statuses: Array.isArray(r.statuses) ? r.statuses.map(s => status(s)) : [],
hashtags: Array.isArray(r.hashtags) ? r.hashtags.map(h => tag(h)) : []
})
export const scheduled_status = (s: Entity.ScheduledStatus): MegalodonEntity.ScheduledStatus => {
return {
id: s.id,
scheduled_at: s.scheduled_at,
params: status_params(s.params),
media_attachments: s.media_attachments ? s.media_attachments.map(a => attachment(a)) : null
}
}
export const source = (s: Entity.Source): MegalodonEntity.Source => s
export const stats = (s: Entity.Stats): MegalodonEntity.Stats => s
export const status = (s: Entity.Status): MegalodonEntity.Status => ({
id: s.id,
uri: s.uri,
url: s.url,
account: account(s.account),
in_reply_to_id: s.in_reply_to_id,
in_reply_to_account_id: s.in_reply_to_account_id,
reblog: s.reblog ? status(s.reblog) : s.quote ? status(s.quote) : null,
content: s.content,
plain_content: null,
created_at: s.created_at,
edited_at: s.edited_at || null,
emojis: Array.isArray(s.emojis) ? s.emojis.map(e => emoji(e)) : [],
replies_count: s.replies_count,
reblogs_count: s.reblogs_count,
favourites_count: s.favourites_count,
reblogged: s.reblogged,
favourited: s.favourited,
muted: s.muted,
sensitive: s.sensitive,
spoiler_text: s.spoiler_text,
visibility: s.visibility,
media_attachments: Array.isArray(s.media_attachments) ? s.media_attachments.map(m => attachment(m)) : [],
mentions: Array.isArray(s.mentions) ? s.mentions.map(m => mention(m)) : [],
tags: s.tags,
card: s.card ? card(s.card) : null,
poll: s.poll ? poll(s.poll) : null,
application: s.application ? application(s.application) : null,
language: s.language,
pinned: s.pinned,
emoji_reactions: [],
bookmarked: s.bookmarked ? s.bookmarked : false,
quote: false
})
export const status_params = (s: Entity.StatusParams): MegalodonEntity.StatusParams => {
return {
text: s.text,
in_reply_to_id: s.in_reply_to_id,
media_ids: s.media_ids,
sensitive: s.sensitive,
spoiler_text: s.spoiler_text,
visibility: s.visibility,
scheduled_at: s.scheduled_at,
application_id: parseInt(s.application_id)
}
}
export const status_source = (s: Entity.StatusSource): MegalodonEntity.StatusSource => s
export const tag = (t: Entity.Tag): MegalodonEntity.Tag => t
export const token = (t: Entity.Token): MegalodonEntity.Token => t
export const urls = (u: Entity.URLs): MegalodonEntity.URLs => u
}
}
export default FriendicaAPI

View file

@ -0,0 +1,29 @@
/// <reference path="emoji.ts" />
/// <reference path="source.ts" />
/// <reference path="field.ts" />
namespace FriendicaEntity {
export type Account = {
id: string
username: string
acct: string
display_name: string
locked: boolean
discoverable?: boolean
group: boolean | null
created_at: string
followers_count: number
following_count: number
statuses_count: number
note: string
url: string
avatar: string
avatar_static: string
header: string
header_static: string
emojis: Array<Emoji>
moved: Account | null
fields: Array<Field>
bot: boolean
source?: Source
}
}

View file

@ -0,0 +1,8 @@
namespace FriendicaEntity {
export type Activity = {
week: string
statuses: string
logins: string
registrations: string
}
}

View file

@ -0,0 +1,7 @@
namespace FriendicaEntity {
export type Application = {
name: string
website?: string | null
vapid_key?: string | null
}
}

View file

@ -0,0 +1,14 @@
/// <reference path="attachment.ts" />
namespace FriendicaEntity {
export type AsyncAttachment = {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
url: string | null
remote_url: string | null
preview_url: string
text_url: string | null
meta: Meta | null
description: string | null
blurhash: string | null
}
}

View file

@ -0,0 +1,49 @@
namespace FriendicaEntity {
export type Sub = {
// For Image, Gifv, and Video
width?: number
height?: number
size?: string
aspect?: number
// For Gifv and Video
frame_rate?: string
// For Audio, Gifv, and Video
duration?: number
bitrate?: number
}
export type Focus = {
x: number
y: number
}
export type Meta = {
original?: Sub
small?: Sub
focus?: Focus
length?: string
duration?: number
fps?: number
size?: string
width?: number
height?: number
aspect?: number
audio_encode?: string
audio_bitrate?: string
audio_channel?: string
}
export type Attachment = {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
url: string
remote_url: string | null
preview_url: string | null
text_url: string | null
meta: Meta | null
description: string | null
blurhash: string | null
}
}

View file

@ -0,0 +1,17 @@
namespace FriendicaEntity {
export type Card = {
url: string
title: string
description: string
type: 'link' | 'photo' | 'video' | 'rich'
image: string | null
author_name: string
author_url: string
provider_name: string
provider_url: string
html: string
width: number
height: number
blurhash: string | null
}
}

View file

@ -0,0 +1,8 @@
/// <reference path="status.ts" />
namespace FriendicaEntity {
export type Context = {
ancestors: Array<Status>
descendants: Array<Status>
}
}

View file

@ -0,0 +1,11 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
namespace FriendicaEntity {
export type Conversation = {
id: string
accounts: Array<Account>
last_status: Status | null
unread: boolean
}
}

View file

@ -0,0 +1,8 @@
namespace FriendicaEntity {
export type Emoji = {
shortcode: string
static_url: string
url: string
visible_in_picker: boolean
}
}

View file

@ -0,0 +1,8 @@
namespace FriendicaEntity {
export type FeaturedTag = {
id: string
name: string
statuses_count: number
last_status_at: string
}
}

View file

@ -0,0 +1,7 @@
namespace FriendicaEntity {
export type Field = {
name: string
value: string
verified_at: string | null
}
}

View file

@ -0,0 +1,12 @@
namespace FriendicaEntity {
export type Filter = {
id: string
phrase: string
context: Array<FilterContext>
expires_at: string | null
irreversible: boolean
whole_word: boolean
}
export type FilterContext = string
}

View file

@ -0,0 +1,27 @@
/// <reference path="emoji.ts" />
/// <reference path="field.ts" />
namespace FriendicaEntity {
export type FollowRequest = {
id: number
username: string
acct: string
display_name: string
locked: boolean
bot: boolean
discoverable?: boolean
group: boolean
created_at: string
note: string
url: string
avatar: string
avatar_static: string
header: string
header_static: string
followers_count: number
following_count: number
statuses_count: number
emojis: Array<Emoji>
fields: Array<Field>
}
}

View file

@ -0,0 +1,7 @@
namespace FriendicaEntity {
export type History = {
day: string
uses: number
accounts: number
}
}

View file

@ -0,0 +1,9 @@
namespace FriendicaEntity {
export type IdentityProof = {
provider: string
provider_username: string
updated_at: string
proof_url: string
profile_url: string
}
}

View file

@ -0,0 +1,28 @@
/// <reference path="account.ts" />
/// <reference path="urls.ts" />
/// <reference path="stats.ts" />
namespace FriendicaEntity {
export type Instance = {
uri: string
title: string
description: string
email: string
version: string
thumbnail: string | null
urls: URLs | null
stats: Stats
languages: Array<string>
registrations: boolean
approval_required: boolean
invites_enabled: boolean
max_toot_chars: number
contact_account: Account
rules: Array<InstanceRule>
}
export type InstanceRule = {
id: string
text: string
}
}

View file

@ -0,0 +1,9 @@
namespace FriendicaEntity {
export type List = {
id: string
title: string
replies_policy: RepliesPolicy
}
export type RepliesPolicy = 'followed' | 'list' | 'none'
}

View file

@ -0,0 +1,14 @@
namespace FriendicaEntity {
export type Marker = {
home: {
last_read_id: string
version: number
updated_at: string
}
notifications: {
last_read_id: string
version: number
updated_at: string
}
}
}

View file

@ -0,0 +1,8 @@
namespace FriendicaEntity {
export type Mention = {
id: string
username: string
url: string
acct: string
}
}

View file

@ -0,0 +1,14 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
namespace FriendicaEntity {
export type Notification = {
account: Account
created_at: string
id: string
status?: Status
type: NotificationType
}
export type NotificationType = string
}

View file

@ -0,0 +1,13 @@
/// <reference path="poll_option.ts" />
namespace FriendicaEntity {
export type Poll = {
id: string
expires_at: string | null
expired: boolean
multiple: boolean
votes_count: number
options: Array<PollOption>
voted: boolean
}
}

View file

@ -0,0 +1,6 @@
namespace FriendicaEntity {
export type PollOption = {
title: string
votes_count: number | null
}
}

View file

@ -0,0 +1,9 @@
namespace FriendicaEntity {
export type Preferences = {
'posting:default:visibility': 'public' | 'unlisted' | 'private' | 'direct'
'posting:default:sensitive': boolean
'posting:default:language': string | null
'reading:expand:media': 'default' | 'show_all' | 'hide_all'
'reading:expand:spoilers': boolean
}
}

View file

@ -0,0 +1,16 @@
namespace FriendicaEntity {
export type Alerts = {
follow: boolean
favourite: boolean
mention: boolean
reblog: boolean
poll: boolean
}
export type PushSubscription = {
id: string
endpoint: string
server_key: string
alerts: Alerts
}
}

View file

@ -0,0 +1,17 @@
namespace FriendicaEntity {
export type Relationship = {
id: string
following: boolean
followed_by: boolean
blocking: boolean
blocked_by: boolean
muting: boolean
muting_notifications: boolean
requested: boolean
domain_blocking: boolean
showing_reblogs: boolean
endorsed: boolean
notifying: boolean
note: string | null
}
}

View file

@ -0,0 +1,16 @@
/// <reference path="account.ts" />
namespace FriendicaEntity {
export type Report = {
id: string
action_taken: boolean
category: Category
comment: string
forwarded: boolean
status_ids: Array<string> | null
rule_ids: Array<string> | null
target_account: Account
}
export type Category = 'spam' | 'violation' | 'other'
}

View file

@ -0,0 +1,11 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
/// <reference path="tag.ts" />
namespace FriendicaEntity {
export type Results = {
accounts: Array<Account>
statuses: Array<Status>
hashtags: Array<Tag>
}
}

View file

@ -0,0 +1,10 @@
/// <reference path="attachment.ts" />
/// <reference path="status_params.ts" />
namespace FriendicaEntity {
export type ScheduledStatus = {
id: string
scheduled_at: string
params: StatusParams
media_attachments: Array<Attachment>
}
}

View file

@ -0,0 +1,10 @@
/// <reference path="field.ts" />
namespace FriendicaEntity {
export type Source = {
privacy: string | null
sensitive: boolean | null
language: string | null
note: string
fields: Array<Field>
}
}

View file

@ -0,0 +1,7 @@
namespace FriendicaEntity {
export type Stats = {
user_count: number
status_count: number
domain_count: number
}
}

Some files were not shown because too many files have changed in this diff Show more