Merge branch 'iceshrimp_mastodon' into 'develop'

refactor: Iceshrimp’s Mastodon API implementation with original extensions

Co-authored-by: Eana Hufwe <eana@1a23.com>
Co-authored-by: Eana Hufwe <firefishdev@apps.1a23.com>
Co-authored-by: CI <project_7_bot_1bfaee5701aed20091a86249a967a6c1@noreply.firefish.dev>
Co-authored-by: Asmodeus <colligare1Asmodeum@gmail.com>
Co-authored-by: Lhcfl <Lhcfl@outlook.com>

Closes #10880, #10920, #10717, #10716, #10699, #10697, #10659, #10657, #10543, #10591, #10230, #9637, and #10278

See merge request firefish/firefish!10905
This commit is contained in:
naskya 2024-07-10 00:43:26 +00:00
commit ee8e00cb2f
220 changed files with 11086 additions and 10731 deletions

3
.gitignore vendored
View file

@ -57,9 +57,6 @@ packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
packages/backend/assets/LICENSE
packages/megalodon/lib
packages/megalodon/.idea
dev/container/firefish
dev/container/db
dev/container/redis

View file

@ -108,7 +108,6 @@ test:build:backend_ts:
paths:
- packages/backend/**/*
- packages/firefish-js/**/*
- packages/megalodon/**/*
when: always
before_script:
- apt-get update && apt-get -y upgrade
@ -128,7 +127,7 @@ test:build:backend_ts:
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command 'CREATE EXTENSION pgroonga'
script:
- pnpm install --frozen-lockfile
- pnpm --filter 'backend' --filter 'firefish-js' --filter 'megalodon' run build:debug
- pnpm --filter 'backend' --filter 'firefish-js' run build:debug
- pnpm run migrate
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command "$(cat docs/downgrade.sql)"

View file

@ -36,7 +36,6 @@ COPY packages/backend/package.json packages/backend/package.json
COPY packages/client/package.json packages/client/package.json
COPY packages/sw/package.json packages/sw/package.json
COPY packages/firefish-js/package.json packages/firefish-js/package.json
COPY packages/megalodon/package.json packages/megalodon/package.json
COPY pnpm-lock.yaml ./
# Install dev mode dependencies for compilation
@ -60,8 +59,6 @@ RUN apk update && apk add --no-cache zip unzip tini ffmpeg curl
COPY . ./
COPY --from=build /firefish/packages/megalodon /firefish/packages/megalodon
# Copy node modules
COPY --from=build /firefish/node_modules /firefish/node_modules
COPY --from=build /firefish/packages/backend/node_modules /firefish/packages/backend/node_modules

View file

@ -45,7 +45,6 @@
"style": {
"noCommaOperator": "error",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNonNullAssertion": "warn",
"noUselessElse": "off",
"noVar": "error",
@ -398,6 +397,20 @@
}
}
}
},
{
"include": ["packages/backend/src/server/api/mastodon/**/*.ts"],
"linter": {
"rules": {
"style": {
"noParameterAssign": "off"
},
"complexity": {
"noStaticOnlyClass": "off",
"noThisInStatic": "off"
}
}
}
}
]
}

View file

@ -5,6 +5,134 @@ Breaking changes are indicated by the :warning: icon.
## v20240710
- Added `readCatLanguage` field to the response of `i` and request of `i/update` (optional).
- The old Mastodon API has been replaced with a new implementation based on Iceshrimps.
- :warning: The new API uses a new format to manage Mastodon sessions in the database, whereas old implementation uses Misskey sessions. All previous client app and token registrations will not work with the new API. All clients need to be re-registered and all users need to re-authenticate.
- :warning: All IDs (of statuses/notes, notifications, users, etc.) will be using the alphanumerical format, aligning with the Firefish/Misskey API. The old numerical IDs will not work when queried against the new API.
<details>
<summary>Available endpoints (under <code>https://instance-domain/api/</code>)</summary>
| method | endpoint | note |
|----------|------------------------------------|--------------------------------------------|
| `POST` | `oauth/token` | |
| `POST` | `oauth/revoke` | |
| `POST` | `v1/apps` | |
| `GET` | `v1/apps/verify_credentials` | |
| `POST` | `v1/firefish/apps/info` | Firefish extension, uses MiAuth |
| `POST` | `v1/firefish/auth/code` | Firefish extension, uses MiAuth |
| | | |
| `GET` | `v1/accounts/verify_credentials` | |
| `PATCH` | `v1/accounts/update_credentials` | |
| `GET` | `v1/accounts/lookup` | |
| `GET` | `v1/accounts/relationships` | |
| `GET` | `v1/accounts/search` | |
| `GET` | `v1/accounts/:id` | |
| `GET` | `v1/accounts/:id/statuses` | |
| `GET` | `v1/accounts/:id/featured_tags` | |
| `GET` | `v1/accounts/:id/followers` | |
| `GET` | `v1/accounts/:id/following` | |
| `GET` | `v1/accounts/:id/lists` | |
| `POST` | `v1/accounts/:id/follow` | |
| `POST` | `v1/accounts/:id/unfollow` | |
| `POST` | `v1/accounts/:id/block` | |
| `POST` | `v1/accounts/:id/unblock` | |
| `POST` | `v1/accounts/:id/mute` | |
| `POST` | `v1/accounts/:id/unmute` | |
| | | |
| `GET` | `v1/featured_tags` | always returns an empty list |
| `GET` | `v1/followed_tags` | always returns an empty list |
| `GET` | `v1/bookmarks` | |
| `GET` | `v1/favourites` | |
| | | |
| `GET` | `v1/mutes` | |
| `GET` | `v1/blocks` | |
| `GET` | `v1/follow_requests` | |
| `POST` | `v1/follow_requests/:id/authorize` | |
| `POST` | `v1/follow_requests/:id/reject` | |
| | | |
| `GET` | `v1/filters` | |
| `POST` | `v1/filters` | |
| `GET` | `v2/filters` | |
| `POST` | `v2/filters` | |
| | | |
| `GET` | `v1/lists` | |
| `POST` | `v1/lists` | |
| `GET` | `v1/lists/:id` | |
| `PUT` | `v1/lists/:id` | |
| `DELETE` | `v1/lists/:id` | |
| `GET` | `v1/lists/:id/accounts` | |
| `POST` | `v1/lists/:id/accounts` | |
| `DELETE` | `v1/lists/:id/accounts` | |
| | | |
| `GET` | `v1/media/:id` | |
| `PUT` | `v1/media/:id` | |
| `POST` | `v1/media` | |
| `POST` | `v2/media` | |
| | | |
| `GET` | `v1/custom_emojis` | |
| `GET` | `v1/instance` | |
| `GET` | `v2/instance` | |
| `GET` | `v1/announcements` | |
| `POST` | `v1/announcements/:id/dismiss` | |
| `GET` | `v1/trends` | pagination is unimplemented |
| `GET` | `v1/trends/tags` | pagination is unimplemented |
| `GET` | `v1/trends/statuses` | |
| `GET` | `v1/trends/links` | always returns an empty list |
| `GET` | `v1/preferences` | |
| `GET` | `v2/suggestions` | |
| | | |
| `GET` | `v1/notifications` | |
| `GET` | `v1/notifications/:id` | |
| `POST` | `v1/notifications/clear` | |
| `POST` | `v1/notifications/:id/dismiss` | |
| `POST` | `v1/conversations/:id/read` | |
| `GET` | `v1/push/subscription` | |
| `POST` | `v1/push/subscription` | |
| `DELETE` | `v1/push/subscription` | |
| | | |
| `GET` | `v1/search` | |
| `GET` | `v2/search` | |
| | | |
| `POST` | `v1/statuses` | |
| `PUT` | `v1/statuses/:id` | |
| `GET` | `v1/statuses/:id` | |
| `DELETE` | `v1/statuses/:id` | |
| `GET` | `v1/statuses/:id/context` | |
| `GET` | `v1/statuses/:id/history` | |
| `GET` | `v1/statuses/:id/source` | |
| `GET` | `v1/statuses/:id/reblogged_by` | |
| `GET` | `v1/statuses/:id/favourited_by` | |
| `POST` | `v1/statuses/:id/favourite` | |
| `POST` | `v1/statuses/:id/unfavourite` | |
| `POST` | `v1/statuses/:id/reblog` | |
| `POST` | `v1/statuses/:id/unreblog` | |
| `POST` | `v1/statuses/:id/bookmark` | |
| `POST` | `v1/statuses/:id/unbookmark` | |
| `POST` | `v1/statuses/:id/pin` | |
| `POST` | `v1/statuses/:id/unpin` | |
| `POST` | `v1/statuses/:id/react/:name` | |
| `POST` | `v1/statuses/:id/unreact/:name` | |
| `POST` | `v1/statuses/:id/translate` | |
| | | |
| `GET` | `v1/polls/:id` | |
| `POST` | `v1/polls/:id/votes` | |
| | | |
| `GET` | `v1/scheduled_statuses` | |
| `GET` | `v1/scheduled_statuses/:id` | reschedule (`PUT` method) is unimplemented |
| `DELETE` | `v1/scheduled_statuses/:id` | |
| | | |
| `GET` | `v1/streaming/health` | |
| | | |
| `GET` | `v1/timelines/public` | |
| `GET` | `v1/timelines/tag/:hashtag` | |
| `GET` | `v1/timelines/home` | |
| `GET` | `v1/timelines/list/:listId` | |
| `GET` | `v1/conversations` | |
| `GET` | `v1/markers` | |
| `POST` | `v1/markers` | |
</details>
## v20240607

View file

@ -5,6 +5,25 @@ Critical security updates are indicated by the :warning: icon.
- Server administrators must check [notice-for-admins.md](./notice-for-admins.md) as well.
- Third-party client/bot developers may want to check [api-change.md](./api-change.md) as well.
## Unreleased
- Mastodon API implementation was ported from Iceshrimp, with added Firefish extensions including push notifications, post languages, schedule post support, and more. (#10880)
### Acknowledgement
The new Mastodon API support would not have been possible without the significant dedication of Laura Hausmann (Iceshrimp lead developer). We thank her and other Iceshrimp contributors from the bottom of our hearts.
### Breaking changes
- The new Mastodon API uses a new format to manage Mastodon sessions in the database, whereas old implementation uses Misskey sessions. All previous client app and token registrations will not work with the new API. All clients need to be re-registered and all users need to re-authenticate.
- All IDs (of statuses/notes, notifications, users, etc.) will be using the alphanumerical format, aligning with the Firefish/Misskey API. The old numerical IDs will not work when queried against the new API.
### Important Notice
The new Mastodon API support still contains some incompatibilities and unimplemented features, so please keep in mind that you may experience glitchy behavior, and please do NOT report such issues to Mastodon client apps. Such a “bug” is likely due to our implementation, and Mastodon client developers should not be bothered by such an invalid bug report. In the worst scenario, they may simply block non-Mastodon implementations (some clients already do that).
If you find an incompatibility issue (a bug not reproducible with a vanilla Mastodon server), file it to the Firefish repository instead. However, please remember that it is impossible to achieve 100% compatibility, given that Mastodon servers dont behave exactly like its own documentation.
## [v20240710](https://firefish.dev/firefish/firefish/-/merge_requests/11110/commits)
- Add ability to disable the cat language conversion (nyaification)

View file

@ -1,6 +1,10 @@
BEGIN;
DELETE FROM "migrations" WHERE name IN (
'AddMastodonSubscriptionType1715181461692',
'SwSubscriptionAccessToken1709395223611',
'UserProfileMentions1711075007936',
'ClientCredentials1713108561474',
'TurnOffCatLanguage1720107645050',
'RefactorScheduledPosts1716804636187',
'RemoveEnumTypenameSuffix1716462794927',
@ -35,6 +39,20 @@ DELETE FROM "migrations" WHERE name IN (
'RemoveNativeUtilsMigration1705877093218'
);
-- addMastodonSubscriptionType
ALTER TABLE "sw_subscription" DROP COLUMN "subscriptionTypes";
DROP TYPE "push_subscription_type";
-- sw-subscription-per-access-token
ALTER TABLE "sw_subscription" DROP CONSTRAINT "FK_98a1aa2db2a5253924f42f38767";
ALTER TABLE "sw_subscription" DROP COLUMN "appAccessTokenId";
-- user-profile-mentions
ALTER TABLE "user_profile" DROP COLUMN "mentions";
-- client-credential-support
ALTER TABLE "access_token" ALTER COLUMN "userId" SET NOT NULL;
-- turn-off-cat-language
ALTER TABLE "user" DROP COLUMN "readCatLanguage";

View file

@ -23,6 +23,14 @@ The new versions are:
[Node v21.x is end-of-life](<https://github.com/nodejs/Release?tab=readme-ov-file#end-of-life-releases>).
### For systemd/pm2 users
You can remove the `packages/megalodon` directory after pulling the latest source code (`git pull --ff origin main`).
```sh
rm --recursive --force packages/megalodon
```
## v20240607
The following environment variables are deprecated and no longer have any effect:

View file

@ -293,6 +293,7 @@ usernameOrUserId: "Username or user id"
noSuchUser: "User not found"
lookup: "Lookup"
announcements: "Announcements"
announcement: "Announcement"
imageUrl: "Image URL"
remove: "Delete"
removed: "Successfully deleted"

View file

@ -254,6 +254,7 @@ usernameOrUserId: "ユーザー名かユーザーID"
noSuchUser: "ユーザーが見つかりません"
lookup: "照会"
announcements: "お知らせ"
announcement: "お知らせ"
imageUrl: "画像URL"
remove: "削除"
removed: "削除しました"

View file

@ -243,6 +243,7 @@ usernameOrUserId: "用户名或用户 ID"
noSuchUser: "用户不存在"
lookup: "查询"
announcements: "公告"
announcement: "公告"
imageUrl: "图片 URL"
remove: "删除"
removed: "已删除"

View file

@ -242,6 +242,7 @@ usernameOrUserId: "使用者名稱或使用者ID"
noSuchUser: "使用者不存在"
lookup: "查詢"
announcements: "公告"
announcement: "公告"
imageUrl: "圖片URL"
remove: "刪除"
removed: "已成功刪除"

View file

@ -8,4 +8,3 @@ This directory contains all of the packages Firefish uses.
- `client`: Web interface written in Vue3 and TypeScript
- `sw`: Web [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) written in TypeScript
- `firefish-js`: TypeScript SDK for both backend and client
- `megalodon`: TypeScript library used for partial Mastodon API compatibility

View file

@ -30,7 +30,7 @@ export interface AccessToken {
createdAt: DateTimeWithTimeZone
token: string
hash: string
userId: string
userId: string | null
appId: string | null
lastUsedAt: DateTimeWithTimeZone | null
session: string | null
@ -417,8 +417,6 @@ export interface FollowRequest {
/** Converts milliseconds to a human readable string. */
export declare function formatMilliseconds(milliseconds: number): string
export declare function fromMastodonId(mastodonId: string): string | null
export interface GalleryLike {
id: string
createdAt: DateTimeWithTimeZone
@ -1149,6 +1147,17 @@ export type PushNotificationKind = 'generic'|
'readAllNotifications'|
'mastodon';
export type PushSubscriptionType = 'adminReport'|
'adminSignUp'|
'favourite'|
'follow'|
'followRequest'|
'mention'|
'poll'|
'reblog'|
'status'|
'update';
export interface RedisConfig {
host: string
port: number
@ -1294,6 +1303,8 @@ export interface Software20 {
/** Escapes `%` and `\` in the given string. */
export declare function sqlLikeEscape(src: string): string
export declare function sqlRegexEscape(src: string): string
export interface Storage {
/** Total storage space in bytes */
total: number
@ -1313,6 +1324,8 @@ export interface SwSubscription {
auth: string
publickey: string
sendReadMessage: boolean
appAccessTokenId: string | null
subscriptionTypes: Array<PushSubscriptionType>
}
export interface SysLogConfig {
@ -1327,8 +1340,6 @@ export interface TlsConfig {
export declare function toDbReaction(reaction?: string | undefined | null, host?: string | undefined | null): Promise<string>
export declare function toMastodonId(firefishId: string): string | null
export declare function toPuny(host: string): string
export declare function unwatchNote(watcherId: string, noteId: string): Promise<void>
@ -1509,6 +1520,7 @@ export interface UserProfile {
preventAiLearning: boolean
isIndexable: boolean
mutedPatterns: Array<string>
mentions: Json
mutedInstances: Array<string>
mutedWords: Array<string>
lang: string | null

View file

@ -379,7 +379,6 @@ module.exports.fetchMeta = nativeBinding.fetchMeta
module.exports.fetchNodeinfo = nativeBinding.fetchNodeinfo
module.exports.FILE_TYPE_BROWSERSAFE = nativeBinding.FILE_TYPE_BROWSERSAFE
module.exports.formatMilliseconds = nativeBinding.formatMilliseconds
module.exports.fromMastodonId = nativeBinding.fromMastodonId
module.exports.generateSecureRandomString = nativeBinding.generateSecureRandomString
module.exports.generateUserToken = nativeBinding.generateUserToken
module.exports.genId = nativeBinding.genId
@ -427,6 +426,7 @@ module.exports.publishToGroupChatStream = nativeBinding.publishToGroupChatStream
module.exports.publishToModerationStream = nativeBinding.publishToModerationStream
module.exports.publishToNotesStream = nativeBinding.publishToNotesStream
module.exports.PushNotificationKind = nativeBinding.PushNotificationKind
module.exports.PushSubscriptionType = nativeBinding.PushSubscriptionType
module.exports.RelayStatus = nativeBinding.RelayStatus
module.exports.removeOldAttestationChallenges = nativeBinding.removeOldAttestationChallenges
module.exports.safeForSql = nativeBinding.safeForSql
@ -435,10 +435,10 @@ module.exports.sendPushNotification = nativeBinding.sendPushNotification
module.exports.shouldNyaify = nativeBinding.shouldNyaify
module.exports.showServerInfo = nativeBinding.showServerInfo
module.exports.sqlLikeEscape = nativeBinding.sqlLikeEscape
module.exports.sqlRegexEscape = nativeBinding.sqlRegexEscape
module.exports.storageUsage = nativeBinding.storageUsage
module.exports.stringToAcct = nativeBinding.stringToAcct
module.exports.toDbReaction = nativeBinding.toDbReaction
module.exports.toMastodonId = nativeBinding.toMastodonId
module.exports.toPuny = nativeBinding.toPuny
module.exports.unwatchNote = nativeBinding.unwatchNote
module.exports.updateAntennaCache = nativeBinding.updateAntennaCache

View file

@ -1,9 +1,18 @@
use once_cell::sync::Lazy;
use regex::Regex;
/// Escapes `%` and `\` in the given string.
#[macros::export]
pub fn sql_like_escape(src: &str) -> String {
src.replace('%', r"\%").replace('_', r"\_")
}
#[macros::export]
pub fn sql_regex_escape(src: &str) -> String {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[!$()*+.:<=>?\[\]\^{|}-]").unwrap());
RE.replace_all(src, r"\$1").to_string()
}
/// Returns `true` if `src` does not contain suspicious characters like `%`.
#[macros::export]
pub fn safe_for_sql(src: &str) -> bool {

View file

@ -1,34 +0,0 @@
#[macros::export]
pub fn to_mastodon_id(firefish_id: &str) -> Option<String> {
let decoded: [u8; 16] = basen::BASE36.decode_var_len(firefish_id)?;
Some(basen::BASE10.encode_var_len(&decoded))
}
#[macros::export]
pub fn from_mastodon_id(mastodon_id: &str) -> Option<String> {
let decoded: [u8; 16] = basen::BASE10.decode_var_len(mastodon_id)?;
Some(basen::BASE36.encode_var_len(&decoded))
}
#[cfg(test)]
mod unit_test {
use pretty_assertions::assert_eq;
#[test]
fn to_mastodon_id() {
assert_eq!(
super::to_mastodon_id("9pdqi3rjl4lxirq3").unwrap(),
"2145531976185871567229403"
);
assert_eq!(super::to_mastodon_id("9pdqi3r*irq3"), None);
}
#[test]
fn from_mastodon_id() {
assert_eq!(
super::from_mastodon_id("2145531976185871567229403").unwrap(),
"9pdqi3rjl4lxirq3"
);
assert_eq!(super::from_mastodon_id("9pdqi3rjl4lxirq3"), None);
}
}

View file

@ -10,7 +10,6 @@ pub mod get_image_size;
pub mod is_quote;
pub mod is_safe_url;
pub mod latest_version;
pub mod mastodon_id;
pub mod note;
pub mod nyaify;
pub mod password;

View file

@ -15,7 +15,7 @@ pub struct Model {
pub token: String,
pub hash: String,
#[sea_orm(column_name = "userId")]
pub user_id: String,
pub user_id: Option<String>,
#[sea_orm(column_name = "appId")]
pub app_id: Option<String>,
#[sea_orm(column_name = "lastUsedAt")]
@ -41,6 +41,8 @@ pub enum Relation {
App,
#[sea_orm(has_many = "super::notification::Entity")]
Notification,
#[sea_orm(has_many = "super::sw_subscription::Entity")]
SwSubscription,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
@ -63,6 +65,12 @@ impl Related<super::notification::Entity> for Entity {
}
}
impl Related<super::sw_subscription::Entity> for Entity {
fn to() -> RelationDef {
Relation::SwSubscription.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()

View file

@ -128,6 +128,36 @@ pub enum PollNoteVisibility {
#[derive(Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[macros::derive_clone_and_export(string_enum = "camelCase")]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "push_subscription_type"
)]
pub enum PushSubscriptionType {
#[sea_orm(string_value = "admin.report")]
AdminReport,
#[sea_orm(string_value = "admin.sign_up")]
AdminSignUp,
#[sea_orm(string_value = "favourite")]
Favourite,
#[sea_orm(string_value = "follow")]
Follow,
#[sea_orm(string_value = "follow_request")]
FollowRequest,
#[sea_orm(string_value = "mention")]
Mention,
#[sea_orm(string_value = "poll")]
Poll,
#[sea_orm(string_value = "reblog")]
Reblog,
#[sea_orm(string_value = "status")]
Status,
#[sea_orm(string_value = "update")]
Update,
}
#[derive(Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[macros::derive_clone_and_export(string_enum = "camelCase")]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "relay_status")]
pub enum RelayStatus {
#[sea_orm(string_value = "accepted")]

View file

@ -1,5 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
use super::sea_orm_active_enums::PushSubscriptionType;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@ -19,10 +20,22 @@ pub struct Model {
pub publickey: String,
#[sea_orm(column_name = "sendReadMessage")]
pub send_read_message: bool,
#[sea_orm(column_name = "appAccessTokenId")]
pub app_access_token_id: Option<String>,
#[sea_orm(column_name = "subscriptionTypes")]
pub subscription_types: Vec<PushSubscriptionType>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::access_token::Entity",
from = "Column::AppAccessTokenId",
to = "super::access_token::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
AccessToken,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
@ -33,6 +46,12 @@ pub enum Relation {
User,
}
impl Related<super::access_token::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccessToken.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()

View file

@ -68,6 +68,8 @@ pub struct Model {
pub is_indexable: bool,
#[sea_orm(column_name = "mutedPatterns")]
pub muted_patterns: Vec<String>,
#[sea_orm(column_type = "JsonBinary")]
pub mentions: Json,
#[sea_orm(column_name = "mutedInstances")]
pub muted_instances: Vec<String>,
#[sea_orm(column_name = "mutedWords")]

View file

@ -1,6 +1,12 @@
use crate::{
config::local_server_info, database::db_conn, misc::note::summarize,
model::entity::sw_subscription, util::http_client,
config::local_server_info,
database::db_conn,
misc::note::summarize,
model::entity::{access_token, app, sw_subscription},
util::{
http_client,
id::{get_timestamp, InvalidIdError},
},
};
use once_cell::sync::OnceCell;
use sea_orm::prelude::*;
@ -19,6 +25,11 @@ pub enum Error {
#[doc = "provided content is invalid"]
#[error("invalid content ({0})")]
InvalidContent(String),
#[doc = "found Mastodon subscription is invalid"]
#[error("invalid subscription ({0})")]
InvalidSubscription(String),
#[error("invalid notification ID")]
InvalidId(#[from] InvalidIdError),
#[error("failed to acquire an HTTP client")]
HttpClient(#[from] http_client::Error),
}
@ -96,6 +107,111 @@ fn compact_content(mut content: serde_json::Value) -> Result<serde_json::Value,
Ok(serde_json::from_value(Json::Object(object.clone()))?)
}
/// Returns a tuple containing the token and client name
async fn get_mastodon_subscription_info(
db: &DbConn,
subscription_id: &str,
token_id: &str,
) -> Result<(String, String), Error> {
let token = access_token::Entity::find()
.filter(access_token::Column::Id.eq(token_id))
.one(db)
.await?;
if token.is_none() {
unsubscribe(db, subscription_id).await?;
return Err(Error::InvalidSubscription(
"access token not found".to_string(),
));
}
let token = token.unwrap();
if token.app_id.is_none() {
unsubscribe(db, subscription_id).await?;
return Err(Error::InvalidSubscription("no app ID".to_string()));
}
let app_id = token.app_id.unwrap();
let client = app::Entity::find()
.filter(app::Column::Id.eq(app_id))
.one(db)
.await?;
if client.is_none() {
unsubscribe(db, subscription_id).await?;
return Err(Error::InvalidSubscription("app not found".to_string()));
}
Ok((token.token, client.unwrap().name))
}
async fn encode_mastodon_payload(
mut content: serde_json::Value,
db: &DbConn,
subscription: &sw_subscription::Model,
) -> Result<String, Error> {
let object = content
.as_object_mut()
.ok_or(Error::InvalidContent("not a JSON object".to_string()))?;
if subscription.app_access_token_id.is_none() {
unsubscribe(db, &subscription.id).await?;
return Err(Error::InvalidSubscription("no access token".to_string()));
}
let (token, client_name) = get_mastodon_subscription_info(
db,
&subscription.id,
subscription.app_access_token_id.as_ref().unwrap(),
)
.await?;
object.insert("access_token".to_string(), serde_json::to_value(token)?);
// Some apps expect notification_id to be an integer,
// but doesnt break when the ID doesnt match the rest of API.
if [
"IceCubesApp",
"Mammoth",
"feather",
"MaserApp",
"Metatext",
"Feditext",
]
.contains(&client_name.as_str())
{
let timestamp = object
.get("notification_id")
.and_then(|id| id.as_str())
.map(get_timestamp)
.transpose()?
.unwrap_or_default();
object.insert("notification_id".to_string(), timestamp.into());
}
let res = serde_json::to_string(&content)?;
// Adding space paddings to the end of JSON payload to prevent
// `esm` from adding null bytes payload which many Mastodon clients dont support.
// https://firefish.dev/firefish/firefish/-/merge_requests/10905#note_6733
// not using the padding parameter directly on `res` because we want the padding to be
// calculated based on the UTF-8 byte size of `res` instead of number of characters.
let pad_length = match res.len() % 128 {
127 => 127,
n => 126 - n,
};
Ok(format!("{}{:pad_length$}", res, ""))
}
async fn unsubscribe(db: &DbConn, subscription_id: &str) -> Result<(), DbErr> {
sw_subscription::Entity::delete_by_id(subscription_id)
.exec(db)
.await?;
Ok(())
}
async fn handle_web_push_failure(
db: &DbConn,
err: WebPushError,
@ -114,9 +230,7 @@ async fn handle_web_push_failure(
| WebPushError::MissingCryptoKeys
| WebPushError::InvalidCryptoKeys
| WebPushError::InvalidResponse => {
sw_subscription::Entity::delete_by_id(subscription_id)
.exec(db)
.await?;
unsubscribe(db, subscription_id).await?;
tracing::info!("{}; {} was unsubscribed", error_message, subscription_id);
tracing::debug!("reason: {:#?}", err);
}
@ -157,9 +271,9 @@ pub async fn send_push_notification(
let use_mastodon_api = matches!(kind, PushNotificationKind::Mastodon);
// TODO: refactoring
let payload = if use_mastodon_api {
// Leave the `content` as it is
serde_json::to_string(content)?
let mut payload = if use_mastodon_api {
// Content generated per subscription
"".to_string()
} else {
// Format the `content` passed from the TypeScript backend
// for Firefish push notifications
@ -206,6 +320,15 @@ pub async fn send_push_notification(
continue;
}
if use_mastodon_api {
if subscription.app_access_token_id.is_none() {
continue;
}
payload = encode_mastodon_payload(content.clone(), db, subscription).await?;
} else if subscription.app_access_token_id.is_some() {
continue;
}
let subscription_info = SubscriptionInfo {
endpoint: subscription.endpoint.to_owned(),
keys: SubscriptionKeys {
@ -246,7 +369,28 @@ pub async fn send_push_notification(
handle_web_push_failure(db, err, &subscription.id, "failed to build a payload").await?;
continue;
}
if let Err(err) = get_client()?.send(message.unwrap()).await {
// Ice Cubes cannot process ";rs=4096" at at the end of Encryption header
let mut message = message.unwrap();
if let Some(payload) = message.payload {
let crypto_headers: Vec<(&str, String)> = payload
.crypto_headers
.into_iter()
.map(|(key, val)| match key {
"Encryption" => (key, val.replace(";rs=4096", "")),
_ => (key, val),
})
.collect();
message.payload = Some(WebPushPayload {
content: payload.content,
content_encoding: payload.content_encoding,
crypto_headers,
});
}
if let Err(err) = get_client()?.send(message).await {
handle_web_push_failure(db, err, &subscription.id, "failed to send").await?;
continue;
}

View file

@ -34,6 +34,8 @@
"adm-zip": "0.5.14",
"ajv": "8.16.0",
"archiver": "7.0.1",
"async-lock": "1.4.0",
"async-mutex": "0.5.0",
"aws-sdk": "2.1655.0",
"axios": "1.7.2",
"backend-rs": "workspace:*",
@ -75,7 +77,6 @@
"koa-mount": "4.0.0",
"koa-remove-trailing-slashes": "2.0.3",
"koa-send": "5.0.1",
"megalodon": "workspace:*",
"mfm-js": "0.24.0",
"mime-types": "2.1.35",
"msgpackr": "1.10.2",
@ -113,12 +114,14 @@
"tmp": "0.2.3",
"typeorm": "0.3.20",
"ulid": "2.3.0",
"unfurl.js": "6.4.0",
"uuid": "10.0.0",
"websocket": "1.0.35",
"xev": "3.0.2"
},
"devDependencies": {
"@types/adm-zip": "0.5.5",
"@types/async-lock": "1.4.0",
"@types/color-convert": "2.0.3",
"@types/content-disposition": "0.5.8",
"@types/escape-regexp": "0.0.3",

View file

@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class SwSubscriptionAccessToken1709395223612
implements MigrationInterface
{
name = "SwSubscriptionAccessToken1709395223612";
async up(queryRunner: QueryRunner) {
await queryRunner.query(
`ALTER TABLE "sw_subscription" ADD COLUMN IF NOT EXISTS "appAccessTokenId" character varying(32)`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ADD CONSTRAINT "FK_98a1aa2db2a5253924f42f38767" FOREIGN KEY ("appAccessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`CREATE INDEX "IDX_98a1aa2db2a5253924f42f3876" ON "sw_subscription" ("appAccessTokenId") `,
);
}
async down(queryRunner: QueryRunner) {
await queryRunner.query(
`ALTER TABLE "sw_subscription" DROP CONSTRAINT "FK_98a1aa2db2a5253924f42f38767"`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" DROP COLUMN "appAccessTokenId"`,
);
}
}

View file

@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class UserProfileMentions1711075007936 implements MigrationInterface {
name = "UserProfileMentions1711075007936";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" ADD "mentions" jsonb NOT NULL DEFAULT '[]'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" DROP COLUMN "mentions"`,
);
}
}

View file

@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class ClientCredentials1713108561474 implements MigrationInterface {
name = "ClientCredentials1713108561474";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "access_token" ALTER COLUMN "userId" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "access_token" ALTER COLUMN "userId" SET NOT NULL`,
);
}
}

View file

@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class AddMastodonSubscriptionType1715181461692
implements MigrationInterface
{
name = "AddMastodonSubscriptionType1715181461692";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "push_subscription_type" AS ENUM ('mention', 'status', 'reblog', 'follow', 'follow_request', 'favourite', 'poll', 'update', 'admin.sign_up', 'admin.report')`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ADD "subscriptionTypes" push_subscription_type array NOT NULL DEFAULT '{}'`,
);
await queryRunner.query(`
UPDATE "sw_subscription"
SET "subscriptionTypes" = ARRAY['mention', 'status', 'reblog', 'follow', 'follow_request', 'favourite', 'poll', 'update']::push_subscription_type[]
WHERE "appAccessTokenId" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "sw_subscription" DROP COLUMN "subscriptionTypes"`,
);
await queryRunner.query(`DROP TYPE "push_subscription_type"`);
}
}

View file

@ -0,0 +1,36 @@
import type { User } from "@/models/entities/user.js";
import type { Note } from "@/models/entities/note.js";
import type { UserProfile } from "@/models/entities/user-profile.js";
import { checkWordMute } from "backend-rs";
import { Cache } from "@/misc/cache.js";
import { UserProfiles } from "@/models/index.js";
const filteredNoteCache = new Cache<boolean>("filteredNote", 60 * 60 * 24);
const mutedWordsCache = new Cache<
Pick<UserProfile, "mutedWords" | "mutedPatterns">
>("mutedWords", 60 * 5);
export async function isFiltered(
note: Note,
user: { id: User["id"] } | null | undefined,
): Promise<boolean> {
if (!user) return false;
const profile = await mutedWordsCache.fetch(user.id, () =>
UserProfiles.findOneBy({ userId: user.id }).then((p) => ({
mutedWords: p?.mutedWords ?? [],
mutedPatterns: p?.mutedPatterns ?? [],
})),
);
if (
!profile ||
(profile.mutedPatterns.length < 1 && profile.mutedWords.length < 1)
)
return false;
const ts = (note.updatedAt ?? note.createdAt) as Date | string;
const identifier =
(typeof ts === "string" ? new Date(ts) : ts)?.getTime() ?? "0";
return filteredNoteCache.fetch(`${note.id}:${identifier}:${user.id}`, () =>
checkWordMute(note, profile.mutedWords, profile.mutedPatterns),
);
}

View file

@ -1,7 +1,12 @@
import type { Packed } from "./schema.js";
type NoteWithUserHost = { user: { host: string | null } | null };
export function isInstanceMuted(
note: Packed<"Note">,
note: NoteWithUserHost & {
reply: NoteWithUserHost | null;
renote: NoteWithUserHost | null;
},
mutedInstances: Set<string>,
): boolean {
if (mutedInstances.has(note?.user?.host ?? "")) return true;

View file

@ -14,7 +14,7 @@ const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
/**
*
*/
type PopulatedEmoji = {
export type PopulatedEmoji = {
name: string;
url: string;
width: number | null;

View file

@ -0,0 +1,90 @@
import { Notes } from "@/models/index.js";
import type { Note } from "@/models/entities/note.js";
import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js";
import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js";
import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js";
import type { SelectQueryBuilder } from "typeorm";
export interface SearchParams {
withFilesOnly: boolean;
limit: number;
myId: string | undefined;
sinceId: string | undefined;
untilId: string | undefined;
sinceDate: number | undefined;
untilDate: number | undefined;
userId: string | null | undefined;
channelId: string | null;
host: string | null | undefined;
}
export async function searchNotes(
params: SearchParams,
modifier?: (query: SelectQueryBuilder<Note>) => void,
): Promise<Note[]> {
const query = makePaginationQuery(
Notes.createQueryBuilder("note"),
params.sinceId ?? undefined,
params.untilId ?? undefined,
params.sinceDate ?? undefined,
params.untilDate ?? undefined,
);
modifier?.(query);
if (params.userId != null) {
query.andWhere("note.userId = :userId", { userId: params.userId });
}
if (params.channelId != null) {
query.andWhere("note.channelId = :channelId", {
channelId: params.channelId,
});
}
query.innerJoinAndSelect("note.user", "user");
// "from: me": search all (public, home, followers, specified) my posts
// otherwise: search public indexable posts only
if (params.userId == null || params.userId !== params.myId) {
query
.andWhere("note.visibility = 'public'")
.andWhere("user.isIndexable = TRUE");
}
if (params.userId != null) {
query.andWhere("note.userId = :userId", { userId: params.userId });
}
if (params.host === null) {
// search local notes only
query.andWhere("note.userHost IS NULL");
}
if (params.host != null) {
query.andWhere("note.userHost = :userHost", { userHost: params.host });
}
if (params.withFilesOnly) {
query.andWhere("note.fileIds != '{}'");
}
query
.leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner")
.leftJoinAndSelect("note.reply", "reply")
.leftJoinAndSelect("note.renote", "renote")
.leftJoinAndSelect("reply.user", "replyUser")
.leftJoinAndSelect("replyUser.avatar", "replyUserAvatar")
.leftJoinAndSelect("replyUser.banner", "replyUserBanner")
.leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
const me = params.myId != null ? { id: params.myId } : null;
generateVisibilityQuery(query, me);
if (params.myId != null) generateMutedUserQuery(query, { id: params.myId });
if (params.myId != null) generateBlockedUserQuery(query, { id: params.myId });
return await query.take(params.limit).getMany();
}

View file

@ -46,8 +46,11 @@ export class AccessToken {
public hash: string;
@Index()
@Column(id())
public userId: User["id"];
@Column({
...id(),
nullable: true,
})
public userId: User["id"] | null;
@Column({
...id(),

View file

@ -312,9 +312,11 @@ export class Note {
}
}
export type IMentionedRemoteUsers = {
export type IMentionedRemoteUser = {
uri: string;
url?: string;
username: string;
host: string;
}[];
};
export type IMentionedRemoteUsers = IMentionedRemoteUser[];

View file

@ -9,6 +9,23 @@ import {
} from "typeorm";
import { User } from "./user.js";
import { id } from "../id.js";
import { AccessToken } from "./access-token.js";
// for Mastodon push notifications
const pushSubscriptionTypes = [
"mention",
"status",
"reblog",
"follow",
"follow_request",
"favourite",
"poll",
"update",
"admin.sign_up",
"admin.report",
] as const;
type pushSubscriptionType = (typeof pushSubscriptionTypes)[number];
@Entity()
export class SwSubscription {
@ -42,11 +59,40 @@ export class SwSubscription {
})
public sendReadMessage: boolean;
/**
* Type of subscription, used for Mastodon API notifications.
* Empty for Misskey notifications.
*/
@Column({
type: "enum",
enum: pushSubscriptionTypes,
array: true,
default: "{}",
})
public subscriptionTypes: pushSubscriptionType[];
/**
* App notification app, used for Mastodon API notifications
*/
@Index()
@Column({
...id(),
nullable: true,
})
public appAccessTokenId: AccessToken["id"] | null;
//#region Relations
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
@JoinColumn()
public user: Relation<User>;
@ManyToOne(() => AccessToken, {
onDelete: "CASCADE",
nullable: true,
})
@JoinColumn()
public appAccessToken: Relation<AccessToken | null>;
//#endregion
}

View file

@ -11,6 +11,7 @@ import { ffVisibility, notificationTypes } from "@/types.js";
import { id } from "../id.js";
import { User } from "./user.js";
import { Page } from "./page.js";
import type { IMentionedRemoteUsers } from "./note.js";
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@ -50,6 +51,11 @@ export class UserProfile {
verified?: boolean;
}[];
@Column("jsonb", {
default: [],
})
public mentions: IMentionedRemoteUsers;
@Column("varchar", {
length: 32,
nullable: true,

View file

@ -37,7 +37,6 @@ import { AppRepository } from "./repositories/app.js";
import { FollowingRepository } from "./repositories/following.js";
import { AbuseUserReportRepository } from "./repositories/abuse-user-report.js";
import { AuthSessionRepository } from "./repositories/auth-session.js";
import { UserProfile } from "./entities/user-profile.js";
import { AttestationChallenge } from "./entities/attestation-challenge.js";
import { UserSecurityKey } from "./entities/user-security-key.js";
import { HashtagRepository } from "./repositories/hashtag.js";
@ -67,6 +66,7 @@ import { Webhook } from "./entities/webhook.js";
import { UserIp } from "./entities/user-ip.js";
import { NoteFileRepository } from "./repositories/note-file.js";
import { NoteEditRepository } from "./repositories/note-edit.js";
import { UserProfileRepository } from "./repositories/user-profile.js";
export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -82,7 +82,7 @@ export const NoteUnreads = db.getRepository(NoteUnread);
export const Polls = db.getRepository(Poll);
export const PollVotes = db.getRepository(PollVote);
export const Users = UserRepository;
export const UserProfiles = db.getRepository(UserProfile);
export const UserProfiles = UserProfileRepository;
export const UserKeypairs = db.getRepository(UserKeypair);
export const UserPendings = db.getRepository(UserPending);
export const AttestationChallenges = db.getRepository(AttestationChallenge);

View file

@ -8,6 +8,7 @@ import { config } from "@/config.js";
import { query, appendQuery } from "@/prelude/url.js";
import { Users, DriveFolders } from "../index.js";
import { deepClone } from "@/misc/clone.js";
import { fetchMeta } from "backend-rs";
type PackOptions = {
detail?: boolean;
@ -221,4 +222,33 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
);
return items.filter((x): x is Packed<"DriveFile"> => x != null);
},
async getFinalUrl(url: string): Promise<string> {
if (!config.proxyRemoteFiles) return url;
if (!url.startsWith("https://") && !url.startsWith("http://")) return url;
if (url.startsWith(`${config.url}/files`)) return url;
if (url.startsWith(`${config.url}/static-assets`)) return url;
if (url.startsWith(`${config.url}/identicon`)) return url;
if (url.startsWith(`${config.url}/avatar`)) return url;
const meta = await fetchMeta();
const baseUrl = meta
? meta.objectStorageBaseUrl ??
`${meta.objectStorageUseSsl ? "https" : "http"}://${
meta.objectStorageEndpoint
}${meta.objectStoragePort ? `:${meta.objectStoragePort}` : ""}/${
meta.objectStorageBucket
}`
: null;
if (baseUrl !== null && url.startsWith(baseUrl)) return url;
return `${config.url}/proxy/${encodeURIComponent(
new URL(url).pathname,
)}?${query({ url: url })}`;
},
async getFinalUrlMaybe(url?: string | null): Promise<string | null> {
if (url == null) return null;
return this.getFinalUrl(url);
},
});

View file

@ -10,6 +10,7 @@ import {
Followings,
Polls,
Channels,
UserProfiles,
Notes,
} from "../index.js";
import type { Packed } from "@/misc/schema.js";
@ -28,6 +29,7 @@ import {
} from "@/misc/populate-emojis.js";
import { db } from "@/db/postgre.js";
import { IdentifiableError } from "@/misc/identifiable-error.js";
import { config } from "@/config.js";
export async function populatePoll(note: Note, meId: User["id"] | null) {
const poll = await Polls.findOneByOrFail({ noteId: note.id });
@ -150,6 +152,29 @@ export const NoteRepository = db.getRepository(Note).extend({
return true;
},
async mentionedRemoteUsers(note: Note): Promise<string | undefined> {
if (note.mentions?.length) {
const mentionedUserIds = [...new Set(note.mentions)].sort();
const mentionedUsers = await Users.findBy({
id: In(mentionedUserIds),
});
const userProfiles = await UserProfiles.findBy({
userId: In(mentionedUserIds),
});
return JSON.stringify(
mentionedUsers.map((u) => ({
username: u.username,
host: u.host ?? config.host,
uri: u.uri ?? `${config.url}/users/${u.id}`,
url:
userProfiles.find((p) => p.userId === u.id)?.url ??
`${config.url}/@${u.username}`,
})),
);
}
return undefined;
},
async pack(
src: Note["id"] | Note,
me?: { id: User["id"] } | null | undefined,
@ -288,6 +313,7 @@ export const NoteRepository = db.getRepository(Note).extend({
}
: {}),
lang: note.lang,
mentionedRemoteUsers: this.mentionedRemoteUsers(note),
});
if (

View file

@ -0,0 +1,87 @@
import { db } from "@/db/postgre.js";
import { UserProfile } from "@/models/entities/user-profile.js";
import mfm from "mfm-js";
import { extractMentions } from "@/misc/extract-mentions.js";
import {
type ProfileMention,
resolveMentionToUserAndProfile,
} from "@/remote/resolve-user.js";
import type {
IMentionedRemoteUser,
IMentionedRemoteUsers,
} from "@/models/entities/note.js";
import { unique } from "@/prelude/array.js";
import { config } from "@/config.js";
import { Mutex, Semaphore } from "async-mutex";
const queue = new Semaphore(5);
export const UserProfileRepository = db.getRepository(UserProfile).extend({
// We must never await this without promiseEarlyReturn, otherwise giant webring-style profile mention trees will cause the queue to stop working
async updateMentions(
id: UserProfile["userId"],
limiter: RecursionLimiter = new RecursionLimiter(),
) {
const profile = await this.findOneBy({ userId: id });
if (!profile) return;
const tokens: mfm.MfmNode[] = [];
if (profile.description) tokens.push(...mfm.parse(profile.description));
if (profile.fields.length > 0)
tokens.push(
...profile.fields.flatMap((p) =>
mfm.parse(p.value).concat(mfm.parse(p.name)),
),
);
return queue.runExclusive(async () => {
const partial = {
mentions: await populateMentions(tokens, profile.userHost, limiter),
};
return UserProfileRepository.update(profile.userId, partial);
});
},
});
async function populateMentions(
tokens: mfm.MfmNode[],
objectHost: string | null,
limiter: RecursionLimiter,
): Promise<IMentionedRemoteUsers> {
const mentions = extractMentions(tokens);
const resolved = await Promise.all(
mentions.map((m) =>
resolveMentionToUserAndProfile(m.username, m.host, objectHost, limiter),
),
);
const remote = resolved.filter(
(p): p is ProfileMention =>
!!p &&
p.data.host !== config.host &&
(p.data.host !== null || objectHost !== null),
);
const res = remote.map((m) => {
return {
uri: m.user.uri,
url: m.profile?.url ?? undefined,
username: m.data.username,
host: m.data.host,
} as IMentionedRemoteUser;
});
return unique(res);
}
export class RecursionLimiter {
private counter;
private mutex = new Mutex();
constructor(count = 10) {
this.counter = count;
}
public shouldContinue(): Promise<boolean> {
return this.mutex.runExclusive(() => {
return this.counter-- > 0;
});
}
}

View file

@ -51,6 +51,17 @@ export function unique<T>(xs: T[]): T[] {
return [...new Set(xs)];
}
/**
* Filters an array of elements based on unique outputs of a key function
*/
export function uniqBy<T, U>(a: T[], key: (elm: T) => U): T[] {
const seen = new Set<U>();
return a.filter((item) => {
const k = key(item);
return seen.has(k) ? false : seen.add(k);
});
}
export function sum(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);
}
@ -150,3 +161,7 @@ export function toArray<T>(x: T | T[] | undefined): T[] {
export function toSingle<T>(x: T | T[] | undefined): T | undefined {
return Array.isArray(x) ? x[0] : x;
}
export function toSingleLast<T>(x: T | T[] | undefined): T | undefined {
return Array.isArray(x) ? x.at(-1) : x;
}

View file

@ -4,7 +4,27 @@ export type Promiseable<T> = {
[K in keyof T]: Promise<T[K]> | T[K];
};
export async function awaitAll<T>(obj: Promiseable<T>): Promise<T> {
type RecursiveResolvePromise<U> = U extends Date
? U
: U extends Array<infer V>
? Array<ResolvedPromise<V>>
: U extends object
? { [key in keyof U]: ResolvedPromise<U[key]> }
: U;
type ResolvedPromise<T> = T extends Promise<infer U>
? RecursiveResolvePromise<U>
: RecursiveResolvePromise<T>;
export type OuterPromise<T> = Promise<{
[K in keyof T]: ResolvedPromise<T[K]>;
}>;
/**
* Resolve all promises in the object recursively,
* and return a promise that resolves to the object with all promises resolved.
*/
export async function awaitAll<T>(obj: Promiseable<T>): OuterPromise<T> {
const target = {} as T;
const keys = unsafeCast<(keyof T)[]>(Object.keys(obj));
const values = Object.values(obj) as any[];
@ -21,5 +41,5 @@ export async function awaitAll<T>(obj: Promiseable<T>): Promise<T> {
target[keys[i]] = resolvedValues[i];
}
return target;
return target as OuterPromise<T>;
}

View file

@ -0,0 +1,13 @@
/**
* Returns T if promise settles before timeout,
* otherwise returns void, finishing execution in the background.
*/
export async function promiseEarlyReturn<T>(
promise: Promise<T>,
after: number,
): Promise<T | void> {
const timer: Promise<void> = new Promise((res) =>
setTimeout(() => res(undefined), after),
);
return Promise.race([promise, timer]);
}

View file

@ -3,21 +3,53 @@ import chalk from "chalk";
import { IsNull } from "typeorm";
import { config } from "@/config.js";
import type { User, IRemoteUser } from "@/models/entities/user.js";
import { Users } from "@/models/index.js";
import { Cache } from "@/misc/cache.js";
import { UserProfiles, Users } from "@/models/index.js";
import { toPuny } from "backend-rs";
import webFinger from "./webfinger.js";
import { createPerson, updatePerson } from "./activitypub/models/person.js";
import { remoteLogger } from "./logger.js";
import { inspect } from "node:util";
import type { UserProfile } from "@/models/entities/user-profile.js";
import { RecursionLimiter } from "@/models/repositories/user-profile.js";
import { promiseEarlyReturn } from "@/prelude/promise.js";
import type { IMentionedRemoteUsers } from "@/models/entities/note.js";
const logger = remoteLogger.createSubLogger("resolve-user");
const localUsernameCache = new Cache<string | null>(
"localUserNameCapitalization",
60 * 60 * 24,
);
const profileMentionCache = new Cache<ProfileMention | null>(
"resolveProfileMentions",
60 * 60,
);
export type ProfileMention = {
user: User;
profile: UserProfile | null;
data: {
username: string;
host: string | null;
};
};
type refreshType =
| "refresh"
| "refresh-in-background"
| "refresh-timeout-1500ms"
| "no-refresh";
export async function resolveUser(
username: string,
host: string | null,
refresh: refreshType = "refresh",
limiter: RecursionLimiter = new RecursionLimiter(),
): Promise<User> {
const usernameLower = username.toLowerCase();
// Return local user if host part is empty
if (host == null) {
logger.info(`return local user: ${usernameLower}`);
return await Users.findOneBy({ usernameLower, host: IsNull() }).then(
@ -33,7 +65,9 @@ export async function resolveUser(
host = toPuny(host);
if (config.host === host) {
// Also return local user if host part is specified but referencing the local instance
if (config.host === host || config.host === host) {
logger.info(`return local user: ${usernameLower}`);
return await Users.findOneBy({ usernameLower, host: IsNull() }).then(
(u) => {
@ -46,24 +80,63 @@ export async function resolveUser(
);
}
const user = (await Users.findOneBy({
// Check if remote user is already in the database
let user = (await Users.findOneBy({
usernameLower,
host,
})) as IRemoteUser | null;
const acctLower = `${usernameLower}@${host}`;
if (user == null) {
const self = await resolveSelf(acctLower);
// If not, look up the user on the remote server
logger.info(`return new remote user: ${chalk.magenta(acctLower)}`);
return await createPerson(self.href);
if (user == null) {
// Run WebFinger
const fingerRes = await resolveUserWebFinger(acctLower);
const finalAcct = subjectToAcct(fingerRes.subject);
const finalAcctLower = finalAcct.toLowerCase();
const m = finalAcct.match(/^([^@]+)@(.*)/);
const subjectHost = m ? m[2] : undefined;
// If subject is different, we're dealing with a split domain setup (that's already been validated by resolveUserWebFinger)
if (acctLower !== finalAcctLower) {
logger.info("re-resolving split domain redirect user...");
const m = finalAcct.match(/^([^@]+)@(.*)/);
if (m) {
// Re-check if we already have the user in the database post-redirect
user = (await Users.findOneBy({
usernameLower: usernameLower,
host: subjectHost,
})) as IRemoteUser | null;
// If yes, return existing user
if (user != null) {
logger.info(
`return existing remote user: ${chalk.magenta(finalAcctLower)}`,
);
return user;
}
// Otherwise create and return new user
else {
logger.info(
`return new remote user: ${chalk.magenta(finalAcctLower)}`,
);
return await createPerson(fingerRes.self.href);
}
}
}
// Not a split domain setup, so we can simply create and return the new user
logger.info(`return new remote user: ${chalk.magenta(finalAcctLower)}`);
return await createPerson(fingerRes.self.href);
}
// If user information is out of date, return it by starting over from WebFilger
// If user information is out of date, return it by starting over from WebFinger
if (
user.lastFetchedAt == null ||
Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24
(refresh === "refresh" || refresh === "refresh-timeout-1500ms") &&
(user.lastFetchedAt == null ||
Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24)
) {
// Prevent multiple attempts to connect to unconnected instances, update before each attempt to prevent subsequent similar attempts
await Users.update(user.id, {
@ -71,17 +144,17 @@ export async function resolveUser(
});
logger.info(`try resync: ${acctLower}`);
const self = await resolveSelf(acctLower);
const fingerRes = await resolveUserWebFinger(acctLower);
if (user.uri !== self.href) {
if (user.uri !== fingerRes.self.href) {
// if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping.
logger.info(`uri missmatch: ${acctLower}`);
logger.info(
`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`,
`recovery mismatch uri for (username=${username}, host=${host}) from ${user.uri} to ${fingerRes.self.href}`,
);
// validate uri
const uri = new URL(self.href);
const uri = new URL(fingerRes.self.href);
if (uri.hostname !== host) {
throw new Error("Invalid uri");
}
@ -92,23 +165,60 @@ export async function resolveUser(
host: host,
},
{
uri: self.href,
uri: fingerRes.self.href,
},
);
} else {
logger.info(`uri is fine: ${acctLower}`);
}
await updatePerson(self.href);
const finalAcct = subjectToAcct(fingerRes.subject);
const finalAcctLower = finalAcct.toLowerCase();
const m = finalAcct.match(/^([^@]+)@(.*)/);
const finalHost = m ? m[2] : null;
logger.info(`return resynced remote user: ${acctLower}`);
return await Users.findOneBy({ uri: self.href }).then((u) => {
// Update user.host if we're dealing with an account that's part of a split domain setup that hasn't been fixed yet
if (m && user.host !== finalHost) {
logger.info(
`updating user host to subject acct host: ${user.host} -> ${finalHost}`,
);
await Users.update(
{
usernameLower,
host: user.host,
},
{
host: finalHost,
},
);
}
if (refresh === "refresh") {
await updatePerson(fingerRes.self.href);
logger.info(`return resynced remote user: ${finalAcctLower}`);
} else if (refresh === "refresh-timeout-1500ms") {
const res = await promiseEarlyReturn(
updatePerson(fingerRes.self.href),
1500,
);
logger.info(`return possibly resynced remote user: ${finalAcctLower}`);
}
return await Users.findOneBy({ uri: fingerRes.self.href }).then((u) => {
if (u == null) {
throw new Error("user not found");
} else {
return u;
}
});
} else if (
refresh === "refresh-in-background" &&
(user.lastFetchedAt == null ||
Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24)
) {
// Run the refresh in the background
// noinspection ES6MissingAwait
resolveUser(username, host, "refresh", limiter);
}
logger.info(`return existing remote user: ${acctLower}`);
@ -136,3 +246,185 @@ async function resolveSelf(acctLower: string) {
}
return self;
}
async function getLocalUsernameCached(
username: string,
): Promise<string | null> {
return localUsernameCache.fetch(username.toLowerCase(), () =>
Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull(),
}).then((p) => (p ? p.username : null)),
);
}
export function getMentionFallbackUri(
username: string,
host: string | null,
objectHost: string | null,
): string {
let fallback = `${config.url}/@${username}`;
if (host !== null && host !== config.host) fallback += `@${host}`;
else if (
objectHost !== null &&
objectHost !== config.host &&
host !== config.host
)
fallback += `@${objectHost}`;
return fallback;
}
export async function resolveMentionFromCache(
username: string,
host: string | null,
objectHost: string | null,
cache: IMentionedRemoteUsers,
): Promise<{
username: string;
href: string;
host: string;
isLocal: boolean;
} | null> {
const isLocal =
(host === null && objectHost === null) || host === config.host;
if (isLocal) {
const finalUsername = await getLocalUsernameCached(username);
if (finalUsername === null) return null;
username = finalUsername;
}
const fallback = getMentionFallbackUri(username, host, objectHost);
const cached = cache.find(
(r) =>
r.username.toLowerCase() === username.toLowerCase() &&
r.host === (host ?? objectHost),
);
const href = cached?.url ?? cached?.uri;
if (cached && href != null)
return {
username: cached.username,
href: href,
isLocal,
host: cached.host,
};
if (isLocal)
return { username: username, href: fallback, isLocal, host: config.host };
return null;
}
export async function resolveMentionToUserAndProfile(
username: string,
host: string | null,
objectHost: string | null,
limiter: RecursionLimiter,
) {
return profileMentionCache.fetch(
`${username}@${host ?? objectHost}`,
async () => {
try {
const user = await resolveUser(
username,
host ?? objectHost,
"no-refresh",
limiter,
);
const profile = await UserProfiles.findOneBy({ userId: user.id });
const data = { username, host: host ?? objectHost };
return { user, profile, data };
} catch {
return null;
}
},
);
}
async function resolveUserWebFinger(
acctLower: string,
recurse = true,
): Promise<{
subject: string;
self: {
href: string;
rel?: string;
};
}> {
logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
const fingerRes = await webFinger(acctLower).catch((e) => {
logger.error(
`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${
e.statusCode || e.message
}`,
);
throw new Error(
`Failed to WebFinger for ${acctLower}: ${e.statusCode || e.message}`,
);
});
const self = fingerRes.links.find(
(link) => link.rel != null && link.rel.toLowerCase() === "self",
);
if (!self) {
logger.error(
`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`,
);
throw new Error("self link not found");
}
if (`${acctToSubject(acctLower)}` !== normalizeSubject(fingerRes.subject)) {
logger.info(
`acct subject mismatch (${acctToSubject(
acctLower,
)} !== ${normalizeSubject(
fingerRes.subject,
)}), possible split domain deployment detected, repeating webfinger`,
);
if (!recurse) {
logger.error(
"split domain verification failed (recurse limit reached), aborting",
);
throw new Error(
"split domain verification failed (recurse limit reached), aborting",
);
}
const initialAcct = subjectToAcct(fingerRes.subject);
const initialAcctLower = initialAcct.toLowerCase();
const splitFingerRes = await resolveUserWebFinger(initialAcctLower, false);
const finalAcct = subjectToAcct(splitFingerRes.subject);
const finalAcctLower = finalAcct.toLowerCase();
if (initialAcct !== finalAcct) {
logger.error(
"split domain verification failed (subject mismatch), aborting",
);
throw new Error(
"split domain verification failed (subject mismatch), aborting",
);
}
logger.info(
`split domain configuration detected: ${acctLower} -> ${finalAcctLower}`,
);
return splitFingerRes;
}
return {
subject: fingerRes.subject,
self: self,
};
}
function subjectToAcct(subject: string): string {
if (!subject.startsWith("acct:")) {
logger.error("Subject isnt a valid acct");
throw "Subject isnt a valid acct";
}
return subject.substring(5);
}
function acctToSubject(acct: string): string {
return normalizeSubject(`acct:${acct}`);
}
function normalizeSubject(subject: string): string {
return subject.toLowerCase();
}

View file

@ -0,0 +1,59 @@
import { Brackets, type SelectQueryBuilder } from "typeorm";
import type { User } from "@/models/entities/user.js";
import { Followings, Notes } from "@/models/index.js";
import { Cache } from "@/misc/cache.js";
import { apiLogger } from "@/server/api/logger.js";
const cache = new Cache<number>("homeTlQueryData", 60 * 60 * 24);
const cutoff = 250; // 250 posts in the last 7 days, constant determined by comparing benchmarks for cutoff values between 100 and 2500
const logger = apiLogger.createSubLogger("heuristics");
export async function generateFollowingQuery(
q: SelectQueryBuilder<any>,
me: { id: User["id"] },
): Promise<void> {
const followingQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId");
const heuristic = await cache.fetch(me.id, async () => {
const curr = new Date();
const prev = new Date();
prev.setDate(prev.getDate() - 7);
return Notes.createQueryBuilder("note")
.where("note.createdAt > :prev", { prev })
.andWhere("note.createdAt < :curr", { curr })
.andWhere(
new Brackets((qb) => {
qb.where(`note.userId IN (${followingQuery.getQuery()})`);
qb.orWhere("note.userId = :meId", { meId: me.id });
}),
)
.getCount()
.then((res) => {
logger.info(
`Calculating heuristics for user ${me.id} took ${
new Date().getTime() - curr.getTime()
}ms`,
);
return res;
});
});
const shouldUseUnion = heuristic < cutoff;
q.andWhere(
new Brackets((qb) => {
if (shouldUseUnion) {
qb.where(
`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`,
);
} else {
qb.where("note.userId = :meId");
qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`);
}
}),
);
q.setParameters({ meId: me.id });
}

View file

@ -0,0 +1,430 @@
import {
Brackets,
type SelectQueryBuilder,
type WhereExpressionBuilder,
} from "typeorm";
import { sqlLikeEscape, sqlRegexEscape } from "backend-rs";
import {
Followings,
NoteFavorites,
NoteReactions,
Users,
} from "@/models/index.js";
import type { Note } from "@/models/entities/note";
import type { Following } from "@/models/entities/following";
import type { NoteFavorite } from "@/models/entities/note-favorite";
const filters = {
from: fromFilter,
"-from": fromFilterInverse,
mention: mentionFilter,
"-mention": mentionFilterInverse,
reply: replyFilter,
"-reply": replyFilterInverse,
to: replyFilter,
"-to": replyFilterInverse,
before: beforeFilter,
until: beforeFilter,
after: afterFilter,
since: afterFilter,
instance: instanceFilter,
"-instance": instanceFilterInverse,
domain: instanceFilter,
"-domain": instanceFilterInverse,
host: instanceFilter,
"-host": instanceFilterInverse,
filter: miscFilter,
"-filter": miscFilterInverse,
in: inFilter,
"-in": inFilterInverse,
has: attachmentFilter,
} as Record<
string,
(query: SelectQueryBuilder<Note>, search: string, id: number) => void
>;
export function generateFtsQuery(
query: SelectQueryBuilder<Note>,
q: string,
): void {
const components = q.trim().split(" ");
const terms: string[] = [];
const finalTerms: string[] = [];
let counter = 0;
let caseSensitive = false;
let matchWords = false;
for (const component of components) {
const split = component.split(":");
if (split.length > 1 && filters[split[0]] !== undefined)
filters[split[0]](query, split.slice(1).join(":"), counter++);
else if (
split.length > 1 &&
(split[0] === "search" || split[0] === "match")
)
matchWords = split[1] === "word" || split[1] === "words";
else if (split.length > 1 && split[0] === "case")
caseSensitive = split[1] === "sensitive";
else terms.push(component);
}
let idx = 0;
let state: "idle" | "quote" | "parenthesis" = "idle";
for (let i = 0; i < terms.length; i++) {
if (state === "idle") {
if (
(terms[i].startsWith('"') && terms[i].endsWith('"')) ||
(terms[i].startsWith("(") && terms[i].endsWith(")"))
) {
finalTerms.push(trimStartAndEnd(terms[i]));
} else if (terms[i].startsWith('"')) {
idx = i;
state = "quote";
} else if (terms[i].startsWith("(")) {
idx = i;
state = "parenthesis";
} else {
finalTerms.push(terms[i]);
}
} else if (state === "quote" && terms[i].endsWith('"')) {
finalTerms.push(extractToken(terms, idx, i));
state = "idle";
} else if (state === "parenthesis" && terms[i].endsWith(")")) {
query.andWhere(
new Brackets((qb) => {
for (const term of extractToken(terms, idx, i).split(" OR ")) {
const id = counter++;
appendSearchQuery(
term,
"or",
query,
qb,
id,
term.startsWith("-"),
matchWords,
caseSensitive,
);
}
}),
);
state = "idle";
}
}
if (state !== "idle") {
finalTerms.push(
...extractToken(terms, idx, terms.length - 1, false)
.substring(1)
.split(" "),
);
}
for (const term of finalTerms) {
const id = counter++;
appendSearchQuery(
term,
"and",
query,
query,
id,
term.startsWith("-"),
matchWords,
caseSensitive,
);
}
}
function fromFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function fromFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function mentionFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true,
});
query.andWhere(
`note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[]`,
);
query.setParameters(userQuery.getParameters());
}
function mentionFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true,
});
query.andWhere(
`NOT (note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[])`,
);
query.setParameters(userQuery.getParameters());
}
function replyFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function replyFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function beforeFilter(query: SelectQueryBuilder<Note>, filter: string) {
query.andWhere("note.createdAt < :before", { before: filter });
}
function afterFilter(query: SelectQueryBuilder<Note>, filter: string) {
query.andWhere("note.createdAt > :after", { after: filter });
}
function instanceFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
if (filter === "local") {
query.andWhere("note.userHost IS NULL");
} else {
query.andWhere(`note.userHost = :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function instanceFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
if (filter === "local") {
query.andWhere("note.userHost IS NOT NULL");
} else {
query.andWhere(`note.userHost <> :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function miscFilter(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<Following> | null = null;
if (filter === "followers") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followerId")
.where("following.followeeId = :meId");
} else if (filter === "following") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId");
} else if (filter === "replies" || filter === "reply") {
query.andWhere("note.replyId IS NOT NULL");
} else if (
filter === "boosts" ||
filter === "boost" ||
filter === "renotes" ||
filter === "renote"
) {
query.andWhere("note.renoteId IS NOT NULL");
}
if (subQuery !== null)
query.andWhere(`note.userId IN (${subQuery.getQuery()})`);
}
function miscFilterInverse(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<Following> | null = null;
if (filter === "followers") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followerId")
.where("following.followeeId = :meId");
} else if (filter === "following") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId");
} else if (filter === "replies" || filter === "reply") {
query.andWhere("note.replyId IS NULL");
} else if (
filter === "boosts" ||
filter === "boost" ||
filter === "renotes" ||
filter === "renote"
) {
query.andWhere("note.renoteId IS NULL");
}
if (subQuery !== null)
query.andWhere(`note.userId NOT IN (${subQuery.getQuery()})`);
}
function inFilter(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<NoteFavorite> | null = null;
if (filter === "bookmarks") {
subQuery = NoteFavorites.createQueryBuilder("bookmark")
.select("bookmark.noteId")
.where("bookmark.userId = :meId");
} else if (
filter === "favorites" ||
filter === "favourites" ||
filter === "reactions" ||
filter === "likes"
) {
subQuery = NoteReactions.createQueryBuilder("react")
.select("react.noteId")
.where("react.userId = :meId");
}
if (subQuery !== null) query.andWhere(`note.id IN (${subQuery.getQuery()})`);
}
function inFilterInverse(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<NoteFavorite> | null = null;
if (filter === "bookmarks") {
subQuery = NoteFavorites.createQueryBuilder("bookmark")
.select("bookmark.noteId")
.where("bookmark.userId = :meId");
} else if (
filter === "favorites" ||
filter === "favourites" ||
filter === "reactions" ||
filter === "likes"
) {
subQuery = NoteReactions.createQueryBuilder("react")
.select("react.noteId")
.where("react.userId = :meId");
}
if (subQuery !== null)
query.andWhere(`note.id NOT IN (${subQuery.getQuery()})`);
}
function attachmentFilter(query: SelectQueryBuilder<Note>, filter: string) {
switch (filter) {
case "image":
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%image/%'`);
break;
case "video":
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%video/%'`);
break;
case "audio":
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%audio/%'`);
break;
case "file":
query.andWhere(`note."attachedFileTypes" <> '{}'`);
query.andWhere(
`NOT (note."attachedFileTypes"::varchar ILIKE '%image/%')`,
);
query.andWhere(
`NOT (note."attachedFileTypes"::varchar ILIKE '%video/%')`,
);
query.andWhere(
`NOT (note."attachedFileTypes"::varchar ILIKE '%audio/%')`,
);
break;
default:
break;
}
}
function generateUserSubquery(filter: string, id: number) {
if (filter.startsWith("@")) filter = filter.substring(1);
const split = filter.split("@");
const query = Users.createQueryBuilder("user")
.select("user.id")
.where(`user.usernameLower = :user_${id}`)
.andWhere(
`user.host ${split[1] !== undefined ? `= :host_${id}` : "IS NULL"}`,
);
query.setParameter(`user_${id}`, split[0].toLowerCase());
if (split[1] !== undefined)
query.setParameter(`host_${id}`, split[1].toLowerCase());
return query;
}
function extractToken(
array: string[],
start: number,
end: number,
trim = true,
) {
const slice = array.slice(start, end + 1).join(" ");
return trim ? trimStartAndEnd(slice) : slice;
}
function trimStartAndEnd(str: string) {
return str.substring(1, str.length - 1);
}
function appendSearchQuery(
term: string,
mode: "and" | "or",
query: SelectQueryBuilder<Note>,
qb: SelectQueryBuilder<Note> | WhereExpressionBuilder,
id: number,
negate: boolean,
matchWords: boolean,
caseSensitive: boolean,
) {
const sql = `note.text ${getSearchMatchOperator(
negate,
matchWords,
caseSensitive,
)} :q_${id}`;
if (mode === "and") qb.andWhere(sql);
else if (mode === "or") qb.orWhere(sql);
query.setParameter(
`q_${id}`,
escapeSqlSearchParam(term.substring(negate ? 1 : 0), matchWords),
);
}
function getSearchMatchOperator(
negate: boolean,
matchWords: boolean,
caseSensitive: boolean,
) {
return `${negate ? "NOT " : ""}${
matchWords ? (caseSensitive ? "~" : "~*") : caseSensitive ? "LIKE" : "ILIKE"
}`;
}
function escapeSqlSearchParam(param: string, matchWords: boolean) {
return matchWords
? `\\y${sqlRegexEscape(param)}\\y`
: `%${sqlLikeEscape(param)}%`;
}

View file

@ -0,0 +1,25 @@
import { Brackets, type SelectQueryBuilder } from "typeorm";
import type { User } from "@/models/entities/user.js";
import { UserListJoinings, UserLists } from "@/models/index.js";
export function generateListQuery(
q: SelectQueryBuilder<any>,
me: { id: User["id"] },
): void {
const listQuery = UserLists.createQueryBuilder("list")
.select("list.id")
.andWhere("list.userId = :meId");
const memberQuery = UserListJoinings.createQueryBuilder("member")
.select("member.userId")
.where(`member.userListId IN (${listQuery.getQuery()})`);
q.andWhere(
new Brackets((qb) => {
qb.where("note.userId = :meId");
qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`);
}),
);
q.setParameters({ meId: me.id });
}

View file

@ -1,12 +1,8 @@
import { Notes } from "@/models/index.js";
import type { Note } from "@/models/entities/note.js";
import define from "@/server/api/define.js";
import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js";
import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js";
import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js";
import { sqlLikeEscape } from "backend-rs";
import type { SelectQueryBuilder } from "typeorm";
import { searchNotes, type SearchParams } from "@/misc/search.js";
export const meta = {
tags: ["notes"],
@ -69,76 +65,23 @@ export const paramDef = {
} as const;
export default define(meta, paramDef, async (ps, me) => {
async function search(
modifier?: (query: SelectQueryBuilder<Note>) => void,
): Promise<Note[]> {
const query = makePaginationQuery(
Notes.createQueryBuilder("note"),
ps.sinceId,
ps.untilId,
ps.sinceDate ?? undefined,
ps.untilDate ?? undefined,
);
modifier?.(query);
if (ps.userId != null) {
query.andWhere("note.userId = :userId", { userId: ps.userId });
}
if (ps.channelId != null) {
query.andWhere("note.channelId = :channelId", {
channelId: ps.channelId,
});
}
query.innerJoinAndSelect("note.user", "user");
// "from: me": search all (public, home, followers, specified) my posts
// otherwise: search public indexable posts only
if (ps.userId == null || ps.userId !== me?.id) {
query
.andWhere("note.visibility = 'public'")
.andWhere("user.isIndexable = TRUE");
}
if (ps.userId != null) {
query.andWhere("note.userId = :userId", { userId: ps.userId });
}
if (ps.host === null) {
query.andWhere("note.userHost IS NULL");
}
if (ps.host != null) {
query.andWhere("note.userHost = :userHost", { userHost: ps.host });
}
if (ps.withFiles === true) {
query.andWhere("note.fileIds != '{}'");
}
query
.leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner")
.leftJoinAndSelect("note.reply", "reply")
.leftJoinAndSelect("note.renote", "renote")
.leftJoinAndSelect("reply.user", "replyUser")
.leftJoinAndSelect("replyUser.avatar", "replyUserAvatar")
.leftJoinAndSelect("replyUser.banner", "replyUserBanner")
.leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
generateVisibilityQuery(query, me);
if (me) generateMutedUserQuery(query, me);
if (me) generateBlockedUserQuery(query, me);
return await query.take(ps.limit).getMany();
}
let notes: Note[];
const params: SearchParams = {
withFilesOnly: ps.withFiles ?? false,
limit: ps.limit,
myId: me?.id,
sinceId: ps.sinceId,
untilId: ps.untilId,
sinceDate: ps.sinceDate ?? undefined,
untilDate: ps.untilDate ?? undefined,
userId: ps.userId,
channelId: ps.channelId,
host: ps.host,
};
if (ps.query != null) {
const q = sqlLikeEscape(ps.query);
const searchWord = sqlLikeEscape(ps.query);
if (ps.searchCwAndAlt) {
// Whether we should return latest notes first
@ -159,15 +102,15 @@ export default define(meta, paramDef, async (ps, me) => {
...new Map(
(
await Promise.all([
search((query) => {
query.andWhere("note.text &@~ :q", { q });
searchNotes(params, (query) => {
query.andWhere("note.text &@~ :q", { q: searchWord });
}),
search((query) => {
query.andWhere("note.cw &@~ :q", { q });
searchNotes(params, (query) => {
query.andWhere("note.cw &@~ :q", { q: searchWord });
}),
search((query) => {
searchNotes(params, (query) => {
query
.andWhere("drive_file.comment &@~ :q", { q })
.andWhere("drive_file.comment &@~ :q", { q: searchWord })
.innerJoin("note.files", "drive_file");
}),
])
@ -179,12 +122,12 @@ export default define(meta, paramDef, async (ps, me) => {
.sort(compare)
.slice(0, ps.limit);
} else {
notes = await search((query) => {
query.andWhere("note.text &@~ :q", { q });
notes = await searchNotes(params, (query) => {
query.andWhere("note.text &@~ :q", { q: searchWord });
});
}
} else {
notes = await search();
notes = await searchNotes(params);
}
return await Notes.packMany(notes, me);

View file

@ -7,10 +7,7 @@ import Router from "@koa/router";
import multer from "@koa/multer";
import bodyParser from "koa-bodyparser";
import cors from "@koa/cors";
import {
apiMastodonCompatible,
getClient,
} from "./mastodon/ApiMastodonCompatibleService.js";
import { setupMastodonApi } from "./mastodon/index.js";
import { AccessTokens, Users } from "@/models/index.js";
import { config } from "@/config.js";
import endpoints from "./endpoints.js";
@ -20,10 +17,6 @@ import signup from "./private/signup.js";
import signin from "./private/signin.js";
import signupPending from "./private/signup-pending.js";
import verifyEmail from "./private/verify-email.js";
import { koaBody } from "koa-body";
import { convertAttachment } from "./mastodon/converters.js";
import { apiLogger } from "./logger.js";
import { inspect } from "node:util";
// Init app
const app = new Koa();
@ -66,64 +59,7 @@ router.use(
}),
);
mastoRouter.use(
koaBody({
multipart: true,
urlencoded: true,
}),
);
mastoFileRouter.post("/v1/media", upload.single("file"), async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: "No image" };
ctx.status = 401;
return;
}
const data = await client.uploadMedia(multipartData);
ctx.body = convertAttachment(data.data as Entity.Attachment);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
mastoFileRouter.post("/v2/media", upload.single("file"), async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: "No image" };
ctx.status = 401;
return;
}
const data = await client.uploadMedia(multipartData, ctx.request.body);
ctx.body = convertAttachment(data.data as Entity.Attachment);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
apiMastodonCompatible(mastoRouter);
setupMastodonApi(mastoRouter);
/**
* Register endpoint handlers

View file

@ -1,163 +0,0 @@
import Router from "@koa/router";
import megalodon, { MegalodonInterface } from "megalodon";
import { apiAuthMastodon } from "./endpoints/auth.js";
import { apiAccountMastodon } from "./endpoints/account.js";
import { apiStatusMastodon } from "./endpoints/status.js";
import { apiFilterMastodon } from "./endpoints/filter.js";
import { apiTimelineMastodon } from "./endpoints/timeline.js";
import { apiNotificationsMastodon } from "./endpoints/notifications.js";
import { apiSearchMastodon } from "./endpoints/search.js";
import { getInstance } from "./endpoints/meta.js";
import {
convertAccount,
convertAnnouncement,
convertFilter,
} from "./converters.js";
import { fromMastodonId } from "backend-rs";
import { Users } from "@/models/index.js";
import { IsNull } from "typeorm";
import { apiLogger } from "../logger.js";
import { inspect } from "node:util";
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(BASE_URL, accessToken) as MegalodonInterface;
return client;
}
export function apiMastodonCompatible(router: Router): void {
apiAuthMastodon(router);
apiAccountMastodon(router);
apiStatusMastodon(router);
apiFilterMastodon(router);
apiTimelineMastodon(router);
apiNotificationsMastodon(router);
apiSearchMastodon(router);
router.get("/v1/custom_emojis", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/instance", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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 Users.findOne({
where: {
host: IsNull(),
isAdmin: true,
isDeleted: false,
isSuspended: false,
},
order: { id: "ASC" },
});
const contact =
admin == null
? null
: convertAccount((await client.getAccount(admin.id)).data);
ctx.body = await getInstance(data.data, contact);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/announcements", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
ctx.body = data.data.map((announcement) =>
convertAnnouncement(announcement),
);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>(
"/v1/announcements/:id/dismiss",
async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.dismissInstanceAnnouncement(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/trends", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/preferences", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -1,95 +0,0 @@
import type { Entity } from "megalodon";
import { toMastodonId } from "backend-rs";
function simpleConvert(data: any) {
// copy the object to bypass weird pass by reference bugs
const result = Object.assign({}, data);
result.id = toMastodonId(data.id);
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) {
const converted = simpleConvert(attachment);
// ref: https://github.com/whitescent/Mastify/pull/102
if (converted.meta == null) return converted;
const result = {
...converted,
meta: {
...converted.meta,
original: {
...converted.meta,
},
},
};
return result;
}
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);
notification.id = toMastodonId(notification.id);
if (notification.status)
notification.status = convertStatus(notification.status);
if (notification.reaction)
notification.reaction = convertReaction(notification.reaction);
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.id = toMastodonId(status.id);
if (status.in_reply_to_account_id)
status.in_reply_to_account_id = toMastodonId(status.in_reply_to_account_id);
if (status.in_reply_to_id)
status.in_reply_to_id = toMastodonId(status.in_reply_to_id);
status.media_attachments = status.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
status.mentions = status.mentions.map((mention) => ({
...mention,
id: toMastodonId(mention.id),
}));
if (status.poll) status.poll = convertPoll(status.poll);
if (status.reblog) status.reblog = convertStatus(status.reblog);
if (status.quote) status.quote = convertStatus(status.quote);
status.reactions = status.reactions.map(convertReaction);
return status;
}
export function convertConversation(conversation: Entity.Conversation) {
conversation.id = toMastodonId(conversation.id);
conversation.accounts = conversation.accounts.map(convertAccount);
if (conversation.last_status) {
conversation.last_status = convertStatus(conversation.last_status);
}
return conversation;
}

View file

@ -0,0 +1,55 @@
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import mfm from "mfm-js";
import type { Announcement } from "@/models/entities/announcement.js";
import type { MastoContext } from "..";
import { I18n } from "@/misc/i18n.js";
import locales from "../../../../../../../locales/index.mjs";
export class AnnouncementConverter {
public static async encode(
announcement: Announcement,
isRead: boolean,
lang = "en-US",
ctx: MastoContext,
): Promise<MastodonEntity.Announcement> {
const locale = locales[lang] || locales["en-US"];
const i18n = new I18n(locale);
return {
id: announcement.id,
content: `<h1>${
(await MfmHelpers.toHtml(
mfm.parse(announcement.title),
[],
null,
false,
null,
ctx,
)) ?? i18n.t("announcement")
}</h1>${
(await MfmHelpers.toHtml(
mfm.parse(announcement.text),
[],
null,
false,
null,
ctx,
)) ?? ""
}`,
starts_at: null,
ends_at: null,
published: true,
all_day: false,
published_at: announcement.createdAt.toISOString(),
updated_at:
announcement.updatedAt?.toISOString() ??
announcement.createdAt.toISOString(),
read: isRead,
mentions: [], //FIXME
statuses: [],
tags: [],
emojis: [], //FIXME
reactions: [],
};
}
}

View file

@ -0,0 +1,13 @@
import type { PopulatedEmoji } from "@/misc/populate-emojis.js";
export class EmojiConverter {
public static encode(e: PopulatedEmoji): MastodonEntity.Emoji {
return {
shortcode: e.name,
static_url: e.url,
url: e.url,
visible_in_picker: true,
category: undefined,
};
}
}

View file

@ -0,0 +1,54 @@
import type { Packed } from "@/misc/schema.js";
import type { MastoContext } from "..";
export class FileConverter {
public static encode(
f: Packed<"DriveFile">,
ctx?: MastoContext,
): MastodonEntity.Attachment {
return {
id: f.id,
type: this.encodefileType(f.type),
url: f.url ?? "",
remote_url: f.url,
preview_url: f.thumbnailUrl ?? null,
text_url: f.url,
meta: {
width: f.properties.width,
height: f.properties.height,
original: {
width: f.properties.width,
height: f.properties.height,
size:
f.properties.width && f.properties.height
? `${f.properties.width}x${f.properties.height}`
: undefined,
aspect:
f.properties.width && f.properties.height
? f.properties.width / f.properties.height
: undefined,
},
},
description: f.comment,
blurhash: f.blurhash,
};
}
private static encodefileType(
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";
}
}

View file

@ -0,0 +1,28 @@
import type { User } from "@/models/entities/user.js";
import { config } from "@/config.js";
import type { IMentionedRemoteUsers } from "@/models/entities/note.js";
export class MentionConverter {
public static encode(
u: User,
m: IMentionedRemoteUsers,
): MastodonEntity.Mention {
let acct = u.username;
let acctUrl = `https://${u.host || 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,
};
}
}

View file

@ -0,0 +1,9 @@
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>");

View file

@ -0,0 +1,669 @@
import type { ILocalUser, User } from "@/models/entities/user.js";
import { getNote } from "@/server/api/common/getters.js";
import type { Note } from "@/models/entities/note.js";
import { config } from "@/config.js";
import mfm, { type MfmLink, type MfmUrl } from "mfm-js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js";
import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js";
import {
aggregateNoteEmojis,
type PopulatedEmoji,
populateEmojis,
prefetchEmojis,
} from "@/misc/populate-emojis.js";
import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js";
import {
DriveFiles,
NoteFavorites,
NoteReactions,
Notes,
NoteThreadMutings,
UserNotePinings,
Users,
} from "@/models/index.js";
import { decodeReaction, isQuote, nyaify } from "backend-rs";
import { MentionConverter } from "@/server/api/mastodon/converters/mention.js";
import { PollConverter } from "@/server/api/mastodon/converters/poll.js";
import { populatePoll } from "@/models/repositories/note.js";
import { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { awaitAll } from "@/prelude/await-all.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { In, IsNull } from "typeorm";
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import {
getStubMastoContext,
type MastoContext,
} from "@/server/api/mastodon/index.js";
import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js";
import { unique } from "@/prelude/array.js";
import type { NoteReaction } from "@/models/entities/note-reaction.js";
import { Cache } from "@/misc/cache.js";
import { isFiltered } from "@/misc/is-filtered.js";
import { unfurl } from "unfurl.js";
export class NoteConverter {
private static cardCache = new Cache<MastodonEntity.Card | null>(
"note:card",
60 * 60,
);
private static applyNyaification(text: string, lang?: string) {
if (text === "") return "";
function nyaifyNode(node: mfm.MfmNode) {
if (node.type === "quote") return;
if (node.type === "text") node.props.text = nyaify(node.props.text, lang);
if (node.children) {
for (const child of node.children) {
nyaifyNode(child);
}
}
}
const tokens = mfm.parse(text);
for (const node of tokens) nyaifyNode(node);
return mfm.toString(tokens);
}
public static async encode(
note: Note,
ctx: MastoContext,
recurseCounter = 2,
): Promise<MastodonEntity.Status> {
const user = ctx.user as ILocalUser | null;
const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, ctx);
if (!(await Notes.isVisibleForMe(note, user?.id ?? null)))
throw new Error("Cannot encode note not visible for user");
const host = Promise.resolve(noteUser).then(
(noteUser) => noteUser.host ?? null,
);
const reactionEmojiNames = Object.keys(note.reactions)
.filter((x) => x?.startsWith(":"))
.map((x) => decodeReaction(x).reaction)
.map((x) => x.replace(/:/g, ""));
const populated = host.then(async (host) =>
populateEmojis(note.emojis.concat(reactionEmojiNames), host),
);
const noteEmoji = populated.then((noteEmoji) =>
noteEmoji
.filter((e) => e.name.indexOf("@") === -1)
.map((e) => EmojiConverter.encode(e)),
);
const reactionCount = Object.values(note.reactions).reduce(
(a, b) => a + b,
0,
);
const aggregateReaction = (
ctx.reactionAggregate as Map<string, NoteReaction | null>
)?.get(note.id);
const reaction =
aggregateReaction !== undefined
? aggregateReaction
: user
? NoteReactions.findOneBy({
userId: user.id,
noteId: note.id,
})
: null;
const isFavorited = Promise.resolve(reaction).then((p) => !!p);
const isReblogged =
(ctx.renoteAggregate as Map<string, boolean>)?.get(note.id) ??
(user
? Notes.exists({
where: {
userId: user.id,
renoteId: note.id,
text: IsNull(),
},
})
: null);
const renote =
note.renote ??
(note.renoteId && recurseCounter > 0
? getNote(note.renoteId, user)
: null);
const isBookmarked =
(ctx.bookmarkAggregate as Map<string, boolean>)?.get(note.id) ??
(user
? NoteFavorites.exists({
where: {
userId: user.id,
noteId: note.id,
},
take: 1,
})
: false);
const isMuted =
(ctx.mutingAggregate as Map<string, boolean>)?.get(
note.threadId ?? note.id,
) ??
(user
? NoteThreadMutings.exists({
where: {
userId: user.id,
threadId: note.threadId || note.id,
},
})
: false);
const files = DriveFiles.packMany(note.fileIds);
const mentions = Promise.all(
note.mentions.map((p) =>
UserHelpers.getUserCached(p, ctx)
.then((u) =>
MentionConverter.encode(u, JSON.parse(note.mentionedRemoteUsers)),
)
.catch(() => null),
),
).then((p) => p.filter((m) => m)) as Promise<MastodonEntity.Mention[]>;
const quoteUri = Promise.resolve(renote).then((renote) => {
if (!renote || !isQuote(note)) return null;
return renote.url ?? renote.uri ?? `${config.url}/notes/${renote.id}`;
});
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
const text = quoteUri
.then((quoteUri) =>
note.text !== null
? quoteUri !== null
? note.text
.replaceAll(`RE: ${quoteUri}`, "")
.replaceAll(quoteUri, "")
.trimEnd()
: note.text
: null,
)
.then(async (text) => {
return awaitAll({
noteUser,
user,
}).then(({ noteUser, user }) => {
if (
noteUser.isCat &&
noteUser.speakAsCat &&
text != null &&
(user == null || user.readCatLanguage)
) {
return this.applyNyaification(text, note.lang);
}
return text;
});
});
const content = text
.then((text) =>
text !== null
? quoteUri
.then((quoteUri) =>
MfmHelpers.toHtml(
mfm.parse(text),
JSON.parse(note.mentionedRemoteUsers),
note.userHost,
false,
quoteUri,
ctx,
),
)
.then((p) => p ?? escapeMFM(text))
: "",
)
.then((p) => p ?? "");
const card = text
.then(async (text) => this.extractUrlFromMfm(text))
.then(async (urls) =>
!urls
? null
: Promise.race([
this.cardCache.fetch(
identifier,
async () => this.generateCard(urls, note.lang ?? undefined),
true,
),
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 5000),
), // Timeout card generation after 5 seconds
]),
);
const isPinned =
(ctx.pinAggregate as Map<string, boolean>)?.get(note.id) ??
(user && note.userId === user.id
? UserNotePinings.exists({
where: { userId: user.id, noteId: note.id },
})
: undefined);
const tags = note.tags.map((tag) => {
return {
name: tag,
url: `${config.url}/tags/${tag}`,
} as MastodonEntity.Tag;
});
const reblog = Promise.resolve(renote).then((renote) =>
recurseCounter > 0 && renote
? this.encode(
renote,
ctx,
isQuote(renote) && !isQuote(note) ? --recurseCounter : 0,
)
: null,
);
const filtered = isFiltered(note, user).then((res) => {
if (
!res ||
ctx.filterContext == null ||
!["home", "public"].includes(ctx.filterContext)
)
return null;
return [
{
filter: {
id: "0",
title: "Hard word mutes",
context: ["home", "public"],
expires_at: null,
filter_action: "hide",
keywords: [],
statuses: [],
},
} as MastodonEntity.FilterResult,
];
});
return await awaitAll({
id: note.id,
uri: note.uri ?? `https://${config.host}/notes/${note.id}`,
url: note.url ?? note.uri ?? `https://${config.host}/notes/${note.id}`,
account: Promise.resolve(noteUser).then((p) =>
UserConverter.encode(p, ctx),
),
in_reply_to_id: note.replyId,
in_reply_to_account_id: note.replyUserId,
reblog: reblog.then((reblog) => (!isQuote(note) ? reblog : null)),
content: content,
content_type: "text/x.misskeymarkdown",
text: text,
created_at: note.createdAt.toISOString(),
emojis: noteEmoji,
replies_count: reblog.then(
(reblog) =>
(!isQuote(note) ? reblog?.replies_count : note.repliesCount) ?? 0,
),
reblogs_count: reblog.then(
(reblog) =>
(!isQuote(note) ? reblog?.reblogs_count : note.renoteCount) ?? 0,
),
favourites_count: reactionCount,
reblogged: isReblogged,
favourited: isFavorited,
muted: isMuted,
sensitive: files.then((files) =>
files.length > 0 ? files.some((f) => f.isSensitive) : false,
),
spoiler_text: note.cw ? note.cw : "",
visibility: VisibilityConverter.encode(note.visibility),
media_attachments: files.then((files) =>
files.length > 0 ? files.map((f) => FileConverter.encode(f)) : [],
),
mentions: mentions,
tags: tags,
card: card,
poll: note.hasPoll
? populatePoll(note, user?.id ?? null).then((p) =>
noteEmoji.then((emojis) =>
PollConverter.encode(p, note.id, emojis),
),
)
: null,
application: null, //FIXME
language: note.lang,
pinned: isPinned,
reactions: populated.then((populated) =>
Promise.resolve(reaction).then((reaction) =>
this.encodeReactions(
note.reactions,
reaction?.reaction,
populated,
ctx,
),
),
),
bookmarked: isBookmarked,
quote: reblog.then((reblog) => (isQuote(note) ? reblog : null)),
quote_id: isQuote(note) ? note.renoteId : null,
edited_at: note.updatedAt?.toISOString() ?? null,
filtered: filtered,
});
}
public static async encodeMany(
notes: Note[],
ctx: MastoContext,
): Promise<MastodonEntity.Status[]> {
await this.aggregateData(notes, ctx);
const encoded = notes.map((n) => this.encode(n, ctx));
return Promise.all(encoded);
}
public static async aggregateData(
notes: Note[],
ctx: MastoContext,
): Promise<void> {
if (notes.length === 0) return;
const user = ctx.user as ILocalUser | null;
const reactionAggregate = new Map<Note["id"], NoteReaction | null>();
const renoteAggregate = new Map<Note["id"], boolean>();
const mutingAggregate = new Map<Note["id"], boolean>();
const bookmarkAggregate = new Map<Note["id"], boolean>();
const pinAggregate = new Map<Note["id"], boolean>();
const renoteIds = notes
.map((n) => n.renoteId)
.filter((n): n is string => n != null);
const noteIds = unique(notes.map((n) => n.id));
const targets = unique([...noteIds, ...renoteIds]);
if (user?.id != null) {
const mutingTargets = unique([...notes.map((n) => n.threadId ?? n.id)]);
const pinTargets = unique([
...notes.filter((n) => n.userId === user.id).map((n) => n.id),
]);
const reactions = await NoteReactions.findBy({
userId: user.id,
noteId: In(targets),
});
const renotes = await Notes.createQueryBuilder("note")
.select("note.renoteId")
.where("note.userId = :meId", { meId: user.id })
.andWhere("note.renoteId IN (:...targets)", { targets })
.andWhere("note.text IS NULL")
.andWhere("note.hasPoll = FALSE")
.andWhere(`note.fileIds = '{}'`)
.getMany();
const mutings = await NoteThreadMutings.createQueryBuilder("muting")
.select("muting.threadId")
.where("muting.userId = :meId", { meId: user.id })
.andWhere("muting.threadId IN (:...targets)", {
targets: mutingTargets,
})
.getMany();
const bookmarks = await NoteFavorites.createQueryBuilder("bookmark")
.select("bookmark.noteId")
.where("bookmark.userId = :meId", { meId: user.id })
.andWhere("bookmark.noteId IN (:...targets)", { targets })
.getMany();
const pins =
pinTargets.length > 0
? await UserNotePinings.createQueryBuilder("pin")
.select("pin.noteId")
.where("pin.userId = :meId", { meId: user.id })
.andWhere("pin.noteId IN (:...targets)", { targets: pinTargets })
.getMany()
: [];
for (const target of targets) {
reactionAggregate.set(
target,
reactions.find((r) => r.noteId === target) ?? null,
);
renoteAggregate.set(
target,
!!renotes.find((n) => n.renoteId === target),
);
bookmarkAggregate.set(
target,
!!bookmarks.find((b) => b.noteId === target),
);
}
for (const target of mutingTargets) {
mutingAggregate.set(
target,
!!mutings.find((m) => m.threadId === target),
);
}
for (const target of pinTargets) {
mutingAggregate.set(target, !!pins.find((m) => m.noteId === target));
}
}
ctx.reactionAggregate = reactionAggregate;
ctx.renoteAggregate = renoteAggregate;
ctx.mutingAggregate = mutingAggregate;
ctx.bookmarkAggregate = bookmarkAggregate;
ctx.pinAggregate = pinAggregate;
const users = notes.filter((p) => !!p.user).map((p) => p.user as User);
await UserConverter.aggregateData([...users], ctx);
await prefetchEmojis(aggregateNoteEmojis(notes));
}
private static encodeReactions(
reactions: Record<string, number>,
myReaction: string | undefined,
populated: PopulatedEmoji[],
ctx: MastoContext,
): MastodonEntity.Reaction[] {
// Client compatibility: SoraSNS requires `reactions` to be a `Dictionary<string, int>`.
if (ctx?.tokenApp?.name === "SoraSNS for iPad") {
return reactions as unknown as MastodonEntity.Reaction[];
}
return Object.keys(reactions)
.map((key) => {
const isCustom = key.startsWith(":") && key.endsWith(":");
const name = isCustom ? key.substring(1, key.length - 1) : key;
const populatedName =
isCustom && name.indexOf("@") === -1 ? `${name}@.` : name;
const url = isCustom
? populated.find((p) => p.name === populatedName)?.url
: undefined;
return {
count: reactions[key],
me: key === myReaction,
name: name,
url: url,
static_url: url,
};
})
.filter((r) => r.count > 0);
}
public static async encodeEvent(
note: Note,
user: ILocalUser | undefined,
filterContext?: string,
): Promise<MastodonEntity.Status> {
const ctx = getStubMastoContext(user, filterContext);
NoteHelpers.fixupEventNote(note);
return NoteConverter.encode(note, ctx);
}
private static removeHash(x: string) {
return x.replace(/#[^#]*$/, "");
}
private static extractUrlFromMfm(text: string | null): string[] {
if (!text) return [];
const nodes = mfm.parse(text);
const urlNodes = mfm.extract(nodes, (node) => {
return (
node.type === "url" || (node.type === "link" && !node.props.silent)
);
}) as (MfmUrl | MfmLink)[];
const urls: string[] = unique(urlNodes.map((x) => x.props.url));
return urls.reduce((array, url) => {
const urlWithoutHash = this.removeHash(url);
if (!array.map((x) => this.removeHash(x)).includes(urlWithoutHash))
array.push(url);
return array;
}, [] as string[]);
}
/** Generate URL preview metadata from the first possible URL in the list provided. */
private static async generateCard(
urls: string[],
lang?: string,
): Promise<MastodonEntity.Card | null> {
if (urls.length === 0) return null;
for (const url of urls) {
try {
const summary = await unfurl(url, {
oembed: true,
follow: 10,
compress: true,
headers: { "Accept-Language": lang ?? "en-US" },
});
if (summary) {
return {
url: summary.canonical_url ?? url,
title: summary.title ?? "",
description: summary.description ?? "",
image:
summary.oEmbed?.thumbnails?.[0]?.url ??
summary.open_graph?.images?.[0]?.secure_url ??
summary.open_graph?.images?.[0]?.url ??
summary.twitter_card?.images?.[0]?.url ??
null,
type:
summary.oEmbed?.type ??
(summary.open_graph?.videos ||
summary.open_graph?.audio ||
summary.twitter_card?.players
? "video"
: "link"),
author_name:
summary.author ??
summary.oEmbed?.author_name ??
summary.open_graph?.article?.author ??
summary.twitter_card?.creator ??
"",
author_url: summary.oEmbed?.author_name ?? "",
provider_name:
summary.oEmbed?.provider_name ??
summary.open_graph?.site_name ??
summary.twitter_card?.site ??
"",
provider_url: summary.oEmbed?.provider_url ?? "",
html: (summary.oEmbed as { html?: string })?.html ?? "",
width:
summary.oEmbed?.thumbnails?.[0]?.width ??
summary.open_graph?.images?.[0]?.width ??
summary.open_graph?.videos?.[0]?.width ??
0,
height:
summary.oEmbed?.thumbnails?.[0]?.height ??
summary.open_graph?.images?.[0]?.height ??
summary.open_graph?.videos?.[0]?.height ??
0,
embed_url:
summary.open_graph?.videos?.[0]?.stream ??
summary.open_graph?.videos?.[0]?.url ??
"",
blurhash: null,
published_at: summary.open_graph?.article?.published_time ?? null,
image_description: summary.open_graph?.images?.[0]?.alt ?? "",
language: lang ?? null,
};
}
} catch {
// no op.
}
}
return null;
}
/** Encode a schduled note. */
public static async encodeScheduledNote(
note: Note,
_: MastoContext,
): Promise<MastodonEntity.ScheduledStatus> {
const renote =
note.renote ??
(note.renoteId ? getNote(note.renoteId, { id: note.userId }) : null);
const quoteUri = Promise.resolve(renote).then((renote) => {
if (!renote || !isQuote(note)) return null;
return renote.url ?? renote.uri ?? `${config.url}/notes/${renote.id}`;
});
const text = quoteUri.then((quoteUri) =>
note.text !== null
? quoteUri !== null
? note.text
.replaceAll(`RE: ${quoteUri}`, "")
.replaceAll(quoteUri, "")
.trimEnd()
: note.text
: "",
);
const files = DriveFiles.packMany(note.fileIds);
const a = await awaitAll({
id: note.id,
scheduled_at: note.scheduledAt!.toISOString(),
params: {
text,
poll: note.hasPoll
? populatePoll(note, note.userId ?? null).then((p) =>
PollConverter.encodeScheduledPoll(p),
)
: null,
media_ids: note.fileIds,
sensitive: files.then((files) =>
files.length > 0 ? files.some((f) => f.isSensitive) : false,
),
spoiler_text: note.cw || "",
visibility: VisibilityConverter.encode(note.visibility),
in_reply_to_id: note.replyId,
language: note.lang,
application_id: 0,
idempotency: note.id,
with_rate_limit: false,
},
media_attachments: files.then((files) =>
files.length > 0 ? files.map((f) => FileConverter.encode(f)) : [],
),
});
return a;
}
/** Encode an array of schduled notes. */
public static async encodeManyScheduledNotes(
scheduledNotes: Note[],
ctx: MastoContext,
): Promise<MastodonEntity.ScheduledStatus[]> {
const encoded = scheduledNotes.map((n) => this.encodeScheduledNote(n, ctx));
return Promise.all(encoded);
}
}

View file

@ -0,0 +1,290 @@
import type { ILocalUser, User } from "@/models/entities/user.js";
import type { Notification } from "@/models/entities/notification.js";
import type { notificationTypes } from "@/types.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { awaitAll } from "@/prelude/await-all.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { getNote } from "@/server/api/common/getters.js";
import {
getStubMastoContext,
type MastoContext,
} from "@/server/api/mastodon/index.js";
import { Notifications } from "@/models/index.js";
import { unique } from "@/prelude/array.js";
import type { Note } from "@/models/entities/note.js";
import type { SwSubscription } from "@/models/entities/sw-subscription.js";
import { fetchMeta, isQuote } from "backend-rs";
import { getNoteSummary, getTimestamp } from "backend-rs";
import type { Packed } from "@/misc/schema";
import { I18n } from "@/misc/i18n.js";
import locales from "../../../../../../../locales/index.mjs";
type NotificationType = (typeof notificationTypes)[number];
export class NotificationConverter {
public static async encode(
notification: Notification,
ctx: MastoContext,
): Promise<MastodonEntity.Notification> {
const localUser = ctx.user as ILocalUser;
if (notification.notifieeId !== localUser.id)
throw new Error("User is not recipient of notification");
const account = notification.notifierId
? UserHelpers.getUserCached(notification.notifierId, ctx).then((p) =>
UserConverter.encode(p, ctx),
)
: UserConverter.encode(localUser, ctx);
let result = {
id: notification.id,
account: account,
created_at: notification.createdAt.toISOString(),
type: this.encodeNotificationType(notification.type),
};
const note =
notification.note ??
(notification.noteId
? await getNote(notification.noteId, localUser)
: null);
if (note) {
const encodedNote =
note.renoteId !== null && !isQuote(note)
? getNote(note.renoteId, localUser).then((note) =>
NoteConverter.encode(note, ctx),
)
: NoteConverter.encode(note, ctx);
result = Object.assign(result, {
status: encodedNote,
});
if (result.type === "poll") {
result = Object.assign(result, {
account: encodedNote.then((p) => p.account),
});
}
if (notification.reaction) {
// FIXME: Implement reactions;
}
}
return awaitAll(result);
}
public static async encodeMany(
notifications: Notification[],
ctx: MastoContext,
): Promise<MastodonEntity.Notification[]> {
await this.aggregateData(notifications, ctx);
const encoded = notifications.map((u) => this.encode(u, ctx));
return Promise.all(encoded).then(
(p) => p.filter((n) => n !== null) as MastodonEntity.Notification[],
);
}
private static async aggregateData(
notifications: Notification[],
ctx: MastoContext,
): Promise<void> {
if (notifications.length === 0) return;
const notes = unique(
notifications.filter((p) => p.note != null).map((n) => n.note as Note),
);
const users = unique(
notifications
.filter((p) => p.notifier != null)
.map((n) => n.notifier as User)
.concat(
notifications
.filter((p) => p.notifiee != null)
.map((n) => n.notifiee as User),
),
);
await NoteConverter.aggregateData(notes, ctx);
await UserConverter.aggregateData(users, ctx);
}
private static encodeNotificationType(
t: NotificationType,
): MastodonEntity.NotificationType {
// FIXME: Implement custom notification for followRequestAccepted
// FIXME: Implement mastodon notification type 'update' on misskey side
switch (t) {
case "follow":
return "follow";
case "mention":
case "reply":
return "mention";
case "renote":
return "reblog";
case "quote":
return "reblog";
case "reaction":
return "favourite";
case "pollEnded":
return "poll";
case "receiveFollowRequest":
return "follow_request";
case "followRequestAccepted":
case "pollVote":
case "groupInvited":
case "app":
throw new Error(`Notification type ${t} not supported`);
}
}
public static async encodeNotificationTypeOrDefault(
t: NotificationType,
): Promise<string> {
try {
return this.encodeNotificationType(t);
} catch (e) {
return t;
}
}
public static async encodeEvent(
target: Notification["id"],
user: ILocalUser,
filterContext?: string,
): Promise<MastodonEntity.Notification | null> {
const ctx = getStubMastoContext(user, filterContext);
const notification = await Notifications.findOneByOrFail({ id: target });
return this.encode(notification, ctx).catch((_) => null);
}
public static async encodeSubscription(
subscription: SwSubscription,
ctx: MastoContext,
): Promise<MastodonEntity.PushSubscription> {
const instance = await fetchMeta();
const result: MastodonEntity.PushSubscription = {
id: subscription.id,
endpoint: subscription.endpoint,
server_key: instance.swPublicKey ?? "",
alerts: {
follow: subscription.subscriptionTypes.includes("follow"),
favourite: subscription.subscriptionTypes.includes("favourite"),
mention: subscription.subscriptionTypes.includes("mention"),
reblog: subscription.subscriptionTypes.includes("reblog"),
poll: subscription.subscriptionTypes.includes("poll"),
status: subscription.subscriptionTypes.includes("status"),
},
};
// IceCubes wants an int for ID despite the docs says string.
if (ctx.tokenApp?.name === "IceCubesApp") {
result.id = getTimestamp(subscription.id);
}
return result;
}
public static async encodePushNotificationPayloadForRust(
body: Packed<"Notification">,
lang = "en-US",
): Promise<Partial<MastodonEntity.NotificationPayload>> {
const locale = locales[lang] || locales["en-US"];
const i18n = new I18n(locale);
let preferred_locale = lang;
let notification_id = "";
let notification_type = "others";
let icon: string | undefined = undefined;
let title = i18n.t("notificationType");
let description = "";
const notificationBody = body;
preferred_locale = notificationBody.note?.lang ?? preferred_locale;
notification_id = notificationBody.id;
notification_type = await this.encodeNotificationTypeOrDefault(
notificationBody.type,
);
const effectiveNote =
notificationBody.note?.renote ?? notificationBody.note;
icon =
notificationBody.user?.avatarUrl ??
notificationBody.note?.user.avatarUrl ??
notificationBody.icon ??
undefined;
const displayName =
notificationBody.user?.name ||
(notificationBody.user?.host &&
`@${notificationBody.user?.username}@${notificationBody.user?.host}`) ||
(notificationBody.user?.username &&
`@${notificationBody.user?.username}`) ||
"Someone";
const username =
(notificationBody.user?.host &&
`@${notificationBody.user?.username}@${notificationBody.user?.host}`) ||
(notificationBody.user?.username &&
`@${notificationBody.user?.username}`) ||
"";
// FIXME: all notification title i18n strings should take `name` as a parameter
switch (notificationBody.type) {
case "mention":
title = i18n.t("_notification.youGotMention", { name: displayName });
break;
case "reply":
title = i18n.t("_notification.youGotReply", { name: displayName });
break;
case "renote":
title = i18n.t("_notification.youRenoted", { name: displayName });
break;
case "quote":
title = i18n.t("_notification.youGotQuote", { name: displayName });
break;
case "reaction":
title = `${displayName} ${i18n.t("_notification.reacted")}`;
break;
case "pollVote":
title = i18n.t("_notification.youGotPoll", { name: displayName });
break;
case "pollEnded":
title = i18n.t("_notification.pollEnded");
break;
case "followRequestAccepted":
title = i18n.t("_notification.yourFollowRequestAccepted");
break;
case "groupInvited":
title = i18n.t("_notification.youWereInvitedToGroup", {
userName: displayName,
});
break;
case "follow":
title = `${displayName} ${i18n.t("_notification.youWereFollowed")}`;
break;
case "receiveFollowRequest":
title = i18n.t("_notification.youReceivedFollowRequest");
break;
case "app":
title = `${notificationBody.header}`;
break;
default:
title = `${i18n.t("notificationType")} ${notificationBody.type}`;
}
description =
(effectiveNote &&
getNoteSummary(
effectiveNote.fileIds,
effectiveNote.text,
effectiveNote.cw,
effectiveNote.hasPoll,
)) ||
notificationBody.body ||
username ||
"";
return {
preferred_locale,
notification_id,
notification_type,
icon,
title,
body: description,
};
}
}

View file

@ -0,0 +1,58 @@
type Choice = {
text: string;
votes: number;
isVoted: boolean;
};
type Poll = {
multiple: boolean;
expiresAt: Date | null;
choices: Array<Choice>;
};
export class PollConverter {
public static encode(
p: Poll,
noteId: string,
emojis: MastodonEntity.Emoji[],
): MastodonEntity.Poll {
const now = new Date();
const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0);
return {
id: noteId,
expires_at: p.expiresAt?.toISOString() ?? null,
expired: p.expiresAt == null ? false : now > p.expiresAt,
multiple: p.multiple,
votes_count: count,
options: p.choices.map((c) => this.encodeChoice(c)),
emojis: emojis,
voted: p.choices.some((c) => c.isVoted),
own_votes: p.choices
.filter((c) => c.isVoted)
.map((c) => p.choices.indexOf(c)),
};
}
private static encodeChoice(c: Choice): MastodonEntity.PollOption {
return {
title: c.text,
votes_count: c.votes,
};
}
public static encodeScheduledPoll(
p: Poll,
): MastodonEntity.StatusParams["poll"] {
const now = new Date();
const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0);
return {
expires_in: (
((p.expiresAt?.getTime() ?? Date.now()) - Date.now()) /
1000
).toString(),
multiple: p.multiple,
hide_totals: false,
options: p.choices.map((c) => c.text),
};
}
}

View file

@ -0,0 +1,237 @@
import type { ILocalUser, User } from "@/models/entities/user.js";
import { config } from "@/config.js";
import { DriveFiles, Followings, UserProfiles, Users } from "@/models/index.js";
import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js";
import { populateEmojis } from "@/misc/populate-emojis.js";
import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js";
import mfm from "mfm-js";
import { awaitAll } from "@/prelude/await-all.js";
import {
type AccountCache,
UserHelpers,
} from "@/server/api/mastodon/helpers/user.js";
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import type { MastoContext } from "@/server/api/mastodon/index.js";
import type { IMentionedRemoteUsers, Note } from "@/models/entities/note.js";
import type { UserProfile } from "@/models/entities/user-profile.js";
import { In } from "typeorm";
import { unique } from "@/prelude/array.js";
type Field = {
name: string;
value: string;
verified?: boolean;
};
export class UserConverter {
public static async encode(
u: User,
ctx: MastoContext,
): Promise<MastodonEntity.Account> {
const localUser = ctx.user as ILocalUser | null;
const cache = ctx.cache as AccountCache;
return cache.locks.acquire(u.id, async () => {
const cacheHit = cache.accounts.find((p) => p.id == u.id);
if (cacheHit) return cacheHit;
const fqn = `${u.username}@${u.host ?? config.host}`;
let acct = u.username;
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
if (u.host) {
acct = `${u.username}@${u.host}`;
acctUrl = `https://${u.host}/@${u.username}`;
}
const aggregateProfile = (
ctx.userProfileAggregate as Map<string, UserProfile | null>
)?.get(u.id);
const profile =
aggregateProfile !== undefined
? aggregateProfile
: UserProfiles.findOneBy({ userId: u.id });
const bio = Promise.resolve(profile)
.then(async (profile) => {
return MfmHelpers.toHtml(
mfm.parse(profile?.description ?? ""),
profile?.mentions,
u.host,
false,
null,
ctx,
)
.then((p) => p ?? escapeMFM(profile?.description ?? ""))
.then((p) => (p !== "<p></p>" ? p : null));
})
.then((p) => p ?? "<p></p>");
const avatar = u.avatarId
? DriveFiles.findOneBy({ id: u.avatarId })
.then((p) => p?.url ?? Users.getIdenticonUrl(u.id))
.then((p) => DriveFiles.getFinalUrl(p))
: Users.getIdenticonUrl(u.id);
const banner = u.bannerId
? DriveFiles.findOneBy({ id: u.bannerId })
.then(
(p) => p?.url ?? `${config.url}/static-assets/transparent.png`,
)
.then((p) => DriveFiles.getFinalUrl(p))
: `${config.url}/static-assets/transparent.png`;
const isFollowedOrSelf =
(ctx.followedOrSelfAggregate as Map<string, boolean>)?.get(u.id) ??
(!!localUser &&
(localUser.id === u.id ||
Followings.exists({
where: {
followeeId: u.id,
followerId: localUser.id,
},
})));
const followersCount = Promise.resolve(profile).then(async (profile) => {
if (profile === null) return u.followersCount;
switch (profile.ffVisibility) {
case "public":
return u.followersCount;
case "followers":
return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf) =>
isFollowedOrSelf ? u.followersCount : 0,
);
case "private":
return localUser?.id === profile.userId ? u.followersCount : 0;
}
});
const followingCount = Promise.resolve(profile).then(async (profile) => {
if (profile === null) return u.followingCount;
switch (profile.ffVisibility) {
case "public":
return u.followingCount;
case "followers":
return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf) =>
isFollowedOrSelf ? u.followingCount : 0,
);
case "private":
return localUser?.id === profile.userId ? u.followingCount : 0;
}
});
const fields = Promise.resolve(profile).then((profile) =>
Promise.all(
profile?.fields.map(async (p) =>
this.encodeField(p, u.host, profile?.mentions),
) ?? [],
),
);
return awaitAll({
id: u.id,
username: u.username,
acct: acct,
fqn: fqn,
display_name: u.name || u.username,
locked: u.isLocked,
created_at: u.createdAt.toISOString(),
followers_count: followersCount,
following_count: followingCount,
statuses_count: u.notesCount,
note: bio,
url: u.uri ?? acctUrl,
avatar: avatar,
avatar_static: avatar,
header: banner,
header_static: banner,
emojis: populateEmojis(u.emojis, u.host).then((emoji) =>
emoji.map((e) => EmojiConverter.encode(e)),
),
moved: null, //FIXME
fields: fields,
bot: u.isBot,
discoverable: u.isExplorable,
}).then((p) => {
UserHelpers.updateUserInBackground(u);
cache.accounts.push(p);
return p;
});
});
}
public static async aggregateData(
users: User[],
ctx: MastoContext,
): Promise<void> {
const user = ctx.user as ILocalUser | null;
const targets = unique(users.map((u) => u.id));
const followedOrSelfAggregate = new Map<User["id"], boolean>();
const userProfileAggregate = new Map<User["id"], UserProfile | null>();
if (user) {
const targetsWithoutSelf = targets.filter((u) => u !== user.id);
if (targetsWithoutSelf.length > 0) {
const followings = await Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId", { meId: user.id })
.andWhere("following.followeeId IN (:...targets)", {
targets: targetsWithoutSelf,
})
.getMany();
for (const userId of targetsWithoutSelf) {
followedOrSelfAggregate.set(
userId,
!!followings.find((f) => f.followerId === userId),
);
}
}
followedOrSelfAggregate.set(user.id, true);
}
const profiles = await UserProfiles.findBy({
userId: In(targets),
});
for (const userId of targets) {
userProfileAggregate.set(
userId,
profiles.find((p) => p.userId === userId) ?? null,
);
}
ctx.followedOrSelfAggregate = followedOrSelfAggregate;
}
public static async encodeMany(
users: User[],
ctx: MastoContext,
): Promise<MastodonEntity.Account[]> {
await this.aggregateData(users, ctx);
const encoded = users.map((u) => this.encode(u, ctx));
return Promise.all(encoded);
}
private static async encodeField(
f: Field,
host: string | null,
mentions: IMentionedRemoteUsers,
ctx?: MastoContext,
): Promise<MastodonEntity.Field> {
return {
name: f.name,
value:
(await MfmHelpers.toHtml(
mfm.parse(f.value),
mentions,
host,
true,
null,
ctx,
)) ?? escapeMFM(f.value),
verified_at: f.verified ? new Date().toISOString() : null,
};
}
}

View file

@ -0,0 +1,37 @@
export type FirefishVisibility =
| "public"
| "home"
| "followers"
| "specified"
| "hidden";
export type MastodonVisibility = "public" | "unlisted" | "private" | "direct";
export class VisibilityConverter {
public static encode(v: FirefishVisibility): MastodonVisibility {
switch (v) {
case "public":
return v;
case "home":
return "unlisted";
case "followers":
return "private";
case "specified":
return "direct";
case "hidden":
throw new Error();
}
}
public static decode(v: MastodonVisibility): FirefishVisibility {
switch (v) {
case "public":
return v;
case "unlisted":
return "home";
case "private":
return "followers";
case "direct":
return "specified";
}
}
}

View file

@ -1,477 +1,284 @@
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { argsToBools, convertTimelinesArgsId, limitToInt } from "./timeline.js";
import { fromMastodonId, toMastodonId } from "backend-rs";
import {
convertAccount,
convertFeaturedTag,
convertList,
convertRelationship,
convertStatus,
} from "../converters.js";
import { apiLogger } from "../../logger.js";
import { inspect } from "node:util";
import type Router from "@koa/router";
import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { ListHelpers } from "@/server/api/mastodon/helpers/list.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { SearchHelpers } from "@/server/api/mastodon/helpers/search.js";
import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js";
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: "",
};
export function apiAccountMastodon(router: Router): void {
router.get("/v1/accounts/verify_credentials", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.verifyAccountCredentials();
let acct = data.data;
acct.id = toMastodonId(acct.id);
acct.display_name = acct.display_name || acct.username;
acct.url = `${BASE_URL}/@${acct.url}`;
acct.note = acct.note || "";
acct.avatar_static = acct.avatar;
acct.header = acct.header || "/static-assets/transparent.png";
acct.header_static = acct.header || "/static-assets/transparent.png";
acct.source = {
note: acct.note,
fields: acct.fields,
privacy: await client.getDefaultPostPrivacy(),
sensitive: false,
language: "",
};
console.log(acct);
ctx.body = acct;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.patch("/v1/accounts/update_credentials", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateCredentials(
(ctx.request as any).body as any,
);
ctx.body = convertAccount(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
export function setupEndpointsAccount(router: Router): void {
router.get(
"/v1/accounts/verify_credentials",
auth(true, ["read:accounts"]),
async (ctx) => {
ctx.body = await UserHelpers.verifyCredentials(ctx);
},
);
router.patch(
"/v1/accounts/update_credentials",
auth(true, ["write:accounts"]),
async (ctx) => {
ctx.body = await UserHelpers.updateCredentials(ctx);
},
);
router.get("/v1/accounts/lookup", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.search(
(ctx.request.query as any).acct,
const args = normalizeUrlQuery(ctx.query);
const user = await UserHelpers.getUserFromAcct(args.acct);
ctx.body = await UserConverter.encode(user, ctx);
});
router.get(
"/v1/accounts/relationships",
auth(true, ["read:follows"]),
async (ctx) => {
const ids =
normalizeUrlQuery(ctx.query, ["id[]"])["id[]"] ??
normalizeUrlQuery(ctx.query, ["id"]).id ??
[];
ctx.body = await UserHelpers.getUserRelationhipToMany(ids, ctx.user.id);
},
);
// This must come before /accounts/:id, otherwise that will take precedence
router.get(
"/v1/accounts/search",
auth(true, ["read:accounts"]),
async (ctx) => {
const args = normalizeUrlQuery(
argsToBools(limitToInt(ctx.query), ["resolve", "following"]),
);
ctx.body = await SearchHelpers.search(
args.q,
"accounts",
args.resolve,
args.following,
undefined,
false,
undefined,
undefined,
args.limit,
args.offset,
ctx,
).then((p) => p.accounts);
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id",
auth(false),
async (ctx) => {
ctx.body = await UserConverter.encode(
await UserHelpers.getUserOr404(ctx.params.id),
ctx,
);
ctx.body = convertAccount(data.data.accounts[0]);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/accounts/relationships", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
let users;
try {
// TODO: this should be body
let ids = ctx.request.query ? ctx.request.query["id[]"] : null;
if (typeof ids === "string") {
ids = [ids];
}
users = ids;
relationshipModel.id = ids?.toString() || "1";
if (!ids) {
ctx.body = [relationshipModel];
return;
}
let reqIds = [];
for (let i = 0; i < ids.length; i++) {
reqIds.push(fromMastodonId(ids[i]));
}
const data = await client.getRelationships(reqIds);
ctx.body = data.data.map((relationship) =>
convertRelationship(relationship),
);
} catch (e: any) {
apiLogger.error(inspect(e));
let data = e.response.data;
data.users = users;
ctx.status = 401;
ctx.body = data;
}
});
router.get<{ Params: { id: string } }>("/v1/accounts/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const calcId = fromMastodonId(ctx.params.id);
const data = await client.getAccount(calcId);
ctx.body = convertAccount(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/statuses",
auth(false, ["read:statuses"]),
filterContext("account"),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountStatuses(
fromMastodonId(ctx.params.id),
convertTimelinesArgsId(argsToBools(limitToInt(ctx.query as any))),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)));
const res = await UserHelpers.getUserStatuses(
query,
args.max_id,
args.since_id,
args.min_id,
args.limit,
args.only_media,
args.exclude_replies,
args.exclude_reblogs,
args.pinned,
args.tagged,
ctx,
);
ctx.body = await NoteConverter.encodeMany(res, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/featured_tags",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFeaturedTags(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data.map((tag) => convertFeaturedTag(tag));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = [];
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/followers",
auth(false),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowers(
fromMastodonId(ctx.params.id),
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await UserHelpers.getUserFollowers(
query,
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await UserConverter.encodeMany(res, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/following",
auth(false),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowing(
fromMastodonId(ctx.params.id),
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await UserHelpers.getUserFollowing(
query,
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await UserConverter.encodeMany(res, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/lists",
auth(true, ["read:lists"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountLists(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data.map((list) => convertList(list));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const member = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await ListHelpers.getListsByMember(member, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/follow",
auth(true, ["write:follows"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.followAccount(fromMastodonId(ctx.params.id));
let acct = convertRelationship(data.data);
acct.following = true;
ctx.body = acct;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
// FIXME: Parse form data
ctx.body = await UserHelpers.followUser(target, true, false, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unfollow",
auth(true, ["write:follows"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unfollowAccount(
fromMastodonId(ctx.params.id),
);
let acct = convertRelationship(data.data);
acct.following = false;
ctx.body = acct;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.unfollowUser(target, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/block",
auth(true, ["write:blocks"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.blockAccount(fromMastodonId(ctx.params.id));
ctx.body = convertRelationship(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.blockUser(target, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unblock",
auth(true, ["write:blocks"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unblockAccount(toMastodonId(ctx.params.id));
ctx.body = convertRelationship(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.unblockUser(target, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/mute",
auth(true, ["write:mutes"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.muteAccount(
fromMastodonId(ctx.params.id),
(ctx.request as any).body as any,
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
// FIXME: parse form data
const args = normalizeUrlQuery(
argsToBools(limitToInt(ctx.query, ["duration"]), ["notifications"]),
);
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.muteUser(
target,
args.notifications,
args.duration,
ctx,
);
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unmute",
auth(true, ["write:mutes"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unmuteAccount(fromMastodonId(ctx.params.id));
ctx.body = convertRelationship(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.unmuteUser(target, ctx);
},
);
router.get("/v1/featured_tags", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFeaturedTags();
ctx.body = data.data.map((tag) => convertFeaturedTag(tag));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = [];
});
router.get("/v1/followed_tags", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowedTags();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = [];
});
router.get("/v1/bookmarks", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBookmarks(
convertTimelinesArgsId(limitToInt(ctx.query as any)),
router.get("/v1/bookmarks", auth(true, ["read:bookmarks"]), async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await UserHelpers.getUserBookmarks(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/favourites", auth(true, ["read:favourites"]), async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await UserHelpers.getUserFavorites(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/mutes", auth(true, ["read:mutes"]), async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
ctx.body = await UserHelpers.getUserMutes(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
});
router.get("/v1/blocks", auth(true, ["read:blocks"]), async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await UserHelpers.getUserBlocks(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.get(
"/v1/follow_requests",
auth(true, ["read:follows"]),
async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await UserHelpers.getUserFollowRequests(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/favourites", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFavourites(
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/mutes", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMutes(
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/blocks", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBlocks(
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/follow_requests", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowRequests(
((ctx.query as any) || { limit: 20 }).limit,
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
ctx.body = await UserConverter.encodeMany(res, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/follow_requests/:id/authorize",
auth(true, ["write:follows"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.acceptFollowRequest(
fromMastodonId(ctx.params.id),
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.acceptFollowRequest(target, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/follow_requests/:id/reject",
auth(true, ["write:follows"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.rejectFollowRequest(
fromMastodonId(ctx.params.id),
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.rejectFollowRequest(target, ctx);
},
);
}

View file

@ -1,80 +1,31 @@
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
import type Router from "@koa/router";
import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js";
import { MiAuth } from "@/server/api/mastodon/middleware/auth.js";
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 function apiAuthMastodon(router: Router): void {
export function setupEndpointsAuth(router: Router): void {
router.post("/v1/apps", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const client = getClient(BASE_URL, "");
const body: any = ctx.request.body || ctx.request.query;
try {
let scope = body.scopes;
if (typeof scope === "string") scope = 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);
ctx.body = await AuthHelpers.registerApp(ctx);
});
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,
};
console.log(returns);
ctx.body = returns;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
router.get("/v1/apps/verify_credentials", async (ctx) => {
ctx.body = await AuthHelpers.verifyAppCredentials(ctx);
});
router.post("/v1/firefish/apps/info", MiAuth(true), async (ctx) => {
ctx.body = await AuthHelpers.getAppInfo(ctx);
});
router.post("/v1/firefish/auth/code", MiAuth(true), async (ctx) => {
ctx.body = await AuthHelpers.getAuthCode(ctx);
});
}
export function setupEndpointsAuthRoot(router: Router): void {
router.post("/oauth/token", async (ctx) => {
ctx.body = await AuthHelpers.getAuthToken(ctx);
});
router.post("/oauth/revoke", async (ctx) => {
ctx.body = await AuthHelpers.revokeAuthToken(ctx);
});
}

View file

@ -1,87 +1,23 @@
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { IdType, convertId } from "@/server/api/index.js";
import { convertFilter } from "../converters.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
import type Router from "@koa/router";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export function apiFilterMastodon(router: Router): void {
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilters();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilter(fromMastodonId(ctx.params.id));
ctx.body = convertFilter(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.createFilter(body.phrase, body.context, body);
ctx.body = convertFilter(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.updateFilter(
fromMastodonId(ctx.params.id),
body.phrase,
body.context,
export function setupEndpointsFilter(router: Router): void {
router.get(
["/v1/filters", "/v2/filters"],
auth(true, ["read:filters"]),
async (ctx) => {
ctx.body = [];
},
);
router.post(
["/v1/filters", "/v2/filters"],
auth(true, ["write:filters"]),
async (ctx) => {
throw new MastoApiError(
400,
"Please change word mute settings in the web frontend settings.",
);
ctx.body = convertFilter(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.delete("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.deleteFilter(fromMastodonId(ctx.params.id));
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
},
);
}

View file

@ -0,0 +1,116 @@
import type Router from "@koa/router";
import {
limitToInt,
normalizeUrlQuery,
} from "@/server/api/mastodon/endpoints/timeline.js";
import { ListHelpers } from "@/server/api/mastodon/helpers/list.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { UserLists } from "@/models/index.js";
import { getUser } from "@/server/api/common/getters.js";
import { toArray } from "@/prelude/array.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export function setupEndpointsList(router: Router): void {
router.get("/v1/lists", auth(true, ["read:lists"]), async (ctx, _reply) => {
ctx.body = await ListHelpers.getLists(ctx);
});
router.get<{ Params: { id: string } }>(
"/v1/lists/:id",
auth(true, ["read:lists"]),
async (ctx, _reply) => {
ctx.body = await ListHelpers.getListOr404(ctx.params.id, ctx);
},
);
router.post("/v1/lists", auth(true, ["write:lists"]), async (ctx, _reply) => {
const body = ctx.request.body as any;
const title = (body.title ?? "").trim();
ctx.body = await ListHelpers.createList(title, ctx);
});
router.put<{ Params: { id: string } }>(
"/v1/lists/:id",
auth(true, ["write:lists"]),
async (ctx, reply) => {
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id,
});
if (!list) throw new MastoApiError(404);
const body = ctx.request.body as any;
const title = (body.title ?? "").trim();
const exclusive = body.exclusive ?? (undefined as boolean | undefined);
ctx.body = await ListHelpers.updateList(list, title, exclusive, ctx);
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id",
auth(true, ["write:lists"]),
async (ctx, _reply) => {
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id,
});
if (!list) throw new MastoApiError(404);
await ListHelpers.deleteList(list, ctx);
ctx.body = {};
},
);
router.get<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
auth(true, ["read:lists"]),
async (ctx, reply) => {
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await ListHelpers.getListUsers(
ctx.params.id,
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await UserConverter.encodeMany(res, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
auth(true, ["write:lists"]),
async (ctx, reply) => {
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id,
});
if (!list) throw new MastoApiError(404);
const body = ctx.request.body as any;
if (!body.account_ids)
throw new MastoApiError(400, "Missing account_ids[] field");
const ids = toArray(body.account_ids);
const targets = await Promise.all(ids.map((p) => getUser(p)));
await ListHelpers.addToList(list, targets, ctx);
ctx.body = {};
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
auth(true, ["write:lists"]),
async (ctx, reply) => {
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id,
});
if (!list) throw new MastoApiError(404);
const body = ctx.request.body as any;
if (!body.account_ids)
throw new MastoApiError(400, "Missing account_ids[] field");
const ids = toArray(body.account_ids);
const targets = await Promise.all(ids.map((p) => getUser(p)));
await ListHelpers.removeFromList(list, targets, ctx);
ctx.body = {};
},
);
}

View file

@ -0,0 +1,34 @@
import type Router from "@koa/router";
import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js";
import { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
export function setupEndpointsMedia(router: Router): void {
router.get<{ Params: { id: string } }>(
"/v1/media/:id",
auth(true, ["write:media"]),
async (ctx) => {
const file = await MediaHelpers.getMediaPackedOr404(ctx.params.id, ctx);
ctx.body = FileConverter.encode(file);
},
);
router.put<{ Params: { id: string } }>(
"/v1/media/:id",
auth(true, ["write:media"]),
async (ctx) => {
const file = await MediaHelpers.getMediaOr404(ctx.params.id, ctx);
ctx.body = await MediaHelpers.updateMedia(file, ctx).then((p) =>
FileConverter.encode(p),
);
},
);
router.post(
["/v2/media", "/v1/media"],
auth(true, ["write:media"]),
async (ctx) => {
ctx.body = await MediaHelpers.uploadMedia(ctx).then((p) =>
FileConverter.encode(p),
);
},
);
}

View file

@ -1,68 +0,0 @@
import type { Entity } from "megalodon";
import { config } from "@/config.js";
import { FILE_TYPE_BROWSERSAFE, fetchMeta } from "backend-rs";
import { Users, Notes } from "@/models/index.js";
import { IsNull } from "typeorm";
export async function getInstance(
response: Entity.Instance,
contact: Entity.Account,
) {
const [meta, totalUsers, totalStatuses] = await Promise.all([
fetchMeta(),
Users.count({ where: { host: IsNull() } }),
Notes.count({ where: { userHost: IsNull() } }),
]);
return {
uri: response.uri,
title: response.title || "Firefish",
short_description:
response.description?.substring(0, 50) || "See real server website",
description:
response.description ||
"This is a vanilla Firefish Instance. It doesn't seem to have a description.",
email: response.email || "",
version: `3.0.0 (compatible; Firefish ${config.version})`,
urls: response.urls,
stats: {
user_count: totalUsers,
status_count: totalStatuses,
domain_count: response.stats.domain_count,
},
thumbnail: response.thumbnail || "/static-assets/transparent.png",
languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations,
approval_required: !response.registrations,
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: 50,
min_expiration: 50,
max_expiration: 2629746,
},
reactions: {
max_reactions: 1,
},
},
contact_account: contact,
rules: [],
};
}

View file

@ -0,0 +1,70 @@
import type Router from "@koa/router";
import { MiscHelpers } from "@/server/api/mastodon/helpers/misc.js";
import {
argsToBools,
limitToInt,
} from "@/server/api/mastodon/endpoints/timeline.js";
import { Announcements } from "@/models/index.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js";
export function setupEndpointsMisc(router: Router): void {
router.get("/v1/custom_emojis", async (ctx) => {
ctx.body = await MiscHelpers.getCustomEmoji();
});
router.get("/v1/instance", async (ctx) => {
ctx.body = await MiscHelpers.getInstance(ctx);
});
router.get("/v2/instance", async (ctx) => {
ctx.body = await MiscHelpers.getInstanceV2(ctx);
});
router.get("/v1/announcements", auth(true), async (ctx) => {
const args = argsToBools(ctx.query, ["with_dismissed"]);
ctx.body = await MiscHelpers.getAnnouncements(args.with_dismissed, ctx);
});
router.post<{ Params: { id: string } }>(
"/v1/announcements/:id/dismiss",
auth(true, ["write:accounts"]),
async (ctx) => {
const announcement = await Announcements.findOneBy({ id: ctx.params.id });
if (!announcement) throw new MastoApiError(404);
await MiscHelpers.dismissAnnouncement(announcement, ctx);
ctx.body = {};
},
);
// FIXME: add link pagination to trends (ref: https://mastodon.social/api/v1/trends/tags?offset=10&limit=1)
router.get(["/v1/trends/tags", "/v1/trends"], async (ctx) => {
const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getTrendingHashtags(args.limit, args.offset);
// FIXME: convert ids
});
router.get("/v1/trends/statuses", filterContext("public"), async (ctx) => {
const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getTrendingStatuses(
args.limit,
args.offset,
ctx,
);
});
router.get("/v1/trends/links", async (ctx) => {
ctx.body = [];
});
router.get("/v1/preferences", auth(true, ["read:accounts"]), async (ctx) => {
ctx.body = await MiscHelpers.getPreferences(ctx);
});
router.get("/v2/suggestions", auth(true, ["read:accounts"]), async (ctx) => {
const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getFollowSuggestions(args.limit, ctx);
});
}

View file

@ -1,98 +1,107 @@
import Router from "@koa/router";
import { fromMastodonId } from "backend-rs";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { convertTimelinesArgsId } from "./timeline.js";
import { convertNotification } from "../converters.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
import type Router from "@koa/router";
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js";
import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js";
function toLimitToInt(q: any) {
if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10);
return q;
}
export function apiNotificationsMastodon(router: Router): void {
router.get("/v1/notifications", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getNotifications(
convertTimelinesArgsId(toLimitToInt(ctx.query)),
export function setupEndpointsNotifications(router: Router): void {
router.get(
"/v1/notifications",
auth(true, ["read:notifications"]),
filterContext("notifications"),
async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query), [
"types[]",
"exclude_types[]",
]);
const res = await NotificationHelpers.getNotifications(
args.max_id,
args.since_id,
args.min_id,
args.limit,
args["types[]"],
args["exclude_types[]"],
args.account_id,
ctx,
);
const notfs = data.data;
const ret = notfs.map((n) => {
n = convertNotification(n);
if (n.type !== "follow" && n.type !== "follow_request") {
if (n.type === "reaction") n.type = "favourite";
return n;
} else {
return n;
}
});
ctx.body = ret;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NotificationConverter.encodeMany(res, ctx);
},
);
router.get(
"/v1/notifications/:id",
auth(true, ["read:notifications"]),
filterContext("notifications"),
async (ctx) => {
const notification = await NotificationHelpers.getNotificationOr404(
ctx.params.id,
ctx,
);
ctx.body = await NotificationConverter.encode(notification, ctx);
},
);
router.post(
"/v1/notifications/clear",
auth(true, ["write:notifications"]),
async (ctx) => {
await NotificationHelpers.clearAllNotifications(ctx);
ctx.body = {};
},
);
router.post(
"/v1/notifications/:id/dismiss",
auth(true, ["write:notifications"]),
async (ctx) => {
const notification = await NotificationHelpers.getNotificationOr404(
ctx.params.id,
ctx,
);
await NotificationHelpers.dismissNotification(notification.id, ctx);
ctx.body = {};
},
);
router.post(
"/v1/conversations/:id/read",
auth(true, ["write:conversations"]),
async (ctx, _reply) => {
await NotificationHelpers.markConversationAsRead(ctx.params.id, ctx);
ctx.body = {};
},
);
router.get("/v1/push/subscription", auth(true, ["push"]), async (ctx) => {
const subscription =
await NotificationHelpers.getPushSubscriptionOr404(ctx);
ctx.body = await NotificationConverter.encodeSubscription(
subscription,
ctx,
);
});
router.get("/v1/notification/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const dataRaw = await client.getNotification(
fromMastodonId(ctx.params.id),
);
const data = convertNotification(dataRaw.data);
ctx.body = data;
if (
data.type !== "follow" &&
data.type !== "follow_request" &&
data.type === "reaction"
) {
data.type = "favourite";
}
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
router.post("/v1/push/subscription", auth(true, ["push"]), async (ctx) => {
const subscription = await NotificationHelpers.setPushSubscription(ctx);
ctx.body = await NotificationConverter.encodeSubscription(
subscription,
ctx,
);
});
router.post("/v1/notifications/clear", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotifications();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
router.delete("/v1/push/subscription", auth(true, ["push"]), async (ctx) => {
await NotificationHelpers.deletePushSubscription(ctx);
ctx.body = {};
});
router.post("/v1/notification/:id/dismiss", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotification(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
router.put("/v1/push/subscription", auth(true, ["push"]), async (ctx) => {
const subscription =
await NotificationHelpers.getPushSubscriptionOr404(ctx);
await NotificationHelpers.putPushSubscription(subscription, ctx);
ctx.body = await NotificationConverter.encodeSubscription(
subscription,
ctx,
);
});
}

View file

@ -1,146 +1,43 @@
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import axios from "axios";
import { Converter } from "megalodon";
import { convertTimelinesArgsId, limitToInt } from "./timeline.js";
import { convertAccount, convertStatus } from "../converters.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
import type Router from "@koa/router";
import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { SearchHelpers } from "@/server/api/mastodon/helpers/search.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
export function apiSearchMastodon(router: Router): void {
router.get("/v1/search", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const query: any = convertTimelinesArgsId(limitToInt(ctx.query));
const type = query.type || "";
const data = await client.search(query.q, type, query);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v2/search", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = convertTimelinesArgsId(limitToInt(ctx.query));
const type = query.type;
const acct =
!type || type === "accounts"
? await client.search(query.q, "accounts", query)
: null;
const stat =
!type || type === "statuses"
? await client.search(query.q, "statuses", query)
: null;
const tags =
!type || type === "hashtags"
? await client.search(query.q, "hashtags", query)
: null;
export function setupEndpointsSearch(router: Router): void {
router.get(
["/v1/search", "/v2/search"],
auth(true, ["read:search"]),
async (ctx) => {
const args = normalizeUrlQuery(
argsToBools(limitToInt(ctx.query), [
"resolve",
"following",
"exclude_unreviewed",
]),
);
const body = await SearchHelpers.search(
args.q,
args.type,
args.resolve,
args.following,
args.account_id,
args.exclude_unreviewed,
args.max_id,
args.min_id,
args.limit,
args.offset,
ctx,
);
ctx.body = {
accounts:
acct?.data?.accounts.map((account) => convertAccount(account)) ?? [],
statuses:
stat?.data?.statuses.map((status) => convertStatus(status)) ?? [],
hashtags: tags?.data?.hashtags ?? [],
};
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/trends/statuses", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const data = await getHighlight(
BASE_URL,
ctx.request.hostname,
accessTokens,
);
ctx.body = data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v2/suggestions", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const query: any = ctx.query;
let data = await getFeaturedUser(
BASE_URL,
ctx.request.hostname,
accessTokens,
query.limit || 20,
);
data = data.map((suggestion) => {
suggestion.account = convertAccount(suggestion.account);
return suggestion;
});
console.log(data);
ctx.body = data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
}
async function getHighlight(
BASE_URL: string,
domain: string,
accessTokens: string | undefined,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/notes/featured`, {
i: accessToken,
});
const data: MisskeyEntity.Note[] = api.data;
return data.map((note) => new Converter(BASE_URL).note(note, domain));
} catch (e: any) {
apiLogger.info(inspect(e));
return [];
}
}
async function getFeaturedUser(
BASE_URL: string,
host: string,
accessTokens: string | undefined,
limit: number,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/users`, {
i: accessToken,
limit,
origin: "local",
sort: "+follower",
state: "alive",
});
const data: MisskeyEntity.UserDetail[] = api.data;
apiLogger.info(inspect(data));
return data.map((u) => {
return {
source: "past_interactions",
account: new Converter(BASE_URL).userDetail(u, host),
};
});
} catch (e: any) {
apiLogger.info(inspect(e));
return [];
}
if (ctx.path === "/v1/search") {
const v1Body = {
...body,
hashtags: body.hashtags.map((p: MastodonEntity.Tag) => p.name),
};
ctx.body = v1Body;
} else {
ctx.body = body;
}
},
);
}

View file

@ -1,460 +1,378 @@
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import querystring from "node:querystring";
import qs from "qs";
import { convertTimelinesArgsId, limitToInt } from "./timeline.js";
import { fetchMeta, fromMastodonId, isUnicodeEmoji } from "backend-rs";
import type Router from "@koa/router";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js";
import {
convertAccount,
convertAttachment,
convertPoll,
convertStatus,
} from "../converters.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
limitToInt,
normalizeUrlQuery,
} from "@/server/api/mastodon/endpoints/timeline.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { PollHelpers } from "@/server/api/mastodon/helpers/poll.js";
import { toArray } from "@/prelude/array.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js";
function normalizeQuery(data: any) {
const str = querystring.stringify(data);
return qs.parse(str);
}
export function setupEndpointsStatus(router: Router): void {
router.post("/v1/statuses", auth(true, ["write:statuses"]), async (ctx) => {
const key = NoteHelpers.getIdempotencyKey(ctx);
if (key !== null) {
const result = await NoteHelpers.getFromIdempotencyCache(key);
export function apiStatusMastodon(router: Router): void {
router.post("/v1/statuses", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
let body: any = ctx.request.body;
if (body.in_reply_to_id)
body.in_reply_to_id = fromMastodonId(body.in_reply_to_id);
if (body.quote_id) body.quote_id = fromMastodonId(body.quote_id);
if (
(!body.poll && body["poll[options][]"]) ||
(!body.media_ids && body["media_ids[]"])
) {
body = normalizeQuery(body);
if (result) {
ctx.body = result;
return;
}
const text = body.status;
const removed = text.replace(/@\S+/g, "").replace(/\s|/g, "");
const isDefaultEmoji = isUnicodeEmoji(removed);
const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
if ((body.in_reply_to_id && isDefaultEmoji) || isCustomEmoji) {
const a = await client.createEmojiReaction(
body.in_reply_to_id,
removed,
);
ctx.body = 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.reactions.filter((e) => e.me)[0].name;
const data = await client.deleteEmojiReaction(id, react);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
}
if (!body.media_ids) body.media_ids = undefined;
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
if (body.media_ids) {
body.media_ids = (body.media_ids as string[]).map((p) =>
fromMastodonId(p),
);
}
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);
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const request = NoteHelpers.normalizeComposeOptions(ctx.request.body);
const status = await NoteHelpers.createNote(request, ctx).then(
async (p) => {
if (!request.scheduled_at) return NoteConverter.encode(p, ctx);
const note = await NoteHelpers.getScheduledNoteOr404(p.id, ctx);
const result = await NoteConverter.encodeScheduledNote(note, ctx);
result.params.idempotency = key ?? result.params.idempotency;
return result;
},
);
ctx.body = status;
if (key !== null && "text" in status)
NoteHelpers.postIdempotencyCache.set(key, { status });
});
router.get<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatus(fromMastodonId(ctx.params.id));
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = ctx.status == 404 ? 404 : 401;
ctx.body = e.response.data;
}
});
router.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteStatus(fromMastodonId(ctx.params.id));
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
interface IReaction {
id: string;
createdAt: string;
user: MisskeyEntity.User;
type: string;
}
router.put(
"/v1/statuses/:id",
auth(true, ["write:statuses"]),
async (ctx) => {
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const request = NoteHelpers.normalizeEditOptions(ctx.request.body);
ctx.body = await NoteHelpers.editNote(request, note, ctx).then((p) =>
NoteConverter.encode(p, ctx),
);
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id",
auth(false, ["read:statuses"]),
filterContext("thread"),
async (ctx) => {
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteConverter.encode(note, ctx);
},
);
router.delete<{ Params: { id: string } }>(
"/v1/statuses/:id",
auth(true, ["write:statuses"]),
async (ctx) => {
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.deleteNote(note, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/context",
auth(false, ["read:statuses"]),
filterContext("thread"),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const id = fromMastodonId(ctx.params.id);
const data = await client.getStatusContext(
id,
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
// FIXME: determine final limits within helper functions instead of here
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const ancestors = await NoteHelpers.getNoteAncestors(
note,
ctx.user ? 4096 : 60,
ctx,
).then((n) => NoteConverter.encodeMany(n, ctx));
const descendants = await NoteHelpers.getNoteDescendants(
note,
ctx.user ? 4096 : 40,
ctx.user ? 4096 : 20,
ctx,
).then((n) => NoteConverter.encodeMany(n, ctx));
data.data.ancestors = data.data.ancestors.map((status) =>
convertStatus(status),
);
data.data.descendants = data.data.descendants.map((status) =>
convertStatus(status),
);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = {
ancestors,
descendants,
};
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/history",
auth(false, ["read:statuses"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusHistory(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.getNoteEditHistory(note, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/source",
auth(true, ["read:statuses"]),
async (ctx) => {
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = NoteHelpers.getNoteSource(note);
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/reblogged_by",
auth(false, ["read:statuses"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusRebloggedBy(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await NoteHelpers.getNoteRebloggedBy(
note,
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await UserConverter.encodeMany(res, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/favourited_by",
auth(false, ["read:statuses"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusFavouritedBy(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query as any));
const res = await NoteHelpers.getNoteFavoritedBy(
note,
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await UserConverter.encodeMany(res, ctx);
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/favourite",
auth(true, ["write:favourites"]),
async (ctx) => {
const meta = await fetchMeta();
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const react = meta.defaultReaction;
try {
const a = (await client.createEmojiReaction(
fromMastodonId(ctx.params.id),
react,
)) as any;
//const data = await client.favouriteStatus(ctx.params.id) as any;
ctx.body = convertStatus(a.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const reaction = await NoteHelpers.getDefaultReaction();
ctx.body = await NoteHelpers.reactToNote(note, reaction, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.favourited = true;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unfavourite",
auth(true, ["write:favourites"]),
async (ctx) => {
const meta = await fetchMeta();
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const react = meta.defaultReaction;
try {
const data = await client.deleteEmojiReaction(
fromMastodonId(ctx.params.id),
react,
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.favourited = false;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/reblog",
auth(true, ["write:statuses"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.reblogStatus(fromMastodonId(ctx.params.id));
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.reblogNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.reblogged = true;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unreblog",
auth(true, ["write:statuses"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unreblogStatus(fromMastodonId(ctx.params.id));
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.unreblogNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.reblogged = false;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/bookmark",
auth(true, ["write:bookmarks"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.bookmarkStatus(fromMastodonId(ctx.params.id));
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.bookmarkNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.bookmarked = true;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unbookmark",
auth(true, ["write:bookmarks"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unbookmarkStatus(
fromMastodonId(ctx.params.id),
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.unbookmarkNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.bookmarked = false;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/pin",
auth(true, ["write:accounts"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.pinStatus(fromMastodonId(ctx.params.id));
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.pinNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.pinned = true;
return p;
});
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unpin",
auth(true, ["write:accounts"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unpinStatus(fromMastodonId(ctx.params.id));
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.unpinNote(note, ctx)
.then((p) => NoteConverter.encode(p, ctx))
.then((p) => {
p.pinned = false;
return p;
});
},
);
router.post<{ Params: { id: string; name: string } }>(
"/v1/statuses/:id/react/:name",
auth(true, ["write:favourites"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.reactStatus(
fromMastodonId(ctx.params.id),
ctx.params.name,
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.reactToNote(note, ctx.params.name, ctx).then(
(p) => NoteConverter.encode(p, ctx),
);
},
);
router.post<{ Params: { id: string; name: string } }>(
"/v1/statuses/:id/unreact/:name",
auth(true, ["write:favourites"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unreactStatus(
fromMastodonId(ctx.params.id),
ctx.params.name,
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx).then((p) =>
NoteConverter.encode(p, ctx),
);
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/translate",
auth(true, ["read:statuses"]),
async (ctx) => {
const targetLang = ctx.request.body.lang;
if (typeof targetLang !== "string")
throw new MastoApiError(400, "Missing lang parameter");
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const encodedNote = await NoteConverter.encode(note, ctx);
ctx.body = await NoteHelpers.translateNote(encodedNote, targetLang, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/polls/:id",
auth(false, ["read:statuses"]),
async (ctx) => {
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await PollHelpers.getPoll(note, ctx);
},
);
router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMedia(fromMastodonId(ctx.params.id));
ctx.body = convertAttachment(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateMedia(
fromMastodonId(ctx.params.id),
ctx.request.body as any,
);
ctx.body = convertAttachment(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getPoll(fromMastodonId(ctx.params.id));
ctx.body = convertPoll(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>(
"/v1/polls/:id/votes",
auth(true, ["write:statuses"]),
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.votePoll(
fromMastodonId(ctx.params.id),
(ctx.request.body as any).choices,
);
ctx.body = convertPoll(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const body: any = ctx.request.body;
const choices = toArray(body.choices ?? []).map((p) =>
Number.parseInt(p),
);
if (choices.length < 1)
throw new MastoApiError(400, "Must vote for at least one option");
ctx.body = await PollHelpers.voteInPoll(choices, note, ctx);
},
);
router.get(
"/v1/scheduled_statuses",
auth(true, ["read:statuses"]),
async (ctx) => {
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await NoteHelpers.getScheduledNotes(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = await NoteConverter.encodeManyScheduledNotes(res, ctx);
},
);
router.get<{ Params: { id: string } }>(
"/v1/scheduled_statuses/:id",
auth(true, ["read:statuses"]),
async (ctx) => {
const note = await NoteHelpers.getScheduledNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteConverter.encodeScheduledNote(note, ctx);
},
);
// Reeschedule a post to a new time
router.put<{ Params: { id: string } }>(
"/v1/scheduled_statuses/:id",
auth(true, ["write:statuses"]),
async (ctx) => {
const scheduledAt = new Date(Date.parse(ctx.request.body.scheduled_at));
// FIXME: Implement, see https://firefish.dev/firefish/firefish/-/issues/10903
throw new MastoApiError(501, "Not implemented");
},
);
router.delete<{ Params: { id: string } }>(
"/v1/scheduled_statuses/:id",
auth(true, ["write:statuses"]),
async (ctx) => {
// FIXME: Implement, see https://firefish.dev/firefish/firefish/-/issues/10903
throw new MastoApiError(501, "Not implemented");
},
);
}

View file

@ -0,0 +1,7 @@
import type Router from "@koa/router";
export function setupEndpointsStreaming(router: Router): void {
router.get("/v1/streaming/health", async (ctx) => {
ctx.body = "OK";
});
}

View file

@ -1,26 +1,28 @@
import type Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import type { ParsedUrlQuery } from "node:querystring";
import {
convertAccount,
convertConversation,
convertList,
convertStatus,
} from "../converters.js";
import { fromMastodonId } from "backend-rs";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserLists } from "@/models/index.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js";
export function limitToInt(q: ParsedUrlQuery) {
let object: any = q;
// TODO: Move helper functions to a helper class
export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) {
const object: any = q;
if (q.limit)
if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10);
if (typeof q.limit === "string")
object.limit = Number.parseInt(q.limit, 10);
if (q.offset)
if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10);
if (typeof q.offset === "string")
object.offset = Number.parseInt(q.offset, 10);
for (const key of additional)
if (typeof q[key] === "string")
object[key] = Number.parseInt(<string>q[key], 10);
return object;
}
export function argsToBools(q: ParsedUrlQuery) {
export function argsToBools(q: ParsedUrlQuery, additional: string[] = []) {
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
const toBoolean = (value: string) =>
!["0", "f", "F", "false", "FALSE", "off", "OFF"].includes(value);
@ -29,273 +31,158 @@ export function argsToBools(q: ParsedUrlQuery) {
// - https://docs.joinmastodon.org/methods/accounts/#statuses
// - https://docs.joinmastodon.org/methods/timelines/#public
// - https://docs.joinmastodon.org/methods/timelines/#tag
let 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;
const keys = [
"only_media",
"exclude_replies",
"exclude_reblogs",
"pinned",
"local",
"remote",
].concat(additional);
const object: any = q;
for (const key of keys)
if (q[key] && typeof q[key] === "string")
object[key] = toBoolean(<string>q[key]);
return object;
}
export function convertTimelinesArgsId(q: ParsedUrlQuery) {
if (typeof q.min_id === "string") q.min_id = fromMastodonId(q.min_id);
if (typeof q.max_id === "string") q.max_id = fromMastodonId(q.max_id);
if (typeof q.since_id === "string") q.since_id = fromMastodonId(q.since_id);
return q;
export function normalizeUrlQuery(
q: ParsedUrlQuery,
arrayKeys: string[] = [],
): any {
const dict: any = {};
for (const k in q) {
if (arrayKeys.includes(k)) dict[k] = Array.isArray(q[k]) ? q[k] : [q[k]];
else dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k];
}
return dict;
}
export function apiTimelineMastodon(router: Router): void {
router.get("/v1/timelines/public", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = ctx.query;
const data =
query.local === "true"
? await client.getLocalTimeline(
convertTimelinesArgsId(argsToBools(limitToInt(query))),
)
: await client.getPublicTimeline(
convertTimelinesArgsId(argsToBools(limitToInt(query))),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
export function setupEndpointsTimeline(router: Router): void {
router.get(
"/v1/timelines/public",
auth(true, ["read:statuses"]),
filterContext("public"),
async (ctx, _reply) => {
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)));
const res = await TimelineHelpers.getPublicTimeline(
args.max_id,
args.since_id,
args.min_id,
args.limit,
args.only_media,
args.local,
args.remote,
ctx,
);
ctx.body = await NoteConverter.encodeMany(res, ctx);
},
);
router.get<{ Params: { hashtag: string } }>(
"/v1/timelines/tag/:hashtag",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getTagTimeline(
ctx.params.hashtag,
convertTimelinesArgsId(argsToBools(limitToInt(ctx.query))),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
auth(false, ["read:statuses"]),
filterContext("public"),
async (ctx, _reply) => {
const tag = (ctx.params.hashtag ?? "").trim().toLowerCase();
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)), [
"any[]",
"all[]",
"none[]",
]);
const res = await TimelineHelpers.getTagTimeline(
tag,
args.max_id,
args.since_id,
args.min_id,
args.limit,
args["any[]"] ?? [],
args["all[]"] ?? [],
args["none[]"] ?? [],
args.only_media,
args.local,
args.remote,
ctx,
);
ctx.body = await NoteConverter.encodeMany(res, ctx);
},
);
router.get("/v1/timelines/home", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getHomeTimeline(
convertTimelinesArgsId(limitToInt(ctx.query)),
router.get(
"/v1/timelines/home",
auth(true, ["read:statuses"]),
filterContext("home"),
async (ctx, _reply) => {
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await TimelineHelpers.getHomeTimeline(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
ctx.body = await NoteConverter.encodeMany(res, ctx);
},
);
router.get<{ Params: { listId: string } }>(
"/v1/timelines/list/:listId",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getListTimeline(
fromMastodonId(ctx.params.listId),
convertTimelinesArgsId(limitToInt(ctx.query)),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/conversations", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getConversationTimeline(
convertTimelinesArgsId(limitToInt(ctx.query)),
auth(true, ["read:lists"]),
filterContext("home"),
async (ctx, _reply) => {
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.listId,
});
if (!list) throw new MastoApiError(404);
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await TimelineHelpers.getListTimeline(
list,
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
ctx.body = data.data.map((conversation) =>
convertConversation(conversation),
ctx.body = await NoteConverter.encodeMany(res, ctx);
},
);
router.get(
"/v1/conversations",
auth(true, ["read:statuses"]),
async (ctx, _reply) => {
const args = normalizeUrlQuery(limitToInt(ctx.query));
ctx.body = await TimelineHelpers.getConversations(
args.max_id,
args.since_id,
args.min_id,
args.limit,
ctx,
);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/lists", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getLists();
ctx.body = data.data.map((list) => convertList(list));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getList(fromMastodonId(ctx.params.id));
ctx.body = convertList(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post("/v1/lists", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createList((ctx.request.body as any).title);
ctx.body = convertList(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateList(
fromMastodonId(ctx.params.id),
(ctx.request.body as any).title,
);
ctx.body = convertList(data.data);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
router.get(
"/v1/markers",
auth(true, ["read:statuses"]),
async (ctx, _reply) => {
const args = normalizeUrlQuery(ctx.query, ["timeline[]"]);
ctx.body = await TimelineHelpers.getMarkers(args["timeline[]"], ctx);
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteList(fromMastodonId(ctx.params.id));
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountsInList(
fromMastodonId(ctx.params.id),
convertTimelinesArgsId(ctx.query as any),
);
ctx.body = data.data.map((account) => convertAccount(account));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.addAccountsToList(
fromMastodonId(ctx.params.id),
(ctx.query.account_ids as string[]).map((id) => fromMastodonId(id)),
);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteAccountsFromList(
fromMastodonId(ctx.params.id),
(ctx.query.account_ids as string[]).map((id) => fromMastodonId(id)),
);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
router.post(
"/v1/markers",
auth(true, ["write:statuses"]),
async (ctx, _reply) => {
const body = ctx.request.body;
ctx.body = await TimelineHelpers.setMarkers(body, ctx);
},
);
}
function escapeHTML(str: string) {
if (!str) {
return "";
}
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function nl2br(str: string) {
if (!str) {
return "";
}
str = str.replace(/\r\n/g, "<br />");
str = str.replace(/(\n|\r)/g, "<br />");
return str;
}

View file

@ -1,11 +1,12 @@
/// <reference path="emoji.ts" />
/// <reference path="source.ts" />
/// <reference path="field.ts" />
namespace Entity {
namespace MastodonEntity {
export type Account = {
id: string;
username: string;
acct: string;
fqn: string;
display_name: string;
locked: boolean;
created_at: string;
@ -22,6 +23,18 @@ namespace Entity {
moved: Account | null;
fields: Array<Field>;
bot: boolean | null;
discoverable: boolean;
source?: Source;
};
export type MutedAccount =
| Account
| {
mute_expires_at: string | null;
};
export type SuggestedAccount = {
source: "staff" | "past_interactions" | "global";
account: Account;
};
}

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type Activity = {
week: string;
statuses: string;

View file

@ -2,7 +2,7 @@
/// <reference path="emoji.ts" />
/// <reference path="reaction.ts" />
namespace Entity {
namespace MastodonEntity {
export type Announcement = {
id: string;
content: string;

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type Application = {
name: string;
website?: string | null;

View file

@ -1,5 +1,5 @@
/// <reference path="attachment.ts" />
namespace Entity {
namespace MastodonEntity {
export type AsyncAttachment = {
id: string;
type: "unknown" | "image" | "gifv" | "video" | "audio";

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type Sub = {
// For Image, Gifv, and Video
width?: number;

View file

@ -0,0 +1,22 @@
namespace MastodonEntity {
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;
embed_url: string;
blurhash: string | null;
/** ISO 8901 date time string */
published_at: string | null;
image_description: string;
language: string | null;
};
}

View file

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

View file

@ -1,7 +1,7 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
namespace Entity {
namespace MastodonEntity {
export type Conversation = {
id: string;
accounts: Array<Account>;

View file

@ -1,9 +1,9 @@
namespace Entity {
namespace MastodonEntity {
export type Emoji = {
shortcode: string;
static_url: string;
url: string;
visible_in_picker: boolean;
category: string;
category: string | undefined;
};
}

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type FeaturedTag = {
id: string;
name: string;

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type Field = {
name: string;
value: string;

View file

@ -0,0 +1,5 @@
import type { MastoContext } from "..";
export type Files = MastoContext["request"]["files"];
type FileOrFileArr = Exclude<Files, undefined>["file"];
export type File = Exclude<FileOrFileArr, FileOrFileArr[]>;

View file

@ -0,0 +1,18 @@
namespace MastodonEntity {
export type Filter = {
id: string;
title: string;
context: Array<FilterContext>;
expires_at: string | null;
filter_action: "warn" | "hide";
keywords: FilterKeyword[];
statuses: FilterStatus[];
};
export type FilterContext =
| "home"
| "notifications"
| "public"
| "thread"
| "account";
}

View file

@ -0,0 +1,7 @@
namespace MastodonEntity {
export type FilterKeyword = {
id: string;
keyword: string;
whole_word: boolean;
};
}

View file

@ -0,0 +1,7 @@
namespace MastodonEntity {
export type FilterResult = {
filter: Filter;
keyword_matches?: string[];
status_matches?: string[];
};
}

View file

@ -0,0 +1,6 @@
namespace MastodonEntity {
export type FilterStatus = {
id: string;
status_id: string;
};
}

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type History = {
day: string;
uses: number;

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type IdentityProof = {
provider: string;
provider_username: string;

View file

@ -0,0 +1,109 @@
/// <reference path="account.ts" />
/// <reference path="urls.ts" />
/// <reference path="stats.ts" />
namespace MastodonEntity {
export type Instance = {
uri: string;
title: string;
description: string;
email: string;
version: string;
thumbnail: string | null;
urls: URLs;
stats: Stats;
languages: Array<string>;
contact_account: Account | null;
max_toot_chars?: number;
registrations?: boolean;
configuration?: {
statuses: {
max_characters: number;
max_media_attachments: number;
characters_reserved_per_url: number;
};
media_attachments: {
supported_mime_types: Array<string>;
image_size_limit: number;
image_matrix_limit: number;
video_size_limit: number;
video_frame_rate_limit: number;
video_matrix_limit: number;
};
polls: {
max_options: number;
max_characters_per_option: number;
min_expiration: number;
max_expiration: number;
};
};
};
export type InstanceV2 = {
domain: string;
title: string;
version: string;
source_url: string;
description: string;
usage: {
users: {
active_month: number;
};
};
thumbnail: {
url: string;
blurhash?: string;
versions?: {
"@1x"?: string;
"@2x"?: string;
};
};
languages: string[];
configuration: {
urls: {
streaming: string;
};
vapid: {
public_key: string;
};
accounts: {
max_featured_tags: number;
};
statuses: {
max_characters: number;
max_media_attachments: number;
characters_reserved_per_url: number;
};
media_attachments: {
supported_mime_types: string[];
image_size_limit: number;
image_matrix_limit: number;
video_size_limit: number;
video_frame_rate_limit: number;
video_matrix_limit: number;
};
polls: {
max_options: number;
max_characters_per_option: number;
min_expiration: number;
max_expiration: number;
};
translation: {
enabled: boolean;
};
};
registrations: {
enabled: boolean;
approval_required: boolean;
message: string | null;
};
contact: {
email: string;
account: Account | null;
};
rules: {
id: string;
text: string;
}[];
};
}

View file

@ -1,6 +1,7 @@
namespace Entity {
namespace MastodonEntity {
export type List = {
id: string;
title: string;
exclusive: boolean;
};
}

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type Marker = {
home?: {
last_read_id: string;

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type Mention = {
id: string;
username: string;

View file

@ -1,7 +1,7 @@
/// <reference path="account.ts" />
/// <reference path="status.ts" />
namespace Entity {
namespace MastodonEntity {
export type Notification = {
account: Account;
created_at: string;
@ -11,5 +11,15 @@ namespace Entity {
type: NotificationType;
};
export type NotificationType = string;
export type NotificationType =
| "mention"
| "status"
| "reblog"
| "follow"
| "follow_request"
| "favourite"
| "poll"
| "update"
| "admin.sign_up"
| "admin.report";
}

View file

@ -3,11 +3,12 @@
* Response data when oauth request.
**/
namespace OAuth {
export type AppDataFromServer = {
export type Application = {
id: string;
name: string;
website: string | null;
redirect_uri: string;
vapid_key: string | undefined;
client_id: string;
client_secret: string;
};
@ -21,49 +22,9 @@ namespace OAuth {
refresh_token: string | null;
};
export class AppData {
public url: string | null;
public session_token: string | null;
constructor(
public id: string,
public name: string,
public website: string | null,
public redirect_uri: string,
public client_id: string,
public client_secret: string,
) {
this.url = null;
this.session_token = null;
}
/**
* Serialize raw application data from server
* @param raw from server
*/
static from(raw: AppDataFromServer) {
return new this(
raw.id,
raw.name,
raw.website,
raw.redirect_uri,
raw.client_id,
raw.client_secret,
);
}
get redirectUri() {
return this.redirect_uri;
}
get clientId() {
return this.client_id;
}
get clientSecret() {
return this.client_secret;
}
}
export class TokenData {
public _scope: string;
constructor(
public access_token: string,
public token_type: string,
@ -96,21 +57,26 @@ namespace OAuth {
get accessToken() {
return this.access_token;
}
get tokenType() {
return this.token_type;
}
get scope() {
return this._scope;
}
/**
* Application ID
*/
get createdAt() {
return this.created_at;
}
get expiresIn() {
return this.expires_in;
}
/**
* OAuth Refresh Token
*/

View file

@ -1,6 +1,6 @@
/// <reference path="poll_option.ts" />
namespace Entity {
namespace MastodonEntity {
export type Poll = {
id: string;
expires_at: string | null;
@ -10,5 +10,6 @@ namespace Entity {
options: Array<PollOption>;
voted: boolean;
own_votes: Array<number>;
emojis: Array<MastodonEntity.Emoji>;
};
}

View file

@ -1,4 +1,4 @@
namespace Entity {
namespace MastodonEntity {
export type PollOption = {
title: string;
votes_count: number | null;

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