Compare commits

...

31 Commits

Author SHA1 Message Date
Kagami Sascha Rosylight
5d4ab609f5 Update MkAd.stories.impl.ts 2023-07-13 02:23:32 +02:00
Kagami Sascha Rosylight
0370b4dfab Merge branch 'develop' into fix-storybook-testbed 2023-07-13 02:05:24 +02:00
Kagami Sascha Rosylight
a516557bb6 Update MkAd.stories.impl.ts 2023-07-13 01:59:57 +02:00
Kagami Sascha Rosylight
b6a432fd7b fix(storybook): solve test failures (#11262)
* fix(locales, storybook): use default import

* fix(storybook): solve test failures

* Update MkAd.stories.impl.ts
2023-07-13 08:41:55 +09:00
Kagami Sascha Rosylight
7c4e2b658d tmp 2023-07-13 01:08:40 +02:00
Kagami Sascha Rosylight
bb2eb57a35 Update MkAd.stories.impl.ts 2023-07-13 00:46:12 +02:00
Kagami Sascha Rosylight
698fa84b5a hmm 2023-07-13 00:30:29 +02:00
Kagami Sascha Rosylight
2184323ae7 use waitFor 2023-07-12 23:57:26 +02:00
Kagami Sascha Rosylight
0ba5edc6aa fix(storybook): solve test failures 2023-07-12 23:39:59 +02:00
Kagami Sascha Rosylight
3fde3fbc4d Merge branch 'develop' into no-cache-sensitive 2023-07-12 22:28:40 +02:00
Kagami Sascha Rosylight
cd9affd568 fix(locales, storybook): use default import (#11259) 2023-07-12 22:27:51 +02:00
Kagami Sascha Rosylight
24a0063af0 Merge remote-tracking branch 'origin/develop' into no-cache-sensitive 2023-07-12 21:27:49 +02:00
Kagami Sascha Rosylight
74e565b71c fix storybook 2023-07-12 21:27:27 +02:00
Lui
b97694b083 fix: typo in custom emojis manager (#11250) 2023-07-12 16:31:48 +09:00
Kagami Sascha Rosylight
6ed7d1a76e Update CHANGELOG.md 2023-07-12 02:16:37 +02:00
Kagami Sascha Rosylight
c277934328 feat(backend): allow disabling cache for sensitive files 2023-07-12 02:13:27 +02:00
anatawa12
9845ccec5b オフライン時の画面にリロードボタンを追加 (#11242)
* feat: オフライン時の画面にリロードボタンを追加

リロードのためのボタンがないとPWAでインターネットが復帰しても何もできなくなるため。

* docs(changelog): add オフライン時の画面にリロードボタンを追加
2023-07-11 18:24:10 +09:00
okayurisotto
cf3e39178b refactor(backend): 存在確認のfindOneByexistに置き換え (#11224)
* refactor(backend): 存在確認の`findOneBy`を`exist`に置き換え

* cleanup
2023-07-11 14:58:58 +09:00
tamaina
48d3341462 chore(frontend): Remove experimental flag from migration feature 2023-07-11 05:56:56 +00:00
nomad
791ae608a5 fix(backend): fix fetchInstanceMetadata error (#11236) 2023-07-11 14:40:56 +09:00
yupix
f4d1fcaf67 feat: ユーザーをcontextmenuからアンテナに追加できるようになど (#11206)
* feat: ユーザーをcontextmenuからアンテナに追加できるように close #11115

* MkAvatars.vue変更

* nanka iroiro

* fix MkAvatars

* ix

* fix

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
2023-07-10 15:55:10 +09:00
Sayamame-beans
239ea39d6f feat: フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように (#11218)
* feat: フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように

* Update CHANGELOG.md

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
2023-07-10 13:30:41 +09:00
akanevrc
63e21a4ee3 fix(frontend): 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように (#11211)
* fix: change tapAction of photoswipe to 'close'

* doc: update CHANGELOG.md

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
2023-07-10 13:26:05 +09:00
anatawa12
1a096c557e refactor: fix lint failure (#11214) 2023-07-09 15:46:17 +02:00
eni
53b1684c39 fix(frontend): use system-ui for system font (#11177)
* fix(frontend): correct system font stack

This was originally set to Hiragino Maru Gothic Pro, which is the same as the current default font.

* just use system-ui

per code review https://github.com/misskey-dev/misskey/pull/11177#discussion_r1257260039

---------

Co-authored-by: Kagami Sascha Rosylight <saschanaz@outlook.com>
2023-07-09 14:24:05 +02:00
yupix
9dd53527ca feat: プロフィールURLをコピー ボタンを追加 close #11190 (#11205) 2023-07-09 17:20:50 +09:00
Kagami Sascha Rosylight
59046d583d refactor(locales, sw): use es module (#11204)
* refactor(locales): use es module

* fix sw build

* fix gulp

* try fixing storybook

* Revert "try fixing storybook"

This reverts commit 5f2a4eee01.

* try fixing storybook 2

* Update main.ts

* Update build.js

* Update main.ts

* Update changes.ts

* fix sw lint

* Update build.js
2023-07-09 17:19:07 +09:00
Kagami Sascha Rosylight
5059d4d7e1 refactor(backend): skip fetching notes when the data is same-origin (#11200)
* refactor(backend): skip fetching notes when the data is same-origin

* Update CHANGELOG.md

* sentFrom
2023-07-09 08:59:44 +09:00
Kagami Sascha Rosylight
74a05ec739 fix(frontend): fix storybook build (#11203) 2023-07-09 07:06:13 +09:00
Caipira
60366a4558 fix(backend): Remove Meilisearch index when notes are deleted (#10988)
* fix(backend): Include feature to delete Meilisearch index notes

* Update variable name
`cascadingNotesFilter` -> `federatedLocalCascadingNotes`

* tweak

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-07-08 21:31:38 +09:00
tamaina
7ec07d5fd2 perf(backend): Reduce memory usage of MemoryKVCache (#11076)
* perf(backend): Reduce memory usage of MemoryKVCache

* fix
2023-07-08 21:18:16 +09:00
97 changed files with 959 additions and 402 deletions

View File

@@ -26,6 +26,11 @@
- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように - 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように
- フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように - フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように
- 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように - 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように
- プロフィールURLをコピーできるボタンを追加 #11190
- ユーザーのContextMenuに「アンテナに追加」ボタンを追加
- フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように
- 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように
- オフライン時の画面にリロードボタンを追加
- Fix: サーバーメトリクスが90度傾いている - Fix: サーバーメトリクスが90度傾いている
- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正 - Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正
- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正 - Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正
@@ -33,12 +38,15 @@
- Fix: ページ遷移でスクロール位置が保持されない問題を修正 - Fix: ページ遷移でスクロール位置が保持されない問題を修正
- Fix: フォルダーのページネーションが機能しない #11180 - Fix: フォルダーのページネーションが機能しない #11180
- Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正 - Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正
- Fix: システムフォント設定が正しく反映されない問題を修正
### Server ### Server
- JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました - JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました
- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように - nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように
- 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用 - 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用
- 全体的なDBクエリのパフォーマンスを向上 - 全体的なDBクエリのパフォーマンスを向上
- featuredートのsignedGet回数を減らしました
- リモートサーバーからのNSFW映像のキャッシュだけを無効化できるオプションを追加しました
## 13.13.2 ## 13.13.2

View File

@@ -2,14 +2,14 @@
* Gulp tasks * Gulp tasks
*/ */
const fs = require('fs'); import * as fs from 'node:fs';
const gulp = require('gulp'); import gulp from 'gulp';
const replace = require('gulp-replace'); import replace from 'gulp-replace';
const terser = require('gulp-terser'); import terser from 'gulp-terser';
const cssnano = require('gulp-cssnano'); import cssnano from 'gulp-cssnano';
const locales = require('./locales'); import locales from './locales/index.js';
const meta = require('./package.json'); import meta from './package.json' assert { type: "json" };
gulp.task('copy:backend:views', () => gulp.task('copy:backend:views', () =>
gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views')) gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views'))

View File

@@ -1,6 +1,6 @@
const fs = require('fs'); import * as fs from 'node:fs';
const yaml = require('js-yaml'); import * as yaml from 'js-yaml';
const ts = require('typescript'); import * as ts from 'typescript';
function createMembers(record) { function createMembers(record) {
return Object.entries(record) return Object.entries(record)
@@ -14,7 +14,7 @@ function createMembers(record) {
)); ));
} }
module.exports = function generateDTS() { export default function generateDTS() {
const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
const members = createMembers(locale); const members = createMembers(locale);
const elements = [ const elements = [

6
locales/index.d.ts vendored
View File

@@ -52,6 +52,7 @@ export interface Locale {
"deleteAndEdit": string; "deleteAndEdit": string;
"deleteAndEditConfirm": string; "deleteAndEditConfirm": string;
"addToList": string; "addToList": string;
"addToAntenna": string;
"sendMessage": string; "sendMessage": string;
"copyRSS": string; "copyRSS": string;
"copyUsername": string; "copyUsername": string;
@@ -59,6 +60,7 @@ export interface Locale {
"copyNoteId": string; "copyNoteId": string;
"copyFileId": string; "copyFileId": string;
"copyFolderId": string; "copyFolderId": string;
"copyProfileUrl": string;
"searchUser": string; "searchUser": string;
"reply": string; "reply": string;
"loadMore": string; "loadMore": string;
@@ -157,6 +159,8 @@ export interface Locale {
"settingGuide": string; "settingGuide": string;
"cacheRemoteFiles": string; "cacheRemoteFiles": string;
"cacheRemoteFilesDescription": string; "cacheRemoteFilesDescription": string;
"cacheRemoteSensitiveFiles": string;
"cacheRemoteSensitiveFilesDescription": string;
"flagAsBot": string; "flagAsBot": string;
"flagAsBotDescription": string; "flagAsBotDescription": string;
"flagAsCat": string; "flagAsCat": string;
@@ -2156,4 +2160,4 @@ export interface Locale {
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;
}; };
export = locales; export default locales;

View File

@@ -2,8 +2,8 @@
* Languages Loader * Languages Loader
*/ */
const fs = require('fs'); import * as fs from 'node:fs';
const yaml = require('js-yaml'); import * as yaml from 'js-yaml';
const merge = (...args) => args.reduce((a, c) => ({ const merge = (...args) => args.reduce((a, c) => ({
...a, ...a,
@@ -51,9 +51,9 @@ const primaries = {
// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く // 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く
const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), '');
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(`${__dirname}/${c}.yml`, 'utf-8'))) || {}, a), {}); const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {});
module.exports = Object.entries(locales) export default Object.entries(locales)
.reduce((a, [k ,v]) => (a[k] = (() => { .reduce((a, [k ,v]) => (a[k] = (() => {
const [lang] = k.split('-'); const [lang] = k.split('-');
switch (k) { switch (k) {

View File

@@ -49,6 +49,7 @@ delete: "削除"
deleteAndEdit: "削除して編集" deleteAndEdit: "削除して編集"
deleteAndEditConfirm: "このートを削除してもう一度編集しますかこのートへのリアクション、Renote、返信も全て削除されます。" deleteAndEditConfirm: "このートを削除してもう一度編集しますかこのートへのリアクション、Renote、返信も全て削除されます。"
addToList: "リストに追加" addToList: "リストに追加"
addToAntenna: "アンテナに追加"
sendMessage: "メッセージを送信" sendMessage: "メッセージを送信"
copyRSS: "RSSをコピー" copyRSS: "RSSをコピー"
copyUsername: "ユーザー名をコピー" copyUsername: "ユーザー名をコピー"
@@ -56,6 +57,7 @@ copyUserId: "ユーザーIDをコピー"
copyNoteId: "ートIDをコピー" copyNoteId: "ートIDをコピー"
copyFileId: "ファイルIDをコピー" copyFileId: "ファイルIDをコピー"
copyFolderId: "フォルダーIDをコピー" copyFolderId: "フォルダーIDをコピー"
copyProfileUrl: "プロフィールURLをコピー"
searchUser: "ユーザーを検索" searchUser: "ユーザーを検索"
reply: "返信" reply: "返信"
loadMore: "もっと見る" loadMore: "もっと見る"
@@ -154,6 +156,8 @@ addEmoji: "絵文字を追加"
settingGuide: "おすすめ設定" settingGuide: "おすすめ設定"
cacheRemoteFiles: "リモートのファイルをキャッシュする" cacheRemoteFiles: "リモートのファイルをキャッシュする"
cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。" cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。"
cacheRemoteSensitiveFiles: "リモートのNSFWファイルをキャッシュする"
cacheRemoteSensitiveFilesDescription: "この設定を無効にすると、リモートのNSFWファイルだけはキャッシュせず直リンクするようになります。"
flagAsBot: "Botとして設定" flagAsBot: "Botとして設定"
flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。" flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。"
flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!" flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!"

3
locales/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,11 @@
export class NsfwCache1689102832143 {
name = 'NsfwCache1689102832143'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "cacheRemoteSensitiveFiles" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "cacheRemoteSensitiveFiles"`);
}
}

View File

@@ -11,10 +11,10 @@ import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
export class CacheService implements OnApplicationShutdown { export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<User>; public userByIdCache: MemoryKVCache<User, User | string>;
public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null>; public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null, string | null>;
public localUserByIdCache: MemoryKVCache<LocalUser>; public localUserByIdCache: MemoryKVCache<LocalUser>;
public uriPersonCache: MemoryKVCache<User | null>; public uriPersonCache: MemoryKVCache<User | null, string | null>;
public userProfileCache: RedisKVCache<UserProfile>; public userProfileCache: RedisKVCache<UserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>; public userMutingsCache: RedisKVCache<Set<string>>;
public userBlockingCache: RedisKVCache<Set<string>>; public userBlockingCache: RedisKVCache<Set<string>>;
@@ -55,10 +55,41 @@ export class CacheService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new MemoryKVCache<User>(Infinity); const localUserByIdCache = new MemoryKVCache<LocalUser>(1000 * 60 * 60 * 6 /* 6h */);
this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null>(Infinity); this.localUserByIdCache = localUserByIdCache;
this.localUserByIdCache = new MemoryKVCache<LocalUser>(Infinity);
this.uriPersonCache = new MemoryKVCache<User | null>(Infinity); // ローカルユーザーならlocalUserByIdCacheにデータを追加し、こちらにはid(文字列)だけを追加する
const userByIdCache = new MemoryKVCache<User, User | string>(1000 * 60 * 60 * 6 /* 6h */, {
toMapConverter: user => {
if (user.host === null) {
localUserByIdCache.set(user.id, user as LocalUser);
return user.id;
}
return user;
},
fromMapConverter: userOrId => typeof userOrId === 'string' ? localUserByIdCache.get(userOrId) : userOrId,
});
this.userByIdCache = userByIdCache;
this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null, string | null>(Infinity, {
toMapConverter: user => {
if (user === null) return null;
localUserByIdCache.set(user.id, user);
return user.id;
},
fromMapConverter: id => id === null ? null : localUserByIdCache.get(id),
});
this.uriPersonCache = new MemoryKVCache<User | null, string | null>(Infinity, {
toMapConverter: user => {
if (user === null) return null;
userByIdCache.set(user.id, user);
return user.id;
},
fromMapConverter: id => id === null ? null : userByIdCache.get(id),
});
this.userProfileCache = new RedisKVCache<UserProfile>(this.redisClient, 'userProfile', { this.userProfileCache = new RedisKVCache<UserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@@ -131,7 +162,7 @@ export class CacheService implements OnApplicationShutdown {
const user = await this.usersRepository.findOneByOrFail({ id: body.id }); const user = await this.usersRepository.findOneByOrFail({ id: body.id });
this.userByIdCache.set(user.id, user); this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === user.id) { if (v.value === user.id) {
this.uriPersonCache.set(k, user); this.uriPersonCache.set(k, user);
} }
} }

View File

@@ -103,7 +103,7 @@ export class FetchInstanceMetadataService {
if (name) updates.name = name; if (name) updates.name = name;
if (description) updates.description = description; if (description) updates.description = description;
if (icon || favicon) updates.iconUrl = icon ?? favicon; if (icon || favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon;
if (favicon) updates.faviconUrl = favicon; if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor; if (themeColor) updates.themeColor = themeColor;

View File

@@ -570,12 +570,14 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.reply) { if (data.reply) {
// 通知 // 通知
if (data.reply.userHost === null) { if (data.reply.userHost === null) {
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ const isThreadMuted = await this.noteThreadMutingsRepository.exist({
userId: data.reply.userId, where: {
threadId: data.reply.threadId ?? data.reply.id, userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
}
}); });
if (!threadMuted) { if (!isThreadMuted) {
nm.push(data.reply.userId, 'reply'); nm.push(data.reply.userId, 'reply');
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
@@ -712,12 +714,14 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis @bindThis
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) { private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) {
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ const isThreadMuted = await this.noteThreadMutingsRepository.exist({
userId: u.id, where: {
threadId: note.threadId ?? note.id, userId: u.id,
threadId: note.threadId ?? note.id,
},
}); });
if (threadMuted) { if (isThreadMuted) {
continue; continue;
} }

View File

@@ -17,6 +17,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
@Injectable() @Injectable()
export class NoteDeleteService { export class NoteDeleteService {
@@ -41,6 +42,7 @@ export class NoteDeleteService {
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
private metaService: MetaService, private metaService: MetaService,
private searchService: SearchService,
private notesChart: NotesChart, private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart, private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
@@ -53,6 +55,7 @@ export class NoteDeleteService {
*/ */
async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; isBot: User['isBot']; }, note: Note, quiet = false) { async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; isBot: User['isBot']; }, note: Note, quiet = false) {
const deletedAt = new Date(); const deletedAt = new Date();
const cascadingNotes = await this.findCascadingNotes(note);
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) { if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
@@ -88,8 +91,8 @@ export class NoteDeleteService {
} }
// also deliever delete activity to cascaded notes // also deliever delete activity to cascaded notes
const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
for (const cascadingNote of cascadingNotes) { for (const cascadingNote of federatedLocalCascadingNotes) {
if (!cascadingNote.user) continue; if (!cascadingNote.user) continue;
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue; if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
@@ -114,6 +117,11 @@ export class NoteDeleteService {
} }
} }
for (const cascadingNote of cascadingNotes) {
this.searchService.unindexNote(cascadingNote);
}
this.searchService.unindexNote(note);
await this.notesRepository.delete({ await this.notesRepository.delete({
id: note.id, id: note.id,
userId: user.id, userId: user.id,
@@ -140,7 +148,7 @@ export class NoteDeleteService {
const cascadingNotes: Note[] = await recursive(note.id); const cascadingNotes: Note[] = await recursive(note.id);
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users return cascadingNotes;
} }
@bindThis @bindThis

View File

@@ -43,11 +43,13 @@ export class NoteReadService implements OnApplicationShutdown {
//#endregion //#endregion
// スレッドミュート // スレッドミュート
const threadMute = await this.noteThreadMutingsRepository.findOneBy({ const isThreadMuted = await this.noteThreadMutingsRepository.exist({
userId: userId, where: {
threadId: note.threadId ?? note.id, userId: userId,
threadId: note.threadId ?? note.id,
},
}); });
if (threadMute) return; if (isThreadMuted) return;
const unread = { const unread = {
id: this.idService.genId(), id: this.idService.genId(),
@@ -62,9 +64,9 @@ export class NoteReadService implements OnApplicationShutdown {
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); const exist = await this.noteUnreadsRepository.exist({ where: { id: unread.id } });
if (exist == null) return; if (!exist) return;
if (params.isMentioned) { if (params.isMentioned) {
this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);

View File

@@ -115,6 +115,15 @@ export class SearchService {
} }
} }
@bindThis
public async unindexNote(note: Note): Promise<void> {
if (!['home', 'public'].includes(note.visibility)) return;
if (this.meilisearch) {
this.meilisearchNoteIndex!.deleteDocument(note.id);
}
}
@bindThis @bindThis
public async searchNote(q: string, me: User | null, opts: { public async searchNote(q: string, me: User | null, opts: {
userId?: Note['userId'] | null; userId?: Note['userId'] | null;

View File

@@ -71,12 +71,12 @@ export class SignupService {
const secret = generateUserToken(); const secret = generateUserToken();
// Check username duplication // Check username duplication
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) {
throw new Error('DUPLICATED_USERNAME'); throw new Error('DUPLICATED_USERNAME');
} }
// Check deleted username duplication // Check deleted username duplication
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) {
throw new Error('USED_USERNAME'); throw new Error('USED_USERNAME');
} }

View File

@@ -122,22 +122,26 @@ export class UserFollowingService implements OnModuleInit {
let autoAccept = false; let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー // 鍵アカウントであっても、既にフォローされていた場合はスルー
const following = await this.followingsRepository.findOneBy({ const isFollowing = await this.followingsRepository.exist({
followerId: follower.id, where: {
followeeId: followee.id, followerId: follower.id,
followeeId: followee.id,
},
}); });
if (following) { if (isFollowing) {
autoAccept = true; autoAccept = true;
} }
// フォローしているユーザーは自動承認オプション // フォローしているユーザーは自動承認オプション
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
const followed = await this.followingsRepository.findOneBy({ const isFollowed = await this.followingsRepository.exist({
followerId: followee.id, where: {
followeeId: follower.id, followerId: followee.id,
followeeId: follower.id,
},
}); });
if (followed) autoAccept = true; if (isFollowed) autoAccept = true;
} }
// Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account.
@@ -206,12 +210,14 @@ export class UserFollowingService implements OnModuleInit {
this.cacheService.userFollowingsCache.refresh(follower.id); this.cacheService.userFollowingsCache.refresh(follower.id);
const req = await this.followRequestsRepository.findOneBy({ const requestExist = await this.followRequestsRepository.exist({
followeeId: followee.id, where: {
followerId: follower.id, followeeId: followee.id,
followerId: follower.id,
},
}); });
if (req) { if (requestExist) {
await this.followRequestsRepository.delete({ await this.followRequestsRepository.delete({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@@ -505,12 +511,14 @@ export class UserFollowingService implements OnModuleInit {
} }
} }
const request = await this.followRequestsRepository.findOneBy({ const requestExist = await this.followRequestsRepository.exist({
followeeId: followee.id, where: {
followerId: follower.id, followeeId: followee.id,
followerId: follower.id,
},
}); });
if (request == null) { if (!requestExist) {
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
} }

View File

@@ -618,12 +618,14 @@ export class ApInboxService {
return 'skip: follower not found'; return 'skip: follower not found';
} }
const following = await this.followingsRepository.findOneBy({ const isFollowing = await this.followingsRepository.exist({
followerId: follower.id, where: {
followeeId: actor.id, followerId: follower.id,
followeeId: actor.id,
},
}); });
if (following) { if (isFollowing) {
await this.userFollowingService.unfollow(follower, actor); await this.userFollowingService.unfollow(follower, actor);
return 'ok: unfollowed'; return 'ok: unfollowed';
} }
@@ -673,22 +675,26 @@ export class ApInboxService {
return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません';
} }
const req = await this.followRequestsRepository.findOneBy({ const requestExist = await this.followRequestsRepository.exist({
followerId: actor.id, where: {
followeeId: followee.id, followerId: actor.id,
followeeId: followee.id,
},
}); });
const following = await this.followingsRepository.findOneBy({ const isFollowing = await this.followingsRepository.exist({
followerId: actor.id, where: {
followeeId: followee.id, followerId: actor.id,
followeeId: followee.id,
},
}); });
if (req) { if (requestExist) {
await this.userFollowingService.cancelFollowRequest(followee, actor); await this.userFollowingService.cancelFollowRequest(followee, actor);
return 'ok: follow request canceled'; return 'ok: follow request canceled';
} }
if (following) { if (isFollowing) {
await this.userFollowingService.unfollow(actor, followee); await this.userFollowingService.unfollow(actor, followee);
return 'ok: unfollowed'; return 'ok: unfollowed';
} }

View File

@@ -323,9 +323,9 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) { if (inReplyToNote != null) {
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); const inReplyToUserExist = await this.usersRepository.exist({ where: { id: inReplyToNote.userId } });
if (inReplyToUser != null) { if (inReplyToUserExist) {
if (inReplyToNote.uri) { if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri; inReplyTo = inReplyToNote.uri;
} else { } else {

View File

@@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository } from '@/models/index.js'; import type { DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { RemoteUser } from '@/models/entities/User.js'; import type { RemoteUser } from '@/models/entities/User.js';
import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { DriveFile } from '@/models/entities/DriveFile.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
@@ -20,9 +19,6 @@ export class ApImageService {
private logger: Logger; private logger: Logger;
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
@@ -47,7 +43,7 @@ export class ApImageService {
const image = await this.apResolverService.createResolver().resolve(value); const image = await this.apResolverService.createResolver().resolve(value);
if (image.url == null) { if (image.url == null) {
throw new Error('invalid image: url not privided'); throw new Error('invalid image: url not provided');
} }
if (typeof image.url !== 'string') { if (typeof image.url !== 'string') {
@@ -62,12 +58,17 @@ export class ApImageService {
const instance = await this.metaService.fetch(); const instance = await this.metaService.fetch();
// Cache if remote file cache is on AND either
// 1. remote sensitive file is also on
// 2. or the image is not sensitive
const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive);
const file = await this.driveService.uploadFromUrl({ const file = await this.driveService.uploadFromUrl({
url: image.url, url: image.url,
user: actor, user: actor,
uri: image.url, uri: image.url,
sensitive: image.sensitive, sensitive: image.sensitive,
isLink: !instance.cacheRemoteFiles, isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH), comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH),
}); });
if (!file.isLink || file.url === image.url) return file; if (!file.isLink || file.url === image.url) return file;

View File

@@ -177,7 +177,7 @@ export class ApNoteService {
// リプライ // リプライ
const reply: Note | null = note.inReplyTo const reply: Note | null = note.inReplyTo
? await this.resolveNote(note.inReplyTo, resolver) ? await this.resolveNote(note.inReplyTo, { resolver })
.then(x => { .then(x => {
if (x == null) { if (x == null) {
this.logger.warn('Specified inReplyTo, but not found'); this.logger.warn('Specified inReplyTo, but not found');
@@ -293,9 +293,8 @@ export class ApNoteService {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/ */
@bindThis @bindThis
public async resolveNote(value: string | IObject, resolver?: Resolver): Promise<Note | null> { public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise<Note | null> {
const uri = typeof value === 'string' ? value : value.id; const uri = getApId(value);
if (uri == null) throw new Error('missing uri');
// ブロックしていたら中断 // ブロックしていたら中断
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
@@ -318,7 +317,8 @@ export class ApNoteService {
// リモートサーバーからフェッチしてきて登録 // リモートサーバーからフェッチしてきて登録
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
return await this.createNote(uri, resolver, true); const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
return await this.createNote(createFrom, options.resolver, true);
} finally { } finally {
unlock(); unlock();
} }

View File

@@ -260,7 +260,7 @@ export class ApPersonService implements OnModuleInit {
// Create user // Create user
let user: RemoteUser | null = null; let user: RemoteUser | null = null;
try { try {
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
user = await transactionalEntityManager.save(new User({ user = await transactionalEntityManager.save(new User({
id: this.idService.genId(), id: this.idService.genId(),
@@ -306,9 +306,9 @@ export class ApPersonService implements OnModuleInit {
} }
}); });
} catch (e) { } catch (e) {
// duplicate key error // duplicate key error
if (isDuplicateKeyValueError(e)) { if (isDuplicateKeyValueError(e)) {
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
const u = await this.usersRepository.findOneBy({ uri: person.id }); const u = await this.usersRepository.findOneBy({ uri: person.id });
if (u == null) throw new Error('already registered'); if (u == null) throw new Error('already registered');
@@ -604,7 +604,10 @@ export class ApPersonService implements OnModuleInit {
const featuredNotes = await Promise.all(items const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
.slice(0, 5) .slice(0, 5)
.map(item => limit(() => this.apNoteService.resolveNote(item, _resolver)))); .map(item => limit(() => this.apNoteService.resolveNote(item, {
resolver: _resolver,
sentFrom: new URL(user.uri),
}))));
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); await transactionalEntityManager.delete(UserNotePining, { userId: user.id });

View File

@@ -47,17 +47,26 @@ export class ChannelEntityService {
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
const hasUnreadNote = meId ? (await this.noteUnreadsRepository.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined; const hasUnreadNote = meId ? await this.noteUnreadsRepository.exist({
where: {
noteChannelId: channel.id,
userId: meId
},
}) : undefined;
const following = meId ? await this.channelFollowingsRepository.findOneBy({ const isFollowing = meId ? await this.channelFollowingsRepository.exist({
followerId: meId, where: {
followeeId: channel.id, followerId: meId,
}) : null; followeeId: channel.id,
},
}) : false;
const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ const isFavorited = meId ? await this.channelFavoritesRepository.exist({
userId: meId, where: {
channelId: channel.id, userId: meId,
}) : null; channelId: channel.id,
},
}) : false;
const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
where: { where: {
@@ -80,8 +89,8 @@ export class ChannelEntityService {
notesCount: channel.notesCount, notesCount: channel.notesCount,
...(me ? { ...(me ? {
isFollowing: following != null, isFollowing,
isFavorited: favorite != null, isFavorited,
hasUnreadNote, hasUnreadNote,
} : {}), } : {}),

View File

@@ -39,7 +39,7 @@ export class ClipEntityService {
description: clip.description, description: clip.description,
isPublic: clip.isPublic, isPublic: clip.isPublic,
favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }),
isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, isFavorited: meId ? await this.clipFavoritesRepository.exist({ where: { clipId: clip.id, userId: meId } }) : undefined,
}); });
} }

View File

@@ -40,7 +40,7 @@ export class FlashEntityService {
summary: flash.summary, summary: flash.summary,
script: flash.script, script: flash.script,
likedCount: flash.likedCount, likedCount: flash.likedCount,
isLiked: meId ? await this.flashLikesRepository.findOneBy({ flashId: flash.id, userId: meId }).then(x => x != null) : undefined, isLiked: meId ? await this.flashLikesRepository.exist({ where: { flashId: flash.id, userId: meId } }) : undefined,
}); });
} }

View File

@@ -46,7 +46,7 @@ export class GalleryPostEntityService {
tags: post.tags.length > 0 ? post.tags : undefined, tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive, isSensitive: post.isSensitive,
likedCount: post.likedCount, likedCount: post.likedCount,
isLiked: meId ? await this.galleryLikesRepository.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined, isLiked: meId ? await this.galleryLikesRepository.exist({ where: { postId: post.id, userId: meId } }) : undefined,
}); });
} }

View File

@@ -106,16 +106,14 @@ export class NoteEntityService implements OnModuleInit {
hide = false; hide = false;
} else { } else {
// フォロワーかどうか // フォロワーかどうか
const following = await this.followingsRepository.findOneBy({ const isFollowing = await this.followingsRepository.exist({
followeeId: packedNote.userId, where: {
followerId: meId, followeeId: packedNote.userId,
followerId: meId,
},
}); });
if (following == null) { hide = !isFollowing;
hide = true;
} else {
hide = false;
}
} }
} }

View File

@@ -97,7 +97,7 @@ export class PageEntityService {
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)), attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)),
likedCount: page.likedCount, likedCount: page.likedCount,
isLiked: meId ? await this.pageLikesRepository.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, isLiked: meId ? await this.pageLikesRepository.exist({ where: { pageId: page.id, userId: meId } }) : undefined,
}); });
} }

View File

@@ -230,12 +230,14 @@ export class UserEntityService implements OnModuleInit {
/* /*
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ const isUnread = (myAntennas.length > 0 ? await this.antennaNotesRepository.exist({
antennaId: In(myAntennas.map(x => x.id)), where: {
read: false, antennaId: In(myAntennas.map(x => x.id)),
}) : null; read: false,
},
}) : false);
return unread != null; return isUnread;
*/ */
return false; // TODO return false; // TODO
} }

View File

@@ -181,14 +181,28 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> { function nothingToDo<T, V = T>(value: T): V {
public cache: Map<string, { date: number; value: T; }>; return value as unknown as V;
}
export class MemoryKVCache<T, V = T> {
public cache: Map<string, { date: number; value: V; }>;
private lifetime: number; private lifetime: number;
private gcIntervalHandle: NodeJS.Timer; private gcIntervalHandle: NodeJS.Timer;
private toMapConverter: (value: T) => V;
private fromMapConverter: (cached: V) => T | undefined;
constructor(lifetime: MemoryKVCache<never>['lifetime']) { constructor(lifetime: MemoryKVCache<never>['lifetime'], options: {
toMapConverter: (value: T) => V;
fromMapConverter: (cached: V) => T | undefined;
} = {
toMapConverter: nothingToDo,
fromMapConverter: nothingToDo,
}) {
this.cache = new Map(); this.cache = new Map();
this.lifetime = lifetime; this.lifetime = lifetime;
this.toMapConverter = options.toMapConverter;
this.fromMapConverter = options.fromMapConverter;
this.gcIntervalHandle = setInterval(() => { this.gcIntervalHandle = setInterval(() => {
this.gc(); this.gc();
@@ -199,7 +213,7 @@ export class MemoryKVCache<T> {
public set(key: string, value: T): void { public set(key: string, value: T): void {
this.cache.set(key, { this.cache.set(key, {
date: Date.now(), date: Date.now(),
value, value: this.toMapConverter(value),
}); });
} }
@@ -211,7 +225,7 @@ export class MemoryKVCache<T> {
this.cache.delete(key); this.cache.delete(key);
return undefined; return undefined;
} }
return cached.value; return this.fromMapConverter(cached.value);
} }
@bindThis @bindThis
@@ -222,9 +236,10 @@ export class MemoryKVCache<T> {
/** /**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
* fetcherの引数はcacheに保存されている値があれば渡されます
*/ */
@bindThis @bindThis
public async fetch(key: string, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { public async fetch(key: string, fetcher: (value: V | undefined) => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cachedValue = this.get(key); const cachedValue = this.get(key);
if (cachedValue !== undefined) { if (cachedValue !== undefined) {
if (validator) { if (validator) {
@@ -239,7 +254,7 @@ export class MemoryKVCache<T> {
} }
// Cache MISS // Cache MISS
const value = await fetcher(); const value = await fetcher(this.cache.get(key)?.value);
this.set(key, value); this.set(key, value);
return value; return value;
} }
@@ -247,9 +262,10 @@ export class MemoryKVCache<T> {
/** /**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
* fetcherの引数はcacheに保存されている値があれば渡されます
*/ */
@bindThis @bindThis
public async fetchMaybe(key: string, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { public async fetchMaybe(key: string, fetcher: (value: V | undefined) => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get(key); const cachedValue = this.get(key);
if (cachedValue !== undefined) { if (cachedValue !== undefined) {
if (validator) { if (validator) {
@@ -264,7 +280,7 @@ export class MemoryKVCache<T> {
} }
// Cache MISS // Cache MISS
const value = await fetcher(); const value = await fetcher(this.cache.get(key)?.value);
if (value !== undefined) { if (value !== undefined) {
this.set(key, value); this.set(key, value);
} }

View File

@@ -1,7 +1,6 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { id } from '../id.js'; import { id } from '../id.js';
import { User } from './User.js'; import { User } from './User.js';
import type { Clip } from './Clip.js';
@Entity() @Entity()
export class Meta { export class Meta {
@@ -126,6 +125,11 @@ export class Meta {
}) })
public cacheRemoteFiles: boolean; public cacheRemoteFiles: boolean;
@Column('boolean', {
default: true,
})
public cacheRemoteSensitiveFiles: boolean;
@Column({ @Column({
...id(), ...id(),
nullable: true, nullable: true,

View File

@@ -12,6 +12,7 @@ import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js'; import type { DbUserDeleteJobData } from '../types.js';
import { SearchService } from "@/core/SearchService.js";
@Injectable() @Injectable()
export class DeleteAccountProcessorService { export class DeleteAccountProcessorService {
@@ -36,6 +37,7 @@ export class DeleteAccountProcessorService {
private driveService: DriveService, private driveService: DriveService,
private emailService: EmailService, private emailService: EmailService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private searchService: SearchService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); this.logger = this.queueLoggerService.logger.createSubLogger('delete-account');
} }
@@ -71,6 +73,10 @@ export class DeleteAccountProcessorService {
cursor = notes[notes.length - 1].id; cursor = notes[notes.length - 1].id;
await this.notesRepository.delete(notes.map(note => note.id)); await this.notesRepository.delete(notes.map(note => note.id));
for (const note of notes) {
await this.searchService.unindexNote(note);
}
} }
this.logger.succ('All of notes deleted'); this.logger.succ('All of notes deleted');

View File

@@ -128,12 +128,12 @@ export class SignupApiService {
} }
if (instance.emailRequiredForSignup) { if (instance.emailRequiredForSignup) {
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) {
throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); throw new FastifyReplyError(400, 'DUPLICATED_USERNAME');
} }
// Check deleted username duplication // Check deleted username duplication
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) {
throw new FastifyReplyError(400, 'USED_USERNAME'); throw new FastifyReplyError(400, 'USED_USERNAME');
} }

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@@ -20,6 +19,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
cacheRemoteSensitiveFiles: {
type: 'boolean',
optional: false, nullable: false,
},
emailRequiredForSignup: { emailRequiredForSignup: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@@ -332,6 +335,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
enableServiceWorker: instance.enableServiceWorker, enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.deeplAuthKey != null,
cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
pinnedUsers: instance.pinnedUsers, pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags, hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,

View File

@@ -50,9 +50,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw e; throw e;
}); });
const exist = await this.promoNotesRepository.findOneBy({ noteId: note.id }); const exist = await this.promoNotesRepository.exist({ where: { noteId: note.id } });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyPromoted); throw new ApiError(meta.errors.alreadyPromoted);
} }

View File

@@ -69,8 +69,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps) => { super(meta, paramDef, async (ps) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); const roleExist = await this.rolesRepository.exist({ where: { id: ps.roleId } });
if (role == null) { if (!roleExist) {
throw new ApiError(meta.errors.noSuchRole); throw new ApiError(meta.errors.noSuchRole);
} }

View File

@@ -43,6 +43,7 @@ export const paramDef = {
defaultLightTheme: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true },
cacheRemoteFiles: { type: 'boolean' }, cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' },
enableHcaptcha: { type: 'boolean' }, enableHcaptcha: { type: 'boolean' },
hcaptchaSiteKey: { type: 'string', nullable: true }, hcaptchaSiteKey: { type: 'string', nullable: true },
@@ -193,6 +194,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.cacheRemoteFiles = ps.cacheRemoteFiles; set.cacheRemoteFiles = ps.cacheRemoteFiles;
} }
if (ps.cacheRemoteSensitiveFiles !== undefined) {
set.cacheRemoteSensitiveFiles = ps.cacheRemoteSensitiveFiles;
}
if (ps.emailRequiredForSignup !== undefined) { if (ps.emailRequiredForSignup !== undefined) {
set.emailRequiredForSignup = ps.emailRequiredForSignup; set.emailRequiredForSignup = ps.emailRequiredForSignup;
} }

View File

@@ -58,12 +58,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const accessToken = secureRndstr(32); const accessToken = secureRndstr(32);
// Fetch exist access token // Fetch exist access token
const exist = await this.accessTokensRepository.findOneBy({ const exist = await this.accessTokensRepository.exist({
appId: session.appId, where: {
userId: me.id, appId: session.appId,
userId: me.id,
},
}); });
if (exist == null) { if (!exist) {
const app = await this.appsRepository.findOneByOrFail({ id: session.appId }); const app = await this.appsRepository.findOneByOrFail({ id: session.appId });
// Generate Hash // Generate Hash

View File

@@ -84,12 +84,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
// Check if already blocking // Check if already blocking
const exist = await this.blockingsRepository.findOneBy({ const exist = await this.blockingsRepository.exist({
blockerId: blocker.id, where: {
blockeeId: blockee.id, blockerId: blocker.id,
blockeeId: blockee.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyBlocking); throw new ApiError(meta.errors.alreadyBlocking);
} }

View File

@@ -84,12 +84,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
// Check not blocking // Check not blocking
const exist = await this.blockingsRepository.findOneBy({ const exist = await this.blockingsRepository.exist({
blockerId: blocker.id, where: {
blockeeId: blockee.id, blockerId: blocker.id,
blockeeId: blockee.id,
}
}); });
if (exist == null) { if (!exist) {
throw new ApiError(meta.errors.notBlocking); throw new ApiError(meta.errors.notBlocking);
} }

View File

@@ -87,12 +87,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw e; throw e;
}); });
const exist = await this.clipNotesRepository.findOneBy({ const exist = await this.clipNotesRepository.exist({
noteId: note.id, where: {
clipId: clip.id, noteId: note.id,
clipId: clip.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyClipped); throw new ApiError(meta.errors.alreadyClipped);
} }

View File

@@ -58,12 +58,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchClip); throw new ApiError(meta.errors.noSuchClip);
} }
const exist = await this.clipFavoritesRepository.findOneBy({ const exist = await this.clipFavoritesRepository.exist({
clipId: clip.id, where: {
userId: me.id, clipId: clip.id,
userId: me.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyFavorited); throw new ApiError(meta.errors.alreadyFavorited);
} }

View File

@@ -34,12 +34,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ const exist = await this.driveFilesRepository.exist({
md5: ps.md5, where: {
userId: me.id, md5: ps.md5,
userId: me.id,
},
}); });
return file != null; return exist;
}); });
} }
} }

View File

@@ -66,12 +66,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
// if already liked // if already liked
const exist = await this.flashLikesRepository.findOneBy({ const exist = await this.flashLikesRepository.exist({
flashId: flash.id, where: {
userId: me.id, flashId: flash.id,
userId: me.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyLiked); throw new ApiError(meta.errors.alreadyLiked);
} }

View File

@@ -99,12 +99,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
// Check if already following // Check if already following
const exist = await this.followingsRepository.findOneBy({ const exist = await this.followingsRepository.exist({
followerId: follower.id, where: {
followeeId: followee.id, followerId: follower.id,
followeeId: followee.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyFollowing); throw new ApiError(meta.errors.alreadyFollowing);
} }

View File

@@ -84,12 +84,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
// Check not following // Check not following
const exist = await this.followingsRepository.findOneBy({ const exist = await this.followingsRepository.exist({
followerId: follower.id, where: {
followeeId: followee.id, followerId: follower.id,
followeeId: followee.id,
},
}); });
if (exist == null) { if (!exist) {
throw new ApiError(meta.errors.notFollowing); throw new ApiError(meta.errors.notFollowing);
} }

View File

@@ -66,12 +66,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
// if already liked // if already liked
const exist = await this.galleryLikesRepository.findOneBy({ const exist = await this.galleryLikesRepository.exist({
postId: post.id, where: {
userId: me.id, postId: post.id,
userId: me.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyLiked); throw new ApiError(meta.errors.alreadyLiked);
} }

View File

@@ -66,8 +66,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private downloadService: DownloadService, private downloadService: DownloadService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const users = await this.usersRepository.findOneBy({ id: me.id }); const userExist = await this.usersRepository.exist({ where: { id: me.id } });
if (users === null) throw new ApiError(meta.errors.noSuchUser); if (!userExist) throw new ApiError(meta.errors.noSuchUser);
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file === null) throw new ApiError(meta.errors.noSuchFile); if (file === null) throw new ApiError(meta.errors.noSuchFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile);

View File

@@ -47,19 +47,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Check if announcement exists // Check if announcement exists
const announcement = await this.announcementsRepository.findOneBy({ id: ps.announcementId }); const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } });
if (announcement == null) { if (!announcementExist) {
throw new ApiError(meta.errors.noSuchAnnouncement); throw new ApiError(meta.errors.noSuchAnnouncement);
} }
// Check if already read // Check if already read
const read = await this.announcementReadsRepository.findOneBy({ const alreadyRead = await this.announcementReadsRepository.exist({
announcementId: ps.announcementId, where: {
userId: me.id, announcementId: ps.announcementId,
userId: me.id,
},
}); });
if (read != null) { if (alreadyRead) {
return; return;
} }

View File

@@ -28,9 +28,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const token = await this.accessTokensRepository.findOneBy({ id: ps.tokenId }); const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } });
if (token) { if (tokenExist) {
await this.accessTokensRepository.delete({ await this.accessTokensRepository.delete({
id: ps.tokenId, id: ps.tokenId,
userId: me.id, userId: me.id,

View File

@@ -83,6 +83,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
cacheRemoteSensitiveFiles: {
type: 'boolean',
optional: false, nullable: false,
},
emailRequiredForSignup: { emailRequiredForSignup: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@@ -329,6 +333,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
...(ps.detail ? { ...(ps.detail ? {
cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
requireSetup: (await this.usersRepository.countBy({ requireSetup: (await this.usersRepository.countBy({
host: IsNull(), host: IsNull(),
})) === 0, })) === 0,

View File

@@ -79,12 +79,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
// Check if already muting // Check if already muting
const exist = await this.mutingsRepository.findOneBy({ const exist = await this.mutingsRepository.exist({
muterId: muter.id, where: {
muteeId: mutee.id, muterId: muter.id,
muteeId: mutee.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyMuting); throw new ApiError(meta.errors.alreadyMuting);
} }

View File

@@ -217,11 +217,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
// Check blocking // Check blocking
if (renote.userId !== me.id) { if (renote.userId !== me.id) {
const block = await this.blockingsRepository.findOneBy({ const blockExist = await this.blockingsRepository.exist({
blockerId: renote.userId, where: {
blockeeId: me.id, blockerId: renote.userId,
blockeeId: me.id,
},
}); });
if (block) { if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked); throw new ApiError(meta.errors.youHaveBeenBlocked);
} }
} }
@@ -240,11 +242,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
// Check blocking // Check blocking
if (reply.userId !== me.id) { if (reply.userId !== me.id) {
const block = await this.blockingsRepository.findOneBy({ const blockExist = await this.blockingsRepository.exist({
blockerId: reply.userId, where: {
blockeeId: me.id, blockerId: reply.userId,
blockeeId: me.id,
},
}); });
if (block) { if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked); throw new ApiError(meta.errors.youHaveBeenBlocked);
} }
} }

View File

@@ -63,12 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
// if already favorited // if already favorited
const exist = await this.noteFavoritesRepository.findOneBy({ const exist = await this.noteFavoritesRepository.exist({
noteId: note.id, where: {
userId: me.id, noteId: note.id,
userId: me.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyFavorited); throw new ApiError(meta.errors.alreadyFavorited);
} }

View File

@@ -66,12 +66,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
// if already liked // if already liked
const exist = await this.pageLikesRepository.findOneBy({ const exist = await this.pageLikesRepository.exist({
pageId: page.id, where: {
userId: me.id, pageId: page.id,
userId: me.id,
},
}); });
if (exist != null) { if (exist) {
throw new ApiError(meta.errors.alreadyLiked); throw new ApiError(meta.errors.alreadyLiked);
} }

View File

@@ -44,12 +44,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw err; throw err;
}); });
const exist = await this.promoReadsRepository.findOneBy({ const exist = await this.promoReadsRepository.exist({
noteId: note.id, where: {
userId: me.id, noteId: note.id,
userId: me.id,
},
}); });
if (exist != null) { if (exist) {
return; return;
} }

View File

@@ -97,11 +97,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (me == null) { if (me == null) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) { } else if (me.id !== user.id) {
const following = await this.followingsRepository.findOneBy({ const isFollowing = await this.followingsRepository.exist({
followeeId: user.id, where: {
followerId: me.id, followeeId: user.id,
followerId: me.id,
},
}); });
if (following == null) { if (!isFollowing) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} }
} }

View File

@@ -97,11 +97,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (me == null) { if (me == null) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) { } else if (me.id !== user.id) {
const following = await this.followingsRepository.findOneBy({ const isFollowing = await this.followingsRepository.exist({
followeeId: user.id, where: {
followerId: me.id, followeeId: user.id,
followerId: me.id,
},
}); });
if (following == null) { if (!isFollowing) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} }
} }

View File

@@ -84,11 +84,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const list = await this.userListsRepository.findOneBy({ const listExist = await this.userListsRepository.exist({
id: ps.listId, where: {
isPublic: true, id: ps.listId,
isPublic: true,
},
}); });
if (list === null) throw new ApiError(meta.errors.noSuchList); if (!listExist) throw new ApiError(meta.errors.noSuchList);
const currentCount = await this.userListsRepository.countBy({ const currentCount = await this.userListsRepository.countBy({
userId: me.id, userId: me.id,
}); });
@@ -114,18 +116,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}); });
if (currentUser.id !== me.id) { if (currentUser.id !== me.id) {
const block = await this.blockingsRepository.findOneBy({ const blockExist = await this.blockingsRepository.exist({
blockerId: currentUser.id, where: {
blockeeId: me.id, blockerId: currentUser.id,
blockeeId: me.id,
},
}); });
if (block) { if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked); throw new ApiError(meta.errors.youHaveBeenBlocked);
} }
} }
const exist = await this.userListJoiningsRepository.findOneBy({ const exist = await this.userListJoiningsRepository.exist({
userListId: userList.id, where: {
userId: currentUser.id, userListId: userList.id,
userId: currentUser.id,
},
}); });
if (exist) { if (exist) {

View File

@@ -41,21 +41,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private idService: IdService, private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userList = await this.userListsRepository.findOneBy({ const userListExist = await this.userListsRepository.exist({
id: ps.listId, where: {
isPublic: true, id: ps.listId,
isPublic: true,
},
}); });
if (userList === null) { if (!userListExist) {
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }
const exist = await this.userListFavoritesRepository.findOneBy({ const exist = await this.userListFavoritesRepository.exist({
userId: me.id, where: {
userListId: ps.listId, userId: me.id,
userListId: ps.listId,
},
}); });
if (exist !== null) { if (exist) {
throw new ApiError(meta.errors.alreadyFavorited); throw new ApiError(meta.errors.alreadyFavorited);
} }

View File

@@ -100,18 +100,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
// Check blocking // Check blocking
if (user.id !== me.id) { if (user.id !== me.id) {
const block = await this.blockingsRepository.findOneBy({ const blockExist = await this.blockingsRepository.exist({
blockerId: user.id, where: {
blockeeId: me.id, blockerId: user.id,
blockeeId: me.id,
},
}); });
if (block) { if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked); throw new ApiError(meta.errors.youHaveBeenBlocked);
} }
} }
const exist = await this.userListJoiningsRepository.findOneBy({ const exist = await this.userListJoiningsRepository.exist({
userListId: userList.id, where: {
userId: user.id, userListId: userList.id,
userId: user.id,
},
}); });
if (exist) { if (exist) {

View File

@@ -69,10 +69,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
userListId: ps.listId, userListId: ps.listId,
}); });
if (me !== null) { if (me !== null) {
additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({ additionalProperties.isLiked = await this.userListFavoritesRepository.exist({
userId: me.id, where: {
userListId: ps.listId, userId: me.id,
}) !== null); userListId: ps.listId,
},
});
} else { } else {
additionalProperties.isLiked = false; additionalProperties.isLiked = false;
} }

View File

@@ -39,12 +39,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userListFavoritesRepository: UserListFavoritesRepository, private userListFavoritesRepository: UserListFavoritesRepository,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userList = await this.userListsRepository.findOneBy({ const userListExist = await this.userListsRepository.exist({
id: ps.listId, where: {
isPublic: true, id: ps.listId,
isPublic: true,
},
}); });
if (userList === null) { if (!userListExist) {
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }

View File

@@ -34,11 +34,13 @@ class UserListChannel extends Channel {
this.listId = params.listId as string; this.listId = params.listId as string;
// Check existence and owner // Check existence and owner
const list = await this.userListsRepository.findOneBy({ const listExist = await this.userListsRepository.exist({
id: this.listId, where: {
userId: this.user!.id, id: this.listId,
userId: this.user!.id,
},
}); });
if (!list) return; if (!listExist) return;
// Subscribe stream // Subscribe stream
this.subscriber.on(`userListStream:${this.listId}`, this.send); this.subscriber.on(`userListStream:${this.listId}`, this.send);

View File

@@ -18,7 +18,8 @@ type MockResponse = {
}; };
export class MockResolver extends Resolver { export class MockResolver extends Resolver {
private _rs = new Map<string, MockResponse>(); #responseMap = new Map<string, MockResponse>();
#remoteGetTrials: string[] = [];
constructor(loggerService: LoggerService) { constructor(loggerService: LoggerService) {
super( super(
@@ -38,18 +39,28 @@ export class MockResolver extends Resolver {
); );
} }
public async _register(uri: string, content: string | Record<string, any>, type = 'application/activity+json') { public register(uri: string, content: string | Record<string, any>, type = 'application/activity+json'): void {
this._rs.set(uri, { this.#responseMap.set(uri, {
type, type,
content: typeof content === 'string' ? content : JSON.stringify(content), content: typeof content === 'string' ? content : JSON.stringify(content),
}); });
} }
public clear(): void {
this.#responseMap.clear();
this.#remoteGetTrials.length = 0;
}
public remoteGetTrials(): string[] {
return this.#remoteGetTrials;
}
@bindThis @bindThis
public async resolve(value: string | IObject): Promise<IObject> { public async resolve(value: string | IObject): Promise<IObject> {
if (typeof value !== 'string') return value; if (typeof value !== 'string') return value;
const r = this._rs.get(value); this.#remoteGetTrials.push(value);
const r = this.#responseMap.get(value);
if (!r) { if (!r) {
throw new Error('Not registed for mock'); throw new Error('Not registed for mock');

View File

@@ -4,6 +4,7 @@ import * as assert from 'assert';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@@ -11,16 +12,22 @@ import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type { IActor } from '@/core/activitypub/type.js'; import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js';
import { Note } from '@/models/index.js'; import { Meta, Note } from '@/models/index.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
import { MetaService } from '@/core/MetaService.js';
import type { RemoteUser } from '@/models/entities/User.js';
import { MockResolver } from '../misc/mock-resolver.js'; import { MockResolver } from '../misc/mock-resolver.js';
const host = 'https://host1.test'; const host = 'https://host1.test';
function createRandomActor(): IActor & { id: string } { type NonTransientIActor = IActor & { id: string };
type NonTransientIPost = IPost & { id: string };
function createRandomActor({ actorHost = host } = {}): NonTransientIActor {
const preferredUsername = secureRndstr(8); const preferredUsername = secureRndstr(8);
const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; const actorId = `${actorHost}/users/${preferredUsername.toLowerCase()}`;
return { return {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
@@ -32,16 +39,75 @@ function createRandomActor(): IActor & { id: string } {
}; };
} }
function createRandomNote(actor: NonTransientIActor): NonTransientIPost {
const id = secureRndstr(8);
const noteId = `${new URL(actor.id).origin}/notes/${id}`;
return {
id: noteId,
type: 'Note',
attributedTo: actor.id,
content: 'test test foo',
};
}
function createRandomNotes(actor: NonTransientIActor, length: number): NonTransientIPost[] {
return new Array(length).fill(null).map(() => createRandomNote(actor));
}
function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): ICollection {
const items = createRandomNotes(actor, length);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: actor.outbox as string,
totalItems: items.length,
items,
};
}
async function createRandomRemoteUser(
resolver: MockResolver,
personService: ApPersonService,
): Promise<RemoteUser> {
const actor = createRandomActor();
resolver.register(actor.id, actor);
return await personService.createPerson(actor.id, resolver);
}
describe('ActivityPub', () => { describe('ActivityPub', () => {
let imageService: ApImageService;
let noteService: ApNoteService; let noteService: ApNoteService;
let personService: ApPersonService; let personService: ApPersonService;
let rendererService: ApRendererService; let rendererService: ApRendererService;
let resolver: MockResolver; let resolver: MockResolver;
beforeEach(async () => { const metaInitial = {
cacheRemoteFiles: true,
cacheRemoteSensitiveFiles: true,
blockedHosts: [] as string[],
sensitiveWords: [] as string[],
} as Meta;
let meta = metaInitial;
beforeAll(async () => {
const app = await Test.createTestingModule({ const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule], imports: [GlobalModule, CoreModule],
}).compile(); })
.overrideProvider(DownloadService).useValue({
async downloadUrl(): Promise<{ filename: string }> {
return {
filename: 'dummy.tmp',
};
},
})
.overrideProvider(MetaService).useValue({
async fetch(): Promise<Meta> {
return meta;
},
}).compile();
await app.init(); await app.init();
app.enableShutdownHooks(); app.enableShutdownHooks();
@@ -49,11 +115,16 @@ describe('ActivityPub', () => {
noteService = app.get<ApNoteService>(ApNoteService); noteService = app.get<ApNoteService>(ApNoteService);
personService = app.get<ApPersonService>(ApPersonService); personService = app.get<ApPersonService>(ApPersonService);
rendererService = app.get<ApRendererService>(ApRendererService); rendererService = app.get<ApRendererService>(ApRendererService);
imageService = app.get<ApImageService>(ApImageService);
resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService)); resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService));
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService); const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService);
jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => {})); jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => { }));
});
beforeEach(() => {
resolver.clear();
}); });
describe('Parse minimum object', () => { describe('Parse minimum object', () => {
@@ -69,7 +140,7 @@ describe('ActivityPub', () => {
}; };
test('Minimum Actor', async () => { test('Minimum Actor', async () => {
resolver._register(actor.id, actor); resolver.register(actor.id, actor);
const user = await personService.createPerson(actor.id, resolver); const user = await personService.createPerson(actor.id, resolver);
@@ -79,8 +150,8 @@ describe('ActivityPub', () => {
}); });
test('Minimum Note', async () => { test('Minimum Note', async () => {
resolver._register(actor.id, actor); resolver.register(actor.id, actor);
resolver._register(post.id, post); resolver.register(post.id, post);
const note = await noteService.createNote(post.id, resolver, true); const note = await noteService.createNote(post.id, resolver, true);
@@ -97,7 +168,7 @@ describe('ActivityPub', () => {
name: secureRndstr(129), name: secureRndstr(129),
}; };
resolver._register(actor.id, actor); resolver.register(actor.id, actor);
const user = await personService.createPerson(actor.id, resolver); const user = await personService.createPerson(actor.id, resolver);
@@ -110,7 +181,7 @@ describe('ActivityPub', () => {
name: '', name: '',
}; };
resolver._register(actor.id, actor); resolver.register(actor.id, actor);
const user = await personService.createPerson(actor.id, resolver); const user = await personService.createPerson(actor.id, resolver);
@@ -126,4 +197,149 @@ describe('ActivityPub', () => {
} as Note); } as Note);
}); });
}); });
describe('Featured', () => {
test('Fetch featured notes from IActor', async () => {
const actor = createRandomActor();
actor.featured = `${actor.id}/collections/featured`;
const featured = createRandomFeaturedCollection(actor, 5);
resolver.register(actor.id, actor);
resolver.register(actor.featured, featured);
await personService.createPerson(actor.id, resolver);
// All notes in `featured` are same-origin, no need to fetch notes again
assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]);
// Created notes without resolving anything
for (const item of featured.items as IPost[]) {
const note = await noteService.fetchNote(item);
assert.ok(note);
assert.strictEqual(note.text, 'test test foo');
assert.strictEqual(note.uri, item.id);
}
});
test('Fetch featured notes from IActor pointing to another remote server', async () => {
const actor1 = createRandomActor();
actor1.featured = `${actor1.id}/collections/featured`;
const actor2 = createRandomActor({ actorHost: 'https://host2.test' });
const actor2Note = createRandomNote(actor2);
const featured = createRandomFeaturedCollection(actor1, 0);
(featured.items as IPost[]).push({
...actor2Note,
content: 'test test bar', // fraud!
});
resolver.register(actor1.id, actor1);
resolver.register(actor1.featured, featured);
resolver.register(actor2.id, actor2);
resolver.register(actor2Note.id, actor2Note);
await personService.createPerson(actor1.id, resolver);
// actor2Note is from a different server and needs to be fetched again
assert.deepStrictEqual(
resolver.remoteGetTrials(),
[actor1.id, actor1.featured, actor2Note.id, actor2.id],
);
const note = await noteService.fetchNote(actor2Note.id);
assert.ok(note);
// Reflects the original content instead of the fraud
assert.strictEqual(note.text, 'test test foo');
assert.strictEqual(note.uri, actor2Note.id);
});
});
describe('Images', () => {
test('Create images', async () => {
const imageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
imageObject,
);
assert.ok(!driveFile.isLink);
const sensitiveImageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
sensitiveImageObject,
);
assert.ok(!sensitiveDriveFile.isLink);
});
test('cacheRemoteFiles=false disables caching', async () => {
meta = { ...metaInitial, cacheRemoteFiles: false };
const imageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
imageObject,
);
assert.ok(driveFile.isLink);
const sensitiveImageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
sensitiveImageObject,
);
assert.ok(sensitiveDriveFile.isLink);
});
test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => {
meta = { ...metaInitial, cacheRemoteSensitiveFiles: false };
const imageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
imageObject,
);
assert.ok(!driveFile.isLink);
const sensitiveImageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
sensitiveImageObject,
);
assert.ok(sensitiveDriveFile.isLink);
});
});
}); });

View File

@@ -1,7 +1,10 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import micromatch from 'micromatch'; import micromatch from 'micromatch';
import main from './main'; import main from './main.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
interface Stats { interface Stats {
readonly modules: readonly { readonly modules: readonly {
@@ -13,8 +16,8 @@ interface Stats {
}[]; }[];
} }
fs.readFile( await fs.readFile(
path.resolve(__dirname, '../storybook-static/preview-stats.json') new URL('../storybook-static/preview-stats.json', import.meta.url)
).then((buffer) => { ).then((buffer) => {
const stats: Stats = JSON.parse(buffer.toString()); const stats: Stats = JSON.parse(buffer.toString());
const keys = new Set(stats.modules.map((stat) => stat.id)); const keys = new Set(stats.modules.map((stat) => stat.id));

View File

@@ -96,7 +96,7 @@ declare global {
} }
} }
function toStories(component: string): string { function toStories(component: string): Promise<string> {
const msw = `${component.slice(0, -'.vue'.length)}.msw`; const msw = `${component.slice(0, -'.vue'.length)}.msw`;
const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`;
const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`;
@@ -394,18 +394,20 @@ function toStories(component: string): string {
} }
// glob('src/{components,pages,ui,widgets}/**/*.vue') // glob('src/{components,pages,ui,widgets}/**/*.vue')
Promise.all([ (async () => {
glob('src/components/global/*.vue'), const globs = await Promise.all([
glob('src/components/Mk{A,B}*.vue'), glob('src/components/global/*.vue'),
glob('src/components/MkDigitalClock.vue'), glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkUserSetupDialog.vue'), glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.*.vue'), glob('src/components/MkUserSetupDialog.vue'),
glob('src/pages/user/home.vue'), glob('src/components/MkUserSetupDialog.*.vue'),
]) glob('src/pages/user/home.vue'),
.then((globs) => globs.flat()) ]);
.then((components) => Promise.all(components.map((component) => { const components = globs.flat();
await Promise.all(components.map(async (component) => {
const stories = component.replace(/\.vue$/, '.stories.ts'); const stories = component.replace(/\.vue$/, '.stories.ts');
return writeFile(stories, toStories(component)); await writeFile(stories, await toStories(component));
}))); }))
})();

View File

@@ -1,7 +1,11 @@
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { StorybookConfig } from '@storybook/vue3-vite'; import type { StorybookConfig } from '@storybook/vue3-vite';
import { type Plugin, mergeConfig } from 'vite'; import { type Plugin, mergeConfig } from 'vite';
import turbosnap from 'vite-plugin-turbosnap'; import turbosnap from 'vite-plugin-turbosnap';
const dirname = fileURLToPath(new URL('.', import.meta.url));
const config = { const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [ addons: [
@@ -9,7 +13,7 @@ const config = {
'@storybook/addon-interactions', '@storybook/addon-interactions',
'@storybook/addon-links', '@storybook/addon-links',
'@storybook/addon-storysource', '@storybook/addon-storysource',
resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), resolve(dirname, '../node_modules/storybook-addon-misskey-theme'),
], ],
framework: { framework: {
name: '@storybook/vue3-vite', name: '@storybook/vue3-vite',
@@ -28,7 +32,8 @@ const config = {
} }
return mergeConfig(config, { return mergeConfig(config, {
plugins: [ plugins: [
turbosnap({ // XXX: https://github.com/IanVS/vite-plugin-turbosnap/issues/8
(turbosnap as any as typeof turbosnap['default'])({
rootDir: config.root ?? process.cwd(), rootDir: config.root ?? process.cwd(),
}), }),
], ],

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -1,9 +1,8 @@
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import locales from '../../../locales/index.js';
import * as locales from '../../../locales';
writeFile( await writeFile(
resolve(__dirname, 'locale.ts'), new URL('locale.ts', import.meta.url),
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
'utf8', 'utf8',
) )

View File

@@ -1,6 +1,5 @@
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import JSON5 from 'json5';
import * as JSON5 from 'json5';
const keys = [ const keys = [
'_dark', '_dark',
@@ -26,9 +25,9 @@ const keys = [
'd-u0', 'd-u0',
] ]
Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
writeFile( writeFile(
resolve(__dirname, './themes.ts'), new URL('./themes.ts', import.meta.url),
`export default ${JSON.stringify( `export default ${JSON.stringify(
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
undefined, undefined,

View File

@@ -3,10 +3,10 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3'; import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic'; import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon'; import { initialize, mswDecorator } from 'msw-storybook-addon';
import { userDetailed } from './fakes'; import { userDetailed } from './fakes.js';
import locale from './locale'; import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks'; import { commonHandlers, onUnhandledRequest } from './mocks.js';
import themes from './themes'; import themes from './themes.js';
import '../src/style.scss'; import '../src/style.scss';
const appInitialized = Symbol(); const appInitialized = Symbol();

View File

@@ -1,5 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2022",
"module": "Node16",
"strict": true, "strict": true,
"allowUnusedLabels": false, "allowUnusedLabels": false,
"allowUnreachableCode": false, "allowUnreachableCode": false,

View File

@@ -4,3 +4,4 @@ import { Cache } from '@/scripts/cache';
export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity); export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity);
export const rolesCache = new Cache(Infinity); export const rolesCache = new Cache(Infinity);
export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity); export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity);
export const antennasCache = new Cache<misskey.entities.Antenna[]>(Infinity);

View File

@@ -1,24 +1,29 @@
<template> <template>
<div> <div>
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> <div v-for="user in users.slice(0, limit)" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
<MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/> <MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/>
</div> </div>
<div v-if="users.length > limit" style="display: inline-block;">...</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import { UserLite } from 'misskey-js/built/entities';
const props = defineProps<{ const props = withDefaults(defineProps<{
userIds: string[]; userIds: string[];
}>(); limit?: number;
}>(), {
limit: Infinity,
});
const users = ref([]); const users = ref<UserLite[]>([]);
onMounted(async () => { onMounted(async () => {
users.value = await os.api('users/show', { users.value = await os.api('users/show', {
userIds: props.userIds, userIds: props.userIds,
}); }) as unknown as UserLite[];
}); });
</script> </script>

View File

@@ -113,7 +113,7 @@ onMounted(() => {
right: 0, right: 0,
}, },
imageClickAction: 'close', imageClickAction: 'close',
tapAction: 'toggle-controls', tapAction: 'close',
bgOpacity: 1, bgOpacity: 1,
showAnimationDuration: 100, showAnimationDuration: 100,
hideAnimationDuration: 100, hideAnimationDuration: 100,

View File

@@ -29,11 +29,11 @@ export const Default = {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link'); const a = canvas.getByRole<HTMLAnchorElement>('link');
await expect(a.href).toMatch(/^https?:\/\/.*#test$/); await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
await userEvent.click(a, { button: 2 }); await userEvent.pointer({ keys: '[MouseRight]', target: a });
await tick(); await tick();
const menu = canvas.getByRole('menu'); const menu = canvas.getByRole('menu');
await expect(menu).toBeInTheDocument(); await expect(menu).toBeInTheDocument();
await userEvent.click(a, { button: 0 }); await userEvent.click(a);
a.blur(); a.blur();
await tick(); await tick();
await expect(menu).not.toBeInTheDocument(); await expect(menu).not.toBeInTheDocument();

View File

@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/testing-library'; import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { i18n } from '@/i18n';
import MkAd from './MkAd.vue'; import MkAd from './MkAd.vue';
import { i18n } from '@/i18n';
import { tick } from '@/scripts/test-utils';
const common = { const common = {
render(args) { render(args) {
return { return {
@@ -36,6 +37,8 @@ const common = {
const i = buttons[0]; const i = buttons[0];
await expect(i).toBeInTheDocument(); await expect(i).toBeInTheDocument();
await userEvent.click(i); await userEvent.click(i);
await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back));
await tick();
await expect(a).not.toBeInTheDocument(); await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument(); await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button'); buttons = canvas.getAllByRole<HTMLButtonElement>('button');
@@ -49,6 +52,8 @@ const common = {
await expect(back).toBeInTheDocument(); await expect(back).toBeInTheDocument();
await expect(back).toHaveTextContent(i18n.ts._ad.back); await expect(back).toHaveTextContent(i18n.ts._ad.back);
await userEvent.click(back); await userEvent.click(back);
await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy());
await tick();
if (reduce) { if (reduce) {
await expect(reduce).not.toBeInTheDocument(); await expect(reduce).not.toBeInTheDocument();
} }

View File

@@ -37,6 +37,13 @@
<template #label>{{ i18n.ts.cacheRemoteFiles }}</template> <template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
</MkSwitch> </MkSwitch>
<template v-if="cacheRemoteFiles">
<MkSwitch v-model="cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
</template>
</div> </div>
</FormSection> </FormSection>
@@ -104,7 +111,6 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkColorInput from '@/components/MkColorInput.vue';
let name: string | null = $ref(null); let name: string | null = $ref(null);
let description: string | null = $ref(null); let description: string | null = $ref(null);
@@ -112,13 +118,14 @@ let maintainerName: string | null = $ref(null);
let maintainerEmail: string | null = $ref(null); let maintainerEmail: string | null = $ref(null);
let pinnedUsers: string = $ref(''); let pinnedUsers: string = $ref('');
let cacheRemoteFiles: boolean = $ref(false); let cacheRemoteFiles: boolean = $ref(false);
let cacheRemoteSensitiveFiles: boolean = $ref(false);
let enableServiceWorker: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false);
let swPublicKey: any = $ref(null); let swPublicKey: any = $ref(null);
let swPrivateKey: any = $ref(null); let swPrivateKey: any = $ref(null);
let deeplAuthKey: string = $ref(''); let deeplAuthKey: string = $ref('');
let deeplIsPro: boolean = $ref(false); let deeplIsPro: boolean = $ref(false);
async function init() { async function init(): Promise<void> {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
name = meta.name; name = meta.name;
description = meta.description; description = meta.description;
@@ -126,6 +133,7 @@ async function init() {
maintainerEmail = meta.maintainerEmail; maintainerEmail = meta.maintainerEmail;
pinnedUsers = meta.pinnedUsers.join('\n'); pinnedUsers = meta.pinnedUsers.join('\n');
cacheRemoteFiles = meta.cacheRemoteFiles; cacheRemoteFiles = meta.cacheRemoteFiles;
cacheRemoteSensitiveFiles = meta.cacheRemoteSensitiveFiles;
enableServiceWorker = meta.enableServiceWorker; enableServiceWorker = meta.enableServiceWorker;
swPublicKey = meta.swPublickey; swPublicKey = meta.swPublickey;
swPrivateKey = meta.swPrivateKey; swPrivateKey = meta.swPrivateKey;
@@ -133,7 +141,7 @@ async function init() {
deeplIsPro = meta.deeplIsPro; deeplIsPro = meta.deeplIsPro;
} }
function save() { function save(): void {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
name, name,
description, description,
@@ -141,6 +149,7 @@ function save() {
maintainerEmail, maintainerEmail,
pinnedUsers: pinnedUsers.split('\n'), pinnedUsers: pinnedUsers.split('\n'),
cacheRemoteFiles, cacheRemoteFiles,
cacheRemoteSensitiveFiles,
enableServiceWorker, enableServiceWorker,
swPublicKey, swPublicKey,
swPrivateKey, swPrivateKey,

View File

@@ -87,7 +87,7 @@ const props = defineProps<{
channelId: string; channelId: string;
}>(); }>();
let tab = $ref('timeline'); let tab = $ref('overview');
let channel = $ref(null); let channel = $ref(null);
let favorited = $ref(false); let favorited = $ref(false);
let searchQuery = $ref(''); let searchQuery = $ref('');
@@ -107,6 +107,9 @@ watch(() => props.channelId, async () => {
channelId: props.channelId, channelId: props.channelId,
}); });
favorited = channel.isFavorited; favorited = channel.isFavorited;
if (favorited || channel.isFollowing) {
tab = 'timeline';
}
}, { immediate: true }); }, { immediate: true });
function edit() { function edit() {

View File

@@ -18,7 +18,7 @@
<MkButton inline @click="setTagBulk">Set tag</MkButton> <MkButton inline @click="setTagBulk">Set tag</MkButton>
<MkButton inline @click="addTagBulk">Add tag</MkButton> <MkButton inline @click="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton> <MkButton inline @click="removeTagBulk">Remove tag</MkButton>
<MkButton inline @click="setLisenceBulk">Set Lisence</MkButton> <MkButton inline @click="setLicenseBulk">Set License</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton> <MkButton inline danger @click="delBulk">Delete</MkButton>
</div> </div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> <MkPagination ref="emojisPaginationComponent" :pagination="pagination">
@@ -221,7 +221,7 @@ const setCategoryBulk = async () => {
emojisPaginationComponent.value.reload(); emojisPaginationComponent.value.reload();
}; };
const setLisenceBulk = async () => { const setLicenseBulk = async () => {
const { canceled, result } = await os.inputText({ const { canceled, result } = await os.inputText({
title: 'License', title: 'License',
}); });

View File

@@ -9,6 +9,7 @@ import XAntenna from './editor.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import { antennasCache } from '@/cache';
const router = useRouter(); const router = useRouter();
@@ -26,13 +27,10 @@ let draft = $ref({
}); });
function onAntennaCreated() { function onAntennaCreated() {
antennasCache.delete();
router.push('/my/antennas'); router.push('/my/antennas');
} }
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({ definePageMetadata({
title: i18n.ts.manageAntennas, title: i18n.ts.manageAntennas,
icon: 'ti ti-antenna', icon: 'ti ti-antenna',

View File

@@ -10,6 +10,7 @@ import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { antennasCache } from '@/cache';
const router = useRouter(); const router = useRouter();
@@ -20,6 +21,7 @@ const props = defineProps<{
}>(); }>();
function onAntennaUpdated() { function onAntennaUpdated() {
antennasCache.delete();
router.push('/my/antennas'); router.push('/my/antennas');
} }
@@ -27,10 +29,6 @@ os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) =
antenna = antennaResponse; antenna = antennaResponse;
}); });
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({ definePageMetadata({
title: i18n.ts.manageAntennas, title: i18n.ts.manageAntennas,
icon: 'ti ti-antenna', icon: 'ti ti-antenna',

View File

@@ -2,15 +2,20 @@
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<div class="ieepwinx"> <div>
<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <div v-if="antennas.length === 0" class="empty">
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
<div class=""> <MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
<MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> <div v-if="antennas.length > 0" class="_gaps">
<div class="name">{{ antenna.name }}</div> <MkA v-for="antenna in antennas" :key="antenna.id" :class="$style.antenna" :to="`/my/antennas/${antenna.id}`">
</MkA> <div class="name">{{ antenna.name }}</div>
</MkPagination> </MkA>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>
@@ -18,19 +23,31 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { antennasCache } from '@/cache';
import { api } from '@/os';
import { onActivated } from 'vue';
import { infoImageUrl } from '@/instance';
const pagination = { const antennas = $computed(() => antennasCache.value.value ?? []);
endpoint: 'antennas/list' as const,
noPaging: true,
limit: 10,
};
const headerActions = $computed(() => []); function fetch() {
antennasCache.fetch(() => api('antennas/list'));
}
fetch();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: () => {
antennasCache.delete();
fetch();
},
}]);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);
@@ -38,30 +55,30 @@ definePageMetadata({
title: i18n.ts.manageAntennas, title: i18n.ts.manageAntennas,
icon: 'ti ti-antenna', icon: 'ti ti-antenna',
}); });
onActivated(() => {
antennasCache.fetch(() => api('antennas/list'));
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.ieepwinx { .add {
margin: 0 auto 16px auto;
}
> .add { .antenna {
margin: 0 auto 16px auto; display: block;
} padding: 16px;
border: solid 1px var(--divider);
border-radius: 6px;
.ljoevbzj { &:hover {
display: block; border: solid 1px var(--accent);
padding: 16px; text-decoration: none;
margin-bottom: 8px;
border: solid 1px var(--divider);
border-radius: 6px;
&:hover {
border: solid 1px var(--accent);
text-decoration: none;
}
> .name {
font-weight: bold;
}
} }
} }
.name {
font-weight: bold;
}
</style> </style>

View File

@@ -3,38 +3,43 @@
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<div class="_gaps"> <div class="_gaps">
<div v-if="items.length === 0" class="empty">
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
<MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination"> <div v-if="items.length > 0" class="_gaps">
<div class="_gaps"> <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`">
<MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> <div style="margin-bottom: 4px;">{{ list.name }}</div>
<div style="margin-bottom: 4px;">{{ list.name }}</div> <MkAvatars :userIds="list.userIds" :limit="10"/>
<MkAvatars :userIds="list.userIds"/> </MkA>
</MkA> </div>
</div>
</MkPagination>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { onActivated } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkAvatars from '@/components/MkAvatars.vue'; import MkAvatars from '@/components/MkAvatars.vue';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { userListsCache } from '@/cache'; import { userListsCache } from '@/cache';
import { infoImageUrl } from '@/instance';
const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>(); const items = $computed(() => userListsCache.value.value ?? []);
const pagination = { function fetch() {
endpoint: 'users/lists/list' as const, userListsCache.fetch(() => os.api('users/lists/list'));
noPaging: true, }
limit: 10,
}; fetch();
async function create() { async function create() {
const { canceled, result: name } = await os.inputText({ const { canceled, result: name } = await os.inputText({
@@ -43,10 +48,18 @@ async function create() {
if (canceled) return; if (canceled) return;
await os.apiWithDialog('users/lists/create', { name: name }); await os.apiWithDialog('users/lists/create', { name: name });
userListsCache.delete(); userListsCache.delete();
pagingComponent.reload(); fetch();
} }
const headerActions = $computed(() => []); const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: () => {
userListsCache.delete();
fetch();
},
}]);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);
@@ -58,6 +71,10 @@ definePageMetadata({
handler: create, handler: create,
}, },
}); });
onActivated(() => {
fetch();
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@@ -166,7 +166,7 @@ const menuDef = computed(() => [{
active: currentPage?.route.name === 'import-export', active: currentPage?.route.name === 'import-export',
}, { }, {
icon: 'ti ti-plane', icon: 'ti ti-plane',
text: `${i18n.ts.accountMigration} (${i18n.ts.experimental})`, text: `${i18n.ts.accountMigration}`,
to: '/settings/migration', to: '/settings/migration',
active: currentPage?.route.name === 'migration', active: currentPage?.route.name === 'migration',
}, { }, {

View File

@@ -1,8 +1,5 @@
<template> <template>
<div class="_gaps_m"> <div class="_gaps_m">
<FormInfo warn>
{{ i18n.ts.thisIsExperimentalFeature }}
</FormInfo>
<MkFolder :defaultOpen="true"> <MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-plane-arrival"></i></template> <template #icon><i class="ti ti-plane-arrival"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveFrom }}</template> <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>

View File

@@ -1,7 +1,8 @@
import { ref } from "vue";
export class Cache<T> { export class Cache<T> {
private cachedAt: number | null = null; private cachedAt: number | null = null;
private value: T | undefined; public value = ref<T | undefined>();
private lifetime: number; private lifetime: number;
constructor(lifetime: Cache<never>['lifetime']) { constructor(lifetime: Cache<never>['lifetime']) {
@@ -10,21 +11,20 @@ export class Cache<T> {
public set(value: T): void { public set(value: T): void {
this.cachedAt = Date.now(); this.cachedAt = Date.now();
this.value = value; this.value.value = value;
} }
public get(): T | undefined { private get(): T | undefined {
if (this.cachedAt == null) return undefined; if (this.cachedAt == null) return undefined;
if ((Date.now() - this.cachedAt) > this.lifetime) { if ((Date.now() - this.cachedAt) > this.lifetime) {
this.value = undefined; this.value.value = undefined;
this.cachedAt = null; this.cachedAt = null;
return undefined; return undefined;
} }
return this.value; return this.value.value;
} }
public delete() { public delete() {
this.value = undefined;
this.cachedAt = null; this.cachedAt = null;
} }

View File

@@ -1,14 +1,15 @@
import { toUnicode } from 'punycode';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from '@/scripts/copy-to-clipboard';
import { host } from '@/config'; import { host, url } from '@/config';
import * as os from '@/os'; import * as os from '@/os';
import { defaultStore, userActions } from '@/store'; import { defaultStore, userActions } from '@/store';
import { $i, iAmModerator } from '@/account'; import { $i, iAmModerator } from '@/account';
import { mainRouter } from '@/router'; import { mainRouter } from '@/router';
import { Router } from '@/nirax'; import { Router } from '@/nirax';
import { rolesCache, userListsCache } from '@/cache'; import { antennasCache, rolesCache, userListsCache } from '@/cache';
export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) {
const meId = $i ? $i.id : null; const meId = $i ? $i.id : null;
@@ -137,6 +138,13 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
action: () => { action: () => {
copyToClipboard(`${user.host ?? host}/@${user.username}.atom`); copyToClipboard(`${user.host ?? host}/@${user.username}.atom`);
}, },
}, {
icon: 'ti ti-share',
text: i18n.ts.copyProfileUrl,
action: () => {
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
copyToClipboard(`${url}/${canonical}`);
},
}, { }, {
icon: 'ti ti-mail', icon: 'ti ti-mail',
text: i18n.ts.sendMessage, text: i18n.ts.sendMessage,
@@ -158,11 +166,39 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
return lists.map(list => ({ return lists.map(list => ({
text: list.name, text: list.name,
action: () => { action: async () => {
os.apiWithDialog('users/lists/push', { await os.apiWithDialog('users/lists/push', {
listId: list.id, listId: list.id,
userId: user.id, userId: user.id,
}); });
userListsCache.delete();
},
}));
},
}, {
type: 'parent',
icon: 'ti ti-antenna',
text: i18n.ts.addToAntenna,
children: async () => {
const antennas = await antennasCache.fetch(() => os.api('antennas/list'));
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
return antennas.filter((a) => a.src === 'users').map(antenna => ({
text: antenna.name,
action: async () => {
await os.apiWithDialog('antennas/update', {
antennaId: antenna.id,
name: antenna.name,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
src: antenna.src,
userListId: antenna.userListId,
users: [...antenna.users, canonical],
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
});
antennasCache.delete();
}, },
})); }));
}, },

View File

@@ -72,7 +72,7 @@ html {
} }
&.useSystemFont { &.useSystemFont {
font-family: 'Hiragino Maru Gothic Pro', sans-serif; font-family: system-ui;
} }
} }

View File

@@ -271,6 +271,7 @@ type DetailedInstanceMetadata = LiteInstanceMetadata & {
pinnedPages: string[]; pinnedPages: string[];
pinnedClipId: string | null; pinnedClipId: string | null;
cacheRemoteFiles: boolean; cacheRemoteFiles: boolean;
cacheRemoteSensitiveFiles: boolean;
requireSetup: boolean; requireSetup: boolean;
proxyAccountName: string | null; proxyAccountName: string | null;
features: Record<string, any>; features: Record<string, any>;
@@ -327,6 +328,10 @@ export type Endpoints = {
req: TODO; req: TODO;
res: TODO; res: TODO;
}; };
'admin/meta': {
req: TODO;
res: TODO;
};
'admin/reset-password': { 'admin/reset-password': {
req: TODO; req: TODO;
res: TODO; res: TODO;
@@ -2756,7 +2761,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// //
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:620:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/api.types.ts:621:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package) // (No @packageDocumentation comment for this package)

View File

@@ -20,6 +20,7 @@ export type Endpoints = {
'admin/get-table-stats': { req: TODO; res: TODO; }; 'admin/get-table-stats': { req: TODO; res: TODO; };
'admin/invite': { req: TODO; res: TODO; }; 'admin/invite': { req: TODO; res: TODO; };
'admin/logs': { req: TODO; res: TODO; }; 'admin/logs': { req: TODO; res: TODO; };
'admin/meta': { req: TODO; res: TODO; };
'admin/reset-password': { req: TODO; res: TODO; }; 'admin/reset-password': { req: TODO; res: TODO; };
'admin/resolve-abuse-user-report': { req: TODO; res: TODO; }; 'admin/resolve-abuse-user-report': { req: TODO; res: TODO; };
'admin/resync-chart': { req: TODO; res: TODO; }; 'admin/resync-chart': { req: TODO; res: TODO; };

View File

@@ -338,6 +338,7 @@ export type DetailedInstanceMetadata = LiteInstanceMetadata & {
pinnedPages: string[]; pinnedPages: string[];
pinnedClipId: string | null; pinnedClipId: string | null;
cacheRemoteFiles: boolean; cacheRemoteFiles: boolean;
cacheRemoteSensitiveFiles: boolean;
requireSetup: boolean; requireSetup: boolean;
proxyAccountName: string | null; proxyAccountName: string | null;
features: Record<string, any>; features: Record<string, any>;

View File

@@ -1,10 +1,13 @@
// @ts-check // @ts-check
const esbuild = require('esbuild'); import { fileURLToPath } from 'node:url';
const locales = require('../../locales'); import * as esbuild from 'esbuild';
const meta = require('../../package.json'); import locales from '../../locales/index.js';
import meta from '../../package.json' assert { type: "json" };
const watch = process.argv[2]?.includes('watch'); const watch = process.argv[2]?.includes('watch');
const __dirname = fileURLToPath(new URL('.', import.meta.url))
console.log('Starting SW building...'); console.log('Starting SW building...');
/** @type {esbuild.BuildOptions} */ /** @type {esbuild.BuildOptions} */

View File

@@ -19,5 +19,6 @@
"eslint": "8.44.0", "eslint": "8.44.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"typescript": "5.1.6" "typescript": "5.1.6"
} },
"type": "module"
} }

View File

@@ -21,6 +21,10 @@ globalThis.addEventListener('activate', ev => {
); );
}); });
function offlineContentHTML(): string {
return `<!doctype html>Offline. Service Worker @${_VERSION_} <button onclick="location.reload()">reload</button>`
}
globalThis.addEventListener('fetch', ev => { globalThis.addEventListener('fetch', ev => {
let isHTMLRequest = false; let isHTMLRequest = false;
if (ev.request.headers.get('sec-fetch-dest') === 'document') { if (ev.request.headers.get('sec-fetch-dest') === 'document') {
@@ -34,7 +38,14 @@ globalThis.addEventListener('fetch', ev => {
if (!isHTMLRequest) return; if (!isHTMLRequest) return;
ev.respondWith( ev.respondWith(
fetch(ev.request) fetch(ev.request)
.catch(() => new Response(`Offline. Service Worker @${_VERSION_}`, { status: 200 })), .catch(() => {
return new Response(offlineContentHTML(), {
status: 200,
headers: {
'content-type': 'text/html',
},
});
}),
); );
}); });