diff --git a/CHANGELOG.md b/CHANGELOG.md index 229e00d2b3..435258f45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### General +- + +### Client +- + +### Server +- + + ## 2026.7.0 ### Note diff --git a/package.json b/package.json index 976626d36d..b70f20b34d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2026.7.0-rc.0", + "version": "2026.7.0", "codename": "nasubi", "repository": { "type": "git", 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/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}`; 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(); }); }); 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/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; } 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