From bcc3bc4fd75663bafd3657565e4f6d2e7de4b266 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 29 Jul 2026 14:47:58 +0900
Subject: [PATCH 1/6] =?UTF-8?q?fix(frontend):=20MkLightbox=E3=81=AE?=
=?UTF-8?q?=E5=8B=95=E7=94=BB=E3=81=AE=E7=B8=A6=E6=A8=AA=E6=AF=94=E3=82=92?=
=?UTF-8?q?=E4=BA=8B=E5=89=8D=E3=81=AB=E7=A2=BA=E5=AE=9A=E3=81=95=E3=81=9B?=
=?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#17822)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように
* fix
* fix
---
.../src/components/MkLightbox.item.vue | 47 +++++++++++++++++--
1 file changed, 42 insertions(+), 5 deletions(-)
diff --git a/packages/frontend/src/components/MkLightbox.item.vue b/packages/frontend/src/components/MkLightbox.item.vue
index 91751f16b5..d358f20767 100644
--- a/packages/frontend/src/components/MkLightbox.item.vue
+++ b/packages/frontend/src/components/MkLightbox.item.vue
@@ -83,16 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else-if="content.type === 'video'"
ref="videoEl"
data-gallery-click-action="video"
- :class="$style.content"
+ :class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
:src="content.url"
:alt="content.file?.comment ?? undefined"
draggable="false"
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
playsinline
- @loadedmetadata="originalContentLoaded = true"
+ @loadedmetadata="onVideoLoadedMetadata"
@click.stop="onVideoClick"
>
-
+
@@ -224,6 +224,21 @@ const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
let canOpenAnimation = false;
+const videoAspectRatio = ref
(
+ 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 footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
@@ -941,6 +956,25 @@ defineExpose({
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 {
position: absolute;
top: 0;
@@ -969,6 +1003,8 @@ defineExpose({
position: relative;
width: 100%;
height: 100%;
+ // .videoSizedが使う100cqw / 100cqhの基準 (= paddingを除いた実際の表示領域)
+ container-type: size;
transition: scale 200ms ease, opacity 200ms ease !important;
}
@@ -985,6 +1021,7 @@ defineExpose({
height: 100%;
display: grid;
place-items: center;
+ pointer-events: none;
}
.playIcon {
@@ -1000,8 +1037,8 @@ defineExpose({
transition: scale 100ms ease;
}
-.playIconWrapper:hover .playIcon,
-.playIcon:hover {
+// アイコン自体はクリックを受け取らないので、hoverは下のvideo要素を経由して拾う
+.video:hover ~ .playIconWrapper .playIcon {
scale: 1.2;
}
From 589d43b38b38d4419252f30965037fe7c438f854 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Thu, 30 Jul 2026 21:31:42 +0900
Subject: [PATCH 2/6] Update
packages/backend/src/logging/PrettyConsoleBackend.ts
Co-authored-by: anatawa12
---
packages/backend/src/logging/PrettyConsoleBackend.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/backend/src/logging/PrettyConsoleBackend.ts b/packages/backend/src/logging/PrettyConsoleBackend.ts
index 779a6192a1..7a5d277bcd 100644
--- a/packages/backend/src/logging/PrettyConsoleBackend.ts
+++ b/packages/backend/src/logging/PrettyConsoleBackend.ts
@@ -73,7 +73,7 @@ export class PrettyConsoleBackend implements LogBackend {
presentationLevel === 'debug' ? chalk.gray(record.message) :
presentationLevel === 'info' ? record.message :
null;
- // 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。
+ // 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。
const worker = record.isPrimary ? '*' : record.workerId;
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
From f74a303556c2a049a5269612417c3d73d38054ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Fri, 31 Jul 2026 13:43:51 +0900
Subject: [PATCH 3/6] =?UTF-8?q?fix:=20sensitive-detector=E3=81=B8=E3=81=AE?=
=?UTF-8?q?=E3=83=AA=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88=E6=99=82=E3=82=82?=
=?UTF-8?q?HttpRequestService=E3=82=92=E4=BD=BF=E3=81=86=E3=82=88=E3=81=86?=
=?UTF-8?q?=E3=81=AB=20(#17828)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/backend/src/core/AiService.ts | 15 ++---
.../backend/src/core/HttpRequestService.ts | 4 +-
packages/backend/test/unit/AiService.ts | 65 +++++++++----------
3 files changed, 38 insertions(+), 46 deletions(-)
diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts
index fe21af09f4..fa4792d638 100644
--- a/packages/backend/src/core/AiService.ts
+++ b/packages/backend/src/core/AiService.ts
@@ -4,7 +4,6 @@
*/
import { Injectable, Inject } from '@nestjs/common';
-import fetch from 'node-fetch';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -131,9 +130,6 @@ export class AiService {
@bindThis
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 {
const form = new FormData();
for (let i = 0; i < chunk.length; i++) {
@@ -148,14 +144,13 @@ export class AiService {
headers['Authorization'] = `Bearer ${apiKey}`;
}
- const res = await fetch(url, {
+ const res = await this.httpRequestService.send(url, {
method: 'POST',
headers,
body: form,
- // 外部サービスとして通常の proxy / private address 制限を適用する。
- // サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
- agent: (u) => this.httpRequestService.getAgentByUrl(u),
- signal: controller.signal,
+ timeout,
+ }, {
+ throwErrorWhenResponseNotOk: false,
});
if (!res.ok) {
@@ -181,8 +176,6 @@ export class AiService {
} catch (err) {
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
return chunk.map(() => null);
- } finally {
- clearTimeout(timer);
}
}
}
diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts
index 5714bde8bf..f7c76f4705 100644
--- a/packages/backend/src/core/HttpRequestService.ts
+++ b/packages/backend/src/core/HttpRequestService.ts
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.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';
export type HttpRequestSendOptions = {
@@ -311,7 +311,7 @@ export class HttpRequestService {
url: string,
args: {
method?: string,
- body?: string,
+ body?: BodyInit,
headers?: Record,
timeout?: number,
size?: number,
diff --git a/packages/backend/test/unit/AiService.ts b/packages/backend/test/unit/AiService.ts
index 1933bd7271..83bf33c079 100644
--- a/packages/backend/test/unit/AiService.ts
+++ b/packages/backend/test/unit/AiService.ts
@@ -4,19 +4,12 @@
*/
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 { HttpRequestService } from '@/core/HttpRequestService.js';
import type { LoggerService } from '@/core/LoggerService.js';
+import { AiService, type Prediction } from '@/core/AiService.js';
-// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と
-// レスポンス解釈を検証する。
-const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
-vi.mock('node-fetch', () => ({ default: fetchMock }));
-
-const { AiService } = await import('@/core/AiService.js');
-
-let getAgentByUrlMock: ReturnType;
+const sendMock = vi.fn();
const DEFAULT_META = {
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
@@ -25,10 +18,9 @@ const DEFAULT_META = {
sensitiveMediaDetectionMaxImagesPerRequest: 4,
};
-function makeService(metaOverrides: Partial = {}): AiServiceType {
+function makeService(metaOverrides: Partial = {}): AiService {
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
- getAgentByUrlMock = vi.fn(() => undefined);
- const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
+ const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
const loggerService = {
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
} as unknown as LoggerService;
@@ -52,11 +44,11 @@ const buf = (s: string) => Buffer.from(s);
describe('AiService', () => {
beforeEach(() => {
- fetchMock.mockReset();
+ sendMock.mockReset();
});
test('正常: 送信順を保った予測値配列を返す', async () => {
- fetchMock.mockResolvedValue(okResponse([
+ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() },
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
]));
@@ -66,30 +58,35 @@ describe('AiService', () => {
[{ className: 'Neutral', probability: 0.99 }],
[{ className: 'Porn', probability: 0.8 }],
]);
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
+ expect(sendMock).toHaveBeenCalledTimes(1);
+ expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
});
- test('外部サービス: 通常の outbound agent を使用する', async () => {
- fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
+ test('外部サービス: HttpRequestService を使用する', async () => {
+ sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
await svc.detectSensitiveMany([buf('a')]);
- const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown };
- requestOptions.agent(new URL('https://detector.example.com/v1/detect-images'));
- expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL));
+ expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
+ method: 'POST',
+ headers: {},
+ body: expect.any(FormData),
+ timeout: 5000,
+ }, {
+ throwErrorWhenResponseNotOk: false,
+ });
});
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
- fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
+ sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService();
const res = await svc.detectSensitive(buf('a'));
expect(res).toEqual(neutral());
});
test('部分失敗: 失敗パーツのみ null になる', async () => {
- fetchMock.mockResolvedValue(okResponse([
+ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() },
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
]));
@@ -100,14 +97,14 @@ describe('AiService', () => {
});
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 res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]);
});
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
- fetchMock.mockRejectedValue(new Error('network down'));
+ sendMock.mockRejectedValue(new Error('network down'));
const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a')]);
expect(res).toEqual([null]);
@@ -117,11 +114,11 @@ describe('AiService', () => {
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]);
- expect(fetchMock).not.toHaveBeenCalled();
+ expect(sendMock).not.toHaveBeenCalled();
});
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
- fetchMock.mockResolvedValue(okResponse([
+ sendMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() },
{ success: true, predictions: neutral() },
{ success: true, predictions: neutral() },
@@ -130,22 +127,24 @@ describe('AiService', () => {
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
- expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(sendMock).toHaveBeenCalledTimes(3);
expect(res).toHaveLength(5);
expect(res.every(x => x != null)).toBe(true);
});
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
- fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
+ sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
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 }).headers;
+ expect(withKeyHeaders.Authorization).toBe('Bearer secret');
- fetchMock.mockClear();
- fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
+ sendMock.mockClear();
+ sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withoutKey = makeService();
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 }).headers;
+ expect(withoutKeyHeaders.Authorization).toBeUndefined();
});
});
From 8dd59a8c06da49b8f302d902d73d04397ee29366 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
<46447427+samunohito@users.noreply.github.com>
Date: Fri, 31 Jul 2026 16:11:39 +0900
Subject: [PATCH 4/6] =?UTF-8?q?fix:=20RSS=E7=B3=BB=E3=82=A6=E3=82=A3?=
=?UTF-8?q?=E3=82=B8=E3=82=A7=E3=83=83=E3=83=88=E3=81=AB=E3=83=95=E3=82=A3?=
=?UTF-8?q?=E3=83=BC=E3=83=89URL=E6=A4=9C=E6=9F=BB=E6=A9=9F=E6=A7=8B?=
=?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(#17831)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/frontend-shared/js/url.ts | 8 +++++++
.../src/ui/_common_/statusbar-rss.vue | 5 +++--
packages/frontend/src/widgets/WidgetRss.vue | 7 ++++---
.../frontend/src/widgets/WidgetRssTicker.vue | 11 +++++-----
packages/frontend/test/unit/url.test.ts | 21 +++++++++++++++++++
5 files changed, 42 insertions(+), 10 deletions(-)
create mode 100644 packages/frontend/test/unit/url.test.ts
diff --git a/packages/frontend-shared/js/url.ts b/packages/frontend-shared/js/url.ts
index 1f3550d951..b70c7cfee4 100644
--- a/packages/frontend-shared/js/url.ts
+++ b/packages/frontend-shared/js/url.ts
@@ -27,6 +27,14 @@ export function extractDomain(url: string) {
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 {
try {
const baseObj = new URL(baseStr);
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index 1c4c29769a..13f69e74fa 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -31,6 +31,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { url as baseUrl } from '@@/js/config.js';
+import { tryParseUrl } from '@@/js/url.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import { shuffle } from '@/utility/shuffle.js';
@@ -56,8 +57,8 @@ const tick = () => {
}
items.value = feed.items.filter((item) => {
if (!item.link) return false;
- const itemUrl = new URL(item.link, baseUrl);
- return ['http:', 'https:'].includes(itemUrl.protocol);
+ const itemUrl = tryParseUrl(item.link, baseUrl);
+ return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index 9cc5d8ad39..510de0234a 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -24,10 +24,11 @@ import { ref, watch, computed } from 'vue';
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 { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
+import { i18n } from '@/i18n.js';
import MkContainer from '@/components/MkContainer.vue';
const name = 'rss';
@@ -83,8 +84,8 @@ const tick = () => {
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
- const itemUrl = new URL(item.link, base);
- return ['http:', 'https:'].includes(itemUrl.protocol);
+ const itemUrl = tryParseUrl(item.link, base);
+ return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
});
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index 7b0ee60e63..7e339d1dd7 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -29,15 +29,16 @@ SPDX-License-Identifier: AGPL-3.0-only