Compare commits

...

25 commits

Author SHA1 Message Date
5e0134b068 version(package): 2026.6.0-beta.1-kyunet.2 2026-06-26 03:01:56 +09:00
66c75896fd custom(mknote-menu-extract) 2026-06-26 03:00:13 +09:00
9b0b459731 feat(oidc): for pre-exists users 2026-06-23 23:18:12 +09:00
b55566ff96 fix: workflows targets 2026-06-23 17:31:44 +09:00
eb24927de4 fix: workflow env 2026-06-23 17:12:22 +09:00
b68e5eba8f workflow: forgejo 2026-06-23 17:06:49 +09:00
eb83f40ef2 feat: oidc 2026-06-23 16:56:43 +09:00
syuilo
2c814ecd83 chore(dev): tweak frontend-js-size.mjs 2026-06-23 15:18:09 +09:00
syuilo
05e00e4c2b refactor(dev): refactor frontend-js-size.mjs 2026-06-23 15:01:19 +09:00
syuilo
4bacb1bfbe chore(dev): fix typo 2026-06-23 14:53:38 +09:00
syuilo
8186742c0f refactor(dev): unify frontend-bundle-visualizer-report.mjs and frontend-js-size.mjs 2026-06-23 14:48:25 +09:00
syuilo
544c4227f7 chore(dev): refactor 2026-06-23 13:12:57 +09:00
syuilo
6e4380f11d enhance(dev): tweak report-backend-memory 2026-06-23 12:37:47 +09:00
syuilo
cb1d1d651a enhance(dev): tweak report-backend-memory 2026-06-23 12:18:47 +09:00
syuilo
c899aafeef enhance(dev): tweak report-backend-memory 2026-06-23 11:54:16 +09:00
syuilo
72d91ce3da refactor(dev): report-backend-memoryのmarkdown生成ロジックを分離 2026-06-23 11:29:45 +09:00
syuilo
09b761e4d1 enhance(dev): tweak report-backend-memory 2026-06-23 11:17:03 +09:00
syuilo
6d11f572b3
enhance(dev): improve backend memory usage comparison workflow (#17591)
* wip

* Update get-backend-memory.yml

* [ci skip] tweak table
2026-06-23 11:01:27 +09:00
syuilo
d54b948085 enhance(dev): tweak Frontend Chunk Report 2026-06-22 22:22:36 +09:00
syuilo
f5806a0560 enhance(dev): tweak Frontend Chunk Report 2026-06-22 22:15:32 +09:00
syuilo
5d8c31b6e5 fix(dev): tweak frontend-bundle-report-comment 2026-06-22 22:11:46 +09:00
syuilo
fff87f6604 enhance(dev): tweak Frontend Chunk Report 2026-06-22 21:59:54 +09:00
syuilo
7a3e03411f Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-06-22 20:49:16 +09:00
syuilo
6d89d479e2 fix(dev): tweak frontend-bundle-report 2026-06-22 20:49:03 +09:00
github-actions[bot]
ab73b8abe3 [skip ci] Update CHANGELOG.md (prepend template) 2026-06-22 11:47:30 +00:00
53 changed files with 2953 additions and 810 deletions

View file

@ -293,7 +293,7 @@ fulltextSearch:
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# ID SETTINGS AFTER THAT!
id: 'aidx'
id: "aidx"
# ┌────────────────┐
#───┘ Error tracking └──────────────────────────────────────────
@ -402,3 +402,33 @@ proxyBypassHosts:
# # Disable query truncation. If set to true, the full text of the query will be output to the log.
# # default: false
# disableQueryTruncation: false
# ┌─────────────────────────────────────────┐
#────┘ External login via OpenID Connect (SSO) └───────────────────────────────
# Allow signing in to Misskey through an external OpenID Connect Identity
# Provider (e.g. Authentik, Keycloak, Auth0). Misskey acts as the OIDC
# Relying Party (client).
#
# Register Misskey as an application/client on your IdP and set its
# redirect URI to: {url}/sso/oidc/callback
# (where {url} is the `url:` value at the top of this file)
#
# NOTE: clientSecret is a credential. Do NOT commit a filled-in value.
#sso:
# oidc:
# # Whether OIDC login is enabled. default: true (when this block exists)
# enabled: true
# # Display name shown on the login button. default: null ("Log in with SSO")
# name: Authentik
# # The issuer URL. Its {issuer}/.well-known/openid-configuration must resolve.
# issuer: https://authentik.example.com/application/o/misskey/
# clientId: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# clientSecret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# # Requested scopes. default: ['openid', 'profile', 'email']
# scopes: ['openid', 'profile', 'email']
# # Automatically create a Misskey account on first login if none is linked.
# # When false, only pre-linked accounts may sign in. default: false
# autoProvision: false
# # The id_token claim used as the username on auto-provision.
# # default: 'preferred_username'
# usernameClaim: preferred_username

View file

@ -0,0 +1,64 @@
name: Publish Docker image (Forgejo)
on:
workflow_dispatch:
inputs:
registry:
description: 'Forgejo container registry host (e.g. codeberg.org)'
required: true
type: string
default: 'codeberg.org'
image:
description: 'Image path within the registry (e.g. owner/misskey)'
required: true
type: string
default: 'misskey/misskey'
tag:
description: 'Tag to publish (e.g. latest, develop, 2025.x.x)'
required: true
type: string
default: 'latest'
platforms:
description: 'Target platforms to build'
required: true
type: choice
default: 'linux/amd64,linux/arm64'
options:
- 'linux/amd64,linux/arm64'
- 'linux/amd64'
- 'linux/arm64'
env:
REGISTRY_IMAGE: ${{ inputs.registry }}/${{ inputs.image }}
jobs:
build:
name: Build and push
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Forgejo container registry
uses: docker/login-action@v4
with:
registry: ${{ inputs.registry }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: ${{ inputs.platforms }}
provenance: false
tags: ${{ env.REGISTRY_IMAGE }}:${{ inputs.tag }}
labels: ${{ inputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ inputs.tag }}

View file

@ -0,0 +1,205 @@
import { readFile, writeFile } from 'node:fs/promises';
const [baseFile, headFile, outputFile] = process.argv.slice(2);
if (baseFile == null || headFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/backend-memory-report.mjs <base-memory.json> <head-memory.json> <report.md>');
process.exit(1);
}
const numberFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
});
const phases = [
{
key: 'afterGc',
title: 'After GC',
},
];
const metrics = [
'HeapUsed',
'Pss',
'Private_Dirty',
'VmRSS',
'External',
];
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatMemory(valueKiB) {
return `${formatNumber(valueKiB / 1024)} MB`;
}
function formatPercent(value) {
return `${formatNumber(value)}%`;
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text).replaceAll('\\%', '\\\\%')}}}$`;
}
function formatDiff(baseKiB, headKiB) {
const diff = headKiB - baseKiB;
if (diff === 0) return formatMemory(0);
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatMemory(Math.abs(diff))}`, diff);
}
function formatDiffPercent(baseKiB, headKiB) {
const diff = headKiB - baseKiB;
if (diff === 0) return '0%';
if (baseKiB <= 0) return '-';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs((diff * 100) / baseKiB))}`, diff);
}
function getMemoryValue(report, phase, metric) {
const value = report?.[phase]?.[metric];
return Number.isFinite(value) ? value : null;
}
function median(values) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
function getSampleValues(report, phase, metric) {
if (!Array.isArray(report?.samples)) return [];
return report.samples
.map(sample => getMemoryValue(sample, phase, metric))
.filter(value => Number.isFinite(value));
}
function getSampleSpread(report, phase, metric) {
const values = getSampleValues(report, phase, metric);
if (values.length < 2) return null;
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
function renderTable(base, head, phase) {
const lines = [
'| Metric | Base | Head | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
for (const metric of metrics) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) continue;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
lines.push(`| ${metric} | ${formatMemory(baseValue)} <br> ± ${formatMemory(baseSpread)} | ${formatMemory(headValue)} <br> ± ${formatMemory(headSpread)} | ${formatDiff(baseValue, headValue)} | ${formatDiffPercent(baseValue, headValue)} |`);
}
return lines.join('\n');
}
function getDiffPercent(base, head, phase, metric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null || baseValue <= 0) return null;
return ((headValue - baseValue) * 100) / baseValue;
}
function getWarningMetric(base, head) {
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS']) {
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
return metric;
}
}
return null;
}
function isBeyondSampleNoise(base, head, phase, metric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) return false;
const diff = headValue - baseValue;
if (diff <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return diff > combinedSpread * 3;
}
function workflowFooter() {
const repository = process.env.GITHUB_REPOSITORY;
const runId = process.env.GITHUB_RUN_ID;
if (repository == null || runId == null) {
return 'See workflow logs for details.';
}
return `[See workflow logs for details](https://github.com/${repository}/actions/runs/${runId})`;
}
function measurementSummary(base, head) {
const baseCount = base?.sampleCount;
const headCount = head?.sampleCount;
const strategy = base?.comparison?.strategy;
if (baseCount == null || headCount == null) return null;
if (strategy === 'interleaved-pairs') {
const rounds = base?.comparison?.rounds ?? baseCount;
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
}
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
}
const base = JSON.parse(await readFile(baseFile, 'utf8'));
const head = JSON.parse(await readFile(headFile, 'utf8'));
const lines = [
'## Backend Memory Usage Report',
'',
];
const summary = measurementSummary(base, head);
if (summary != null) {
lines.push(summary);
lines.push('');
}
for (const phase of phases) {
lines.push(`### ${phase.title}`);
lines.push(renderTable(base, head, phase.key));
lines.push('');
}
const warningMetric = getWarningMetric(base, head);
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
lines.push('');
}
lines.push(workflowFooter());
await writeFile(outputFile, `${lines.join('\n')}\n`);

View file

@ -1,276 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
const [beforeFile, afterFile, outputFile] = process.argv.slice(2);
if (beforeFile == null || afterFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <before-stats.json> <after-stats.json> <report.md>');
process.exit(1);
}
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatPercent(value) {
return `${Math.round(value)}%`;
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return formatPercent((value / total) * 100);
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiff(before, after, formatter) {
const diff = after - before;
if (diff === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff);
}
function formatDiffPercent(before, after) {
if (before === 0 && after === 0) return '0%';
if (before === 0) return '-';
const diff = after - before;
if (diff === 0) return '0%';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff);
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function collectReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${formatDiff(before.summary[key], after.summary[key], formatNumber)}</td>`),
...metrics.map((key) => `<td>${formatDiff(before.metrics[key], after.metrics[key], formatBytes)}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
const beforeData = JSON.parse(await readFile(beforeFile, 'utf8'));
const afterData = JSON.parse(await readFile(afterFile, 'utf8'));
const before = collectReport(beforeData);
const after = collectReport(afterData);
const lines = [
'## Frontend Bundle Report',
'',
...renderSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
await writeFile(outputFile, `${lines.join('\n')}\n`);

View file

@ -1,8 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
@ -33,47 +40,88 @@ async function* walk(dir) {
}
}
function formatBytes(size) {
if (size == null) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${stripTrailingZeros((size / 1024).toFixed(1))} KiB`;
return `${stripTrailingZeros((size / 1024 / 1024).toFixed(2))} MiB`;
function formatNumber(value) {
return numberFormatter.format(value);
}
function stripTrailingZeros(value) {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatMathText(text) {
function escapeLatex(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\\\%');
.replaceAll('%', '\\%');
}
function formatDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
function formatColoredDiff(text, diff) {
if (diff === 0) return text;
const color = diff > 0 ? 'orange' : 'green';
const sign = diff > 0 ? '+' : '-';
const text = `${sign}${formatBytes(Math.abs(diff))}`;
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
}
function formatDiffPercent(beforeSize, afterSize) {
if (beforeSize == null || beforeSize === 0 || afterSize == null || afterSize === 0) return '-';
const diff = afterSize - beforeSize;
function formatNumberDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
return formatColoredDiff(formatNumber(Math.abs(diff)), diff);
}
function formatBytesDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
if (diff === 0) return '0 B';
return formatColoredDiff(formatBytes(Math.abs(diff)), diff);
}
function formatDiffPercent(before, after) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const diff = after - before;
if (diff === 0) return `0%`;
const percent = Math.round(diff / beforeSize * 100);
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(percent.toString() + '%')}}}$`;
const percent = Math.round(diff / before * 100);
return formatColoredDiff(`${percent}%`, diff);
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return Math.round((value / total) * 100) + '%';
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName || entry.file;
@ -174,19 +222,148 @@ async function collectReport(repoDir) {
};
}
function commonKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] != null);
function collectVisualizerReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function addedKeys(before, after) {
return Object.keys(after.chunks)
.filter((key) => before.chunks[key] == null);
}
function renderVisualizerSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
function removedKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] == null);
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
function getChunkComparisonRows(keys, before, after) {
@ -201,12 +378,32 @@ function getChunkComparisonRows(keys, before, after) {
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function markdownTable(rows, total) {
function summarizeChunkChanges(rows) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
removed: rows.filter((row) => row.changeType === 'removed').length,
};
}
function formatChunkChangeSummary(label, summary) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareChunkComparisonRows(a, b) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
function chunkMarkdownTable(rows, total) {
if (rows.length === 0) return '_No data_';
const lines = [
@ -214,131 +411,140 @@ function markdownTable(rows, total) {
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatBytesDiff(total.beforeSize, total.afterSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
}
}
return lines.join('\n');
}
function chunkRows(keys, report) {
return keys.map((key) => {
const entry = report.chunks[key];
return {
key,
name: entryDisplayName(entry),
chunkFile: entry.file,
size: entry.size,
};
});
}
function markdownChunkTable(rows) {
if (rows.length === 0) return '_No data_';
const lines = [
'| Chunk | Size |',
'| --- | ---: |',
function renderFrontendChunkReport(before, after) {
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
const allChunkKeys = [
...commonChunkKeys,
...addedChunkKeys,
...removedChunkKeys,
];
for (const row of rows) {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.size)} |`);
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
return [
'<details open>',
`<summary>${formatChunkChangeSummary('Diffs', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
].join('\n');
}
function renderFrontendBundleReport(before, after) {
const lines = [
...renderVisualizerSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
return lines.join('\n');
}
const beforeDir = process.argv[2];
const afterDir = process.argv[3];
const outFile = process.argv[4];
const beforeSha = process.env.BASE_SHA;
const afterSha = process.env.HEAD_SHA;
const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const commonChunkKeys = commonKeys(before, after);
const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const diffRows = comparisonRows
.filter((row) => row.beforeSize !== row.afterSize)
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name))
.slice(0, 30);
const diffTotal = {
beforeSize: comparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: comparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const addedRows = chunkRows(addedKeys(before, after), after)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const removedRows = chunkRows(removedKeys(before, after), before)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name));
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const largeRows = comparisonRows
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
.slice(0, 30);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8'));
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8'));
const body = [
marker,
`## Frontend chunk report (${locale})`,
'',
'<details open>',
`<summary>Diffs</summary>`,
`## Frontend Chunk Report`,
'',
markdownTable(diffRows, diffTotal),
renderFrontendChunkReport(before, after),
'',
'</details>',
'',
'<details>',
`<summary>Added (${addedRows.length})</summary>`,
'',
markdownChunkTable(addedRows),
'',
'</details>',
'',
'<details>',
`<summary>Removed (${removedRows.length})</summary>`,
'',
markdownChunkTable(removedRows),
'',
'</details>',
'',
'<details>',
`<summary>Startup</summary>`,
'',
markdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
'<details>',
`<summary>Largest</summary>`,
'',
markdownTable(largeRows),
'',
'</details>',
'## Frontend Bundle Report',
'',
renderFrontendBundleReport(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
].join('\n');
await fs.writeFile(outFile, body);

View file

@ -0,0 +1,224 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
console.error('Usage: node .github/scripts/measure-backend-memory-comparison.mjs <base-dir> <head-dir> <base-output.json> <head-output.json>');
process.exit(1);
}
function readIntegerEnv(name, defaultValue, min) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
function commandName(command) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(commandName(command), args, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
if (options.logStdout) process.stderr.write(data);
});
child.stderr.on('data', data => {
stderr += data;
process.stderr.write(data);
});
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolvePromise(stdout);
} else {
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
}
});
});
}
async function resetState(repoDir) {
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
const pg = require('pg');
const Redis = require('ioredis');
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
try {
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
} finally {
await postgres.end();
}
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
try {
await redis.flushall();
} finally {
redis.disconnect();
}
}
function median(values) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
function summarizeSamples(samples) {
const summary = {};
for (const phase of phases) {
summary[phase] = {};
const metricKeys = new Set();
for (const sample of samples) {
for (const key of Object.keys(sample[phase] ?? {})) {
metricKeys.add(key);
}
}
for (const key of metricKeys) {
const values = samples
.map(sample => sample[phase]?.[key])
.filter(value => Number.isFinite(value));
if (values.length > 0) summary[phase][key] = median(values);
}
}
return summary;
}
async function measureRepo(label, repoDir, round, orderIndex) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState(repoDir);
process.stderr.write(`[${label}] Running migrations\n`);
await run('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
env: process.env,
logStdout: true,
});
process.stderr.write(`[${label}] Measuring memory\n`);
const stdout = await run('node', ['packages/backend/scripts/measure-memory.mjs'], {
cwd: repoDir,
env: {
...process.env,
MK_MEMORY_SAMPLE_COUNT: '1',
},
});
const report = JSON.parse(stdout);
const sample = report.samples?.[0] ?? {
timestamp: report.timestamp,
beforeGc: report.beforeGc,
afterGc: report.afterGc,
afterRequest: report.afterRequest,
};
return {
...sample,
label,
round,
orderIndex,
};
}
async function main() {
const baseDir = resolve(baseDirArg);
const headDir = resolve(headDirArg);
const baseOutput = resolve(baseOutputArg);
const headOutput = resolve(headOutputArg);
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
const repos = {
base: {
dir: baseDir,
samples: [],
},
head: {
dir: headDir,
samples: [],
},
};
for (let round = 1; round <= warmupRounds; round++) {
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
for (const label of ['base', 'head']) {
await measureRepo(label, repos[label].dir, -round, 0);
}
}
for (let round = 1; round <= rounds; round++) {
const order = round % 2 === 1 ? ['base', 'head'] : ['head', 'base'];
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const [orderIndex, label] of order.entries()) {
const sample = await measureRepo(label, repos[label].dir, round, orderIndex);
repos[label].samples.push(sample);
}
}
for (const label of ['base', 'head']) {
const report = {
timestamp: new Date().toISOString(),
sampleCount: repos[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
startedAt,
},
...summarizeSamples(repos[label].samples),
samples: repos[label].samples,
};
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View file

@ -26,7 +26,6 @@ on:
- pnpm-workspace.yaml
- .node-version
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@ -104,6 +103,9 @@ jobs:
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) return { done: true, run };
core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`);
return { done: true, run: null };
}
return { done: false, run: null };
@ -126,8 +128,30 @@ jobs:
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
- name: Download bundle report from workflow_run
- name: Find bundle report artifact
if: github.event_name == 'workflow_run'
id: find-report-artifact
uses: actions/github-script@v9
with:
script: |
const artifactName = 'frontend-bundle-report';
const { owner, repo } = context.repo;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: context.payload.workflow_run.id,
per_page: 100,
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) {
core.setOutput('exists', 'true');
} else {
core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`);
core.setOutput('exists', 'false');
}
- name: Download bundle report from workflow_run
if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true'
uses: actions/download-artifact@v8
with:
name: frontend-bundle-report
@ -147,7 +171,7 @@ jobs:
run-id: ${{ steps.find-report-run.outputs.run-id }}
- name: Comment on pull request
if: github.event_name == 'workflow_run' || steps.find-report-run.outputs.run-id != ''
if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != ''
uses: actions/github-script@v9
with:
github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }}
@ -160,30 +184,25 @@ jobs:
const reportMarkers = [jsSizeMarker, visualizerMarker];
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
const visualizerReportPath = path.join(reportDir, 'frontend-bundle-visualizer-report.md');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
const headShaPath = path.join(reportDir, 'head-sha.txt');
const workflowRun = context.payload.workflow_run;
const pullRequest = context.payload.pull_request;
const headSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
const { owner, repo } = context.repo;
if (!fs.existsSync(jsSizeReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
return;
}
if (!fs.existsSync(visualizerReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-bundle-visualizer-report.md.');
return;
}
const artifactHeadSha = fs.existsSync(headShaPath)
? fs.readFileSync(headShaPath, 'utf8').trim()
: null;
if (headSha != null && artifactHeadSha != null && artifactHeadSha !== headSha) {
core.setFailed(`The artifact head SHA (${artifactHeadSha}) does not match the workflow head SHA (${headSha}).`);
return;
if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) {
core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`);
}
const reportHeadSha = artifactHeadSha ?? eventHeadSha;
const artifactPrNumber = fs.existsSync(prNumberPath)
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
@ -203,25 +222,29 @@ jobs:
}
}
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner,
repo,
commit_sha: headSha,
per_page: 100,
});
for (const pullRequest of pullRequestsForCommit) {
associatedPullRequests.set(pullRequest.number, pullRequest);
if (reportHeadSha != null) {
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner,
repo,
commit_sha: reportHeadSha,
per_page: 100,
});
for (const pullRequest of pullRequestsForCommit) {
associatedPullRequests.set(pullRequest.number, pullRequest);
}
}
if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) {
issue_number = artifactPrNumber;
} else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) {
issue_number = artifactPrNumber;
} else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) {
issue_number = [...associatedPullRequests.keys()][0];
} else if (Number.isInteger(artifactPrNumber)) {
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${headSha}.`);
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`);
return;
} else {
core.setFailed(`Could not determine the pull request associated with ${headSha}.`);
core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`);
return;
}
} else {
@ -229,16 +252,23 @@ jobs:
return;
}
const currentPullRequest = await github.rest.pulls.get({
owner,
repo,
pull_number: issue_number,
});
const currentHeadSha = currentPullRequest.data.head?.sha;
if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) {
core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`);
return;
}
const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim();
if (!jsSizeReport.includes(jsSizeMarker)) {
core.setFailed('The frontend JS size report is missing the expected marker.');
return;
}
const visualizerReport = fs.readFileSync(visualizerReportPath, 'utf8').trim();
let body = [
jsSizeReport,
visualizerReport,
].join('\n\n') + '\n';
let body = `${jsSizeReport}\n`;
const maxCommentLength = 65_000;
if (body.length > maxCommentLength) {

View file

@ -21,7 +21,6 @@ on:
- pnpm-workspace.yaml
- .node-version
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@ -56,21 +55,25 @@ jobs:
path: after
submodules: true
- name: Backport visualizer tooling to base if needed
- name: Check base visualizer support
id: check-base-visualizer
shell: bash
run: |
if ! grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
cp after/packages/frontend/package.json before/packages/frontend/package.json
cp after/packages/frontend/vite.config.ts before/packages/frontend/vite.config.ts
cp after/pnpm-lock.yaml before/pnpm-lock.yaml
if grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
echo 'supported=true' >> "$GITHUB_OUTPUT"
else
echo 'supported=false' >> "$GITHUB_OUTPUT"
echo 'Base commit does not support frontend bundle visualizer. Skipping frontend bundle report.' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Setup pnpm
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
@ -80,17 +83,21 @@ jobs:
after/pnpm-lock.yaml
- name: Install dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare report output
if: steps.check-base-visualizer.outputs.supported == 'true'
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
- name: Build frontend report for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
@ -99,14 +106,17 @@ jobs:
run: pnpm --filter frontend run build
- name: Install dependencies for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
run: pnpm --filter "frontend^..." run build
- name: Build frontend report for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
@ -115,6 +125,7 @@ jobs:
run: pnpm --filter frontend run build
- name: Generate report markdown
if: steps.check-base-visualizer.outputs.supported == 'true'
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@ -122,25 +133,23 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/frontend-js-size-report.md"
node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-visualizer-report.md"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check report
if: steps.check-base-visualizer.outputs.supported == 'true'
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
test -s "$REPORT_DIR/before-stats.json"
test -s "$REPORT_DIR/after-stats.json"
test -s "$REPORT_DIR/frontend-js-size-report.md"
test -s "$REPORT_DIR/frontend-bundle-visualizer-report.md"
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
printf '\n\n' >> "$GITHUB_STEP_SUMMARY"
cat "$REPORT_DIR/frontend-bundle-visualizer-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-report

View file

@ -9,7 +9,9 @@ on:
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/scripts/backend-memory-report.mjs
- .github/workflows/get-backend-memory.yml
- .github/workflows/report-backend-memory.yml
jobs:
get-memory-usage:
@ -17,15 +19,6 @@ jobs:
permissions:
contents: read
strategy:
matrix:
memory-json-name: [memory-base.json, memory-head.json]
include:
- memory-json-name: memory-base.json
ref: ${{ github.base_ref }}
- memory-json-name: memory-head.json
ref: refs/pull/${{ github.event.number }}/merge
services:
postgres:
image: postgres:18
@ -40,37 +33,70 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
ref: ${{ matrix.ref }}
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: head/package.json
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
node-version-file: 'head/.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
cache-dependency-path: |
base/pnpm-lock.yaml
head/pnpm-lock.yaml
- name: Install base dependencies
working-directory: base
run: pnpm i --frozen-lockfile
- name: Check base pnpm-lock.yaml
working-directory: base
run: git diff --exit-code pnpm-lock.yaml
- name: Copy Configure
run: cp .github/misskey/test.yml .config/default.yml
- name: Compile Configure
run: pnpm compile-config
- name: Build
run: pnpm build
- name: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
- name: Configure base
working-directory: base
run: |
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build base
working-directory: base
run: pnpm build
- name: Install head dependencies
working-directory: head
run: pnpm i --frozen-lockfile
- name: Check head pnpm-lock.yaml
working-directory: head
run: git diff --exit-code pnpm-lock.yaml
- name: Configure head
working-directory: head
run: |
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build head
working-directory: head
run: pnpm build
- name: Measure backend memory usage
env:
MK_MEMORY_COMPARE_ROUNDS: 5
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
run: node head/.github/scripts/measure-backend-memory-comparison.mjs base head memory-base.json memory-head.json
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
name: memory-artifact-results
path: |
memory-base.json
memory-head.json
save-pr-number:
runs-on: ubuntu-latest

View file

@ -11,9 +11,14 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Download artifact
uses: actions/github-script@v9
with:
@ -48,120 +53,9 @@ jobs:
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Compare memory usage
id: compare
run: |
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
variation() {
calc() {
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0")
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0")
DIFF=$((HEAD - BASE))
if [ "$BASE" -gt 0 ]; then
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
else
DIFF_PERCENT=0
fi
# Convert KB to MB for readability
BASE_MB=$(echo "scale=2; $BASE / 1024" | bc)
HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc)
JSON=$(jq -c -n \
--argjson base "$BASE_MB" \
--argjson head "$HEAD_MB" \
--argjson diff "$DIFF_MB" \
--argjson diff_percent "$DIFF_PERCENT" \
'{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson VmRSS "$(calc $1 VmRSS)" \
--argjson VmHWM "$(calc $1 VmHWM)" \
--argjson VmSize "$(calc $1 VmSize)" \
--argjson VmData "$(calc $1 VmData)" \
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson beforeGc "$(variation beforeGc)" \
--argjson afterGc "$(variation afterGc)" \
--argjson afterRequest "$(variation afterRequest)" \
'{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}')
echo "res=$JSON" >> "$GITHUB_OUTPUT"
- id: build-comment
name: Build memory comment
env:
RES: ${{ steps.compare.outputs.res }}
run: |
HEADER="## Backend memory usage comparison"
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
echo "$HEADER" > ./output.md
echo >> ./output.md
table() {
echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md
echo "|--------|------:|------:|------:|------:|" >> ./output.md
line() {
METRIC=$2
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff")
DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent")
if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then
DIFF="+$DIFF"
DIFF_PERCENT="+$DIFF_PERCENT"
fi
# highlight VmRSS
if [ "$2" = "VmRSS" ]; then
METRIC="**${METRIC}**"
BASE="**${BASE}**"
HEAD="**${HEAD}**"
DIFF="**${DIFF}**"
DIFF_PERCENT="**${DIFF_PERCENT}**"
fi
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
}
line $1 VmRSS
line $1 VmHWM
line $1 VmSize
line $1 VmData
}
echo "### Before GC" >> ./output.md
table beforeGc
echo >> ./output.md
echo "### After GC" >> ./output.md
table afterGc
echo >> ./output.md
echo "### After Request" >> ./output.md
table afterRequest
echo >> ./output.md
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi
echo "$FOOTER" >> ./output.md
run: node .github/scripts/backend-memory-report.mjs ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}

View file

@ -1,3 +1,16 @@
## Unreleased
### General
- Feat: 外部 OpenID Connect プロバイダー (Authentik 等) を用いた SSO ログインに対応 (設定ファイルの `sso.oidc`)
- Feat: 既存のアカウントの設定画面から外部 OIDC アカウントを連携・解除できるように
### Client
- Enhance: ノートのフッターに「詳細」「リモートで表示」ボタンを追加 (メニューから移動)
### Server
-
## 2026.6.0
### General

View file

@ -2,7 +2,7 @@
_lang_: "English"
headlineMisskey: "A network connected by notes"
introMisskey: "Welcome! Misskey is an open source, decentralized microblogging service.\nCreate \"notes\" to share your thoughts with everyone around you. 📡\nWith \"reactions\", you can also quickly express your feelings about everyone's notes. 👍\nLet's explore a new world! 🚀"
poweredByMisskeyDescription: "{name} is one of the services powered by the open source platform <b>Misskey</b> (referred to as a \"Misskey instance\")."
poweredByMisskeyDescription: '{name} is one of the services powered by the open source platform <b>Misskey</b> (referred to as a "Misskey instance").'
monthAndDay: "{month}/{day}"
search: "Search"
reset: "Reset"
@ -81,7 +81,7 @@ import: "Import"
export: "Export"
files: "Files"
download: "Download"
driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted."
driveFileDeleteConfirm: 'Are you sure you want to delete "{name}"? All notes with this file attached will also be deleted.'
unfollowConfirm: "Are you sure you want to unfollow {name}?"
cancelFollowRequestConfirm: "Are you sure that you want to cancel your follow request to {name}?"
rejectFollowRequestConfirm: "Are you sure that you want to reject the follow request from {name}?"
@ -138,7 +138,7 @@ pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when
emojiPickerDisplay: "Emoji picker display"
overwriteFromPinnedEmojisForReaction: "Override from reaction settings"
overwriteFromPinnedEmojis: "Override from general settings"
reactionSettingDescription2: "Drag to reorder, click to delete, press \"+\" to add."
reactionSettingDescription2: 'Drag to reorder, click to delete, press "+" to add.'
rememberNoteVisibility: "Remember note visibility settings"
attachCancel: "Remove attachment"
deleteFile: "Delete file"
@ -287,8 +287,8 @@ announcements: "Announcements"
imageUrl: "Image URL"
remove: "Delete"
removed: "Successfully deleted"
removeAreYouSure: "Are you sure that you want to remove \"{x}\"?"
deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?"
removeAreYouSure: 'Are you sure that you want to remove "{x}"?'
deleteAreYouSure: 'Are you sure that you want to delete "{x}"?'
resetAreYouSure: "Really reset?"
areYouSure: "Are you sure?"
saved: "Saved"
@ -331,7 +331,7 @@ dark: "Dark"
lightThemes: "Light themes"
darkThemes: "Dark themes"
syncDeviceDarkMode: "Sync Dark Mode with your device settings"
switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" is turned on. Would you like to turn off synchronization and switch modes manually?"
switchDarkModeManuallyWhenSyncEnabledConfirm: '"{x}" is turned on. Would you like to turn off synchronization and switch modes manually?'
drive: "Drive"
fileName: "Filename"
selectFile: "Select a file"
@ -399,7 +399,7 @@ bannerUrl: "Banner image URL"
backgroundImageUrl: "Background image URL"
basicInfo: "Basic info"
pinnedUsers: "Pinned users"
pinnedUsersDescription: "List usernames separated by line breaks to be pinned in the \"Explore\" tab."
pinnedUsersDescription: 'List usernames separated by line breaks to be pinned in the "Explore" tab.'
pinnedPages: "Pinned Pages"
pinnedPagesDescription: "Enter the paths of the Pages you want to pin to the top page of this instance, separated by line breaks."
pinnedClipId: "ID of the clip to pin"
@ -475,7 +475,7 @@ unregister: "Unregister"
passwordLessLogin: "Password-less login"
passwordLessLoginDescription: "Allows password-less login using a security- or passkey only"
resetPassword: "Reset password"
newPasswordIs: "The new password is \"{password}\""
newPasswordIs: 'The new password is "{password}"'
reduceUiAnimation: "Reduce UI animations"
share: "Share"
notFound: "Not found"
@ -576,7 +576,7 @@ objectStorageUseSSL: "Use SSL"
objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections"
objectStorageUseProxy: "Connect over Proxy"
objectStorageUseProxyDesc: "Turn this off if you are not going to use a Proxy for API connections"
objectStorageSetPublicRead: "Set \"public-read\" on upload"
objectStorageSetPublicRead: 'Set "public-read" on upload'
s3ForcePathStyleDesc: "If s3ForcePathStyle is enabled, the bucket name has to included in the path of the URL as opposed to the hostname of the URL. You may need to enable this setting when using services such as a self-hosted Minio instance."
serverLogs: "Server logs"
deleteAll: "Delete all"
@ -703,7 +703,7 @@ regexpError: "Regular Expression error"
regexpErrorDescription: "An error occurred in the regular expression on line {line} of your {tab} word mutes:"
instanceMute: "Instance Mutes"
userSaysSomething: "{name} said something"
userSaysSomethingAbout: "{name} said something about \"{word}\""
userSaysSomethingAbout: '{name} said something about "{word}"'
makeActive: "Activate"
display: "Display"
copy: "Copy"
@ -752,7 +752,7 @@ createNew: "Create new"
optional: "Optional"
createNewClip: "Create new clip"
unclip: "Unclip"
confirmToUnclipAlreadyClippedNote: "This note is already part of the \"{name}\" clip. Do you want to remove it from this clip instead?"
confirmToUnclipAlreadyClippedNote: 'This note is already part of the "{name}" clip. Do you want to remove it from this clip instead?'
removeFromAntenna: "Remove from this antenna"
removeNoteFromAntennaConfirm: "Are you sure you want to remove this note from {name}?"
public: "Public"
@ -777,7 +777,7 @@ driveFilesCount: "Number of Drive files"
driveUsage: "Drive space usage"
noCrawle: "Reject crawler indexing"
noCrawleDescription: "Ask search engines to not index your profile page, notes, Pages, etc."
lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", your notes will be visible to anyone, even if you require followers to be manually approved."
lockedAccountInfo: 'Unless you set your note visiblity to "Followers only", your notes will be visible to anyone, even if you require followers to be manually approved.'
alwaysMarkSensitive: "Mark as sensitive by default"
loadRawImages: "Load original images instead of showing thumbnails"
disableShowingAnimatedImages: "Don't play animated images"
@ -796,8 +796,8 @@ experimentalFeatures: "Experimental features"
experimental: "Experimental"
thisIsExperimentalFeature: "This is an experimental feature. Its functionality is subject to change, and it may not operate as intended."
developer: "Developer"
makeExplorable: "Make account visible in \"Explore\""
makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section."
makeExplorable: 'Make account visible in "Explore"'
makeExplorableDescription: 'If you turn this off, your account will not show up in the "Explore" section.'
duplicate: "Duplicate"
left: "Left"
center: "Center"
@ -852,7 +852,7 @@ unlikeConfirm: "Really remove your like?"
fullView: "Full view"
quitFullView: "Exit full view"
addDescription: "Add description"
userPagePinTip: "You can display notes here by selecting \"Pin to profile\" from the menu of individual notes."
userPagePinTip: 'You can display notes here by selecting "Pin to profile" from the menu of individual notes.'
notSpecifiedMentionWarning: "This note contains mentions of users not included as recipients"
info: "About"
userInfo: "User information"
@ -942,7 +942,7 @@ continueThread: "View thread continuation"
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
incorrectPassword: "Incorrect password."
incorrectTotp: "The one-time password is incorrect or has expired."
voteConfirm: "Confirm your vote for \"{choice}\"?"
voteConfirm: 'Confirm your vote for "{choice}"?'
hide: "Hide"
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
welcomeBackWithName: "Welcome back, {name}"
@ -1098,7 +1098,7 @@ nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from
rolesAssignedToMe: "Roles assigned to me"
resetPasswordConfirm: "Really reset your password?"
sensitiveWords: "Sensitive words"
sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks."
sensitiveWordsDescription: 'The visibility of all notes containing any of the configured words will be set to "Home" automatically. You can list multiple by separating them via line breaks.'
sensitiveWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression."
prohibitedWords: "Prohibited words"
prohibitedWordsDescription: "Enables an error when attempting to post a note containing the set word(s). Multiple words can be set, separated by a new line."
@ -1117,7 +1117,7 @@ retryAllQueuesConfirmText: "This will temporarily increase the server load."
enableChartsForRemoteUser: "Generate remote user data charts"
enableChartsForFederatedInstances: "Generate remote instance data charts"
enableStatsForFederatedInstances: "Receive remote server stats"
showClipButtonInNoteFooter: "Add \"Clip\" to note action menu"
showClipButtonInNoteFooter: 'Add "Clip" to note action menu'
reactionsDisplaySize: "Reaction display size"
limitWidthOfReaction: "Limit the maximum width of reactions and display them in reduced size."
noteIdOrUrl: "Note ID or URL"
@ -1161,7 +1161,7 @@ displayOfNote: "Note display"
initialAccountSetting: "Profile setup"
youFollowing: "Followed"
preventAiLearning: "Reject usage in Machine Learning (Generative AI)"
preventAiLearningDescription: "Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a \"noai\" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored."
preventAiLearningDescription: 'Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a "noai" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored.'
options: "Options"
specifyUser: "Specific user"
lookupConfirm: "Do you want to look up?"
@ -1202,7 +1202,7 @@ used: "Used"
expired: "Expired"
doYouAgree: "Agree?"
beSureToReadThisAsItIsImportant: "Please read this important information."
iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree."
iHaveReadXCarefullyAndAgree: 'I have read the text "{x}" and agree.'
dialog: "Dialog"
icon: "Icon"
forYou: "For you"
@ -1261,7 +1261,7 @@ refreshing: "Refreshing..."
pullDownToRefresh: "Pull down to refresh"
useGroupedNotifications: "Display grouped notifications"
emailVerificationFailedError: "A problem occurred while verifying your email address. The link may have expired."
cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided."
cwNotationRequired: 'If "Hide content" is enabled, a description must be provided.'
doReaction: "Add reaction"
code: "Code"
reloadRequiredToApplySettings: "Reloading is required to apply the settings."
@ -1313,6 +1313,7 @@ modified: "Modified"
discard: "Discard"
thereAreNChanges: "There are {n} change(s)"
signinWithPasskey: "Sign in with Passkey"
signinWithSso: "Sign in with SSO"
unknownWebAuthnKey: "Unknown Passkey"
passkeyVerificationFailed: "Passkey verification has failed."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled."
@ -1333,7 +1334,7 @@ federationDisabled: "Federation is disabled on this server. You cannot interact
draft: "Drafts"
draftsAndScheduledNotes: "Drafts and scheduled notes"
confirmOnReact: "Confirm when reacting"
reactAreYouSure: "Would you like to add a \"{emoji}\" reaction?"
reactAreYouSure: 'Would you like to add a "{emoji}" reaction?'
markAsSensitiveConfirm: "Do you want to set this media as sensitive?"
unmarkAsSensitiveConfirm: "Do you want to remove the sensitive designation for this media?"
preferences: "Preferences"
@ -1385,7 +1386,7 @@ unmuteX: "Unmute {x}"
abort: "Abort"
tip: "Tips & Tricks"
redisplayAllTips: "Show all “Tips & Tricks” again"
hideAllTips: "Hide all \"Tips & Tricks\""
hideAllTips: 'Hide all "Tips & Tricks"'
defaultImageCompressionLevel: "Default image compression level"
defaultImageCompressionLevel_description: "Lower level preserves image quality but increases file size.<br>Higher level reduce file size, but reduce image quality."
defaultCompressionLevel: "Default compression level"
@ -1573,7 +1574,7 @@ _settings:
_preferencesProfile:
profileName: "Profile name"
profileNameDescription: "Set a name that identifies this device."
profileNameDescription2: "Example: \"Main PC\", \"Smartphone\""
profileNameDescription2: 'Example: "Main PC", "Smartphone"'
manageProfiles: "Manage Profiles"
shareSameProfileBetweenDevicesIsNotRecommended: "We do not recommend sharing the same profile across multiple devices."
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "If there are settings you wish to synchronize across multiple devices, enable the “Synchronize across multiple devices” option individually for each device."
@ -1636,11 +1637,11 @@ _announcement:
forExistingUsers: "Existing users only"
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."
needConfirmationToRead: "Require separate read confirmation"
needConfirmationToReadDescription: "A separate prompt to confirm marking this announcement as read will be displayed if enabled. This announcement will also be excluded from any \"Mark all as read\" functionality."
needConfirmationToReadDescription: 'A separate prompt to confirm marking this announcement as read will be displayed if enabled. This announcement will also be excluded from any "Mark all as read" functionality.'
end: "Archive announcement"
tooManyActiveAnnouncementDescription: "Having too many active announcements may worsen the user experience. Please consider archiving announcements that have become obsolete."
readConfirmTitle: "Mark as read?"
readConfirmText: "This will mark the contents of \"{title}\" as read."
readConfirmText: 'This will mark the contents of "{title}" as read.'
shouldNotBeUsedToPresentPermanentInfo: "It's best to use announcements to publish fresh and time-bound information, not for information that will be relevant in the long term."
dialogAnnouncementUxWarn: "Having two or more dialog-style notifications simultaneously can significantly impact the user experience, so please use them carefully."
silence: "No notification"
@ -1652,7 +1653,7 @@ _initialAccountSetting:
profileSetting: "Profile settings"
privacySetting: "Privacy settings"
theseSettingsCanEditLater: "You can always change these settings later."
youCanEditMoreSettingsInSettingsPageLater: "There are many more settings you can configure from the \"Settings\" page. Be sure to visit it later."
youCanEditMoreSettingsInSettingsPageLater: 'There are many more settings you can configure from the "Settings" page. Be sure to visit it later.'
followUsers: "Try following some users that interest you to build up your timeline."
pushNotificationDescription: "Enabling push notifications will allow you to receive notifications from {name} directly on your device."
initialAccountSettingCompleted: "Profile setup complete!"
@ -1706,18 +1707,18 @@ _initialTutorial:
localOnly: "Posting with this flag will not federate the note to other servers. Users on other servers will not be able to view these notes directly, regardless of the display settings above."
_cw:
title: "Content Warning"
description: "Instead of the body, the content written in 'comments' field will be displayed. Pressing \"read more\" will reveal the body."
description: 'Instead of the body, the content written in ''comments'' field will be displayed. Pressing "read more" will reveal the body.'
_exampleNote:
cw: "This will surely make you hungry!"
note: "Just had a chocolate-glazed donut 🍩😋"
useCases: "This is used when following the server guidelines, for necessary notes, or for self-restriction of spoiler or sensitive text."
_howToMakeAttachmentsSensitive:
title: "How to Mark Attachments as Sensitive?"
description: "For attachments that are required by server guidelines or that should not be left intact, add a \"sensitive\" flag."
description: 'For attachments that are required by server guidelines or that should not be left intact, add a "sensitive" flag.'
tryThisFile: "Try marking the image attached in this form as sensitive!"
_exampleNote:
note: "Oops, messed up opening the natto lid..."
method: "To mark an attachment as sensitive, click the file thumbnail, open the menu, and click \"Mark as Sensitive.\""
method: 'To mark an attachment as sensitive, click the file thumbnail, open the menu, and click "Mark as Sensitive."'
sensitiveSucceeded: "When attaching files, please set sensitivities in accordance with the server guidelines."
doItToContinue: "Mark the attachment file as sensitive to proceed."
_done:
@ -1952,7 +1953,7 @@ _achievements:
description: "Look at your list of achievements for at least 3 minutes"
_iLoveMisskey:
title: "I Love Misskey"
description: "Post \"I ❤ #Misskey\""
description: 'Post "I ❤ #Misskey"'
flavor: "Misskey's development team greatly appreciates your support!"
_foundTreasure:
title: "Treasure Hunt"
@ -1961,7 +1962,7 @@ _achievements:
title: "Short break"
description: "Keep Misskey opened for at least 30 minutes"
_client60min:
title: "No \"Miss\" in Misskey"
title: 'No "Miss" in Misskey'
description: "Keep Misskey opened for at least 60 minutes"
_noteDeletedWithin1min:
title: "Nevermind"
@ -1985,7 +1986,7 @@ _achievements:
description: "View your instance's charts"
_outputHelloWorldOnScratchpad:
title: "Hello, world!"
description: "Output \"hello world\" in the Scratchpad"
description: 'Output "hello world" in the Scratchpad'
_open3windows:
title: "Multi-Window"
description: "Have at least 3 windows open at the same time"
@ -2003,7 +2004,7 @@ _achievements:
description: "Has a chance to be obtained with a probability of 0.005% every 10 seconds"
_setNameToSyuilo:
title: "God Complex"
description: "Set your name to \"syuilo\""
description: 'Set your name to "syuilo"'
_passedSinceAccountCreated1:
title: "One Year Anniversary"
description: "One year has passed since your account was created"
@ -2132,7 +2133,7 @@ _role:
isBot: "Bot Users"
isSuspended: "Suspended user"
isLocked: "Private accounts"
isExplorable: "Effective user of \"make an account discoverable\""
isExplorable: 'Effective user of "make an account discoverable"'
createdLessThan: "Less than X has passed since account creation"
createdMoreThan: "More than X has passed since account creation"
followersLessThanOrEq: "Has X or fewer followers"
@ -2211,12 +2212,12 @@ _preferencesBackups:
save: "Save changes"
inputName: "Please enter a name for this backup"
cannotSave: "Saving failed"
nameAlreadyExists: "A backup called \"{name}\" already exists. Please enter a different name."
applyConfirm: "Do you really want to apply the \"{name}\" backup to this device? Existing settings of this device will be overwritten."
nameAlreadyExists: 'A backup called "{name}" already exists. Please enter a different name.'
applyConfirm: 'Do you really want to apply the "{name}" backup to this device? Existing settings of this device will be overwritten.'
saveConfirm: "Save backup as {name}?"
deleteConfirm: "Delete the {name} backup?"
renameConfirm: "Rename this backup from \"{old}\" to \"{new}\"?"
noBackups: "No backups exist. You may backup your client settings on this server by using \"Create new backup\"."
renameConfirm: 'Rename this backup from "{old}" to "{new}"?'
noBackups: 'No backups exist. You may backup your client settings on this server by using "Create new backup".'
createdAt: "Created at: {date} {time}"
updatedAt: "Updated at: {date} {time}"
cannotLoad: "Loading failed"
@ -2413,6 +2414,20 @@ _2fa:
backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentification as soon as possible if you are no longer able to use it."
backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentification app, you will be unable to access this account. Please reconfigure two-factor authentification."
moreDetailedGuideHere: "Here is detailed guide"
_sso:
connectedAccounts: "Connected external accounts"
description: "Linking an external identity provider (OIDC) account to this account lets you sign in with that account as well."
link: "Link an external account"
linkProvider: "Link with {name}"
unlink: "Unlink"
unlinkConfirm: "Unlink this external account? You will no longer be able to sign in with it afterwards."
noLinkedAccounts: "There are no linked external accounts."
lastUsedAt: "Last used"
linked: "External account linked."
unlinked: "Unlinked."
linkFailed: "Failed to link the external account."
alreadyLinkedToOther: "This external account is already linked to another account."
backToSecuritySettings: "Back to security settings"
_permissions:
"read:account": "View your account information"
"write:account": "Edit your account information"
@ -2502,7 +2517,7 @@ _permissions:
"read:chat": "Browse Chat"
_auth:
shareAccessTitle: "Granting application permissions"
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
shareAccess: 'Would you like to authorize "{name}" to access this account?'
shareAccessAsk: "Are you sure you want to authorize this application to access your account?"
permission: "{name} requests the following permissions"
permissionAsk: "This application requests the following permissions"
@ -2821,7 +2836,7 @@ _notification:
exportOfXCompleted: "Export of {x} has been completed"
login: "Someone logged in"
createToken: "An access token has been created"
createTokenDescription: "If you have no idea, delete the access token through \"{text}\"."
createTokenDescription: 'If you have no idea, delete the access token through "{text}".'
_types:
all: "All"
note: "New notes"
@ -2868,9 +2883,9 @@ _deck:
deleteProfile: "Delete profile"
introduction: "Create the perfect interface for you by arranging columns freely!"
introduction2: "Click on the + on the right of the screen to add new columns whenever you want."
widgetsIntroduction: "Please select \"Edit widgets\" in the column menu and add a widget."
widgetsIntroduction: 'Please select "Edit widgets" in the column menu and add a widget.'
useSimpleUiForNonRootPages: "Use simple UI for navigated pages"
usedAsMinWidthWhenFlexible: "Minimum width will be used for this when the \"Auto-adjust width\" option is enabled"
usedAsMinWidthWhenFlexible: 'Minimum width will be used for this when the "Auto-adjust width" option is enabled'
flexible: "Auto-adjust width"
enableSyncBetweenDevicesForProfiles: "Enable profile information sync between devices"
showHowToUse: ""
@ -3184,8 +3199,8 @@ _customEmojisManager:
_register:
uploadSettingTitle: "Upload settings"
uploadSettingDescription: "On this screen, you can configure the behavior when uploading Emojis."
directoryToCategoryLabel: "Enter the directory name in the \"category\" field"
directoryToCategoryCaption: "When you drag and drop a directory, enter the directory name in the \"category\" field."
directoryToCategoryLabel: 'Enter the directory name in the "category" field'
directoryToCategoryCaption: 'When you drag and drop a directory, enter the directory name in the "category" field.'
confirmRegisterEmojisDescription: "Register the Emojis from the list as new custom Emojis. Are you sure to continue? (To avoid overload, only {count} Emoji(s) can be registered in a single operation)"
confirmClearEmojisDescription: "Discard the edits and clear the Emojis from the list. Are you sure to continue?"
confirmUploadEmojisDescription: "Upload the dragged and dropped {count} file(s) to the drive. Are you sure to continue?"
@ -3205,7 +3220,7 @@ _embedCodeGen:
codeGeneratedDescription: "Paste the generated code into your website to embed the content."
_selfXssPrevention:
warning: "WARNING"
title: "\"Paste something on this screen\" is all a scam."
title: '"Paste something on this screen" is all a scam.'
description1: "If you paste something here, a malicious user could hijack your account or steal your personal information."
description2: "If you do not understand exactly what you are trying to paste, %cstop working right now and close this window."
description3: "For more information, please refer to this. {link}"
@ -3290,7 +3305,7 @@ _serverSetupWizard:
largeScaleServerAdvice: "Large servers may require advanced infrastructure knowledge, such as load balancing and database replication."
doYouConnectToFediverse: "Do you want to connect to the Fediverse?"
doYouConnectToFediverse_description1: "When connected to a network of distributed servers (Fediverse) content can be exchanged with other servers."
doYouConnectToFediverse_description2: "Connecting with the Fediverse is also called \"federation\""
doYouConnectToFediverse_description2: 'Connecting with the Fediverse is also called "federation"'
youCanConfigureMoreFederationSettingsLater: "Advanced settings such as specifying federated servers can be configured later."
remoteContentsCleaning: "Automatic cleanup of received contents"
remoteContentsCleaning_description: "Federation may result in a continuous inflow of content. Enabling automatic cleanup will remove outdated and unreferenced content from the server to save storage."

View file

@ -1313,6 +1313,7 @@ modified: "変更あり"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
signinWithPasskey: "パスキーでログイン"
signinWithSso: "SSOでログイン"
unknownWebAuthnKey: "登録されていないパスキーです。"
passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
@ -2466,6 +2467,21 @@ _2fa:
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
moreDetailedGuideHere: "詳細なガイドはこちら"
_sso:
connectedAccounts: "連携済みの外部アカウント"
description: "外部のIDプロバイダーOIDCアカウントをこのアカウントに連携すると、そのアカウントでもログインできるようになります。"
link: "外部アカウントを連携"
linkProvider: "{name}と連携"
unlink: "連携を解除"
unlinkConfirm: "この外部アカウントの連携を解除しますか?解除後はこのアカウントでログインできなくなります。"
noLinkedAccounts: "連携済みの外部アカウントはありません。"
lastUsedAt: "最終使用"
linked: "外部アカウントを連携しました。"
unlinked: "連携を解除しました。"
linkFailed: "外部アカウントの連携に失敗しました。"
alreadyLinkedToOther: "この外部アカウントは既に別のアカウントに連携されています。"
backToSecuritySettings: "セキュリティ設定に戻る"
_permissions:
"read:account": "アカウントの情報を見る"
"write:account": "アカウントの情報を変更する"

View file

@ -85,7 +85,7 @@ driveFileDeleteConfirm: "{name} 파일을 삭제하시겠습니까? 이
unfollowConfirm: "{name}님을 언팔로우하시겠습니까?"
cancelFollowRequestConfirm: "{name}(으)로의 팔로우 신청을 취소하시겠습니까?"
rejectFollowRequestConfirm: "{name}(으)로부터의 팔로우 신청을 거부하시겠습니까?"
exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다."
exportRequested: '내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 "드라이브"에 추가됩니다.'
importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다."
lists: "리스트"
noLists: "리스트가 없습니다"
@ -287,8 +287,8 @@ announcements: "공지사항"
imageUrl: "이미지 URL"
remove: "삭제"
removed: "삭제했습니다"
removeAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?"
deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?"
removeAreYouSure: '"{x}" 을(를) 삭제하시겠습니까?'
deleteAreYouSure: '"{x}" 을(를) 삭제하시겠습니까?'
resetAreYouSure: "초기화 하시겠습니까?"
areYouSure: "계속 진행하시겠습니까?"
saved: "저장했습니다"
@ -399,7 +399,7 @@ bannerUrl: "배너 이미지 URL"
backgroundImageUrl: "배경 이미지 URL"
basicInfo: "기본 정보"
pinnedUsers: "고정한 유저"
pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다."
pinnedUsersDescription: '"발견하기" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다.'
pinnedPages: "고정한 페이지"
pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다."
pinnedClipId: "고정할 클립의 ID"
@ -475,7 +475,7 @@ unregister: "등록 해제"
passwordLessLogin: "비밀번호 없이 로그인"
passwordLessLoginDescription: "비밀번호 없이 보안 키 또는 패스키만 사용해서 로그인합니다."
resetPassword: "비밀번호 재설정"
newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다"
newPasswordIs: '새로운 비밀번호는 "{password}" 입니다'
reduceUiAnimation: "UI의 애니메이션을 줄이기"
share: "공유"
notFound: "찾을 수 없습니다"
@ -703,7 +703,7 @@ regexpError: "정규 표현식 오류"
regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했습니다:"
instanceMute: "서버 뮤트"
userSaysSomething: "{name}님이 무언가를 말했습니다"
userSaysSomethingAbout: "{name}님이 \"{word}\"를 언급했습니다."
userSaysSomethingAbout: '{name}님이 "{word}"를 언급했습니다.'
makeActive: "활성화"
display: "보기"
copy: "복사"
@ -797,7 +797,7 @@ experimental: "실험실"
thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양이 변경되거나 정상적으로 동작하지 않을 가능성이 있습니다."
developer: "개발자"
makeExplorable: "계정을 쉽게 발견하도록 하기"
makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다."
makeExplorableDescription: '비활성화하면 "발견하기"에 나의 계정을 표시하지 않습니다.'
duplicate: "복제"
left: "왼쪽"
center: "가운데"
@ -942,7 +942,7 @@ continueThread: "글타래 더 보기"
deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까? "
incorrectPassword: "비밀번호가 올바르지 않습니다."
incorrectTotp: "OTP 번호가 틀렸거나 유효기간이 만료되어 있을 수 있습니다."
voteConfirm: "\"{choice}\"에 투표하시겠습니까?"
voteConfirm: '"{choice}"에 투표하시겠습니까?'
hide: "숨기기"
useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시"
welcomeBackWithName: "{name}님, 환영합니다."
@ -1202,7 +1202,7 @@ used: "사용됨"
expired: "만료됨"
doYouAgree: "동의하십니까?"
beSureToReadThisAsItIsImportant: "중요하므로 반드시 읽어주십시오."
iHaveReadXCarefullyAndAgree: "\"{x}\"의 내용을 읽고 동의합니다."
iHaveReadXCarefullyAndAgree: '"{x}"의 내용을 읽고 동의합니다.'
dialog: "다이얼로그"
icon: "아바타"
forYou: "나에게"
@ -1313,6 +1313,7 @@ modified: "변경 있음"
discard: "파기"
thereAreNChanges: "{n}건 변경이 있습니다."
signinWithPasskey: "패스키로 로그인"
signinWithSso: "SSO로 로그인"
unknownWebAuthnKey: "등록되지 않은 패스키입니다."
passkeyVerificationFailed: "패스키 검증을 실패했습니다."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "입력된 패스키는 정상적이나, 비밀번호 없이 로그인 하는 기능이 비활성화 되어있습니다."
@ -1333,7 +1334,7 @@ federationDisabled: "이 서버는 연합을 하지 않고 있습니다. 리모
draft: "초안"
draftsAndScheduledNotes: "초안과 예약 게시물"
confirmOnReact: "리액션할 때 확인"
reactAreYouSure: "\" {emoji} \"로 리액션하시겠습니까?"
reactAreYouSure: '" {emoji} "로 리액션하시겠습니까?'
markAsSensitiveConfirm: "이 미디어를 민감한 미디어로 설정하시겠습니까?"
unmarkAsSensitiveConfirm: "이 미디어의 민감한 미디어 지정을 해제하시겠습니까?"
preferences: "환경설정"
@ -1952,7 +1953,7 @@ _achievements:
description: "도전 과제 목록을 3분 이상 쳐다봤다"
_iLoveMisskey:
title: "I Love Misskey"
description: "\"I ❤ #Misskey\"를 게시했다"
description: '"I ❤ #Misskey"를 게시했다'
flavor: "Misskey를 이용해 주셔서 감사합니다! ― 개발 팀"
_foundTreasure:
title: "보물찾기"
@ -1961,7 +1962,7 @@ _achievements:
title: "잠시 쉬어요"
description: "클라이언트를 시작하고 30분이 경과했다"
_client60min:
title: "No \"Miss\" in Misskey"
title: 'No "Miss" in Misskey'
description: "클라이언트를 시작하고 60분이 경과했다"
_noteDeletedWithin1min:
title: "있었는데요 없었습니다"
@ -2211,12 +2212,12 @@ _preferencesBackups:
save: "현재 설정으로 덮어쓰기"
inputName: "백업 이름을 입력하세요"
cannotSave: "저장하지 못했습니다"
nameAlreadyExists: "\"{name}\" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오."
applyConfirm: "\"{name}\" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다."
nameAlreadyExists: '"{name}" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오.'
applyConfirm: '"{name}" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다.'
saveConfirm: "{name} 백업을 덮어쓰시겠습니까?"
deleteConfirm: "{name} 백업을 삭제하시겠습니까?"
renameConfirm: "{old} 백업을 {new} 백업으로 바꾸시겠습니까?"
noBackups: "저장된 백업이 없습니다. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다."
noBackups: '저장된 백업이 없습니다. "새 백업 만들기"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다.'
createdAt: "만든 날짜: {date} {time}"
updatedAt: "고친 날짜: {date} {time}"
cannotLoad: "가져오기에 실패했습니다"
@ -2413,6 +2414,20 @@ _2fa:
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
moreDetailedGuideHere: "여기에 자세한 설명이 있습니다"
_sso:
connectedAccounts: "연동된 외부 계정"
description: "외부 ID 공급자(OIDC) 계정을 이 계정에 연동하면 해당 계정으로도 로그인할 수 있게 됩니다."
link: "외부 계정 연동"
linkProvider: "{name}와(과) 연동"
unlink: "연동 해제"
unlinkConfirm: "이 외부 계정의 연동을 해제할까요? 해제하면 이 계정으로 로그인할 수 없게 됩니다."
noLinkedAccounts: "연동된 외부 계정이 없습니다."
lastUsedAt: "마지막 사용"
linked: "외부 계정을 연동했습니다."
unlinked: "연동을 해제했습니다."
linkFailed: "외부 계정 연동에 실패했습니다."
alreadyLinkedToOther: "이 외부 계정은 이미 다른 계정에 연동되어 있습니다."
backToSecuritySettings: "보안 설정으로 돌아가기"
_permissions:
"read:account": "계정의 정보를 봅니다"
"write:account": "계정의 정보를 변경합니다"
@ -2868,7 +2883,7 @@ _deck:
deleteProfile: "프로파일 삭제"
introduction: "칼럼을 조합해서 나만의 인터페이스를 구성해 보아요!"
introduction2: "나중에라도 화면 우측의 + 버튼을 눌러 새 칼럼을 추가할 수 있습니다."
widgetsIntroduction: "칼럼 메뉴의 \"위젯 편집\"에서 위젯을 추가해 주세요"
widgetsIntroduction: '칼럼 메뉴의 "위젯 편집"에서 위젯을 추가해 주세요'
useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기"
usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용됩니다"
flexible: "폭 자동 조정"
@ -3184,8 +3199,8 @@ _customEmojisManager:
_register:
uploadSettingTitle: "업로드 설정"
uploadSettingDescription: "여기서 이모지를 업로드 할 때의 동작을 설정할 수 있습니다."
directoryToCategoryLabel: "디렉토리 이름을 \"category\"로 입력하기"
directoryToCategoryCaption: "디렉토리를 드래그 앤 드롭한 경우, 디렉토리 이름을 \"category\"로 입력합니다."
directoryToCategoryLabel: '디렉토리 이름을 "category"로 입력하기'
directoryToCategoryCaption: '디렉토리를 드래그 앤 드롭한 경우, 디렉토리 이름을 "category"로 입력합니다.'
confirmRegisterEmojisDescription: "리스트에 표시되어진 이모지를 새로운 커스텀 이모지로 등록합니다. 실행할까요? (부하를 피하기 위해, 한 번에 등록할 수 있는 이모지는 {count}건까지 입니다.)"
confirmClearEmojisDescription: "편집 내용을 지우고, 목록에 표시되어진 이모지를 지웁니다. 실행할까요?"
confirmUploadEmojisDescription: "드래그 앤 드롭한 {count}개의 파일을 드라이브에 업로드 합니다. 실행할까요?"
@ -3205,7 +3220,7 @@ _embedCodeGen:
codeGeneratedDescription: "만들어진 코드를 웹 사이트에 붙여서 사용하세요."
_selfXssPrevention:
warning: "경고"
title: "“이 화면에 뭔가를 붙여넣어라\"는 것은 모두 사기입니다."
title: '“이 화면에 뭔가를 붙여넣어라"는 것은 모두 사기입니다.'
description1: "여기에 무언가를 붙여넣으면 악의적인 유저에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
description3: "자세한 내용은 여기를 확인해 주세요. {link}"

View file

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.6.0",
"version": "2026.6.0-beta.1-kyunet.2",
"codename": "nasubi",
"repository": {
"type": "git",

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddUserSsoIdentity1782199402503 {
name = 'AddUserSsoIdentity1782199402503'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "user_sso_identity" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastUsedAt" TIMESTAMP WITH TIME ZONE, "issuer" character varying(512) NOT NULL, "sub" character varying(512) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_c860890d77720817a8068ea7a24" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_9712cbad45d26350f12b14670d" ON "user_sso_identity" ("issuer") `);
await queryRunner.query(`CREATE INDEX "IDX_f1ecf493e8a2bb4599dd77a16b" ON "user_sso_identity" ("userId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6e108c01a468d54613cc02021d" ON "user_sso_identity" ("issuer", "sub") `);
await queryRunner.query(`ALTER TABLE "user_sso_identity" ADD CONSTRAINT "FK_f1ecf493e8a2bb4599dd77a16b3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_sso_identity" DROP CONSTRAINT "FK_f1ecf493e8a2bb4599dd77a16b3"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6e108c01a468d54613cc02021d"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f1ecf493e8a2bb4599dd77a16b"`);
await queryRunner.query(`DROP INDEX "public"."IDX_9712cbad45d26350f12b14670d"`);
await queryRunner.query(`DROP TABLE "user_sso_identity"`);
}
}

View file

@ -120,6 +120,7 @@
"node-html-parser": "7.1.0",
"nodemailer": "8.0.10",
"nsfwjs": "4.3.0",
"openid-client": "6.8.4",
"os-utils": "0.0.14",
"otpauth": "9.5.1",
"pg": "8.21.0",

View file

@ -20,11 +20,23 @@ import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SAMPLE_COUNT = 3; // Number of samples to measure
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
function readIntegerEnv(name, defaultValue, min) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const keys = {
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
const procStatusKeys = {
VmPeak: 0,
VmSize: 0,
VmHWM: 0,
@ -37,30 +49,152 @@ const keys = {
VmSwap: 0,
};
async function getMemoryUsage(pid) {
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const smapsRollupKeys = {
Pss: 0,
Shared_Clean: 0,
Shared_Dirty: 0,
Private_Clean: 0,
Private_Dirty: 0,
Swap: 0,
SwapPss: 0,
};
const runtimeKeys = {
HeapTotal: 0,
HeapUsed: 0,
External: 0,
ArrayBuffers: 0,
};
const memoryKeys = {
...procStatusKeys,
...smapsRollupKeys,
...runtimeKeys,
};
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
function parseMemoryFile(content, keys, path, required) {
const result = {};
for (const key of Object.keys(keys)) {
const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) {
result[key] = parseInt(match[1], 10);
} else {
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
} else if (required) {
throw new Error(`Failed to parse ${key} from ${path}`);
}
}
return result;
}
function bytesToKiB(value) {
return Math.round(value / 1024);
}
async function getMemoryUsage(pid) {
const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8');
return parseMemoryFile(status, procStatusKeys, path, true);
}
async function getSmapsRollupMemoryUsage(pid) {
const path = `/proc/${pid}/smaps_rollup`;
try {
const smapsRollup = await fs.readFile(path, 'utf-8');
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EACCES') {
process.stderr.write(`Failed to read ${path}: ${err.message}\n`);
return {};
}
throw err;
}
}
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
return new Promise((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
serverProcess.off('message', onMessage);
reject(new Error(`Timed out waiting for ${description}`));
}, timeout);
const onMessage = (message) => {
if (!predicate(message)) return;
globalThis.clearTimeout(timer);
serverProcess.off('message', onMessage);
resolve(message);
};
serverProcess.on('message', onMessage);
});
}
async function getRuntimeMemoryUsage(serverProcess) {
const response = waitForMessage(
serverProcess,
message => message != null && typeof message === 'object' && message.type === 'memory usage',
'memory usage',
);
serverProcess.send('memory usage');
const message = await response;
const memoryUsage = message.value;
return {
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
External: bytesToKiB(memoryUsage.external),
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
};
}
async function getAllMemoryUsage(serverProcess) {
const pid = serverProcess.pid;
return {
...await getMemoryUsage(pid),
...await getSmapsRollupMemoryUsage(pid),
...await getRuntimeMemoryUsage(serverProcess),
};
}
function median(values) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
function summarizeResults(results) {
const summary = {};
for (const phase of phases) {
summary[phase] = {};
for (const key of Object.keys(memoryKeys)) {
const values = results
.map(result => result[phase][key])
.filter(value => Number.isFinite(value));
if (values.length > 0) {
summary[phase][key] = median(values);
}
}
}
return result;
return summary;
}
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/entry.js'), ['expose-gc'], {
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_ONLY_SERVER: '1',
MK_NO_DAEMONS: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: [...process.execArgv, '--expose-gc'],
@ -90,15 +224,18 @@ async function measureMemory() {
});
async function triggerGc() {
const ok = new Promise((resolve) => {
serverProcess.once('message', (message) => {
if (message === 'gc ok') resolve();
});
});
const ok = waitForMessage(
serverProcess,
message => message === 'gc ok' || message === 'gc unavailable',
'GC completion',
);
serverProcess.send('gc');
await ok;
const message = await ok;
if (message === 'gc unavailable') {
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
}
await setTimeout(1000);
}
@ -139,23 +276,20 @@ async function measureMemory() {
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
const pid = serverProcess.pid;
const beforeGc = await getMemoryUsage(pid);
const beforeGc = await getAllMemoryUsage(serverProcess);
await triggerGc();
const afterGc = await getMemoryUsage(pid);
const afterGc = await getAllMemoryUsage(serverProcess);
// create some http requests to simulate load
const REQUEST_COUNT = 10;
await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
);
await triggerGc();
const afterRequest = await getMemoryUsage(pid);
const afterRequest = await getAllMemoryUsage(serverProcess);
// Stop the server
serverProcess.kill('SIGTERM');
@ -187,35 +321,27 @@ async function measureMemory() {
}
async function main() {
// 直列の方が時間的に分散されて正確そうだから直列でやる
const results = [];
for (let i = 0; i < SAMPLE_COUNT; i++) {
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
const res = await measureMemory();
results.push(res);
}
// Calculate averages
const beforeGc = structuredClone(keys);
const afterGc = structuredClone(keys);
const afterRequest = structuredClone(keys);
for (const res of results) {
for (const key of Object.keys(keys)) {
beforeGc[key] += res.beforeGc[key];
afterGc[key] += res.afterGc[key];
afterRequest[key] += res.afterRequest[key];
}
}
for (const key of Object.keys(keys)) {
beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
}
const summary = summarizeResults(results);
const result = {
timestamp: new Date().toISOString(),
beforeGc,
afterGc,
afterRequest,
sampleCount: SAMPLE_COUNT,
aggregation: 'median',
measurement: {
startupTimeoutMs: STARTUP_TIMEOUT,
memorySettleTimeMs: MEMORY_SETTLE_TIME,
ipcTimeoutMs: IPC_TIMEOUT,
requestCount: REQUEST_COUNT,
},
...summary,
samples: results,
};
// Output as JSON to stdout

View file

@ -6,6 +6,7 @@
import { NestFactory } from '@nestjs/core';
import { init } from 'slacc';
import { NestLogger } from '@/NestLogger.js';
import { envOption } from '@/env.js';
import type { Config } from '@/config.js';
let slaccInitialized = false;
@ -31,7 +32,7 @@ export async function server() {
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test') {
if (process.env.NODE_ENV !== 'test' && !envOption.noDaemons) {
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
@ -54,7 +55,9 @@ export async function jobQueue() {
});
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
if (!envOption.noDaemons) {
jobQueue.get(ChartManagementService).start();
}
return jobQueue;
}

View file

@ -91,10 +91,20 @@ process.on('message', msg => {
if (msg === 'gc') {
if (global.gc != null) {
logger.info('Manual GC triggered');
global.gc();
for (let i = 0; i < 3; i++) {
global.gc();
}
if (process.send != null) process.send('gc ok');
} else {
logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.');
if (process.send != null) process.send('gc unavailable');
}
} else if (msg === 'memory usage') {
if (process.send != null) {
process.send({
type: 'memory usage',
value: process.memoryUsage(),
});
}
}
});

View file

@ -115,6 +115,30 @@ type Source = {
enableQueryParamLogging?: boolean,
}
}
sso?: {
oidc?: {
enabled?: boolean;
name?: string;
issuer: string;
clientId: string;
clientSecret: string;
scopes?: string[];
autoProvision?: boolean;
usernameClaim?: string;
};
};
};
export type SsoOidcConfig = {
enabled: boolean;
name: string | null;
issuer: string;
clientId: string;
clientSecret: string;
scopes: string[];
autoProvision: boolean;
usernameClaim: string;
};
export type Config = {
@ -212,6 +236,7 @@ export type Config = {
perUserNotificationsMaxCount: number;
deactivateAntennaThreshold: number;
pidFile: string;
ssoOidc: SsoOidcConfig | null;
};
export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
@ -340,6 +365,25 @@ export function loadConfig(): Config {
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
pidFile: config.pidFile,
logging: config.logging,
ssoOidc: normalizeSsoOidc(config.sso?.oidc),
};
}
function normalizeSsoOidc(source: NonNullable<NonNullable<Source['sso']>['oidc']> | undefined): SsoOidcConfig | null {
if (source == null) return null;
if (!source.issuer || !source.clientId || !source.clientSecret) {
throw new Error('sso.oidc requires issuer, clientId and clientSecret');
}
return {
enabled: source.enabled ?? true,
name: source.name ?? null,
issuer: source.issuer,
clientId: source.clientId,
clientSecret: source.clientSecret,
scopes: source.scopes ?? ['openid', 'profile', 'email'],
autoProvision: source.autoProvision ?? false,
usernameClaim: source.usernameClaim ?? 'preferred_username',
};
}

View file

@ -55,6 +55,7 @@ import { RelayService } from './RelayService.js';
import { RoleService } from './RoleService.js';
import { S3Service } from './S3Service.js';
import { SignupService } from './SignupService.js';
import { SsoOidcService } from './SsoOidcService.js';
import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
@ -199,6 +200,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
const $SsoOidcService: Provider = { provide: 'SsoOidcService', useExisting: SsoOidcService };
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
@ -352,6 +354,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
SsoOidcService,
WebAuthnService,
UserBlockingService,
CacheService,
@ -502,6 +505,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
$SsoOidcService,
$WebAuthnService,
$UserBlockingService,
$CacheService,
@ -652,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
SsoOidcService,
WebAuthnService,
UserBlockingService,
CacheService,
@ -801,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
$SsoOidcService,
$WebAuthnService,
$UserBlockingService,
$CacheService,

View file

@ -0,0 +1,132 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as oidc from 'openid-client';
import type { Config, SsoOidcConfig } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
// state (CSRF nonce + PKCE verifier) lifetime: 5min, single-use
export const OIDC_STATE_TTL = 60 * 5;
export type OidcStateData = {
verifier: string;
nonce: string;
/**
* When set, the flow is a "link" flow: instead of signing in, the resolved
* identity is attached to this already-authenticated local user.
*/
userId?: string;
};
/**
* OIDC issuer discovery / authorization-request building shared by the Fastify
* SSO routes ({@link OidcClientService}) and the authenticated link API
* endpoints. Lives in CoreModule so endpoints can inject it; the signin /
* provisioning / handoff side effects stay in the Fastify adapter.
*/
@Injectable()
export class SsoOidcService {
#oidcConfigPromise: Promise<oidc.Configuration> | null = null;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
}
private get oidcConfig(): SsoOidcConfig | null {
const c = this.config.ssoOidc;
return c != null && c.enabled ? c : null;
}
@bindThis
public isAvailable(): boolean {
return this.oidcConfig != null;
}
private get callbackUrl(): string {
return `${this.config.url}/sso/oidc/callback`;
}
/**
* Lazily run OIDC issuer discovery so that an unreachable IdP at boot does
* not prevent the server from starting. The discovered Configuration is
* memoized; on failure the promise is cleared so the next request retries.
*/
@bindThis
public async getConfiguration(): Promise<oidc.Configuration> {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
throw new Error('OIDC SSO is not configured');
}
if (this.#oidcConfigPromise == null) {
this.#oidcConfigPromise = oidc.discovery(
new URL(oidcConf.issuer),
oidcConf.clientId,
oidcConf.clientSecret,
).catch((err) => {
this.#oidcConfigPromise = null;
throw err;
});
}
return this.#oidcConfigPromise;
}
/**
* Build an authorization request URL, persisting the per-request PKCE
* verifier + nonce (and, for link flows, the target userId) under a
* single-use state key in Redis.
*/
@bindThis
public async buildAuthorizationUrl(opts: { userId?: string }): Promise<string> {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
throw new Error('OIDC SSO is not configured');
}
const configuration = await this.getConfiguration();
const verifier = oidc.randomPKCECodeVerifier();
const challenge = await oidc.calculatePKCECodeChallenge(verifier);
const state = oidc.randomState();
const nonce = oidc.randomNonce();
await this.redisClient.setex(
`oidc:state:${state}`,
OIDC_STATE_TTL,
JSON.stringify({ verifier, nonce, userId: opts.userId } satisfies OidcStateData),
);
const authorizationUrl = oidc.buildAuthorizationUrl(configuration, {
redirect_uri: this.callbackUrl,
scope: oidcConf.scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state,
nonce,
});
return authorizationUrl.href;
}
/**
* Atomically read + delete the state, so a given state can only be
* consumed once. Returns null when the state is unknown or expired.
*/
@bindThis
public async consumeState(state: string): Promise<OidcStateData | null> {
const raw = await this.redisClient.getdel(`oidc:state:${state}`);
if (!raw) return null;
return JSON.parse(raw) as OidcStateData;
}
}

View file

@ -135,6 +135,8 @@ export class MetaEntityService {
noteSearchableScope: (this.config.fulltextSearch?.provider === 'meilisearch' && this.config.meilisearch?.scope === 'local') ? 'local' : 'global',
maxFileSize: this.config.maxFileSize,
federation: this.meta.federation,
ssoOidcEnabled: this.config.ssoOidc?.enabled === true,
ssoOidcName: this.config.ssoOidc?.name ?? null,
};
return packed;

View file

@ -30,6 +30,7 @@ export const DI = {
userKeypairsRepository: Symbol('userKeypairsRepository'),
userPendingsRepository: Symbol('userPendingsRepository'),
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userSsoIdentitiesRepository: Symbol('userSsoIdentitiesRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),
userListFavoritesRepository: Symbol('userListFavoritesRepository'),

View file

@ -78,6 +78,7 @@ import {
MiUserProfile,
MiUserPublickey,
MiUserSecurityKey,
MiUserSsoIdentity,
MiWebhook,
MiChatMessage,
MiChatRoom,
@ -190,6 +191,12 @@ const $userPublickeysRepository: Provider = {
inject: [DI.db],
};
const $userSsoIdentitiesRepository: Provider = {
provide: DI.userSsoIdentitiesRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserSsoIdentity).extend(miRepository as MiRepository<MiUserSsoIdentity>),
inject: [DI.db],
};
const $userListsRepository: Provider = {
provide: DI.userListsRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserList).extend(miRepository as MiRepository<MiUserList>),
@ -564,6 +571,7 @@ const $reversiGamesRepository: Provider = {
$userPendingsRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userSsoIdentitiesRepository,
$userListsRepository,
$userListFavoritesRepository,
$userListMembershipsRepository,
@ -642,6 +650,7 @@ const $reversiGamesRepository: Provider = {
$userPendingsRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userSsoIdentitiesRepository,
$userListsRepository,
$userListFavoritesRepository,
$userListMembershipsRepository,

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('user_sso_identity')
@Index(['issuer', 'sub'], { unique: true })
export class MiUserSsoIdentity {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Column('timestamp with time zone', {
nullable: true,
})
public lastUsedAt: Date | null;
/**
* The OIDC issuer this identity belongs to.
*/
@Index()
@Column('varchar', {
length: 512,
})
public issuer: string;
/**
* The stable subject identifier (`sub` claim) at the issuer.
*/
@Column('varchar', {
length: 512,
})
public sub: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
}

View file

@ -83,6 +83,7 @@ import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiUserSsoIdentity } from '@/models/UserSsoIdentity.js';
import { MiWebhook } from '@/models/Webhook.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@ -157,6 +158,7 @@ export {
MiUserProfile,
MiUserPublickey,
MiUserSecurityKey,
MiUserSsoIdentity,
MiWebhook,
MiSystemWebhook,
MiChannel,
@ -237,6 +239,7 @@ export type UserPendingsRepository = Repository<MiUserPending> & MiRepository<Mi
export type UserProfilesRepository = Repository<MiUserProfile> & MiRepository<MiUserProfile>;
export type UserPublickeysRepository = Repository<MiUserPublickey> & MiRepository<MiUserPublickey>;
export type UserSecurityKeysRepository = Repository<MiUserSecurityKey> & MiRepository<MiUserSecurityKey>;
export type UserSsoIdentitiesRepository = Repository<MiUserSsoIdentity> & MiRepository<MiUserSsoIdentity>;
export type WebhooksRepository = Repository<MiWebhook> & MiRepository<MiWebhook>;
export type SystemWebhooksRepository = Repository<MiSystemWebhook> & MiRepository<MiWebhook>;
export type ChannelsRepository = Repository<MiChannel> & MiRepository<MiChannel>;

View file

@ -309,6 +309,14 @@ export const packedMetaLiteSchema = {
enum: ['all', 'specified', 'none'],
optional: false, nullable: false,
},
ssoOidcEnabled: {
type: 'boolean',
optional: false, nullable: false,
},
ssoOidcName: {
type: 'string',
optional: false, nullable: true,
},
},
} as const;

View file

@ -70,6 +70,7 @@ import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiUserSsoIdentity } from '@/models/UserSsoIdentity.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
import { MiChannel } from '@/models/Channel.js';
@ -195,6 +196,7 @@ export const entities = [
MiUserListMembership,
MiUserNotePining,
MiUserSecurityKey,
MiUserSsoIdentity,
MiUsedUsername,
MiFollowing,
MiFollowRequest,

View file

@ -29,6 +29,7 @@ import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { OidcClientService } from './sso/OidcClientService.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { MainChannel } from './api/stream/channels/main.js';
@ -102,6 +103,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
NoteStreamingHidingService,
OpenApiServerService,
OAuth2ProviderService,
OidcClientService,
],
exports: [
ServerService,

View file

@ -31,6 +31,7 @@ import { HealthServerService } from './HealthServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { OidcClientService } from './sso/OidcClientService.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@ -68,6 +69,7 @@ export class ServerService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private loggerService: LoggerService,
private oauth2ProviderService: OAuth2ProviderService,
private oidcClientService: OidcClientService,
) {
this.logger = this.loggerService.getLogger('server', 'gray');
}
@ -148,6 +150,7 @@ export class ServerService implements OnApplicationShutdown {
fastify.register(this.wellKnownServerService.createServer);
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
fastify.register(this.oidcClientService.createServer, { prefix: '/sso/oidc' });
fastify.register(this.healthServerService.createServer, { prefix: '/healthz' });
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {

View file

@ -33,8 +33,16 @@ export class SigninService {
) {
}
/**
* Run the side effects shared by every successful signin (history record,
* login notification, new-login email) and return the user's native token.
*
* This is the redirect-flow-friendly core of {@link signin}: it does not
* touch the reply, so callers that respond with a redirect (e.g. OIDC SSO)
* can reuse it without being tied to the XHR/JSON response shape.
*/
@bindThis
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
public finalizeSignin(request: FastifyRequest, user: MiLocalUser): string {
setImmediate(async () => {
this.notificationService.createNotification(user.id, 'login', {});
@ -56,11 +64,18 @@ export class SigninService {
}
});
return user.token!;
}
@bindThis
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
const token = this.finalizeSignin(request, user);
reply.code(200);
return {
finished: true,
id: user.id,
i: user.token!,
i: token,
} satisfies Misskey.entities.SigninFlowResponse;
}
}

View file

@ -292,6 +292,9 @@ export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-
export * as 'i/registry/set' from './endpoints/i/registry/set.js';
export * as 'i/revoke-token' from './endpoints/i/revoke-token.js';
export * as 'i/signin-history' from './endpoints/i/signin-history.js';
export * as 'i/sso/oidc/generate-link-url' from './endpoints/i/sso/oidc/generate-link-url.js';
export * as 'i/sso/oidc/list' from './endpoints/i/sso/oidc/list.js';
export * as 'i/sso/oidc/unlink' from './endpoints/i/sso/oidc/unlink.js';
export * as 'i/unpin' from './endpoints/i/unpin.js';
export * as 'i/update' from './endpoints/i/update.js';
export * as 'i/update-email' from './endpoints/i/update-email.js';

View file

@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { SsoOidcService } from '@/core/SsoOidcService.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '823ed611-2311-451d-9337-90752b85ae59',
},
unavailable: {
message: 'OIDC SSO is not available on this server.',
code: 'SSO_OIDC_UNAVAILABLE',
id: '0421fab7-215f-4444-b2a8-ec92898bc922',
},
unreachable: {
message: 'Failed to reach the identity provider.',
code: 'SSO_OIDC_UNREACHABLE',
id: '94c2a42c-bade-4f7e-8896-08ca73751e5c',
},
},
res: {
type: 'object',
optional: false, nullable: false,
properties: {
url: {
type: 'string',
optional: false, nullable: false,
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userAuthService: UserAuthService,
private ssoOidcService: SsoOidcService,
) {
super(meta, paramDef, async (ps, me) => {
if (!this.ssoOidcService.isAvailable()) {
throw new ApiError(meta.errors.unavailable);
}
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
if (profile.twoFactorEnabled) {
if (ps.token == null) {
throw new Error('authentication failed');
}
try {
await this.userAuthService.twoFactorAuthenticate(profile, ps.token);
} catch (_) {
throw new Error('authentication failed');
}
}
const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
let url: string;
try {
url = await this.ssoOidcService.buildAuthorizationUrl({ userId: me.id });
} catch {
throw new ApiError(meta.errors.unreachable);
}
return { url };
});
}
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserSsoIdentitiesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
secure: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'misskey:id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
lastUsedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
issuer: {
type: 'string',
optional: false, nullable: false,
},
sub: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userSsoIdentitiesRepository)
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
) {
super(meta, paramDef, async (ps, me) => {
const identities = await this.userSsoIdentitiesRepository.findBy({ userId: me.id });
return identities
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map(identity => ({
id: identity.id,
createdAt: identity.createdAt.toISOString(),
lastUsedAt: identity.lastUsedAt ? identity.lastUsedAt.toISOString() : null,
issuer: identity.issuer,
sub: identity.sub,
}));
});
}
}

View file

@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository, UserSsoIdentitiesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '9f90b4a9-af39-4d53-9b37-a967d2a91ec6',
},
noSuchIdentity: {
message: 'No such SSO identity.',
code: 'NO_SUCH_SSO_IDENTITY',
id: '39e92893-8944-4dfa-9ac9-865004def6ba',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
identityId: { type: 'string', format: 'misskey:id' },
password: { type: 'string' },
token: { type: 'string', nullable: true },
},
required: ['identityId', 'password'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSsoIdentitiesRepository)
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
private userAuthService: UserAuthService,
) {
super(meta, paramDef, async (ps, me) => {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
if (profile.twoFactorEnabled) {
if (ps.token == null) {
throw new Error('authentication failed');
}
try {
await this.userAuthService.twoFactorAuthenticate(profile, ps.token);
} catch (_) {
throw new Error('authentication failed');
}
}
const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
const identity = await this.userSsoIdentitiesRepository.findOneBy({ id: ps.identityId, userId: me.id });
if (identity == null) {
throw new ApiError(meta.errors.noSuchIdentity);
}
await this.userSsoIdentitiesRepository.delete({ id: identity.id });
});
}
}

View file

@ -0,0 +1,301 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { IsNull } from 'typeorm';
import * as oidc from 'openid-client';
import type { Config, SsoOidcConfig } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { UsersRepository, UserSsoIdentitiesRepository } from '@/models/_.js';
import type { MiLocalUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { SignupService } from '@/core/SignupService.js';
import { SsoOidcService } from '@/core/SsoOidcService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import { SigninService } from '@/server/api/SigninService.js';
import type { FastifyInstance, FastifyReply } from 'fastify';
// one-time token handoff code lifetime: 2min, single-use
const HANDOFF_TTL = 60 * 2;
@Injectable()
export class OidcClientService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userSsoIdentitiesRepository)
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
private idService: IdService,
private signupService: SignupService,
private signinService: SigninService,
private ssoOidcService: SsoOidcService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('oidc-client');
}
private get oidcConfig(): SsoOidcConfig | null {
const c = this.config.ssoOidc;
return c != null && c.enabled ? c : null;
}
private replyError(reply: FastifyReply, status: number, message: string): void {
reply.header('Cache-Control', 'no-store');
reply.code(status);
reply.header('Content-Type', 'text/plain; charset=utf-8');
reply.send(message);
}
/**
* Bounce the browser back to the in-app redirect page with the outcome of a
* link flow. The user is already signed in, so there is no token handoff.
*/
private redirectLinkResult(reply: FastifyReply, result: 'success' | 'error', reason?: string): FastifyReply {
reply.header('Cache-Control', 'no-store');
const params = new URLSearchParams({ link: result });
if (reason != null) params.set('reason', reason);
return reply.redirect(`/sso/oidc/redirect?${params.toString()}`);
}
/**
* Derive a valid, unused local username from an OIDC claim for
* auto-provisioning. Local usernames must match /^\w{1,20}$/.
*/
private async resolveProvisionUsername(claimValue: unknown): Promise<string> {
const base = (typeof claimValue === 'string' ? claimValue : '')
.replace(/[^\w]/g, '_')
.slice(0, 20)
.replace(/^_+|_+$/g, '');
const seed = base.length > 0 ? base : 'user';
// Try the sanitized value first, then fall back to suffixed variants.
for (let i = 0; i < 10; i++) {
const candidate = i === 0
? seed.slice(0, 20)
: `${seed.slice(0, 20 - 5)}_${secureRndstr(4, { chars: '0123456789abcdefghijklmnopqrstuvwxyz' })}`;
const exists = await this.usersRepository.exists({
where: { usernameLower: candidate.toLowerCase(), host: IsNull() },
});
if (!exists) return candidate;
}
throw new Error('Could not allocate a unique username for auto-provisioning');
}
@bindThis
public async createServer(fastify: FastifyInstance): Promise<void> {
fastify.get('/login', async (_request, reply) => {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
return this.replyError(reply, 404, 'OIDC SSO is not enabled.');
}
let authorizationUrl: string;
try {
authorizationUrl = await this.ssoOidcService.buildAuthorizationUrl({});
} catch (err) {
this.logger.error('OIDC issuer discovery failed', { err });
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
}
reply.header('Cache-Control', 'no-store');
return reply.redirect(authorizationUrl);
});
fastify.get('/callback', async (request, reply) => {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
return this.replyError(reply, 404, 'OIDC SSO is not enabled.');
}
const query = request.query as Record<string, string | undefined>;
const state = query.state;
if (!state) {
return this.replyError(reply, 400, 'Missing state parameter.');
}
// consumeState = atomic read + delete, so a state can only be consumed once.
const stateData = await this.ssoOidcService.consumeState(state);
if (stateData == null) {
return this.replyError(reply, 403, 'Invalid or expired login session. Please try again.');
}
const { verifier, nonce, userId } = stateData;
let configuration: oidc.Configuration;
try {
configuration = await this.ssoOidcService.getConfiguration();
} catch (err) {
this.logger.error('OIDC issuer discovery failed', { err });
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
}
let claims: oidc.IDToken | undefined;
let issuer: string;
let sub: string;
try {
const currentUrl = new URL(request.url, this.config.url);
const tokens = await oidc.authorizationCodeGrant(configuration, currentUrl, {
pkceCodeVerifier: verifier,
expectedNonce: nonce,
expectedState: state,
idTokenExpected: true,
});
claims = tokens.claims();
if (claims == null) {
throw new Error('id_token has no claims');
}
issuer = claims.iss;
sub = claims.sub;
} catch (err) {
this.logger.warn('OIDC code exchange / id_token validation failed', { err });
if (userId != null) {
return this.redirectLinkResult(reply, 'error', 'auth_failed');
}
return this.replyError(reply, 403, 'Authentication with the identity provider failed.');
}
// Link flow: attach this identity to the already-authenticated user
// rather than signing in / auto-provisioning.
if (userId != null) {
return this.handleLink(reply, issuer, sub, userId);
}
let identity = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
let user: MiLocalUser | null = null;
if (identity != null) {
user = await this.usersRepository.findOneBy({
id: identity.userId,
host: IsNull(),
}) as MiLocalUser | null;
if (user == null) {
this.logger.warn(`Dangling SSO identity for issuer=${issuer} sub=${sub}; user missing`);
return this.replyError(reply, 403, 'The linked account no longer exists.');
}
} else {
if (!oidcConf.autoProvision) {
return this.replyError(reply, 403, 'No Misskey account is linked to this identity, and auto-provisioning is disabled.');
}
let username: string;
try {
username = await this.resolveProvisionUsername(claims[oidcConf.usernameClaim]);
} catch (err) {
this.logger.error('Failed to resolve username for auto-provisioning', { err });
return this.replyError(reply, 500, 'Could not create an account automatically.');
}
try {
const { account } = await this.signupService.signup({ username, password: null });
user = account as MiLocalUser;
} catch (err) {
this.logger.error('Auto-provisioning signup failed', { err });
return this.replyError(reply, 500, 'Could not create an account automatically.');
}
identity = await this.userSsoIdentitiesRepository.insertOne({
id: this.idService.gen(),
createdAt: new Date(),
lastUsedAt: null,
issuer,
sub,
userId: user.id,
});
this.logger.info(`Auto-provisioned user ${user.id} (@${username}) for issuer=${issuer} sub=${sub}`);
}
if (user.isSuspended) {
return this.replyError(reply, 403, 'This account has been suspended.');
}
await this.userSsoIdentitiesRepository.update({ id: identity.id }, { lastUsedAt: new Date() });
const token = this.signinService.finalizeSignin(request, user);
// One-time handoff code: the SPA exchanges it for the native token,
// avoiding leaking the token via the redirect URL itself.
const handoffCode = secureRndstr(32);
await this.redisClient.setex(`oidc:handoff:${handoffCode}`, HANDOFF_TTL, token);
reply.header('Cache-Control', 'no-store');
return reply.redirect(`/sso/oidc/redirect?session=${handoffCode}`);
});
fastify.post<{ Body: { session?: string } }>('/exchange', async (request, reply) => {
reply.header('Cache-Control', 'no-store');
const session = request.body?.session;
if (!session || typeof session !== 'string') {
reply.code(400);
return { error: 'Missing session' };
}
const token = await this.redisClient.getdel(`oidc:handoff:${session}`);
if (!token) {
reply.code(401);
return { error: 'Invalid or expired session' };
}
reply.code(200);
return { token };
});
// NOTE: intentionally no catch-all here. Unmatched paths under this
// prefix (e.g. `/sso/oidc/redirect`) must fall through to the SPA
// handler so the frontend redirect page can render.
}
/**
* Attach a freshly authenticated OIDC identity to an existing local user.
* Idempotent when the identity is already linked to that same user; refuses
* when it belongs to someone else.
*/
@bindThis
private async handleLink(reply: FastifyReply, issuer: string, sub: string, userId: string): Promise<FastifyReply> {
const existing = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
if (existing != null) {
if (existing.userId === userId) {
await this.userSsoIdentitiesRepository.update({ id: existing.id }, { lastUsedAt: new Date() });
return this.redirectLinkResult(reply, 'success');
}
// The identity is already bound to a different account.
return this.redirectLinkResult(reply, 'error', 'already_linked');
}
const target = await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null;
if (target == null) {
this.logger.warn(`Link flow target user ${userId} missing for issuer=${issuer} sub=${sub}`);
return this.redirectLinkResult(reply, 'error', 'no_user');
}
if (target.isSuspended) {
return this.redirectLinkResult(reply, 'error', 'suspended');
}
await this.userSsoIdentitiesRepository.insertOne({
id: this.idService.gen(),
createdAt: new Date(),
lastUsedAt: new Date(),
issuer,
sub,
userId,
});
this.logger.info(`Linked SSO identity issuer=${issuer} sub=${sub} to user ${userId}`);
return this.redirectLinkResult(reply, 'success');
}
}

View file

@ -127,6 +127,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkReactionsViewer>
<footer :class="$style.footer">
<button :class="$style.footerButton" class="_button" :aria-label="i18n.ts.details" @click="openDetail()">
<i class="ti ti-info-circle"></i>
</button>
<button :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
@ -154,6 +157,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button v-if="remoteUrl" :class="$style.footerButton" class="_button" :aria-label="i18n.ts.showOnRemote" @click="showOnRemote()">
<i class="ti ti-external-link"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
@ -290,6 +296,7 @@ if (noteViewInterruptors.length > 0) {
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note) ?? note;
const remoteUrl = appearNote.url ?? appearNote.uri ?? null;
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
parentNote: note,
@ -483,6 +490,16 @@ async function renote() {
subscribeManuallyToNoteCapture();
}
function openDetail(): void {
if (props.mock) return;
os.pageWindow(`/notes/${appearNote.id}`);
}
function showOnRemote(): void {
if (remoteUrl == null) return;
window.open(remoteUrl, '_blank', 'noopener');
}
async function reply() {
if (props.mock) return;

View file

@ -179,6 +179,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button v-if="remoteUrl" class="_button" :class="$style.noteFooterButton" :aria-label="i18n.ts.showOnRemote" @click="showOnRemote()">
<i class="ti ti-external-link"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
@ -320,6 +323,7 @@ if (noteViewInterruptors.length > 0) {
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note) ?? note;
const remoteUrl = appearNote.url ?? appearNote.uri ?? null;
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
parentNote: note,
@ -470,6 +474,11 @@ async function renote() {
subscribeManuallyToNoteCapture();
}
function showOnRemote(): void {
if (remoteUrl == null) return;
window.open(remoteUrl, '_blank', 'noopener');
}
async function reply() {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;

View file

@ -48,6 +48,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }}
</MkButton>
</div>
<!-- 外部IdP(OIDC)ログイン -->
<template v-if="instance.ssoOidcEnabled">
<div :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
<div>
<MkButton type="button" style="margin: auto auto;" large rounded @click="onSsoOidcClick">
<i class="ti ti-login" style="font-size: medium;"></i>{{ instance.ssoOidcName ? i18n.tsx.signinWith({ x: instance.ssoOidcName }) : i18n.ts.signinWithSso }}
</MkButton>
</div>
</template>
</div>
</div>
</template>
@ -60,6 +72,7 @@ import { query, extractDomain } from '@@/js/url.js';
import { host as configHost } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
@ -85,6 +98,11 @@ const host = toUnicode(configHost);
const username = ref(props.initialUsername ?? '');
function onSsoOidcClick(): void {
// Full-page redirect into the OIDC authorization flow.
window.location.href = '/sso/oidc/login';
}
//#region Open on remote
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
switch (options.type) {

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']">
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa', 'sso']">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00">
<SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText>
@ -24,6 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<X2fa/>
<XSso/>
<SearchMarker :keywords="['signin', 'login', 'history', 'log']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template>
@ -59,6 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, markRaw } from 'vue';
import X2fa from './2fa.vue';
import XSso from './sso.vue';
import FormSection from '@/components/form/section.vue';
import FormSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';

View file

@ -0,0 +1,151 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker v-if="instance.ssoOidcEnabled" markerId="sso" :keywords="['sso', 'oidc', 'oauth', 'login']">
<FormSection :first="first">
<template #label><SearchLabel>{{ i18n.ts._sso.connectedAccounts }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts._sso.description }}</SearchText></template>
<div class="_gaps_s">
<MkLoading v-if="fetching"/>
<template v-else>
<MkInfo v-if="identities.length === 0">{{ i18n.ts._sso.noLinkedAccounts }}</MkInfo>
<div v-else class="_gaps_s">
<div v-for="identity in identities" :key="identity.id" v-panel :class="$style.item">
<div :class="$style.itemBody">
<div :class="$style.itemName">{{ providerName }}</div>
<div :class="$style.itemSub">{{ identity.sub }}</div>
<div v-if="identity.lastUsedAt" :class="$style.itemMeta">
{{ i18n.ts._sso.lastUsedAt }}: <MkTime :time="identity.lastUsedAt"/>
</div>
</div>
<MkButton danger @click="unlink(identity)">{{ i18n.ts._sso.unlink }}</MkButton>
</div>
</div>
<MkButton primary @click="link">
<i class="ti ti-link"></i> {{ providerName ? i18n.tsx._sso.linkProvider({ name: providerName }) : i18n.ts._sso.link }}
</MkButton>
</template>
</div>
</FormSection>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
type SsoIdentity = {
id: string;
createdAt: string;
lastUsedAt: string | null;
issuer: string;
sub: string;
};
withDefaults(defineProps<{
first?: boolean;
}>(), {
first: false,
});
const identities = ref<SsoIdentity[]>([]);
const fetching = ref(true);
const providerName = computed(() => instance.ssoOidcName ?? '');
async function refresh(): Promise<void> {
if (!instance.ssoOidcEnabled) return;
fetching.value = true;
try {
identities.value = await misskeyApi('i/sso/oidc/list', {});
} finally {
fetching.value = false;
}
}
async function link(): Promise<void> {
const auth = await os.authenticateDialog();
if (auth.canceled) return;
try {
const res = await os.apiWithDialog('i/sso/oidc/generate-link-url', {
password: auth.result.password,
token: auth.result.token,
});
// Hand the browser over to the identity provider; the callback returns to
// /sso/oidc/redirect, which reports the result.
window.location.href = res.url;
} catch {
// apiWithDialog already surfaced the error.
}
}
async function unlink(identity: SsoIdentity): Promise<void> {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._sso.unlinkConfirm,
});
if (canceled) return;
const auth = await os.authenticateDialog();
if (auth.canceled) return;
try {
await os.apiWithDialog('i/sso/oidc/unlink', {
identityId: identity.id,
password: auth.result.password,
token: auth.result.token,
});
os.toast(i18n.ts._sso.unlinked);
await refresh();
} catch {
// apiWithDialog already surfaced the error.
}
}
onMounted(() => {
refresh();
});
</script>
<style lang="scss" module>
.item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--MI-radius);
}
.itemBody {
flex: 1;
min-width: 0;
}
.itemName {
font-weight: 700;
}
.itemSub {
font-size: 0.85em;
opacity: 0.8;
word-break: break-all;
}
.itemMeta {
margin-top: 4px;
font-size: 0.85em;
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,128 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<MkLoading v-if="state === 'loading'"/>
<div v-else-if="state === 'linked'" class="_gaps_m" :class="$style.message">
<i class="ti ti-circle-check" :class="$style.successIcon"></i>
<div>{{ i18n.ts._sso.linked }}</div>
<MkButton primary rounded style="margin: 0 auto;" @click="backToSettings">{{ i18n.ts._sso.backToSecuritySettings }}</MkButton>
</div>
<div v-else-if="state === 'linkError'" class="_gaps_m" :class="$style.message">
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
<div>{{ linkErrorText }}</div>
<MkButton primary rounded style="margin: 0 auto;" @click="backToSettings">{{ i18n.ts._sso.backToSecuritySettings }}</MkButton>
</div>
<div v-else-if="state === 'error'" class="_gaps_m" :class="$style.message">
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
<div>{{ i18n.ts.signinFailed }}</div>
<MkButton primary rounded style="margin: 0 auto;" @click="retry">{{ i18n.ts.retry }}</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { login } from '@/accounts.js';
import { useRouter } from '@/router.js';
import { definePage } from '@/page.js';
const props = defineProps<{
session?: string;
link?: string;
reason?: string;
}>();
const router = useRouter();
const state = ref<'loading' | 'error' | 'linked' | 'linkError'>('loading');
const linkErrorText = computed(() => {
switch (props.reason) {
case 'already_linked': return i18n.ts._sso.alreadyLinkedToOther;
default: return i18n.ts._sso.linkFailed;
}
});
async function exchange(): Promise<void> {
state.value = 'loading';
if (!props.session) {
state.value = 'error';
return;
}
try {
const res = await window.fetch('/sso/oidc/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: props.session }),
});
if (!res.ok) {
state.value = 'error';
return;
}
const body = await res.json() as { token?: string };
if (!body.token) {
state.value = 'error';
return;
}
// Persists the account and reloads into the app.
await login(body.token, '/');
} catch {
state.value = 'error';
}
}
function retry(): void {
// Restart the whole OIDC flow; the one-time session code is already consumed.
window.location.href = '/sso/oidc/login';
}
function backToSettings(): void {
router.push('/settings/security');
}
onMounted(() => {
// The link flow lands here with a `link` result instead of a session code.
if (props.link != null) {
state.value = props.link === 'success' ? 'linked' : 'linkError';
return;
}
exchange();
});
definePage(() => ({
title: 'SSO',
icon: 'ti ti-login',
}));
</script>
<style lang="scss" module>
.root {
min-height: 100svh;
display: grid;
place-content: center;
padding: 32px;
box-sizing: border-box;
}
.message {
text-align: center;
}
.errorIcon {
font-size: 2.5em;
color: var(--MI_THEME-error);
}
.successIcon {
font-size: 2.5em;
color: var(--MI_THEME-success);
}
</style>

View file

@ -297,6 +297,14 @@ export const ROUTE_DEF = [{
}, {
path: '/oauth/authorize',
component: page(() => import('@/pages/oauth.vue')),
}, {
path: '/sso/oidc/redirect',
component: page(() => import('@/pages/sso-oidc-redirect.vue')),
query: {
session: 'session',
link: 'link',
reason: 'reason',
},
}, {
path: '/tags/:tag',
component: page(() => import('@/pages/tag.vue')),

View file

@ -297,10 +297,6 @@ export function getNoteMenu(props: {
});
}
function openDetail(): void {
os.pageWindow(`/notes/${appearNote.id}`);
}
async function translate(): Promise<void> {
if (props.translation.value != null) return;
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
@ -364,10 +360,6 @@ export function getNoteMenu(props: {
}
menuItems.push({
icon: 'ti ti-info-circle',
text: i18n.ts.details,
action: openDetail,
}, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
@ -380,12 +372,6 @@ export function getNoteMenu(props: {
action: () => {
copyToClipboard(link);
},
}, {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(link, '_blank', 'noopener');
},
});
} else {
const embedMenu = getNoteEmbedCodeMenu(appearNote, i18n.ts.embed);
@ -541,10 +527,6 @@ export function getNoteMenu(props: {
}
} else {
menuItems.push({
icon: 'ti ti-info-circle',
text: i18n.ts.details,
action: openDetail,
}, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
@ -557,12 +539,6 @@ export function getNoteMenu(props: {
action: () => {
copyToClipboard(link);
},
}, {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(link, '_blank', 'noopener');
},
});
} else {
const embedMenu = getNoteEmbedCodeMenu(appearNote, i18n.ts.embed);

View file

@ -5264,6 +5264,10 @@ export interface Locale extends ILocale {
*
*/
"signinWithPasskey": string;
/**
* SSOでログイン
*/
"signinWithSso": string;
/**
*
*/
@ -9377,6 +9381,60 @@ export interface Locale extends ILocale {
*/
"moreDetailedGuideHere": string;
};
"_sso": {
/**
*
*/
"connectedAccounts": string;
/**
* IDプロバイダーOIDC
*/
"description": string;
/**
*
*/
"link": string;
/**
* {name}
*/
"linkProvider": ParameterizedString<"name">;
/**
*
*/
"unlink": string;
/**
*
*/
"unlinkConfirm": string;
/**
*
*/
"noLinkedAccounts": string;
/**
* 使
*/
"lastUsedAt": string;
/**
*
*/
"linked": string;
/**
*
*/
"unlinked": string;
/**
*
*/
"linkFailed": string;
/**
*
*/
"alreadyLinkedToOther": string;
/**
*
*/
"backToSecuritySettings": string;
};
"_permissions": {
/**
*

View file

@ -1994,6 +1994,10 @@ declare namespace entities {
IRevokeTokenRequest,
ISigninHistoryRequest,
ISigninHistoryResponse,
ISsoOidcGenerateLinkUrlRequest,
ISsoOidcGenerateLinkUrlResponse,
ISsoOidcListResponse,
ISsoOidcUnlinkRequest,
IUnpinRequest,
IUnpinResponse,
IUpdateRequest,
@ -2774,6 +2778,18 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200
// @public (undocumented)
function isPureRenote(note: Note): note is PureRenote;
// @public (undocumented)
type ISsoOidcGenerateLinkUrlRequest = operations['i___sso___oidc___generate-link-url']['requestBody']['content']['application/json'];
// @public (undocumented)
type ISsoOidcGenerateLinkUrlResponse = operations['i___sso___oidc___generate-link-url']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ISsoOidcListResponse = operations['i___sso___oidc___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ISsoOidcUnlinkRequest = operations['i___sso___oidc___unlink']['requestBody']['content']['application/json'];
// @public (undocumented)
export interface IStream extends EventEmitter<StreamEvents> {
// (undocumented)

View file

@ -3425,6 +3425,42 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/sso/oidc/generate-link-url', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/sso/oidc/list', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/sso/oidc/unlink', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -463,6 +463,10 @@ import type {
IRevokeTokenRequest,
ISigninHistoryRequest,
ISigninHistoryResponse,
ISsoOidcGenerateLinkUrlRequest,
ISsoOidcGenerateLinkUrlResponse,
ISsoOidcListResponse,
ISsoOidcUnlinkRequest,
IUnpinRequest,
IUnpinResponse,
IUpdateRequest,
@ -974,6 +978,9 @@ export type Endpoints = {
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse };
'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse };
'i/sso/oidc/generate-link-url': { req: ISsoOidcGenerateLinkUrlRequest; res: ISsoOidcGenerateLinkUrlResponse };
'i/sso/oidc/list': { req: EmptyRequest; res: ISsoOidcListResponse };
'i/sso/oidc/unlink': { req: ISsoOidcUnlinkRequest; res: EmptyResponse };
'i/unpin': { req: IUnpinRequest; res: IUnpinResponse };
'i/update': { req: IUpdateRequest; res: IUpdateResponse };
'i/update-email': { req: IUpdateEmailRequest; res: IUpdateEmailResponse };

View file

@ -466,6 +466,10 @@ export type IRegistrySetRequest = operations['i___registry___set']['requestBody'
export type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json'];
export type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json'];
export type ISsoOidcGenerateLinkUrlRequest = operations['i___sso___oidc___generate-link-url']['requestBody']['content']['application/json'];
export type ISsoOidcGenerateLinkUrlResponse = operations['i___sso___oidc___generate-link-url']['responses']['200']['content']['application/json'];
export type ISsoOidcListResponse = operations['i___sso___oidc___list']['responses']['200']['content']['application/json'];
export type ISsoOidcUnlinkRequest = operations['i___sso___oidc___unlink']['requestBody']['content']['application/json'];
export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];
export type IUnpinResponse = operations['i___unpin']['responses']['200']['content']['application/json'];
export type IUpdateRequest = operations['i___update']['requestBody']['content']['application/json'];

View file

@ -2810,6 +2810,36 @@ export type paths = {
*/
post: operations['i___signin-history'];
};
'/i/sso/oidc/generate-link-url': {
/**
* i/sso/oidc/generate-link-url
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___sso___oidc___generate-link-url'];
};
'/i/sso/oidc/list': {
/**
* i/sso/oidc/list
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___sso___oidc___list'];
};
'/i/sso/oidc/unlink': {
/**
* i/sso/oidc/unlink
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___sso___oidc___unlink'];
};
'/i/unpin': {
/**
* i/unpin
@ -5555,6 +5585,8 @@ export type components = {
maxFileSize: number;
/** @enum {string} */
federation: 'all' | 'specified' | 'none';
ssoOidcEnabled: boolean;
ssoOidcName: string | null;
};
MetaDetailedOnly: {
features?: {
@ -27691,6 +27723,206 @@ export interface operations {
};
};
};
'i___sso___oidc___generate-link-url': {
requestBody: {
content: {
'application/json': {
password: string;
token?: string | null;
};
};
};
responses: {
/** @description OK (with results) */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': {
url: string;
};
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
i___sso___oidc___list: {
responses: {
/** @description OK (with results) */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': {
/** Format: misskey:id */
id: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
lastUsedAt: string | null;
issuer: string;
sub: string;
}[];
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
i___sso___oidc___unlink: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
identityId: string;
password: string;
token?: string | null;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
headers: {
[name: string]: unknown;
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
i___unpin: {
requestBody: {
content: {

171
pnpm-lock.yaml generated
View file

@ -80,7 +80,7 @@ importers:
version: 11.5.2
start-server-and-test:
specifier: 3.0.9
version: 3.0.9
version: 3.0.9(supports-color@5.5.0)
typescript:
specifier: 5.9.3
version: 5.9.3
@ -292,6 +292,9 @@ importers:
nsfwjs:
specifier: 4.3.0
version: 4.3.0(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(buffer@6.0.3)
openid-client:
specifier: 6.8.4
version: 6.8.4
os-utils:
specifier: 0.0.14
version: 0.0.14
@ -475,10 +478,10 @@ importers:
version: 8.18.1
'@typescript-eslint/eslint-plugin':
specifier: 8.61.0
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: 8.61.0
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
version: 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: 4.1.8
version: 4.1.8(vitest@4.1.8)
@ -493,7 +496,7 @@ importers:
version: 10.1.0
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
execa:
specifier: 9.6.1
version: 9.6.1
@ -846,10 +849,10 @@ importers:
version: 1.4.6
'@typescript-eslint/eslint-plugin':
specifier: 8.61.0
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: 8.61.0
version: 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: 4.1.8
version: 4.1.8(vitest@4.1.8)
@ -870,10 +873,10 @@ importers:
version: 15.17.0
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-vue:
specifier: 10.9.2
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2))
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
happy-dom:
specifier: 20.10.2
version: 20.10.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
@ -924,7 +927,7 @@ importers:
version: 3.0.5
start-server-and-test:
specifier: 3.0.9
version: 3.0.9(supports-color@10.2.2)
version: 3.0.9
storybook:
specifier: 10.4.3
version: 10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6)
@ -954,7 +957,7 @@ importers:
version: 3.3.4
vue-eslint-parser:
specifier: 10.4.1
version: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
version: 10.4.1(eslint@9.39.4)
vue-tsc:
specifier: 3.3.4
version: 3.3.4(typescript@5.9.3)
@ -1179,10 +1182,10 @@ importers:
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-vue:
specifier: 10.9.2
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@5.5.0))
vue-eslint-parser:
specifier: 10.4.1
version: 10.4.1(eslint@9.39.4)
version: 10.4.1(eslint@9.39.4)(supports-color@5.5.0)
packages/i18n:
dependencies:
@ -4546,7 +4549,7 @@ packages:
engines: {node: '>= 14'}
aiscript-vscode@https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67:
resolution: {gitHosted: true, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67}
resolution: {gitHosted: true, integrity: sha512-S4eSTHasZz29AMlnU2/zdGP8zikiDiYfYW9kNooAfwVo8OghXdxKuTDDKDAjWsbBxa1+P4uQHa4BNk9MY74rJQ==, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67}
version: 0.1.16
engines: {vscode: ^1.83.0}
@ -6782,6 +6785,9 @@ packages:
resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==}
engines: {node: '>= 20'}
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-beautify@1.15.4:
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
engines: {node: '>=14'}
@ -7632,6 +7638,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -7721,6 +7730,9 @@ packages:
peerDependencies:
typescript: ^5.x
openid-client@6.8.4:
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -9074,7 +9086,7 @@ packages:
engines: {node: '>= 0.4'}
storybook-addon-misskey-theme@https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640:
resolution: {gitHosted: true, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640}
resolution: {gitHosted: true, integrity: sha512-QaH1uZSlApQ2CZPkHfhmNm89I92L02s3MdbUPG66TmAyqMaqzxd/AvobORBjtTZ0ymUSa3ii482dRXi+fFb19w==, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640}
version: 0.0.0
peerDependencies:
'@storybook/blocks': ^7.0.0-rc.4
@ -10485,7 +10497,7 @@ snapshots:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
@ -10493,7 +10505,7 @@ snapshots:
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -10505,7 +10517,7 @@ snapshots:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
@ -10545,7 +10557,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@10.2.2))':
dependencies:
'@babel/core': 7.29.0(supports-color@10.2.2)
'@babel/helper-module-imports': 7.28.6
@ -10587,7 +10599,7 @@ snapshots:
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -10913,7 +10925,7 @@ snapshots:
'@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@ -10933,7 +10945,7 @@ snapshots:
'@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@ -12826,7 +12838,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@ -13077,12 +13089,12 @@ snapshots:
dependencies:
'@types/node': 24.13.1
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.61.0
eslint: 9.39.4
@ -13109,19 +13121,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/types': 8.61.0
@ -13133,11 +13133,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.61.0(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/types': 8.61.0
debug: 4.4.3(supports-color@10.2.2)
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13146,7 +13149,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/types': 8.61.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13160,12 +13163,12 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.4
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@ -13177,7 +13180,7 @@ snapshots:
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.4
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@ -13186,28 +13189,13 @@ snapshots:
'@typescript-eslint/types@8.61.0': {}
'@typescript-eslint/typescript-estree@8.61.0(supports-color@10.2.2)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.61.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@10.2.2)
minimatch: 10.2.5
semver: 7.8.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.61.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 10.2.5
semver: 7.8.4
tinyglobby: 0.2.17
@ -13810,9 +13798,9 @@ snapshots:
aws4@1.13.2: {}
axios@1.16.0(debug@4.4.3(supports-color@10.2.2)):
axios@1.16.0(debug@4.4.3(supports-color@5.5.0)):
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2))
follow-redirects: 1.16.0(debug@4.4.3(supports-color@5.5.0))
form-data: 4.0.6
proxy-from-env: 2.1.0
transitivePeerDependencies:
@ -14943,11 +14931,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1):
dependencies:
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
transitivePeerDependencies:
@ -14963,7 +14951,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -14974,7 +14962,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1)
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@ -14986,7 +14974,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -15021,7 +15009,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2)):
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@5.5.0)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
eslint: 9.39.4
@ -15029,11 +15017,11 @@ snapshots:
nth-check: 2.1.1
postcss-selector-parser: 7.1.1
semver: 7.8.4
vue-eslint-parser: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
vue-eslint-parser: 10.4.1(eslint@9.39.4)(supports-color@5.5.0)
xml-name-validator: 4.0.0
optionalDependencies:
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)):
dependencies:
@ -15086,7 +15074,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@ -15438,13 +15426,13 @@ snapshots:
async: 0.2.10
which: 1.3.1
follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)):
follow-redirects@1.16.0(debug@4.4.3(supports-color@5.5.0)):
optionalDependencies:
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@5.5.0)
follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
for-each@0.3.5:
dependencies:
@ -16175,6 +16163,8 @@ snapshots:
'@hapi/topo': 6.0.2
'@standard-schema/spec': 1.1.0
jose@6.2.3: {}
js-beautify@1.15.4:
dependencies:
config-chain: 1.1.13
@ -16840,7 +16830,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@ -17211,6 +17201,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
oauth4webapi@3.8.6: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -17320,6 +17312,11 @@ snapshots:
typescript: 5.9.3
yargs-parser: 21.1.1
openid-client@6.8.4:
dependencies:
jose: 6.2.3
oauth4webapi: 3.8.6
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -18195,7 +18192,7 @@ snapshots:
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
@ -18504,7 +18501,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -18804,7 +18801,7 @@ snapshots:
dependencies:
arg: 5.0.2
check-more-types: 2.24.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
execa: 5.1.1
lazy-ass: 2.0.3
tree-kill: 1.2.2
@ -18812,15 +18809,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
start-server-and-test@3.0.9(supports-color@10.2.2):
start-server-and-test@3.0.9(supports-color@5.5.0):
dependencies:
arg: 5.0.2
check-more-types: 2.24.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@5.5.0)
execa: 5.1.1
lazy-ass: 2.0.3
tree-kill: 1.2.2
wait-on: 9.0.10(debug@4.4.3(supports-color@10.2.2))
wait-on: 9.0.10(debug@4.4.3(supports-color@5.5.0))
transitivePeerDependencies:
- supports-color
@ -19640,7 +19637,7 @@ snapshots:
vue-eslint-parser@10.4.1(eslint@9.39.4):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.4
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
@ -19650,9 +19647,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2):
vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@5.5.0):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.4
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
@ -19682,9 +19679,9 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
wait-on@9.0.10(debug@4.4.3(supports-color@10.2.2)):
wait-on@9.0.10(debug@4.4.3(supports-color@5.5.0)):
dependencies:
axios: 1.16.0(debug@4.4.3(supports-color@10.2.2))
axios: 1.16.0(debug@4.4.3(supports-color@5.5.0))
joi: 18.2.1
lodash: 4.18.1
minimist: 1.2.8