Merge branch 'develop' into refactor/types

This commit is contained in:
naskya 2024-04-27 09:09:17 +09:00
commit 1b143ebfaa
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
62 changed files with 2805 additions and 955 deletions

View file

@ -13,8 +13,6 @@ redis:
host: firefish_redis
port: 6379
id: 'aid'
#allowedPrivateNetworks: [
# '10.69.1.0/24'
#]

1242
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,36 +5,40 @@ resolver = "2"
[workspace.dependencies]
macro_rs = { path = "packages/macro-rs" }
napi = { version = "2.16.2", default-features = false }
napi-derive = "2.16.2"
napi = { version = "2.16.4", default-features = false }
napi-derive = "2.16.3"
napi-build = "2.1.3"
argon2 = "0.5.3"
basen = "0.1.0"
bcrypt = "0.15.1"
chrono = "0.4.37"
chrono = "0.4.38"
convert_case = "0.6.0"
cuid2 = "0.1.2"
emojis = "0.6.1"
emojis = "0.6.2"
idna = "0.5.0"
image = "0.25.1"
nom-exif = "1.2.0"
once_cell = "1.19.0"
openssl = "0.10.64"
pretty_assertions = "1.4.0"
proc-macro2 = "1.0.79"
proc-macro2 = "1.0.81"
quote = "1.0.36"
rand = "0.8.5"
redis = "0.25.3"
regex = "1.10.4"
reqwest = "0.12.4"
rmp-serde = "1.2.0"
sea-orm = "0.12.15"
serde = "1.0.197"
serde_json = "1.0.115"
serde = "1.0.198"
serde_json = "1.0.116"
serde_yaml = "0.9.34"
strum = "0.26.2"
syn = "2.0.58"
thiserror = "1.0.58"
syn = "2.0.60"
thiserror = "1.0.59"
tokio = "1.37.0"
tracing = "0.1.40"
tracing-subscriber = "0.3.1"
tracing-subscriber = "0.3.18"
url = "2.5.0"
urlencoding = "2.1.3"

View file

@ -3,7 +3,7 @@ FROM docker.io/node:20-alpine as build
WORKDIR /firefish
# Install compilation dependencies
RUN apk update && apk add --no-cache build-base linux-headers curl ca-certificates python3
RUN apk update && apk add --no-cache build-base linux-headers curl ca-certificates python3 perl
RUN curl --proto '=https' --tlsv1.2 --silent --show-error --fail https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
@ -34,6 +34,7 @@ RUN corepack enable && corepack prepare pnpm@latest --activate && pnpm install -
# Copy in the rest of the rust files
COPY packages/backend-rs packages/backend-rs/
# COPY packages/macro-rs packages/macro-rs/
# Compile backend-rs
RUN NODE_ENV='production' pnpm run --filter backend-rs build

View file

@ -5,6 +5,12 @@ Critical security updates are indicated by the :warning: icon.
- Server administrators should 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
- Add features to share links to an account in the three dots menu on the profile page
- Improve server logs
- Fix bugs
## [v20240424](https://firefish.dev/firefish/firefish/-/merge_requests/10765/commits)
- Improve the usability of the feature to prevent forgetting to write alt texts

View file

@ -1,6 +1,7 @@
BEGIN;
DELETE FROM "migrations" WHERE name IN (
'AlterAkaType1714099399879',
'AddDriveFileUsage1713451569342',
'ConvertCwVarcharToText1713225866247',
'FixChatFileConstraint1712855579316',
@ -24,6 +25,13 @@ DELETE FROM "migrations" WHERE name IN (
'RemoveNativeUtilsMigration1705877093218'
);
-- alter-aka-type
ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld";
ALTER TABLE "user" ADD COLUMN "alsoKnownAs" text;
UPDATE "user" SET "alsoKnownAs" = array_to_string("alsoKnownAsOld", ',');
COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too';
ALTER TABLE "user" DROP COLUMN "alsoKnownAsOld";
-- AddDriveFileUsage
ALTER TABLE "drive_file" DROP COLUMN "usageHint";
DROP TYPE "drive_file_usage_hint_enum";

View file

@ -19,7 +19,7 @@ deleteAndEditConfirm: Сигурни ли сте, че искате да изт
copyUsername: Копиране на потребителското име
searchUser: Търсене на потребител
reply: Отговор
showMore: Покажи още
showMore: Показване на повече
loadMore: Зареди още
followRequestAccepted: Заявката за последване е приета
importAndExport: Импорт/експорт на данни
@ -336,6 +336,10 @@ _pages:
title: Заглавие
my: Моите страници
pageSetting: Настройки на страницата
url: Адрес на страницата
summary: Кратко обобщение
alignCenter: Центриране на елементите
variables: Променливи
_deck:
_columns:
notifications: Известия
@ -398,7 +402,7 @@ sendMessage: Изпращане на съобщение
jumpToPrevious: Премини към предишно
newer: по-ново
older: по-старо
showLess: Покажи по-малко
showLess: Показване на по-малко
youGotNewFollower: те последва
receiveFollowRequest: Заявка за последване получена
mention: Споменаване
@ -754,7 +758,7 @@ _feeds:
general: Общи
metadata: Метаданни
disk: Диск
featured: Препоръчани
featured: Препоръчано
yearsOld: на {age} години
reload: Опресняване
invites: Покани
@ -940,3 +944,11 @@ showGapBetweenNotesInTimeline: Показване на празнина межд
lookup: Поглеждане
media: Мултимедия
welcomeBackWithName: Добре дошли отново, {name}
reduceUiAnimation: Намаляване на UI анимациите
clickToFinishEmailVerification: Моля, натиснете [{ok}], за да завършите потвърждаването
на ел. поща.
_cw:
show: Показване на съдържанието
remoteFollow: Отдалечено последване
messagingUnencryptedInfo: Чатовете във Firefish не са шифровани от край до край. Не
споделяйте чувствителна информация през Firefish.

View file

@ -1011,6 +1011,8 @@ isSystemAccount: "This account is created and automatically operated by the syst
Please do not moderate, edit, delete, or otherwise tamper with this account, or
it may break your server."
typeToConfirm: "Please enter {x} to confirm"
useThisAccountConfirm: "Do you want to continue with this account?"
inputAccountId: "Please input your account (e.g., @firefish@info.firefish.dev)"
deleteAccount: "Delete account"
document: "Documentation"
numberOfPageCache: "Number of cached pages"
@ -1157,6 +1159,9 @@ addRe: "Add \"re:\" at the beginning of comment in reply to a post with a conten
confirm: "Confirm"
importZip: "Import ZIP"
exportZip: "Export ZIP"
getQrCode: "Show QR code"
remoteFollow: "Remote follow"
copyRemoteFollowUrl: "Copy remote follow URL"
emojiPackCreator: "Emoji pack creator"
indexable: "Indexable"
indexableDescription: "Allow built-in search to show your public posts"

View file

@ -928,6 +928,8 @@ colored: "Coloré"
label: "Étiquette"
localOnly: "Local seulement"
account: "Comptes"
getQrCode: "Obtenir le code QR"
_emailUnavailable:
used: "Adresse non disponible"
format: "Le format de cette adresse de courriel est invalide"

View file

@ -1825,6 +1825,7 @@ _notification:
reacted: mereaksi postinganmu
renoted: memposting ulang postinganmu
voted: memilih di angketmu
andCountUsers: dan {count} lebih banyak pengguna {acted}
_deck:
alwaysShowMainColumn: "Selalu tampilkan kolom utama"
columnAlign: "Luruskan kolom"
@ -2267,3 +2268,13 @@ markLocalFilesNsfwByDefaultDescription: Terlepas dari pengaturan ini, pengguna d
menghapus sendiri tanda NSFW. Berkas yang ada tidak berpengaruh.
noteEditHistory: Riwayat penyuntingan kiriman
media: Media
antennaLimit: Jumlah antena maksimum yang dapat dibuat oleh setiap pengguna
showAddFileDescriptionAtFirstPost: Buka formulir secara otomatis untuk menulis deskripsi
ketika mencoba mengirim berkas tanpa deskripsi
remoteFollow: Ikuti jarak jauh
foldNotification: Kelompokkan notifikasi yang sama
getQrCode: Tampilkan kode QR
cannotEditVisibility: Kamu tidak bisa menyunting keterlihatan
useThisAccountConfirm: Apakah kamu ingin melanjutkan dengan akun ini?
inputAccountId: Silakan memasukkan akunmu (misalnya, @firefish@info.firefish.dev)
copyRemoteFollowUrl: Salin URL ikuti jarak jauh

View file

@ -1902,6 +1902,7 @@ _notification:
reacted: がリアクションしました
renoted: がブーストしました
voted: が投票しました
andCountUsers: と{count}人が{acted}しました
_deck:
alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ"
@ -2059,3 +2060,10 @@ markLocalFilesNsfwByDefaultDescription: この設定が有効でも、ユーザ
noteEditHistory: 編集履歴
showAddFileDescriptionAtFirstPost: 説明の無い添付ファイルを投稿しようとした際に説明を書く画面を自動で開く
antennaLimit: 各ユーザーが作れるアンテナの最大数
inputAccountId: 'あなたのアカウントを入力してください(例: @firefish@info.firefish.dev'
remoteFollow: リモートフォロー
cannotEditVisibility: 公開範囲は変更できません
useThisAccountConfirm: このアカウントで操作を続けますか?
getQrCode: QRコードを表示
copyRemoteFollowUrl: リモートからフォローするURLをコピー
foldNotification: 同じ種類の通知をまとめて表示する

View file

@ -879,6 +879,8 @@ driveCapOverrideCaption: "输入 0 或以下的值将容量重置为默认值。
requireAdminForView: "您需要使用管理员账号登录才能查看。"
isSystemAccount: "该账号由系统自动创建。请不要修改、编辑、删除或以其它方式篡改这个账号,否则可能会破坏您的服务器。"
typeToConfirm: "输入 {x} 以确认操作"
useThisAccountConfirm: "您想使用此帐户继续执行此操作吗?"
inputAccountId: "请输入您的帐户(例如 @firefish@info.firefish.dev "
deleteAccount: "删除账号"
document: "文档"
numberOfPageCache: "缓存页数"
@ -1974,6 +1976,9 @@ origin: 起源
confirm: 确认
importZip: 导入 ZIP
exportZip: 导出 ZIP
getQrCode: "获取二维码"
remoteFollow: "远程关注"
copyRemoteFollowUrl: "复制远程关注 URL"
emojiPackCreator: 表情包创建工具
objectStorageS3ForcePathStyleDesc: 打开此选项可构建格式为 "s3.amazonaws.com/<bucket>/" 而非 "<bucket>.s3.amazonaws.com"
的端点 URL。

View file

@ -45,11 +45,11 @@
"js-yaml": "4.1.0"
},
"devDependencies": {
"@biomejs/biome": "1.6.4",
"@biomejs/cli-darwin-arm64": "^1.6.4",
"@biomejs/cli-darwin-x64": "^1.6.4",
"@biomejs/cli-linux-arm64": "^1.6.4",
"@biomejs/cli-linux-x64": "^1.6.4",
"@biomejs/biome": "1.7.1",
"@biomejs/cli-darwin-arm64": "^1.7.1",
"@biomejs/cli-darwin-x64": "^1.7.1",
"@biomejs/cli-linux-arm64": "^1.7.1",
"@biomejs/cli-linux-x64": "^1.7.1",
"@types/node": "20.12.7",
"execa": "8.0.1",
"pnpm": "8.15.7",

View file

@ -24,10 +24,14 @@ chrono = { workspace = true }
cuid2 = { workspace = true }
emojis = { workspace = true }
idna = { workspace = true }
image = { workspace = true }
nom-exif = { workspace = true }
once_cell = { workspace = true }
openssl = { workspace = true, features = ["vendored"] }
rand = { workspace = true }
redis = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] }
rmp-serde = { workspace = true }
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
serde = { workspace = true, features = ["derive"] }

View file

@ -248,6 +248,11 @@ export function sqlLikeEscape(src: string): string
export function safeForSql(src: string): boolean
/** Convert milliseconds to a human readable string */
export function formatMilliseconds(milliseconds: number): string
export interface ImageSize {
width: number
height: number
}
export function getImageSizeFromUrl(url: string): Promise<ImageSize>
/** TODO: handle name collisions better */
export interface NoteLikeForGetNoteSummary {
fileIds: Array<string>
@ -1012,10 +1017,10 @@ export interface User {
isDeleted: boolean
driveCapacityOverrideMb: number | null
movedToUri: string | null
alsoKnownAs: string | null
speakAsCat: boolean
emojiModPerm: UserEmojimodpermEnum
isIndexable: boolean
alsoKnownAs: Array<string> | null
}
export interface UserGroup {
id: string
@ -1144,6 +1149,7 @@ export interface Webhook {
export function initializeRustLogger(): void
export function watchNote(watcherId: string, noteAuthorId: string, noteId: string): Promise<void>
export function unwatchNote(watcherId: string, noteId: string): Promise<void>
export function publishToChannelStream(channelId: string, userId: string): void
export enum ChatEvent {
Message = 'message',
Read = 'read',
@ -1151,6 +1157,31 @@ export enum ChatEvent {
Typing = 'typing'
}
export function publishToChatStream(senderUserId: string, receiverUserId: string, kind: ChatEvent, object: any): void
export enum ChatIndexEvent {
Message = 'message',
Read = 'read'
}
export function publishToChatIndexStream(userId: string, kind: ChatIndexEvent, object: any): void
export interface PackedEmoji {
id: string
aliases: Array<string>
name: string
category: string | null
host: string | null
url: string
license: string | null
width: number | null
height: number | null
}
export function publishToBroadcastStream(emoji: PackedEmoji): void
export function publishToGroupChatStream(groupId: string, kind: ChatEvent, object: any): void
export interface AbuseUserReportLike {
id: string
targetUserId: string
reporterId: string
comment: string
}
export function publishToModerationStream(moderatorId: string, report: AbuseUserReportLike): void
export function getTimestamp(id: string): number
/**
* The generated ID results in the form of `[8 chars timestamp] + [cuid2]`.

View file

@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, watchNote, unwatchNote, ChatEvent, publishToChatStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
module.exports.SECOND = SECOND
module.exports.MINUTE = MINUTE
@ -337,6 +337,7 @@ module.exports.isUnicodeEmoji = isUnicodeEmoji
module.exports.sqlLikeEscape = sqlLikeEscape
module.exports.safeForSql = safeForSql
module.exports.formatMilliseconds = formatMilliseconds
module.exports.getImageSizeFromUrl = getImageSizeFromUrl
module.exports.getNoteSummary = getNoteSummary
module.exports.toMastodonId = toMastodonId
module.exports.fromMastodonId = fromMastodonId
@ -364,8 +365,14 @@ module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotific
module.exports.initializeRustLogger = initializeRustLogger
module.exports.watchNote = watchNote
module.exports.unwatchNote = unwatchNote
module.exports.publishToChannelStream = publishToChannelStream
module.exports.ChatEvent = ChatEvent
module.exports.publishToChatStream = publishToChatStream
module.exports.ChatIndexEvent = ChatIndexEvent
module.exports.publishToChatIndexStream = publishToChatIndexStream
module.exports.publishToBroadcastStream = publishToBroadcastStream
module.exports.publishToGroupChatStream = publishToGroupChatStream
module.exports.publishToModerationStream = publishToModerationStream
module.exports.getTimestamp = getTimestamp
module.exports.genId = genId
module.exports.genIdAt = genIdAt

View file

@ -0,0 +1,200 @@
use crate::misc::redis_cache::{get_cache, set_cache, CacheError};
use crate::util::http_client;
use image::{io::Reader, ImageError, ImageFormat};
use nom_exif::{parse_jpeg_exif, EntryValue, ExifTag};
use std::io::Cursor;
use tokio::sync::Mutex;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Redis cache error: {0}")]
CacheErr(#[from] CacheError),
#[error("Reqwest error: {0}")]
ReqwestErr(#[from] reqwest::Error),
#[error("Image decoding error: {0}")]
ImageErr(#[from] ImageError),
#[error("Image decoding error: {0}")]
IoErr(#[from] std::io::Error),
#[error("Exif extraction error: {0}")]
ExifErr(#[from] nom_exif::Error),
#[error("Emoji meta attempt limit exceeded: {0}")]
TooManyAttempts(String),
#[error("Unsupported image type: {0}")]
UnsupportedImageErr(String),
}
const BROWSER_SAFE_IMAGE_TYPES: [ImageFormat; 8] = [
ImageFormat::Png,
ImageFormat::Jpeg,
ImageFormat::Gif,
ImageFormat::WebP,
ImageFormat::Tiff,
ImageFormat::Bmp,
ImageFormat::Ico,
ImageFormat::Avif,
];
static MTX_GUARD: Mutex<()> = Mutex::const_new(());
#[derive(Debug, PartialEq)]
#[crate::export(object)]
pub struct ImageSize {
pub width: u32,
pub height: u32,
}
#[crate::export]
pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
let attempted: bool;
{
let _ = MTX_GUARD.lock().await;
let key = format!("fetchImage:{}", url);
attempted = get_cache::<bool>(&key)?.is_some();
if !attempted {
set_cache(&key, &true, 10 * 60)?;
}
}
if attempted {
tracing::warn!("attempt limit exceeded: {}", url);
return Err(Error::TooManyAttempts(url.to_string()));
}
tracing::info!("retrieving image size from {}", url);
let image_bytes = http_client()?.get(url).send().await?.bytes().await?;
let reader = Reader::new(Cursor::new(&image_bytes)).with_guessed_format()?;
let format = reader.format();
if format.is_none() || !BROWSER_SAFE_IMAGE_TYPES.contains(&format.unwrap()) {
return Err(Error::UnsupportedImageErr(format!("{:?}", format)));
}
let size = reader.into_dimensions()?;
let res = ImageSize {
width: size.0,
height: size.1,
};
if format.unwrap() != ImageFormat::Jpeg {
return Ok(res);
}
// handle jpeg orientation
// https://magnushoff.com/articles/jpeg-orientation/
let exif = parse_jpeg_exif(&*image_bytes)?;
if exif.is_none() {
return Ok(res);
}
let orientation = exif.unwrap().get_value(&ExifTag::Orientation)?;
let rotated =
orientation.is_some() && matches!(orientation.unwrap(), EntryValue::U32(v) if v >= 5);
if !rotated {
return Ok(res);
}
Ok(ImageSize {
width: size.1,
height: size.0,
})
}
#[cfg(test)]
mod unit_test {
use super::{get_image_size_from_url, ImageSize};
use crate::misc::redis_cache::delete_cache;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_get_image_size() {
let png_url_1 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/splash.png";
let png_url_2 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/notification-badges/at.png";
let png_url_3 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/api-doc.png";
let rotated_jpeg_url = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/test/resources/rotate.jpg";
let webp_url_1 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/custom/assets/badges/error.webp";
let webp_url_2 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/screenshots/1.webp";
let ico_url = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/favicon.ico";
let gif_url = "https://firefish.dev/firefish/firefish/-/raw/b9c3dfbd3d473cb2cee20c467eeae780bc401271/packages/backend/test/resources/anime.gif";
let mp3_url = "https://firefish.dev/firefish/firefish/-/blob/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/sounds/aisha/1.mp3";
// Delete caches in case you run this test multiple times
// (should be disabled in CI tasks)
delete_cache(&format!("fetchImage:{}", png_url_1)).unwrap();
delete_cache(&format!("fetchImage:{}", png_url_2)).unwrap();
delete_cache(&format!("fetchImage:{}", png_url_3)).unwrap();
delete_cache(&format!("fetchImage:{}", rotated_jpeg_url)).unwrap();
delete_cache(&format!("fetchImage:{}", webp_url_1)).unwrap();
delete_cache(&format!("fetchImage:{}", webp_url_2)).unwrap();
delete_cache(&format!("fetchImage:{}", ico_url)).unwrap();
delete_cache(&format!("fetchImage:{}", gif_url)).unwrap();
delete_cache(&format!("fetchImage:{}", mp3_url)).unwrap();
let png_size_1 = ImageSize {
width: 1024,
height: 1024,
};
let png_size_2 = ImageSize {
width: 96,
height: 96,
};
let png_size_3 = ImageSize {
width: 1024,
height: 354,
};
let rotated_jpeg_size = ImageSize {
width: 256,
height: 512,
};
let webp_size_1 = ImageSize {
width: 256,
height: 256,
};
let webp_size_2 = ImageSize {
width: 1080,
height: 2340,
};
let ico_size = ImageSize {
width: 256,
height: 256,
};
let gif_size = ImageSize {
width: 256,
height: 256,
};
assert_eq!(
png_size_1,
get_image_size_from_url(png_url_1).await.unwrap()
);
assert_eq!(
png_size_2,
get_image_size_from_url(png_url_2).await.unwrap()
);
assert_eq!(
png_size_3,
get_image_size_from_url(png_url_3).await.unwrap()
);
assert_eq!(
rotated_jpeg_size,
get_image_size_from_url(rotated_jpeg_url).await.unwrap()
);
assert_eq!(
webp_size_1,
get_image_size_from_url(webp_url_1).await.unwrap()
);
assert_eq!(
webp_size_2,
get_image_size_from_url(webp_url_2).await.unwrap()
);
assert_eq!(ico_size, get_image_size_from_url(ico_url).await.unwrap());
assert_eq!(gif_size, get_image_size_from_url(gif_url).await.unwrap());
assert!(get_image_size_from_url(mp3_url).await.is_err());
}
}

View file

@ -6,6 +6,7 @@ pub mod convert_host;
pub mod emoji;
pub mod escape_sql;
pub mod format_milliseconds;
pub mod get_image_size;
pub mod get_note_summary;
pub mod mastodon_id;
pub mod meta;

View file

@ -3,7 +3,7 @@ use redis::{Commands, RedisError};
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum Error {
pub enum CacheError {
#[error("Redis error: {0}")]
RedisError(#[from] RedisError),
#[error("Data serialization error: {0}")]
@ -12,27 +12,37 @@ pub enum Error {
DeserializeError(#[from] rmp_serde::decode::Error),
}
fn prefix_key(key: &str) -> String {
redis_key(format!("cache:{}", key))
}
pub fn set_cache<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
value: &V,
expire_seconds: u64,
) -> Result<(), Error> {
) -> Result<(), CacheError> {
redis_conn()?.set_ex(
redis_key(key),
prefix_key(key),
rmp_serde::encode::to_vec(&value)?,
expire_seconds,
)?;
Ok(())
}
pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(key: &str) -> Result<Option<V>, Error> {
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(redis_key(key))?;
pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
) -> Result<Option<V>, CacheError> {
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(prefix_key(key))?;
Ok(match serialized_value {
Some(v) => Some(rmp_serde::from_slice::<V>(v.as_ref())?),
None => None,
})
}
pub fn delete_cache(key: &str) -> Result<(), CacheError> {
Ok(redis_conn()?.del(prefix_key(key))?)
}
#[cfg(test)]
mod unit_test {
use super::{get_cache, set_cache};

View file

@ -71,14 +71,14 @@ pub struct Model {
pub drive_capacity_override_mb: Option<i32>,
#[sea_orm(column_name = "movedToUri")]
pub moved_to_uri: Option<String>,
#[sea_orm(column_name = "alsoKnownAs", column_type = "Text", nullable)]
pub also_known_as: Option<String>,
#[sea_orm(column_name = "speakAsCat")]
pub speak_as_cat: bool,
#[sea_orm(column_name = "emojiModPerm")]
pub emoji_mod_perm: UserEmojimodpermEnum,
#[sea_orm(column_name = "isIndexable")]
pub is_indexable: bool,
#[sea_orm(column_name = "alsoKnownAs")]
pub also_known_as: Option<Vec<String>>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -13,7 +13,7 @@ pub fn initialize_logger() {
"info" => Level::INFO,
"debug" => Level::DEBUG,
"trace" => Level::TRACE,
_ => Level::INFO,
_ => Level::INFO, // Fallback
});
} else if let Some(levels) = &CONFIG.log_level {
// `logLevel` config is Deprecated
@ -27,13 +27,25 @@ pub fn initialize_logger() {
builder = builder.with_max_level(Level::WARN);
} else if levels.contains(&"error".to_string()) {
builder = builder.with_max_level(Level::ERROR);
} else {
// Fallback
builder = builder.with_max_level(Level::INFO);
}
} else {
// Fallback
builder = builder.with_max_level(Level::INFO);
};
let subscriber = builder.with_level(true).pretty().finish();
let subscriber = builder
.without_time()
.with_level(true)
.with_ansi(true)
.with_target(true)
.with_thread_names(true)
.with_line_number(true)
.log_internal_errors(true)
.compact()
.finish();
tracing::subscriber::set_global_default(subscriber).expect("Failed to initialize the logger");
}

View file

@ -1,5 +1,10 @@
pub mod antenna;
pub mod channel;
pub mod chat;
pub mod chat_index;
pub mod custom_emoji;
pub mod group_chat;
pub mod moderation;
use crate::config::CONFIG;
use crate::database::redis_conn;
@ -10,9 +15,9 @@ pub enum Stream {
#[strum(serialize = "internal")]
Internal,
#[strum(serialize = "broadcast")]
Broadcast,
#[strum(to_string = "adminStream:{user_id}")]
Admin { user_id: String },
CustomEmoji,
#[strum(to_string = "adminStream:{moderator_id}")]
Moderation { moderator_id: String },
#[strum(to_string = "user:{user_id}")]
User { user_id: String },
#[strum(to_string = "channelStream:{channel_id}")]
@ -37,7 +42,7 @@ pub enum Stream {
#[strum(to_string = "messagingStream:{group_id}")]
GroupChat { group_id: String },
#[strum(to_string = "messagingIndexStream:{user_id}")]
MessagingIndex { user_id: String },
ChatIndex { user_id: String },
}
#[derive(thiserror::Error, Debug)]
@ -57,7 +62,7 @@ pub fn publish_to_stream(
) -> Result<(), Error> {
let message = if let Some(kind) = kind {
format!(
"{{ \"type\": \"{}\", \"body\": {} }}",
"{{\"type\":\"{}\",\"body\":{}}}",
kind,
value.unwrap_or("null".to_string()),
)
@ -67,10 +72,7 @@ pub fn publish_to_stream(
redis_conn()?.publish(
&CONFIG.host,
format!(
"{{ \"channel\": \"{}\", \"message\": {} }}",
stream, message,
),
format!("{{\"channel\":\"{}\",\"message\":{}}}", stream, message),
)?;
Ok(())
@ -84,10 +86,10 @@ mod unit_test {
#[test]
fn channel_to_string() {
assert_eq!(Stream::Internal.to_string(), "internal");
assert_eq!(Stream::Broadcast.to_string(), "broadcast");
assert_eq!(Stream::CustomEmoji.to_string(), "broadcast");
assert_eq!(
Stream::Admin {
user_id: "9tb42br63g5apjcq".to_string()
Stream::Moderation {
moderator_id: "9tb42br63g5apjcq".to_string()
}
.to_string(),
"adminStream:9tb42br63g5apjcq"

View file

@ -0,0 +1,10 @@
use crate::service::stream::{publish_to_stream, Error, Stream};
#[crate::export(js_name = "publishToChannelStream")]
pub fn publish(channel_id: String, user_id: String) -> Result<(), Error> {
publish_to_stream(
&Stream::Channel { channel_id },
Some("typing".to_string()),
Some(format!("\"{}\"", user_id)),
)
}

View file

@ -13,12 +13,15 @@ pub enum ChatEvent {
Typing,
}
// We want to merge `kind` and `object` into a single enum
// https://github.com/napi-rs/napi-rs/issues/2036
#[crate::export(js_name = "publishToChatStream")]
pub fn publish(
sender_user_id: String,
receiver_user_id: String,
kind: ChatEvent,
object: &serde_json::Value, // TODO?: change this to enum
object: &serde_json::Value,
) -> Result<(), Error> {
publish_to_stream(
&Stream::Chat {

View file

@ -0,0 +1,26 @@
use crate::service::stream::{publish_to_stream, Error, Stream};
#[derive(strum::Display)]
#[crate::export(string_enum = "camelCase")]
pub enum ChatIndexEvent {
#[strum(serialize = "message")]
Message,
#[strum(serialize = "read")]
Read,
}
// We want to merge `kind` and `object` into a single enum
// https://github.com/napi-rs/napi-rs/issues/2036
#[crate::export(js_name = "publishToChatIndexStream")]
pub fn publish(
user_id: String,
kind: ChatIndexEvent,
object: &serde_json::Value,
) -> Result<(), Error> {
publish_to_stream(
&Stream::ChatIndex { user_id },
Some(kind.to_string()),
Some(serde_json::to_string(object)?),
)
}

View file

@ -0,0 +1,27 @@
use crate::service::stream::{publish_to_stream, Error, Stream};
use serde::{Deserialize, Serialize};
// TODO: define schema type in other place
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[crate::export(object)]
pub struct PackedEmoji {
pub id: String,
pub aliases: Vec<String>,
pub name: String,
pub category: Option<String>,
pub host: Option<String>,
pub url: String,
pub license: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
}
#[crate::export(js_name = "publishToBroadcastStream")]
pub fn publish(emoji: &PackedEmoji) -> Result<(), Error> {
publish_to_stream(
&Stream::CustomEmoji,
Some("emojiAdded".to_string()),
Some(format!("{{\"emoji\":{}}}", serde_json::to_string(emoji)?)),
)
}

View file

@ -0,0 +1,13 @@
use crate::service::stream::{chat::ChatEvent, publish_to_stream, Error, Stream};
// We want to merge `kind` and `object` into a single enum
// https://github.com/napi-rs/napi-rs/issues/2036
#[crate::export(js_name = "publishToGroupChatStream")]
pub fn publish(group_id: String, kind: ChatEvent, object: &serde_json::Value) -> Result<(), Error> {
publish_to_stream(
&Stream::GroupChat { group_id },
Some(kind.to_string()),
Some(serde_json::to_string(object)?),
)
}

View file

@ -0,0 +1,21 @@
use crate::service::stream::{publish_to_stream, Error, Stream};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[crate::export(object)]
pub struct AbuseUserReportLike {
pub id: String,
pub target_user_id: String,
pub reporter_id: String,
pub comment: String,
}
#[crate::export(js_name = "publishToModerationStream")]
pub fn publish(moderator_id: String, report: &AbuseUserReportLike) -> Result<(), Error> {
publish_to_stream(
&Stream::Moderation { moderator_id },
Some("newAbuseUserReport".to_string()),
Some(serde_json::to_string(report)?),
)
}

View file

@ -0,0 +1,24 @@
use crate::config::CONFIG;
use once_cell::sync::OnceCell;
use reqwest::{Client, Error, NoProxy, Proxy};
use std::time::Duration;
static CLIENT: OnceCell<Client> = OnceCell::new();
pub fn http_client() -> Result<Client, Error> {
CLIENT
.get_or_try_init(|| {
let mut builder = Client::builder().timeout(Duration::from_secs(5));
if let Some(proxy_url) = &CONFIG.proxy {
let mut proxy = Proxy::all(proxy_url)?;
if let Some(proxy_bypass_hosts) = &CONFIG.proxy_bypass_hosts {
proxy = proxy.no_proxy(NoProxy::from_string(&proxy_bypass_hosts.join(",")));
}
builder = builder.proxy(proxy);
}
builder.build()
})
.cloned()
}

View file

@ -1,2 +1,5 @@
pub use http_client::http_client;
pub mod http_client;
pub mod id;
pub mod random;

View file

@ -22,21 +22,21 @@
"@swc/core-android-arm64": "1.3.11"
},
"dependencies": {
"@bull-board/api": "5.15.5",
"@bull-board/koa": "5.15.5",
"@bull-board/ui": "5.15.5",
"@bull-board/api": "5.16.0",
"@bull-board/koa": "5.16.0",
"@bull-board/ui": "5.16.0",
"@discordapp/twemoji": "^15.0.3",
"@koa/cors": "5.0.0",
"@koa/multer": "3.0.2",
"@koa/router": "12.0.1",
"@ladjs/koa-views": "9.0.0",
"@peertube/http-signature": "1.7.0",
"@redocly/openapi-core": "1.11.0",
"@redocly/openapi-core": "1.12.0",
"@sinonjs/fake-timers": "11.2.2",
"adm-zip": "0.5.10",
"ajv": "8.12.0",
"archiver": "7.0.1",
"aws-sdk": "2.1599.0",
"aws-sdk": "2.1608.0",
"axios": "^1.6.8",
"backend-rs": "workspace:*",
"firefish-js": "workspace:*",
@ -62,7 +62,7 @@
"gunzip-maybe": "^1.4.2",
"happy-dom": "^14.7.1",
"hpagent": "1.2.0",
"ioredis": "5.3.2",
"ioredis": "5.4.1",
"ip-cidr": "4.0.0",
"is-svg": "5.0.0",
"json5": "2.2.3",
@ -126,7 +126,7 @@
},
"devDependencies": {
"@swc/cli": "0.3.12",
"@swc/core": "1.4.13",
"@swc/core": "1.5.0",
"@types/adm-zip": "^0.5.5",
"@types/color-convert": "^2.0.3",
"@types/content-disposition": "^0.5.8",
@ -156,7 +156,7 @@
"@types/pug": "2.0.10",
"@types/punycode": "2.1.4",
"@types/qrcode": "1.5.5",
"@types/qs": "6.9.14",
"@types/qs": "6.9.15",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
@ -171,7 +171,7 @@
"@types/websocket": "1.0.10",
"@types/ws": "8.5.10",
"cross-env": "7.0.3",
"eslint": "^9.0.0",
"eslint": "^9.1.1",
"mocha": "10.4.0",
"pug": "3.0.2",
"strict-event-emitter-types": "2.0.0",
@ -179,7 +179,7 @@
"ts-loader": "9.5.1",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"type-fest": "4.15.0",
"type-fest": "4.17.0",
"typescript": "5.4.5",
"webpack": "^5.91.0",
"ws": "8.16.0"

View file

@ -0,0 +1,36 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class AlterAkaType1714099399879 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld"`,
);
await queryRunner.query(
`ALTER TABLE "user" ADD COLUMN "alsoKnownAs" character varying(512)[]`,
);
await queryRunner.query(
`UPDATE "user" SET "alsoKnownAs" = string_to_array("alsoKnownAsOld", ',')::character varying[]`,
);
await queryRunner.query(
`UPDATE "user" SET "alsoKnownAs" = NULL WHERE "alsoKnownAs" = '{}'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`,
);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAsOld"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld"`,
);
await queryRunner.query(`ALTER TABLE "user" ADD COLUMN "alsoKnownAs" text`);
await queryRunner.query(
`UPDATE "user" SET "alsoKnownAs" = array_to_string("alsoKnownAsOld", ',')`,
);
await queryRunner.query(
`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`,
);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAsOld"`);
}
}

View file

@ -1,59 +0,0 @@
import probeImageSize from "probe-image-size";
import { Mutex } from "redis-semaphore";
import { FILE_TYPE_BROWSERSAFE } from "backend-rs";
import Logger from "@/services/logger.js";
import { Cache } from "./cache.js";
import { redisClient } from "@/db/redis.js";
import { inspect } from "node:util";
export type Size = {
width: number;
height: number;
};
const cache = new Cache<boolean>("emojiMeta", 60 * 10); // once every 10 minutes for the same url
const logger = new Logger("emoji");
export async function getEmojiSize(url: string): Promise<Size> {
let attempted = true;
const lock = new Mutex(redisClient, "getEmojiSize");
await lock.acquire();
try {
attempted = (await cache.get(url)) === true;
if (!attempted) {
await cache.set(url, true);
}
} finally {
await lock.release();
}
if (attempted) {
logger.warn(`Attempt limit exceeded: ${url}`);
throw new Error("Too many attempts");
}
try {
logger.debug(`Retrieving emoji size from ${url}`);
const { width, height, mime } = await probeImageSize(url, {
timeout: 5000,
});
if (!(mime.startsWith("image/") && FILE_TYPE_BROWSERSAFE.includes(mime))) {
throw new Error("Unsupported image type");
}
return { width, height };
} catch (e) {
throw new Error(`Unable to retrieve metadata:\n${inspect(e)}`);
}
}
export function getNormalSize(
{ width, height }: Size,
orientation?: number,
): Size {
return (orientation || 0) >= 5
? { width: height, height: width }
: { width, height };
}

View file

@ -88,7 +88,9 @@ export class User {
})
public movedToUri: string | null;
@Column("simple-array", {
@Column("varchar", {
length: 512,
array: true,
nullable: true,
comment: "URIs the user is known as too",
})

View file

@ -3,7 +3,7 @@ import { IsNull } from "typeorm";
import { Emojis } from "@/models/index.js";
import { queueLogger } from "../../logger.js";
import { getEmojiSize } from "@/misc/emoji-meta.js";
import { getImageSizeFromUrl } from "backend-rs";
import { inspect } from "node:util";
const logger = queueLogger.createSubLogger("local-emoji-size");
@ -21,7 +21,7 @@ export async function setLocalEmojiSizes(
for (let i = 0; i < emojis.length; i++) {
try {
const size = await getEmojiSize(emojis[i].publicUrl);
const size = await getImageSizeFromUrl(emojis[i].publicUrl);
await Emojis.update(emojis[i].id, {
width: size.width || null,
height: size.height || null,

View file

@ -13,7 +13,13 @@ import { extractPollFromQuestion } from "./question.js";
import vote from "@/services/note/polls/vote.js";
import { apLogger } from "../logger.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
import { extractHost, isSameOrigin, toPuny } from "backend-rs";
import {
type ImageSize,
extractHost,
getImageSizeFromUrl,
isSameOrigin,
toPuny,
} from "backend-rs";
import {
Emojis,
Polls,
@ -46,7 +52,6 @@ import { UserProfiles } from "@/models/index.js";
import { In } from "typeorm";
import { config } from "@/config.js";
import { truncate } from "@/misc/truncate.js";
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
import { langmap } from "@/misc/langmap.js";
import { inspect } from "node:util";
@ -488,11 +493,16 @@ export async function extractEmojis(
tag.icon!.url !== exists.originalUrl ||
!(exists.width && exists.height)
) {
let size: Size = { width: 0, height: 0 };
try {
size = await getEmojiSize(tag.icon!.url);
} catch {
/* skip if any error happens */
let size: ImageSize | null = null;
if (tag.icon?.url != null) {
try {
size = await getImageSizeFromUrl(tag.icon.url);
} catch (err) {
apLogger.info(
`Failed to determine the size of the image: ${tag.icon.url}`,
);
apLogger.debug(inspect(err));
}
}
await Emojis.update(
{
@ -504,8 +514,8 @@ export async function extractEmojis(
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
updatedAt: new Date(),
width: size.width || null,
height: size.height || null,
width: size?.width || null,
height: size?.height || null,
},
);
@ -520,9 +530,9 @@ export async function extractEmojis(
apLogger.info(`register emoji host=${host}, name=${name}`);
let size: Size = { width: 0, height: 0 };
let size: ImageSize = { width: 0, height: 0 };
try {
size = await getEmojiSize(tag.icon!.url);
size = await getImageSizeFromUrl(tag.icon!.url);
} catch {
/* skip if any error happens */
}

View file

@ -1,9 +1,11 @@
import { publishMainStream } from "@/services/stream.js";
import {
publishMainStream,
publishGroupMessagingStream,
} from "@/services/stream.js";
import { publishToChatStream, ChatEvent } from "backend-rs";
import { publishMessagingIndexStream } from "@/services/stream.js";
publishToChatStream,
publishToGroupChatStream,
publishToChatIndexStream,
ChatEvent,
ChatIndexEvent,
} from "backend-rs";
import { pushNotification } from "@/services/push-notification.js";
import type { User, IRemoteUser } from "@/models/entities/user.js";
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
@ -55,7 +57,7 @@ export async function readUserMessagingMessage(
// Publish event
publishToChatStream(otherpartyId, userId, ChatEvent.Read, messageIds);
publishMessagingIndexStream(userId, "read", messageIds);
publishToChatIndexStream(userId, ChatIndexEvent.Read, messageIds);
if (!(await Users.getHasUnreadMessagingMessage(userId))) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
@ -126,11 +128,11 @@ export async function readGroupMessagingMessage(
}
// Publish event
publishGroupMessagingStream(groupId, "read", {
publishToGroupChatStream(groupId, ChatEvent.Read, {
ids: reads,
userId: userId,
userId,
});
publishMessagingIndexStream(userId, "read", reads);
publishToChatIndexStream(userId, ChatIndexEvent.Read, reads);
if (!(await Users.getHasUnreadMessagingMessage(userId))) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行

View file

@ -1,12 +1,17 @@
import define from "@/server/api/define.js";
import { Emojis, DriveFiles } from "@/models/index.js";
import { genId } from "backend-rs";
import {
type ImageSize,
genId,
getImageSizeFromUrl,
publishToBroadcastStream,
} from "backend-rs";
import { insertModerationLog } from "@/services/insert-moderation-log.js";
import { ApiError } from "@/server/api/error.js";
import rndstr from "rndstr";
import { publishBroadcastStream } from "@/services/stream.js";
import { db } from "@/db/postgre.js";
import { getEmojiSize } from "@/misc/emoji-meta.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
export const meta = {
tags: ["admin", "emoji"],
@ -49,7 +54,13 @@ export default define(meta, paramDef, async (ps, me) => {
? file.name.split(".")[0]
: `_${rndstr("a-z0-9", 8)}_`;
const size = await getEmojiSize(file.url);
let size: ImageSize | null = null;
try {
size = await getImageSizeFromUrl(file.url);
} catch (err) {
apiLogger.info(`Failed to determine the image size: ${file.url}`);
apiLogger.debug(inspect(err));
}
const emoji = await Emojis.insert({
id: genId(),
@ -62,15 +73,13 @@ export default define(meta, paramDef, async (ps, me) => {
publicUrl: file.webpublicUrl ?? file.url,
type: file.webpublicType ?? file.type,
license: null,
width: size.width || null,
height: size.height || null,
width: size?.width || null,
height: size?.height || null,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
await db.queryResultCache!.remove(["meta_emojis"]);
publishBroadcastStream("emojiAdded", {
emoji: await Emojis.pack(emoji.id),
});
publishToBroadcastStream(await Emojis.pack(emoji));
insertModerationLog(me, "addEmoji", {
emojiId: emoji.id,

View file

@ -1,12 +1,17 @@
import define from "@/server/api/define.js";
import { Emojis } from "@/models/index.js";
import { genId } from "backend-rs";
import {
type ImageSize,
genId,
getImageSizeFromUrl,
publishToBroadcastStream,
} from "backend-rs";
import { ApiError } from "@/server/api/error.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
import { publishBroadcastStream } from "@/services/stream.js";
import { db } from "@/db/postgre.js";
import { getEmojiSize } from "@/misc/emoji-meta.js";
import { apiLogger } from "@/server/api/logger.js";
import { inspect } from "node:util";
export const meta = {
tags: ["admin", "emoji"],
@ -76,7 +81,14 @@ export default define(meta, paramDef, async (ps, me) => {
throw new ApiError();
}
const size = await getEmojiSize(driveFile.url);
let size: ImageSize | null = null;
try {
size = await getImageSizeFromUrl(driveFile.url);
} catch (err) {
apiLogger.info(`Failed to determine the image size: ${driveFile.url}`);
apiLogger.debug(inspect(err));
}
const copied = await Emojis.insert({
id: genId(),
@ -88,15 +100,13 @@ export default define(meta, paramDef, async (ps, me) => {
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type,
license: emoji.license,
width: size.width || null,
height: size.height || null,
width: size?.width ?? null,
height: size?.height ?? null,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
await db.queryResultCache!.remove(["meta_emojis"]);
publishBroadcastStream("emojiAdded", {
emoji: await Emojis.pack(copied.id),
});
publishToBroadcastStream(await Emojis.pack(copied));
return {
id: copied.id,

View file

@ -1,10 +1,8 @@
import * as mfm from "mfm-js";
import sanitizeHtml from "sanitize-html";
import { publishAdminStream } from "@/services/stream.js";
import { AbuseUserReports, UserProfiles, Users } from "@/models/index.js";
import { genId } from "backend-rs";
import { genId, publishToModerationStream } from "backend-rs";
import { sendEmail } from "@/services/send-email.js";
import { fetchMeta } from "backend-rs";
import { getUser } from "@/server/api/common/getters.js";
import { ApiError } from "@/server/api/error.js";
import define from "@/server/api/define.js";
@ -86,9 +84,8 @@ export default define(meta, paramDef, async (ps, me) => {
],
});
const meta = await fetchMeta(true);
for (const moderator of moderators) {
publishAdminStream(moderator.id, "newAbuseUserReport", {
publishToModerationStream(moderator.id, {
id: report.id,
targetUserId: report.targetUserId,
reporterId: report.reporterId,

View file

@ -15,10 +15,11 @@ import {
import type { AccessToken } from "@/models/entities/access-token.js";
import type { UserProfile } from "@/models/entities/user-profile.js";
import {
publishChannelStream,
publishGroupMessagingStream,
} from "@/services/stream.js";
import { publishToChatStream, ChatEvent } from "backend-rs";
publishToChannelStream,
publishToChatStream,
publishToGroupChatStream,
ChatEvent,
} from "backend-rs";
import type { UserGroup } from "@/models/entities/user-group.js";
import type { Packed } from "@/misc/schema.js";
import { readNotification } from "@/server/api/common/read-notification.js";
@ -512,9 +513,9 @@ export default class Connection {
}
}
private typingOnChannel(channel: ChannelModel["id"]) {
private typingOnChannel(channelId: ChannelModel["id"]) {
if (this.user) {
publishChannelStream(channel, "typing", this.user.id);
publishToChannelStream(channelId, this.user.id);
}
}
@ -530,8 +531,8 @@ export default class Connection {
ChatEvent.Typing,
this.user.id,
);
} else if (param.group) {
publishGroupMessagingStream(param.group, "typing", this.user.id);
} else if (param.group != null) {
publishToGroupChatStream(param.group, ChatEvent.Typing, this.user.id);
}
}
}

View file

@ -47,8 +47,8 @@ export default class Logger {
return logger;
}
private showThisLog(logLevel: Level, configLevel: string) {
switch (configLevel) {
private showThisLog(logLevel: Level, configMaxLevel: string) {
switch (configMaxLevel) {
case "error":
return ["error"].includes(logLevel);
case "warning":
@ -75,7 +75,10 @@ export default class Logger {
if (
(config.maxLogLevel != null &&
!this.showThisLog(level, config.maxLogLevel)) ||
(config.logLevel != null && !config.logLevel.includes(level))
(config.logLevel != null && !config.logLevel.includes(level)) ||
(config.maxLogLevel == null &&
config.logLevel == null &&
!this.showThisLog(level, "info"))
)
return;
if (!this.store) store = false;

View file

@ -7,13 +7,17 @@ import {
Mutings,
Users,
} from "@/models/index.js";
import { genId, publishToChatStream, toPuny, ChatEvent } from "backend-rs";
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
import {
publishMessagingIndexStream,
publishMainStream,
publishGroupMessagingStream,
} from "@/services/stream.js";
genId,
publishToChatStream,
publishToGroupChatStream,
publishToChatIndexStream,
toPuny,
ChatEvent,
ChatIndexEvent,
} from "backend-rs";
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
import { publishMainStream } from "@/services/stream.js";
import { pushNotification } from "@/services/push-notification.js";
import { Not } from "typeorm";
import type { Note } from "@/models/entities/note.js";
@ -57,7 +61,11 @@ export async function createMessage(
ChatEvent.Message,
messageObj,
);
publishMessagingIndexStream(message.userId, "message", messageObj);
publishToChatIndexStream(
message.userId,
ChatIndexEvent.Message,
messageObj,
);
publishMainStream(message.userId, "messagingMessage", messageObj);
}
@ -69,19 +77,27 @@ export async function createMessage(
ChatEvent.Message,
messageObj,
);
publishMessagingIndexStream(recipientUser.id, "message", messageObj);
publishToChatIndexStream(
recipientUser.id,
ChatIndexEvent.Message,
messageObj,
);
publishMainStream(recipientUser.id, "messagingMessage", messageObj);
}
} else if (recipientGroup) {
// グループのストリーム
publishGroupMessagingStream(recipientGroup.id, "message", messageObj);
} else if (recipientGroup != null) {
// group's stream
publishToGroupChatStream(recipientGroup.id, ChatEvent.Message, messageObj);
// メンバーのストリーム
// member's stream
const joinings = await UserGroupJoinings.findBy({
userGroupId: recipientGroup.id,
});
for (const joining of joinings) {
publishMessagingIndexStream(joining.userId, "message", messageObj);
publishToChatIndexStream(
joining.userId,
ChatIndexEvent.Message,
messageObj,
);
publishMainStream(joining.userId, "messagingMessage", messageObj);
}
}

View file

@ -1,8 +1,11 @@
import { config } from "@/config.js";
import { MessagingMessages, Users } from "@/models/index.js";
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
import { publishGroupMessagingStream } from "@/services/stream.js";
import { publishToChatStream, ChatEvent } from "backend-rs";
import {
publishToChatStream,
publishToGroupChatStream,
ChatEvent,
} from "backend-rs";
import { renderActivity } from "@/remote/activitypub/renderer/index.js";
import renderDelete from "@/remote/activitypub/renderer/delete.js";
import renderTombstone from "@/remote/activitypub/renderer/tombstone.js";
@ -42,7 +45,7 @@ async function postDeleteMessage(message: MessagingMessage) {
);
deliver(user, activity, recipient.inbox);
}
} else if (message.groupId) {
publishGroupMessagingStream(message.groupId, "deleted", message.id);
} else if (message.groupId != null) {
publishToGroupChatStream(message.groupId, ChatEvent.Deleted, message.id);
}
}

View file

@ -2,21 +2,21 @@ import { redisClient } from "@/db/redis.js";
import type { User } from "@/models/entities/user.js";
import type { Note } from "@/models/entities/note.js";
import type { UserList } from "@/models/entities/user-list.js";
import type { UserGroup } from "@/models/entities/user-group.js";
// import type { UserGroup } from "@/models/entities/user-group.js";
import { config } from "@/config.js";
// import type { Antenna } from "@/models/entities/antenna.js";
import type { Channel } from "@/models/entities/channel.js";
// import type { Channel } from "@/models/entities/channel.js";
import type {
StreamChannels,
AdminStreamTypes,
// AdminStreamTypes,
// AntennaStreamTypes,
BroadcastTypes,
ChannelStreamTypes,
// BroadcastTypes,
// ChannelStreamTypes,
DriveStreamTypes,
GroupMessagingStreamTypes,
// GroupMessagingStreamTypes,
InternalStreamTypes,
MainStreamTypes,
MessagingIndexStreamTypes,
// MessagingIndexStreamTypes,
// MessagingStreamTypes,
NoteStreamTypes,
UserListStreamTypes,
@ -64,16 +64,17 @@ class Publisher {
);
};
public publishBroadcastStream = <K extends keyof BroadcastTypes>(
type: K,
value?: BroadcastTypes[K],
): void => {
this.publish(
"broadcast",
type,
typeof value === "undefined" ? null : value,
);
};
/* ported to backend-rs */
// public publishBroadcastStream = <K extends keyof BroadcastTypes>(
// type: K,
// value?: BroadcastTypes[K],
// ): void => {
// this.publish(
// "broadcast",
// type,
// typeof value === "undefined" ? null : value,
// );
// };
public publishMainStream = <K extends keyof MainStreamTypes>(
userId: User["id"],
@ -110,17 +111,18 @@ class Publisher {
});
};
public publishChannelStream = <K extends keyof ChannelStreamTypes>(
channelId: Channel["id"],
type: K,
value?: ChannelStreamTypes[K],
): void => {
this.publish(
`channelStream:${channelId}`,
type,
typeof value === "undefined" ? null : value,
);
};
/* ported to backend-rs */
// public publishChannelStream = <K extends keyof ChannelStreamTypes>(
// channelId: Channel["id"],
// type: K,
// value?: ChannelStreamTypes[K],
// ): void => {
// this.publish(
// `channelStream:${channelId}`,
// type,
// typeof value === "undefined" ? null : value,
// );
// };
public publishUserListStream = <K extends keyof UserListStreamTypes>(
listId: UserList["id"],
@ -161,49 +163,52 @@ class Publisher {
// );
// };
public publishGroupMessagingStream = <
K extends keyof GroupMessagingStreamTypes,
>(
groupId: UserGroup["id"],
type: K,
value?: GroupMessagingStreamTypes[K],
): void => {
this.publish(
`messagingStream:${groupId}`,
type,
typeof value === "undefined" ? null : value,
);
};
/* ported to backend-rs */
// public publishGroupMessagingStream = <
// K extends keyof GroupMessagingStreamTypes,
// >(
// groupId: UserGroup["id"],
// type: K,
// value?: GroupMessagingStreamTypes[K],
// ): void => {
// this.publish(
// `messagingStream:${groupId}`,
// type,
// typeof value === "undefined" ? null : value,
// );
// };
public publishMessagingIndexStream = <
K extends keyof MessagingIndexStreamTypes,
>(
userId: User["id"],
type: K,
value?: MessagingIndexStreamTypes[K],
): void => {
this.publish(
`messagingIndexStream:${userId}`,
type,
typeof value === "undefined" ? null : value,
);
};
/* ported to backend-rs */
// public publishMessagingIndexStream = <
// K extends keyof MessagingIndexStreamTypes,
// >(
// userId: User["id"],
// type: K,
// value?: MessagingIndexStreamTypes[K],
// ): void => {
// this.publish(
// `messagingIndexStream:${userId}`,
// type,
// typeof value === "undefined" ? null : value,
// );
// };
public publishNotesStream = (note: Note): void => {
this.publish("notesStream", null, note);
};
public publishAdminStream = <K extends keyof AdminStreamTypes>(
userId: User["id"],
type: K,
value?: AdminStreamTypes[K],
): void => {
this.publish(
`adminStream:${userId}`,
type,
typeof value === "undefined" ? null : value,
);
};
/* ported to backend-rs */
// public publishAdminStream = <K extends keyof AdminStreamTypes>(
// userId: User["id"],
// type: K,
// value?: AdminStreamTypes[K],
// ): void => {
// this.publish(
// `adminStream:${userId}`,
// type,
// typeof value === "undefined" ? null : value,
// );
// };
}
const publisher = new Publisher();
@ -212,17 +217,15 @@ export default publisher;
export const publishInternalEvent = publisher.publishInternalEvent;
export const publishUserEvent = publisher.publishUserEvent;
export const publishBroadcastStream = publisher.publishBroadcastStream;
// export const publishBroadcastStream = publisher.publishBroadcastStream;
export const publishMainStream = publisher.publishMainStream;
export const publishDriveStream = publisher.publishDriveStream;
export const publishNoteStream = publisher.publishNoteStream;
export const publishNotesStream = publisher.publishNotesStream;
export const publishChannelStream = publisher.publishChannelStream;
// export const publishChannelStream = publisher.publishChannelStream;
export const publishUserListStream = publisher.publishUserListStream;
// export const publishAntennaStream = publisher.publishAntennaStream;
// export const publishMessagingStream = publisher.publishMessagingStream;
export const publishGroupMessagingStream =
publisher.publishGroupMessagingStream;
export const publishMessagingIndexStream =
publisher.publishMessagingIndexStream;
export const publishAdminStream = publisher.publishAdminStream;
// export const publishGroupMessagingStream = publisher.publishGroupMessagingStream;
// export const publishMessagingIndexStream = publisher.publishMessagingIndexStream;
// export const publishAdminStream = publisher.publishAdminStream;

View file

@ -28,13 +28,14 @@
"@types/matter-js": "0.19.6",
"@types/prismjs": "^1.26.3",
"@types/punycode": "2.1.4",
"@types/qrcode": "1.5.5",
"@types/seedrandom": "3.0.8",
"@types/textarea-caret": "^3.0.3",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8",
"@vitejs/plugin-vue": "5.0.4",
"@vue/runtime-core": "3.4.21",
"@vue/runtime-core": "3.4.25",
"autobind-decorator": "2.4.0",
"autosize": "6.0.1",
"broadcast-channel": "7.0.0",
@ -60,14 +61,18 @@
"insert-text-at-cursor": "0.3.0",
"json5": "2.2.3",
"katex": "0.16.10",
"long": "^5.2.3",
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
"matter-js": "0.19.0",
"mfm-js": "0.24.0",
"multer": "1.4.5-lts.1",
"moment": "2.30.1",
"photoswipe": "5.4.3",
"prismjs": "1.29.0",
"punycode": "2.3.1",
"rollup": "4.14.2",
"qrcode": "1.5.3",
"qrcode-vue3": "^1.6.8",
"rollup": "4.16.4",
"s-age": "1.1.2",
"sass": "1.75.0",
"seedrandom": "3.0.5",
@ -75,19 +80,19 @@
"swiper": "11.1.1",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.163.0",
"three": "0.164.1",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tinyld": "^1.3.4",
"typescript": "5.4.5",
"unicode-emoji-json": "^0.6.0",
"uuid": "9.0.1",
"vite": "5.2.8",
"vite": "5.2.10",
"vite-plugin-compression": "^0.5.1",
"vue": "3.4.21",
"vue": "3.4.25",
"vue-draggable-plus": "^0.4.0",
"vue-plyr": "^7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
"vue-tsc": "2.0.13"
"vue-tsc": "2.0.14"
}
}

View file

@ -113,6 +113,7 @@
:detailed="true"
:detailed-view="detailedView"
:parent-id="appearNote.id"
:is-long-judger="isLongJudger"
@push="(e) => router.push(notePage(e))"
@focusfooter="footerEl!.focus()"
@expanded="(e) => setPostExpanded(e)"
@ -325,6 +326,7 @@ const props = defineProps<{
collapsedReply?: boolean;
hideFooter?: boolean;
hideEmojiViewer?: boolean;
isLongJudger?: (note: entities.Note) => boolean;
}>();
const inChannel = inject("inChannel", null);

View file

@ -26,7 +26,6 @@
: notification.reaction
"
:custom-emojis="notification.note.emojis"
:no-style="true"
/>
<XReactionIcon
v-else-if="
@ -60,18 +59,20 @@
class="content"
:note="removeReplyTo(notification.note.renote)"
:hide-emoji-viewer="true"
:is-long-judger="isLongJudger"
/>
<XNote
v-else
class="content"
:note="removeReplyTo(notification.note)"
:hide-emoji-viewer="true"
:is-long-judger="isLongJudger"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Connection } from "firefish-js/src/streaming";
import type { Channels } from "firefish-js/src/streaming.types";
import XReactionIcon from "@/components/MkReactionIcon.vue";
@ -114,12 +115,23 @@ const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReact
? instance.defaultReaction
: "⭐";
const users = ref(props.notification.users.slice(0, 5));
const userleft = ref(props.notification.users.length - users.value.length);
const users = computed(() => props.notification.users.slice(0, 5));
const userleft = computed(
() => props.notification.users.length - users.value.length,
);
let readObserver: IntersectionObserver | undefined;
let connection: Connection<Channels["main"]> | null = null;
function isLongJudger(note: entities.Note) {
return (
note.text != null &&
(note.text.split("\n").length > 5 ||
note.text.length > 300 ||
note.files.length > 4)
);
}
function getText() {
let res = "";
switch (props.notification.type) {

View file

@ -1,5 +1,9 @@
<template>
<MkPagination ref="pagingComponent" :pagination="pagination">
<MkPagination
ref="pagingComponent"
:pagination="pagination"
:folder="convertNotification"
>
<template #empty>
<div class="_fullinfo">
<img
@ -11,9 +15,9 @@
</div>
</template>
<template #default="{ items: notifications }">
<template #default="{ foldedItems: notifications }">
<XList
:items="convertNotification(notifications)"
:items="notifications"
v-slot="{ item: notification }"
class="elsfgstc"
:no-gap="true"
@ -92,7 +96,7 @@ const pagination = Object.assign(
},
shouldFold
? {
limit: FETCH_LIMIT,
limit: 50,
secondFetchLimit: FETCH_LIMIT,
}
: {
@ -134,11 +138,11 @@ const onNotification = (notification: entities.Notification) => {
let connection: StreamTypes.ChannelOf<"main"> | undefined;
function convertNotification(n: entities.Notification[]) {
function convertNotification(ns: entities.Notification[]) {
if (shouldFold) {
return foldNotifications(n, FETCH_LIMIT);
return foldNotifications(ns);
} else {
return n;
return ns;
}
}

View file

@ -38,7 +38,7 @@
</MkButton>
<MkLoading v-else class="loading" />
</div>
<slot :items="items"></slot>
<slot :items="items" :foldedItems="foldedItems"></slot>
<div
v-show="!pagination.reversed && more"
key="_more_"
@ -66,8 +66,8 @@
</transition>
</template>
<script lang="ts" setup generic="E extends PagingKey">
import type { ComponentPublicInstance, ComputedRef } from "vue";
<script lang="ts" setup generic="E extends PagingKey, Fold extends PagingAble">
import type { ComponentPublicInstance, ComputedRef, Ref } from "vue";
import {
computed,
isRef,
@ -79,12 +79,7 @@ import {
} from "vue";
import type { Endpoints, TypeUtils } from "firefish-js";
import * as os from "@/os";
import {
getScrollContainer,
getScrollPosition,
isTopVisible,
onScrollTop,
} from "@/scripts/scroll";
import { isTopVisible, onScrollTop } from "@/scripts/scroll";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
@ -105,11 +100,15 @@ export type MkPaginationType<
reload: () => Promise<void>;
refresh: () => Promise<void>;
prepend: (item: Item) => Promise<void>;
append: (item: Item) => Promise<void>;
append: (...item: Item[]) => Promise<void>;
removeItem: (finder: (item: Item) => boolean) => boolean;
updateItem: (id: string, replacer: (old: Item) => Item) => boolean;
};
export type PagingAble = {
id: string;
};
export type PagingKeyOf<T> = TypeUtils.EndpointsOf<T[]>;
// biome-ignore lint/suspicious/noExplicitAny: Used Intentionally
export type PagingKey = PagingKeyOf<any>;
@ -142,13 +141,18 @@ export interface Paging<E extends PagingKey = PagingKey> {
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
type Item = Endpoints[E]["res"][number];
type Param = Endpoints[E]["req"] | Record<string, never>;
const SECOND_FETCH_LIMIT_DEFAULT = 30;
const FIRST_FETCH_LIMIT_DEFAULT = 10;
const props = withDefaults(
defineProps<{
pagination: Paging<E>;
disableAutoLoad?: boolean;
displayLimit?: number;
folder?: (i: Item[]) => Fold[];
}>(),
{
displayLimit: 30,
@ -156,7 +160,7 @@ const props = withDefaults(
);
const slots = defineSlots<{
default(props: { items: Item[] }): unknown;
default(props: { items: Item[]; foldedItems: Fold[] }): unknown;
empty(props: Record<string, never>): never;
}>();
@ -165,13 +169,59 @@ const emit = defineEmits<{
(ev: "status", hasError: boolean): void;
}>();
type Param = Endpoints[E]["req"] | Record<string, never>;
type Item = Endpoints[E]["res"][number];
const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]);
const foldedItems = ref([]) as Ref<Fold[]>;
// To improve performance, we do not use vues `computed` here
function calculateItems() {
function getItems<T>(folder: (ns: Item[]) => T[]) {
const res = [
folder(prepended.value.toReversed()),
...arrItems.value.map((arr) => folder(arr)),
folder(appended.value),
].flat(1);
if (props.pagination.reversed) {
res.reverse();
}
return res;
}
items.value = getItems((x) => x);
if (props.folder) foldedItems.value = getItems(props.folder);
}
const queue = ref<Item[]>([]);
/**
* The cached elements inserted front by `prepend` function
*/
const prepended = ref<Item[]>([]);
/**
* The array of "frozen" items
*/
const arrItems = ref<Item[][]>([]);
/**
* The cached elements inserted back by `append` function
*/
const appended = ref<Item[]>([]);
const idMap = new Map<string, boolean>();
const offset = ref(0);
type PagingByParam =
| {
offset: number;
}
| {
sinceId: string;
}
| {
untilId: string;
}
| Record<string, never>;
let nextPagingBy: PagingByParam = {};
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
@ -184,54 +234,14 @@ const init = async (): Promise<void> => {
queue.value = [];
fetching.value = true;
const params = props.pagination.params ? unref(props.pagination.params) : {};
await os
.api(props.pagination.endpoint, {
...params,
limit: props.pagination.noPaging
? props.pagination.limit || 10
: (props.pagination.limit || 10) + 1,
...(props.pagination.ascending
? {
// An initial value smaller than all possible ids must be filled in here.
sinceId: "0",
}
: {}),
})
.then(
(res: Item[]) => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
}
if (
!props.pagination.noPaging &&
res.length > (props.pagination.limit || 10)
) {
res.pop();
items.value = props.pagination.reversed ? res.toReversed() : res;
more.value = true;
} else {
items.value = props.pagination.reversed ? res.toReversed() : res;
more.value = false;
}
offset.value = res.length;
error.value = false;
fetching.value = false;
},
(_err) => {
error.value = true;
fetching.value = false;
},
);
await fetch(true);
};
const reload = (): Promise<void> => {
items.value = [];
arrItems.value = [];
appended.value = [];
prepended.value = [];
idMap.clear();
return init();
};
@ -240,30 +250,18 @@ const refresh = async (): Promise<void> => {
await os
.api(props.pagination.endpoint, {
...params,
limit: items.value.length + 1,
limit: (items.value.length || foldedItems.value.length) + 1,
offset: 0,
})
.then(
(res: Item[]) => {
const ids = items.value.reduce(
(a, b) => {
a[b.id] = true;
return a;
},
{} as Record<string, boolean>,
);
appended.value = [];
prepended.value = [];
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (!updateItem(item.id, (_old) => item)) {
append(item);
}
delete ids[item.id];
}
// appended should be inserted into arrItems to fix the element position
arrItems.value = [res];
for (const id in ids) {
removeItem((i) => i.id === id);
}
calculateItems();
},
(_err) => {
error.value = true;
@ -272,155 +270,145 @@ const refresh = async (): Promise<void> => {
);
};
const fetchMore = async (): Promise<void> => {
if (
!more.value ||
fetching.value ||
moreFetching.value ||
items.value.length === 0
)
return;
moreFetching.value = true;
backed.value = true;
async function fetch(firstFetching?: boolean) {
let limit: number;
if (firstFetching) {
limit = props.pagination.noPaging
? props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT
: (props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT) + 1;
if (props.pagination.ascending) {
nextPagingBy = {
// An initial value smaller than all possible ids must be filled in here.
sinceId: "0",
};
}
} else {
if (
!more.value ||
fetching.value ||
moreFetching.value ||
items.value.length === 0
)
return;
moreFetching.value = true;
backed.value = true;
limit =
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1;
}
const params = props.pagination.params ? unref(props.pagination.params) : {};
await os
.api(props.pagination.endpoint, {
...params,
limit:
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
...(props.pagination.offsetMode
? {
offset: offset.value,
}
: props.pagination.reversed
? {
sinceId: items.value[0].id,
}
: props.pagination.ascending
? {
sinceId: items.value[items.value.length - 1].id,
}
: {
untilId: items.value[items.value.length - 1].id,
}),
limit,
...nextPagingBy,
})
.then(
(res: Item[]) => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
if (!props.pagination.reversed)
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - (firstFetching ? 2 : 9))
item._shouldInsertAd_ = true;
} else {
if (i === (firstFetching ? 3 : 10)) item._shouldInsertAd_ = true;
}
}
}
if (
res.length >
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
) {
if (!props.pagination.noPaging && res.length > limit - 1) {
res.pop();
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = true;
} else {
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = false;
}
offset.value += res.length;
error.value = false;
fetching.value = false;
moreFetching.value = false;
const lastRes = res[res.length - 1];
if (props.pagination.offsetMode) {
nextPagingBy = {
offset: offset.value,
};
} else if (props.pagination.ascending) {
nextPagingBy = {
sinceId: lastRes?.id,
};
} else {
nextPagingBy = {
untilId: lastRes?.id,
};
}
if (firstFetching && props.folder != null) {
// In this way, prepended has some initial values for folding
prepended.value = res.toReversed();
} else {
// For ascending and offset modes, append and prepend may cause item duplication
// so they need to be filtered out.
if (props.pagination.offsetMode || props.pagination.ascending) {
for (const item of appended.value) {
idMap.set(item.id, true);
}
// biome-ignore lint/style/noParameterAssign: assign it intentially
res = res.filter((item) => {
if (idMap.has(item)) return false;
idMap.set(item, true);
return true;
});
}
// appended should be inserted into arrItems to fix the element position
arrItems.value.push(appended.value);
arrItems.value.push(res);
appended.value = [];
}
calculateItems();
},
(_err) => {
error.value = true;
fetching.value = false;
moreFetching.value = false;
},
);
}
const fetchMore = async (): Promise<void> => {
await fetch();
};
const fetchMoreAhead = async (): Promise<void> => {
if (
!more.value ||
fetching.value ||
moreFetching.value ||
items.value.length === 0
)
return;
moreFetching.value = true;
const params = props.pagination.params ? unref(props.pagination.params) : {};
await os
.api(props.pagination.endpoint, {
...params,
limit:
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
...(props.pagination.offsetMode
? {
offset: offset.value,
}
: props.pagination.reversed
? {
untilId: items.value[0].id,
}
: {
sinceId: items.value[items.value.length - 1].id,
}),
})
.then(
(res: Item[]) => {
if (
res.length >
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
) {
res.pop();
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = true;
} else {
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = false;
}
offset.value += res.length;
moreFetching.value = false;
},
(_err) => {
moreFetching.value = false;
},
);
await fetch();
};
const prepend = (item: Item): void => {
const prepend = (...item: Item[]): void => {
// If there are too many prepended, merge them into arrItems
if (
prepended.value.length >
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
) {
arrItems.value.unshift(prepended.value.toReversed());
prepended.value = [];
// We don't need to calculate here because it won't cause any changes in items
}
if (props.pagination.reversed) {
if (rootEl.value) {
const container = getScrollContainer(rootEl.value);
if (container == null) {
// TODO?
} else {
const pos = getScrollPosition(rootEl.value);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = pos + viewHeight > height - 32;
if (isBottom) {
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
// items.value = items.value.slice(-props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.shift();
}
more.value = true;
}
}
}
}
items.value.push(item);
// TODO
prepended.value.push(...item);
calculateItems();
} else {
// unshiftOK
// When displaying for the first time, just do this is OK
if (!rootEl.value) {
items.value.unshift(item);
prepended.value.push(...item);
calculateItems();
return;
}
@ -429,52 +417,63 @@ const prepend = (item: Item): void => {
(document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
if (isTop) {
// Prepend the item
items.value.unshift(item);
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
// this.items = items.value.slice(0, props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.pop();
}
more.value = true;
}
prepended.value.push(...item);
calculateItems();
} else {
queue.value.push(item);
queue.value.push(...item);
onScrollTop(rootEl.value, () => {
for (const queueItem of queue.value) {
prepend(queueItem);
}
prepend(...queue.value);
queue.value = [];
});
}
}
};
const append = (item: Item): void => {
items.value.push(item);
const append = (...items: Item[]): void => {
appended.value.push(...items);
calculateItems();
};
const _removeItem = (arr: Item[], finder: (item: Item) => boolean): boolean => {
const i = arr.findIndex(finder);
if (i === -1) {
return false;
}
arr.splice(i, 1);
return true;
};
const _updateItem = (
arr: Item[],
id: Item["id"],
replacer: (old: Item) => Item,
): boolean => {
const i = arr.findIndex((item) => item.id === id);
if (i === -1) {
return false;
}
arr[i] = replacer(arr[i]);
return true;
};
const removeItem = (finder: (item: Item) => boolean): boolean => {
const i = items.value.findIndex(finder);
if (i === -1) {
return false;
}
items.value.splice(i, 1);
return true;
const res =
_removeItem(prepended.value, finder) ||
_removeItem(appended.value, finder) ||
arrItems.value.filter((arr) => _removeItem(arr, finder)).length > 0;
calculateItems();
return res;
};
const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => {
const i = items.value.findIndex((item) => item.id === id);
if (i === -1) {
return false;
}
items.value[i] = replacer(items.value[i]);
return true;
const res =
_updateItem(prepended.value, id, replacer) ||
_updateItem(appended.value, id, replacer) ||
arrItems.value.filter((arr) => _updateItem(arr, id, replacer)).length > 0;
calculateItems();
return res;
};
if (props.pagination.params && isRef<Param>(props.pagination.params)) {

View file

@ -0,0 +1,68 @@
<template>
<MkModal ref="modal" :z-priority="'middle'" @closed="$emit('closed')">
<div :class="$style.root">
<div :class="$style.title">
<QRCodeVue3
:value="qrCode"
/>
</div>
<MkButton :class="$style.gotIt" primary full @click="gotIt()">{{
i18n.ts.gotIt
}}</MkButton>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { shallowRef } from "vue";
import MkModal from "@/components/MkModal.vue";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
import QRCodeVue3 from "qrcode-vue3";
const props = defineProps<{
qrCode: string;
}>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
const gotIt = () => {
modal.value.close();
};
</script>
<style lang="scss" module>
.root {
margin: auto;
position: relative;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
> img {
border-radius: 10px;
max-height: 100%;
max-width: 100%;
}
}
.title {
font-weight: bold;
> p {
margin: 0;
}
}
.time {
font-size: 0.8rem;
}
.gotIt {
margin: 8px 0 0 0;
}
</style>

View file

@ -196,13 +196,26 @@ import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
import icon from "@/scripts/icon";
const props = defineProps<{
note: entities.Note;
parentId?: string;
conversation?: entities.Note[];
detailed?: boolean;
detailedView?: boolean;
}>();
const props = withDefaults(
defineProps<{
note: entities.Note;
parentId?: string;
conversation?: entities.Note[];
detailed?: boolean;
detailedView?: boolean;
isLongJudger?: (note: entities.Note) => boolean;
}>(),
{
isLongJudger: (note: entities.Note) => {
return (
note.text != null &&
(note.text.split("\n").length > 10 ||
note.text.length > 800 ||
note.files.length > 4)
);
},
},
);
const emit = defineEmits<{
(ev: "push", v): void;
@ -216,10 +229,7 @@ const showMoreButton = ref<HTMLElement>();
const isLong =
!props.detailedView &&
props.note.cw == null &&
((props.note.text != null &&
(props.note.text.split("\n").length > 10 ||
props.note.text.length > 800)) ||
props.note.files.length > 4);
props.isLongJudger(props.note);
const collapsed = ref(props.note.cw == null && isLong);
const urls = props.note.text
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)

View file

@ -338,7 +338,7 @@ defineExpose({
content: "";
position: absolute;
inset: -2px 0;
border: 2px solid var(--accentDarken);
border-bottom: 2px solid var(--accentDarken);
mask: linear-gradient(
to right,
transparent,

View file

@ -7,6 +7,7 @@ import type { Component, MaybeRef, Ref } from "vue";
import { defineAsyncComponent, markRaw, ref } from "vue";
import { i18n } from "./i18n";
import MkDialog from "@/components/MkDialog.vue";
import MkQrCode from "@/components/MkQrCode.vue";
import MkPostFormDialog from "@/components/MkPostFormDialog.vue";
import MkToast from "@/components/MkToast.vue";
import MkWaitingDialog from "@/components/MkWaitingDialog.vue";
@ -1000,6 +1001,25 @@ export function post(
});
}
export async function displayQrCode(qrCode: string) {
(
await new Promise<(() => void) | undefined>((resolve) => {
let dispose: (() => void) | undefined;
popup(
MkQrCode,
{ qrCode },
{
closed: () => {
resolve(dispose);
},
},
).then((res) => {
dispose = res.dispose;
});
})
)?.();
}
export const deckGlobalEvents = new EventEmitter();
/*

View file

@ -0,0 +1,75 @@
<template>
<div class="mk-follow-page"></div>
</template>
<script lang="ts" setup>
import { acct } from "firefish-js";
import * as os from "@/os";
import { i18n } from "@/i18n";
import { host as hostRaw } from "@/config";
import { isSignedIn, me } from "@/me";
import { waiting } from "@/os";
const acctUri = new URL(location.href).searchParams.get("acct");
if (acctUri == null) {
throw new Error("acct required");
}
// If the user is already logged in, ask whether to follow using the current account.
if (isSignedIn(me)) {
const { canceled } = await os.confirm({
type: "question",
text: i18n.ts.useThisAccountConfirm,
});
// use the current account
if (!canceled) {
waiting();
window.location.href = `/authorize-follow?acct=${acctUri}`;
}
}
// Otherwise ask the user what the other account ID is
const remoteAccountId = await os.inputText({
text: i18n.ts.inputAccountId,
});
// If the user do not want enter uri, the user will be redirected to the user page.
if (!remoteAccountId.result) {
waiting();
window.location.href = `/@${acctUri}`;
} else {
const remoteAcctInfo = acct.parse(remoteAccountId.result);
// If the user on this server, redirect directly
if (remoteAcctInfo.host === hostRaw || remoteAcctInfo.host === null) {
waiting();
window.location.href = `/authorize-follow?acct=${acctUri}`;
} else {
waiting();
// If not, find the interaction url through webfinger interface
fetch(
`https://${remoteAcctInfo.host}/.well-known/webfinger?resource=${remoteAcctInfo.username}@${remoteAcctInfo.host}`,
{
method: "GET",
},
)
.then((response) => response.json())
.then((data) => {
const subscribeUri = data.links.find(
(link: { rel: string }) =>
link.rel === "http://ostatus.org/schema/1.0/subscribe",
).template;
window.location.href = subscribeUri.replace(
"{uri}",
acctUri.includes("@") ? acctUri : `${acctUri}@${hostRaw}`,
);
})
.catch((_) => {
// TODO: It would be better to provide more information, but the priority of
// waiting component is too high and the pop-up window will be blocked.
window.location.href = `/@${acctUri}`;
});
}
}
</script>

View file

@ -325,6 +325,10 @@ export const routes: RouteDef[] = [
component: page(() => import("./pages/follow.vue")),
loginRequired: true,
},
{
path: "/follow-me",
component: page(() => import("./pages/follow-me.vue")),
},
{
path: "/authorize_interaction",
component: page(() => import("./pages/authorize_interaction.vue")),

View file

@ -20,7 +20,6 @@ interface FoldOption {
*/
export function foldItems<ItemFolded, Item>(
ns: Item[],
fetch_limit: number,
classfier: (n: Item, index: number) => string,
aggregator: (ns: Item[], key: string) => ItemFolded,
_options?: FoldOption,
@ -30,55 +29,48 @@ export function foldItems<ItemFolded, Item>(
const options: FoldOption = _options ?? {};
options.skipSingleElement ??= true;
for (let i = 0; i < ns.length; i += fetch_limit) {
const toFold = ns.slice(i, i + fetch_limit);
const toAppendKeys: string[] = [];
const foldMap = new Map<string, Item[]>();
const toAppendKeys: string[] = [];
const foldMap = new Map<string, Item[]>();
for (const [index, n] of toFold.entries()) {
const key = classfier(n, index);
const arr = foldMap.get(key);
if (arr != null) {
arr.push(n);
} else {
foldMap.set(key, [n]);
toAppendKeys.push(key);
}
for (const [index, n] of ns.entries()) {
const key = classfier(n, index);
const arr = foldMap.get(key);
if (arr != null) {
arr.push(n);
} else {
foldMap.set(key, [n]);
toAppendKeys.push(key);
}
res = res.concat(
toAppendKeys.map((key) => {
const arr = foldMap.get(key)!;
if (arr?.length === 1 && options?.skipSingleElement) {
return arr[0];
}
return aggregator(arr, key);
}),
);
}
res = toAppendKeys.map((key) => {
const arr = foldMap.get(key)!;
if (arr?.length === 1 && options?.skipSingleElement) {
return arr[0];
}
return aggregator(arr, key);
});
return res;
}
export function foldNotifications(
ns: entities.Notification[],
fetch_limit: number,
) {
export function foldNotifications(ns: entities.Notification[]) {
// By the implement of MkPagination, lastId is unique and is safe for key
const lastId = ns[ns.length - 1]?.id ?? "prepend";
return foldItems(
ns,
fetch_limit,
(n) => {
switch (n.type) {
case "renote":
return `renote-of:${n.note.renote.id}`;
return `renote-${n.note.renote.id}`;
case "reaction":
return `reaction:${n.reaction}:of:${n.note.id}`;
return `reaction-${n.reaction}-of-${n.note.id}`;
default: {
return `${n.id}`;
}
}
},
(ns) => {
(ns, key) => {
const represent = ns[0];
function check(
ns: entities.Notification[],
@ -94,6 +86,7 @@ export function foldNotifications(
userIds: ns.map((nn) => nn.userId),
users: ns.map((nn) => nn.user),
notifications: ns!,
id: `G-${lastId}-${key}`,
} as NotificationFolded;
},
);

View file

@ -255,6 +255,27 @@ export function getUserMenu(user, router: Router = mainRouter) {
router.push(`/user-info/${user.id}`);
},
},
{
icon: `${icon("ph-share")}`,
text: i18n.ts.share,
type: "parent",
children: [
{
icon: "ph-qr-code ph-bold ph-lg",
text: i18n.ts.getQrCode,
action: () => {
os.displayQrCode(`https://${host}/follow-me?acct=${user.username}`);
},
},
{
icon: `${icon("ph-hand-waving")}`,
text: i18n.ts.copyRemoteFollowUrl,
action: () => {
copyToClipboard(`https://${host}/follow-me?acct=${user.username}`);
},
},
],
},
{
icon: `${icon("ph-newspaper")}`,
text: i18n.ts._feeds.copyFeed,
@ -290,6 +311,15 @@ export function getUserMenu(user, router: Router = mainRouter) {
os.post({ specified: user });
},
},
!isSignedIn(me)
? {
icon: `${icon("ph-hand-waving")}`,
text: i18n.ts.remoteFollow,
action: () => {
router.push(`/follow-me?acct=${user.username}`);
},
}
: undefined,
isSignedIn(me) && me.id !== user.id
? {
type: "link",

View file

@ -4,6 +4,9 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
if (el == null) return null;
const overflow = window.getComputedStyle(el).getPropertyValue("overflow-y");
if (overflow === "scroll" || overflow === "auto") {
if (el.tagName === "HTML") {
return null;
}
return el;
} else {
return getScrollContainer(el.parentElement);

View file

@ -22,7 +22,7 @@
},
"devDependencies": {
"@swc/cli": "0.3.12",
"@swc/core": "1.4.13",
"@swc/core": "1.5.0",
"@swc/types": "^0.1.6",
"@types/jest": "^29.5.12",
"@types/node": "20.12.7",

View file

@ -11,7 +11,7 @@
"devDependencies": {
"firefish-js": "workspace:*",
"idb-keyval": "^6.2.1",
"vite": "5.2.8",
"vite": "5.2.10",
"vite-plugin-compression": "^0.5.1"
}
}

File diff suppressed because it is too large Load diff