Compare commits

...

12 Commits

Author SHA1 Message Date
syuilo a98ac7216d New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 21:20:20 +09:00
syuilo 491fea52a5 New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 20:01:25 +09:00
syuilo c507ae9988 New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 18:39:21 +09:00
syuilo daaa0dac88 New translations ja-jp.yml (Persian)
[ci skip]
2026-08-01 17:42:59 +09:00
syuilo 8e467aec09 New translations ja-jp.yml (Chinese Traditional)
[ci skip]
2026-08-01 02:12:15 +09:00
4ster1sk 4db251696d fix(test): setup.sh で $1.config.json の生成条件が $1.default.yml を参照していたのを修正 (#17826)
* fix(test): setup.sh で $1.config.json の生成条件が $1.default.yml を参照していたのを修正

* update .gitignore
2026-07-31 19:34:17 +09:00
github-actions[bot] d54a9dcd3e [skip ci] Update CHANGELOG.md (prepend template) 2026-07-31 07:48:04 +00:00
github-actions[bot] 8ea4a0ecac Release: 2026.7.0 2026-07-31 07:47:55 +00:00
おさむのひと 8dd59a8c06 fix: RSS系ウィジェットにフィードURL検査機構を追加 (#17831) 2026-07-31 16:11:39 +09:00
おさむのひと f74a303556 fix: sensitive-detectorへのリクエスト時もHttpRequestServiceを使うように (#17828) 2026-07-31 13:43:51 +09:00
おさむのひと 589d43b38b Update packages/backend/src/logging/PrettyConsoleBackend.ts
Co-authored-by: anatawa12 <anatawa12@icloud.com>
2026-07-30 21:31:42 +09:00
かっこかり bcc3bc4fd7 fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように (#17822)
* fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように

* fix

* fix
2026-07-29 14:47:58 +09:00
17 changed files with 235 additions and 65 deletions
+12
View File
@@ -1,3 +1,15 @@
## Unreleased
### General
-
### Client
-
### Server
-
## 2026.7.0 ## 2026.7.0
### Note ### Note
+81
View File
@@ -0,0 +1,81 @@
---
_lang_: "فارسی"
headlineMisskey: "یک شبکه از طریق یادداشت ها متصل گردید."
introMisskey: "خوش آمدید! Misskey یک سرویس میکروبلاگینگ متن باز و غیرمتمرکز می باشد.\nیک \"یادداشت\" درست کن و در مورد اتفاق هایی که داره می افته و یا به دیگران درمورد خودت بگو📡\nشما می توانید به سرعت با کمک قابلیت \"واکنش ها\" به یادداشت های دیگران واکنش نشون بدید👍\nیک دنیای جدید رو کاوش کنید🚀"
poweredByMisskeyDescription: "{name} یکی از سرور های متن باز پلتفرم <b>Misskey</b> می باشد."
monthAndDay: "{day}/{month}"
search: "جستجو"
reset: "بازنشانی"
notifications: "اعلان ها"
username: "نام کاربری"
password: "گذرواژه"
initialPasswordForSetup: "گذرواژه برای راه‌اندازی تنظیمات اولیه"
initialPasswordIsIncorrect: "گذرواژه برای راه‌اندازی تنظیمات اولیه نادرست می باشد."
initialPasswordForSetupDescription: "اگر خودتان Misskey را نصب کرده اید، لطفا از گذرواژه ای که در فایل تنظیمات وارد کرده اید استفاده کنید.\nاگه از سرویس میزبانی برای Misskey استفاده می کنید، لطفا از گذرواژه ای که توسط آنها ارائه شده استفاده کنید.\nاگر گذرواژه ای تنظیم نکردید، بخش را خالی بگذارید و ادامه دهید."
forgotPassword: "گذرواژه ام را فراموش کردم"
fetchingAsApObject: "در حال دریافت از فدیوِرس"
ok: "باشه"
gotIt: "متوجه شدم"
cancel: "لغو"
noThankYou: "الان نه"
enterUsername: "نام کاربری خود را وارد کنید"
renotedBy: "بازنشر شده توسط {user}"
noNotes: "هیچ یادداشتی موجود نیست"
noNotifications: "اعلانی موجود نیست"
instance: "سرور"
settings: "تنظیمات"
notificationSettings: "تنظیمات اعلان"
basicSettings: "تنظیمات اصلی"
otherSettings: "تنظیمات بیشتر"
openInWindow: "در پنجره باز کن"
profile: "نمایه"
timeline: "نوار زمانی"
noAccountDescription: "این کاربر هنوز درون شرح حال خودش چیزی ننوشته."
login: "ورود"
loggingIn: "وارد شده"
logout: "خروج"
signup: "ثبت‌نام"
uploading: "بارگذاری..."
save: "ذخیره"
users: "کاربران"
addUser: "افزودن کاربر"
favorite: "افزودن به برگزیده ها"
favorites: "برگزیده ها"
unfavorite: "حذف از برگزیده ها"
favorited: "به برگزیده ها اضافه شد."
alreadyFavorited: "قبلا به برگزیده ها اضافه شده."
cantFavorite: "نمی توان به برگزیده ها اضافه کرد."
pin: "سنجاق کردن"
unpin: "حذف سنجاق"
copyContent: "رونوشت از محتوا"
copyLink: "رونوشت از پیوند"
copyRemoteLink: "رونوشت از پیوند خارجی"
copyLinkRenote: "رونوشت از پیوند بازنشر"
delete: "حذف"
deleteAndEdit: "حذف و ویرایش"
deleteAndEditConfirm: "آیا می خواهید که این یادداشت را حذف و دوباره ویرایش کنید؟ در این صورت همچنین تمام واکنش ها، بازنشر ها و پاسخ ها به این یادداشت پاک خواهد شد."
addToList: "افزودن به فهرست"
pinned: "سنجاق کردن"
instances: "سرور"
remove: "حذف"
smtpUser: "نام کاربری"
smtpPass: "گذرواژه"
user: "کاربران"
searchByGoogle: "جستجو"
_sfx:
notification: "اعلان ها"
_2fa:
renewTOTPCancel: "الان نه"
_widgets:
profile: "نمایه"
notifications: "اعلان ها"
timeline: "نوار زمانی"
_profile:
username: "نام کاربری"
_notification:
_types:
login: "ورود"
_deck:
_columns:
notifications: "اعلان ها"
tl: "نوار زمانی"
+15
View File
@@ -619,6 +619,8 @@ output: "輸出"
script: "腳本" script: "腳本"
disablePagesScript: "停用頁面的 AiScript 腳本" disablePagesScript: "停用頁面的 AiScript 腳本"
updateRemoteUser: "更新遠端使用者資訊" updateRemoteUser: "更新遠端使用者資訊"
unsetMfa: "解除雙重驗證"
unsetMfaConfirm: "要解除雙重驗證嗎?"
unsetUserAvatar: "移除使用者的大頭貼" unsetUserAvatar: "移除使用者的大頭貼"
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?" unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
unsetUserBanner: "移除使用者的橫幅圖像" unsetUserBanner: "移除使用者的橫幅圖像"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "增加表情符號調色盤"
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?" emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
append: "加在最後" append: "加在最後"
prepend: "加在前面" prepend: "加在前面"
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
_imageEditing: _imageEditing:
_vars: _vars:
caption: "檔案標題" caption: "檔案標題"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。" setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。"
analyzeVideos: "啟用影片分析" analyzeVideos: "啟用影片分析"
analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。" analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。"
externalServiceInfo: "敏感媒體的判定已分離至外部服務(sensitive-detector)。若要使用此功能,必須另外設定 Sidecar 服務,並設定下方的連接資訊。若未設定連接資訊,則不會執行判定(視為非敏感內容)。"
apiUrl: "判定服務的連接資訊 URL"
apiUrlDescription: "sensitive-detector 服務的基礎網址(例如:http://localhost:3009)。若要連接位於私有網路上的服務,請在設定檔的 allowedPrivateNetworks 中允許對應的連接網路。若使用代理伺服器(Proxy),也請一併設定 proxyBypassHosts。若留白,則不會執行敏感內容判定。"
apiKey: "API 金鑰"
apiKeyDescription: "若判定服務端已設定認證(Bearer Token),請輸入此資訊。若未設定,請保持空白。"
timeout: "連線逾時(微秒)"
timeoutDescription: "單次判定請求的逾時時間。"
maxImagesPerRequest: "每筆請求的最大圖片數量"
maxImagesPerRequestDescription: "當判定包含多個影格的內容(如影片等)時,為每筆請求所允許的單次最大圖片數量。超過此數量的部分將會分割並依序發送。請確保此數值不超過 sensitive-detector 端的 maxParts 設定(預設值:10)。若超過該數值,該區塊(Chunk)的所有項目將被視為非敏感內容。"
_emailUnavailable: _emailUnavailable:
used: "已被使用" used: "已被使用"
format: "格式無效" format: "格式無效"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "查看審查紀錄" "read:admin:show-moderation-log": "查看審查紀錄"
"read:admin:show-user": "查看使用者的私密資訊" "read:admin:show-user": "查看使用者的私密資訊"
"write:admin:suspend-user": "凍結使用者" "write:admin:suspend-user": "凍結使用者"
"write:admin:unset-mfa": "解除使用者的雙重驗證"
"write:admin:unset-user-avatar": "刪除使用者的頭像" "write:admin:unset-user-avatar": "刪除使用者的頭像"
"write:admin:unset-user-banner": "刪除使用者的橫幅" "write:admin:unset-user-banner": "刪除使用者的橫幅"
"write:admin:unsuspend-user": "解除凍結使用者" "write:admin:unsuspend-user": "解除凍結使用者"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "建立頭像裝飾" createAvatarDecoration: "建立頭像裝飾"
updateAvatarDecoration: "更新頭像裝飾" updateAvatarDecoration: "更新頭像裝飾"
deleteAvatarDecoration: "刪除頭像裝飾" deleteAvatarDecoration: "刪除頭像裝飾"
unsetMfa: "解除使用者的雙重驗證"
unsetUserAvatar: "移除使用者的大頭貼" unsetUserAvatar: "移除使用者的大頭貼"
unsetUserBanner: "移除使用者的橫幅圖像" unsetUserBanner: "移除使用者的橫幅圖像"
createSystemWebhook: "建立 SystemWebhook" createSystemWebhook: "建立 SystemWebhook"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2026.7.0-rc.0", "version": "2026.7.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
+4 -11
View File
@@ -4,7 +4,6 @@
*/ */
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import fetch from 'node-fetch';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -131,9 +130,6 @@ export class AiService {
@bindThis @bindThis
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> { private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try { try {
const form = new FormData(); const form = new FormData();
for (let i = 0; i < chunk.length; i++) { for (let i = 0; i < chunk.length; i++) {
@@ -148,14 +144,13 @@ export class AiService {
headers['Authorization'] = `Bearer ${apiKey}`; headers['Authorization'] = `Bearer ${apiKey}`;
} }
const res = await fetch(url, { const res = await this.httpRequestService.send(url, {
method: 'POST', method: 'POST',
headers, headers,
body: form, body: form,
// 外部サービスとして通常の proxy / private address 制限を適用する。 timeout,
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。 }, {
agent: (u) => this.httpRequestService.getAgentByUrl(u), throwErrorWhenResponseNotOk: false,
signal: controller.signal,
}); });
if (!res.ok) { if (!res.ok) {
@@ -181,8 +176,6 @@ export class AiService {
} catch (err) { } catch (err) {
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`); this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
return chunk.map(() => null); return chunk.map(() => null);
} finally {
clearTimeout(timer);
} }
} }
} }
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js'; import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch'; import type { BodyInit, Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
export type HttpRequestSendOptions = { export type HttpRequestSendOptions = {
@@ -311,7 +311,7 @@ export class HttpRequestService {
url: string, url: string,
args: { args: {
method?: string, method?: string,
body?: string, body?: BodyInit,
headers?: Record<string, string>, headers?: Record<string, string>,
timeout?: number, timeout?: number,
size?: number, size?: number,
@@ -73,7 +73,7 @@ export class PrettyConsoleBackend implements LogBackend {
presentationLevel === 'debug' ? chalk.gray(record.message) : presentationLevel === 'debug' ? chalk.gray(record.message) :
presentationLevel === 'info' ? record.message : presentationLevel === 'info' ? record.message :
null; null;
// 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。 // 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。
const worker = record.isPrimary ? '*' : record.workerId; const worker = record.isPrimary ? '*' : record.workerId;
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`; let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
@@ -4,3 +4,4 @@ volumes
docker.env docker.env
*.test.conf *.test.conf
*.test.default.yml *.test.default.yml
*.test.config.json
+1 -1
View File
@@ -28,7 +28,7 @@ function generate {
-days 500 -days 500
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi if [ ! -f .config/$1.config.json ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
} }
generate a.test generate a.test
+32 -33
View File
@@ -4,19 +4,12 @@
*/ */
import { describe, test, expect, vi, beforeEach } from 'vitest'; import { describe, test, expect, vi, beforeEach } from 'vitest';
import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js';
import type { MiMeta } from '@/models/_.js'; import type { MiMeta } from '@/models/_.js';
import type { HttpRequestService } from '@/core/HttpRequestService.js'; import type { HttpRequestService } from '@/core/HttpRequestService.js';
import type { LoggerService } from '@/core/LoggerService.js'; import type { LoggerService } from '@/core/LoggerService.js';
import { AiService, type Prediction } from '@/core/AiService.js';
// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と const sendMock = vi.fn();
// レスポンス解釈を検証する。
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock('node-fetch', () => ({ default: fetchMock }));
const { AiService } = await import('@/core/AiService.js');
let getAgentByUrlMock: ReturnType<typeof vi.fn>;
const DEFAULT_META = { const DEFAULT_META = {
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null, sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
@@ -25,10 +18,9 @@ const DEFAULT_META = {
sensitiveMediaDetectionMaxImagesPerRequest: 4, sensitiveMediaDetectionMaxImagesPerRequest: 4,
}; };
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType { function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta; const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
getAgentByUrlMock = vi.fn(() => undefined); const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
const loggerService = { const loggerService = {
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }), getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
} as unknown as LoggerService; } as unknown as LoggerService;
@@ -52,11 +44,11 @@ const buf = (s: string) => Buffer.from(s);
describe('AiService', () => { describe('AiService', () => {
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); sendMock.mockReset();
}); });
test('正常: 送信順を保った予測値配列を返す', async () => { test('正常: 送信順を保った予測値配列を返す', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] }, { success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
])); ]));
@@ -66,30 +58,35 @@ describe('AiService', () => {
[{ className: 'Neutral', probability: 0.99 }], [{ className: 'Neutral', probability: 0.99 }],
[{ className: 'Porn', probability: 0.8 }], [{ className: 'Porn', probability: 0.8 }],
]); ]);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
}); });
test('外部サービス: 通常の outbound agent を使用する', async () => { test('外部サービス: HttpRequestService を使用する', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' }); const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
await svc.detectSensitiveMany([buf('a')]); await svc.detectSensitiveMany([buf('a')]);
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown }; expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images')); method: 'POST',
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL)); headers: {},
body: expect.any(FormData),
timeout: 5000,
}, {
throwErrorWhenResponseNotOk: false,
});
}); });
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => { test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService(); const svc = makeService();
const res = await svc.detectSensitive(buf('a')); const res = await svc.detectSensitive(buf('a'));
expect(res).toEqual(neutral()); expect(res).toEqual(neutral());
}); });
test('部分失敗: 失敗パーツのみ null になる', async () => { test('部分失敗: 失敗パーツのみ null になる', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } }, { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
])); ]));
@@ -100,14 +97,14 @@ describe('AiService', () => {
}); });
test('非200: チャンク全件 null(例外を投げない)', async () => { test('非200: チャンク全件 null(例外を投げない)', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
const svc = makeService(); const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]); expect(res).toEqual([null, null]);
}); });
test('通信エラー: チャンク全件 null(例外を投げない)', async () => { test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
fetchMock.mockRejectedValue(new Error('network down')); sendMock.mockRejectedValue(new Error('network down'));
const svc = makeService(); const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a')]); const res = await svc.detectSensitiveMany([buf('a')]);
expect(res).toEqual([null]); expect(res).toEqual([null]);
@@ -117,11 +114,11 @@ describe('AiService', () => {
const svc = makeService({ sensitiveMediaDetectionApiUrl: null }); const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]); expect(res).toEqual([null, null]);
expect(fetchMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
}); });
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => { test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
{ success: true, predictions: neutral() }, { success: true, predictions: neutral() },
@@ -130,22 +127,24 @@ describe('AiService', () => {
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 }); const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]); const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。 // 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
expect(fetchMock).toHaveBeenCalledTimes(3); expect(sendMock).toHaveBeenCalledTimes(3);
expect(res).toHaveLength(5); expect(res).toHaveLength(5);
expect(res.every(x => x != null)).toBe(true); expect(res.every(x => x != null)).toBe(true);
}); });
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => { test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' }); const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
await withKey.detectSensitiveMany([buf('a')]); await withKey.detectSensitiveMany([buf('a')]);
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret'); const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
fetchMock.mockClear(); sendMock.mockClear();
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withoutKey = makeService(); const withoutKey = makeService();
await withoutKey.detectSensitiveMany([buf('a')]); await withoutKey.detectSensitiveMany([buf('a')]);
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined(); const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withoutKeyHeaders.Authorization).toBeUndefined();
}); });
}); });
+8
View File
@@ -27,6 +27,14 @@ export function extractDomain(url: string) {
return match ? match[1] : null; return match ? match[1] : null;
} }
export function tryParseUrl(url: string | URL, base?: string | URL): URL | null {
try {
return new URL(url, base);
} catch {
return null;
}
}
export function maybeMakeRelative(urlStr: string, baseStr: string): string { export function maybeMakeRelative(urlStr: string, baseStr: string): string {
try { try {
const baseObj = new URL(baseStr); const baseObj = new URL(baseStr);
@@ -83,16 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else-if="content.type === 'video'" v-else-if="content.type === 'video'"
ref="videoEl" ref="videoEl"
data-gallery-click-action="video" data-gallery-click-action="video"
:class="$style.content" :class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
:src="content.url" :src="content.url"
:alt="content.file?.comment ?? undefined" :alt="content.file?.comment ?? undefined"
draggable="false" draggable="false"
:controls="prefer.s.useNativeUiForVideoAudioPlayer" :controls="prefer.s.useNativeUiForVideoAudioPlayer"
playsinline playsinline
@loadedmetadata="originalContentLoaded = true" @loadedmetadata="onVideoLoadedMetadata"
@click.stop="onVideoClick" @click.stop="onVideoClick"
></video> ></video>
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" data-gallery-click-action="video" :class="$style.playIconWrapper"> <div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
<div :class="$style.playIcon"> <div :class="$style.playIcon">
<i class="ti ti-player-play"></i> <i class="ti ti-player-play"></i>
</div> </div>
@@ -224,6 +224,21 @@ const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false); const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
let canOpenAnimation = false; let canOpenAnimation = false;
const videoAspectRatio = ref<number | null>(
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
? props.content.width / props.content.height
: null
);
function onVideoLoadedMetadata() {
originalContentLoaded.value = true;
// ドライブ上のメタデータが無い場合に限り、動画自体の初期サイズから縦横比を確定させる
if (videoAspectRatio.value != null) return;
if (videoEl.value == null || videoEl.value.videoWidth === 0 || videoEl.value.videoHeight === 0) return;
videoAspectRatio.value = videoEl.value.videoWidth / videoEl.value.videoHeight;
}
const headerSize = 30; const headerSize = 30;
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0; const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
@@ -941,6 +956,25 @@ defineExpose({
object-fit: contain; object-fit: contain;
} }
.video {
display: block;
user-select: none;
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
width: 100%;
height: 100%;
object-fit: contain;
}
.videoSized {
width: min(100cqw, calc(100cqh * v-bind("videoAspectRatio ?? 16 / 9")));
height: auto;
background-color: #000;
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
}
.loading { .loading {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -969,6 +1003,8 @@ defineExpose({
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
// .videoSizedが使う100cqw / 100cqhの基準 (= paddingを除いた実際の表示領域)
container-type: size;
transition: scale 200ms ease, opacity 200ms ease !important; transition: scale 200ms ease, opacity 200ms ease !important;
} }
@@ -985,6 +1021,7 @@ defineExpose({
height: 100%; height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
pointer-events: none;
} }
.playIcon { .playIcon {
@@ -1000,8 +1037,8 @@ defineExpose({
transition: scale 100ms ease; transition: scale 100ms ease;
} }
.playIconWrapper:hover .playIcon, // アイコン自体はクリックを受け取らないので、hoverは下のvideo要素を経由して拾う
.playIcon:hover { .video:hover ~ .playIconWrapper .playIcon {
scale: 1.2; scale: 1.2;
} }
@@ -31,6 +31,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { url as baseUrl } from '@@/js/config.js'; import { url as baseUrl } from '@@/js/config.js';
import { tryParseUrl } from '@@/js/url.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue'; import MkMarqueeText from '@/components/MkMarqueeText.vue';
import { shuffle } from '@/utility/shuffle.js'; import { shuffle } from '@/utility/shuffle.js';
@@ -56,8 +57,8 @@ const tick = () => {
} }
items.value = feed.items.filter((item) => { items.value = feed.items.filter((item) => {
if (!item.link) return false; if (!item.link) return false;
const itemUrl = new URL(item.link, baseUrl); const itemUrl = tryParseUrl(item.link, baseUrl);
return ['http:', 'https:'].includes(itemUrl.protocol); return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
}); });
fetching.value = false; fetching.value = false;
key.value++; key.value++;
+4 -3
View File
@@ -24,10 +24,11 @@ import { ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js'; import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { tryParseUrl } from '@@/js/url.js';
import { useWidgetPropsManager } from './widget.js'; import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import { i18n } from '@/i18n.js';
import MkContainer from '@/components/MkContainer.vue'; import MkContainer from '@/components/MkContainer.vue';
const name = 'rss'; const name = 'rss';
@@ -83,8 +84,8 @@ const tick = () => {
.then((feed: Misskey.entities.FetchRssResponse) => { .then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items.filter((item) => { rawItems.value = feed.items.filter((item) => {
if (!item.link) return false; if (!item.link) return false;
const itemUrl = new URL(item.link, base); const itemUrl = tryParseUrl(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol); return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
}); });
fetching.value = false; fetching.value = false;
}); });
@@ -29,15 +29,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, computed } from 'vue'; import { ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import { tryParseUrl } from '@@/js/url.js';
import { useWidgetPropsManager } from './widget.js'; import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import MkContainer from '@/components/MkContainer.vue'; import MkContainer from '@/components/MkContainer.vue';
import { shuffle } from '@/utility/shuffle.js'; import { shuffle } from '@/utility/shuffle.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
const name = 'rssTicker'; const name = 'rssTicker';
@@ -123,8 +124,8 @@ const tick = () => {
.then((feed: Misskey.entities.FetchRssResponse) => { .then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items.filter((item) => { rawItems.value = feed.items.filter((item) => {
if (!item.link) return false; if (!item.link) return false;
const itemUrl = new URL(item.link, base); const itemUrl = tryParseUrl(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol); return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
}); });
fetching.value = false; fetching.value = false;
key.value++; key.value++;
+21
View File
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import { tryParseUrl } from '@@/js/url.js';
describe('tryParseUrl', () => {
test('parses an absolute URL', () => {
expect(tryParseUrl('https://example.com/path')?.href).toBe('https://example.com/path');
});
test('parses a relative URL against a base URL', () => {
expect(tryParseUrl('/path', 'https://example.com/base')?.href).toBe('https://example.com/path');
});
test('returns null for an invalid URL', () => {
expect(tryParseUrl('https://[invalid')).toBeNull();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2026.7.0-rc.0", "version": "2026.7.0",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",