Compare commits

...

8 Commits

Author SHA1 Message Date
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
おさむのひと 6f579b6854 chore: docker_example.ymlのsentryForBackend更新が漏れていたのを追記 (#17817) 2026-07-28 21:25:08 +09:00
16 changed files with 162 additions and 65 deletions
+23
View File
@@ -173,8 +173,31 @@ id: 'aidx'
#sentryForBackend: #sentryForBackend:
# enableNodeProfiling: true # enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options: # options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
#sentryForFrontend: #sentryForFrontend:
# vueIntegration: # vueIntegration:
+12
View File
@@ -1,3 +1,15 @@
## Unreleased
### General
-
### Client
-
### Server
-
## 2026.7.0 ## 2026.7.0
### Note ### Note
+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",