This commit is contained in:
ThatOneCalculator 2022-06-28 10:11:20 -07:00
commit 4f0f4ed1ff
313 changed files with 11420 additions and 10303 deletions

View File

@ -57,6 +57,7 @@ db:
redis: redis:
host: localhost host: localhost
port: 6379 port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass #pass: example-pass
#prefix: example-prefix #prefix: example-prefix
#db: 1 #db: 1

View File

@ -12,14 +12,24 @@ You should also include the user name that made the change.
## 12.x.x (unreleased) ## 12.x.x (unreleased)
### Improvements ### Improvements
- Server: Allow GET method for some endpoints @syuilo
- Server: Add rate limit to i/notifications @tamaina - Server: Add rate limit to i/notifications @tamaina
- Client: Improve files page of control panel @syuilo - Client: Improve control panel @syuilo
- Client: Show warning in control panel when there is an unresolved abuse report @syuilo
- Make possible to delete an account by admin @syuilo
- Improve player detection in URL preview @mei23
- Add Badge Image to Push Notification #8012 @tamaina
- Client: Removing entries from a clip @futchitwo
- Server: Supports IPv6 on Redis transport. @mei23
IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`.
- Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator - Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator
- You may have to `yarn run clean-all` and `yarn set version berry` before running `yarn install` if you're still on yarn classic - You may have to `yarn run clean-all` and `yarn set version berry` before running `yarn install` if you're still on yarn classic
### Bugfixes ### Bugfixes
- Server: Fix GenerateVideoThumbnail failed @mei23 - Server: Fix GenerateVideoThumbnail failed @mei23
- Server: Ensure temp directory cleanup @Johann150 - Server: Ensure temp directory cleanup @Johann150
- favicons of federated instances not showing @syuilo
- Admin: The checkbox for blocking an instance works again @Johann150
## 12.111.1 (2022/06/13) ## 12.111.1 (2022/06/13)

View File

@ -643,6 +643,8 @@ clip: "クリップ"
createNew: "新規作成" createNew: "新規作成"
optional: "任意" optional: "任意"
createNewClip: "新しいクリップを作成" createNewClip: "新しいクリップを作成"
unclip: "クリップ解除"
confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれています。ノートをこのクリップから除外しますか?"
public: "パブリック" public: "パブリック"
i18nInfo: "Misskeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。" i18nInfo: "Misskeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。"
manageAccessTokens: "アクセストークンの管理" manageAccessTokens: "アクセストークンの管理"
@ -845,6 +847,16 @@ failedToFetchAccountInformation: "アカウント情報の取得に失敗しま
rateLimitExceeded: "レート制限を超えました" rateLimitExceeded: "レート制限を超えました"
cropImage: "画像のクロップ" cropImage: "画像のクロップ"
cropImageAsk: "画像をクロップしますか?" cropImageAsk: "画像をクロップしますか?"
file: "ファイル"
recentNHours: "直近{n}時間"
recentNDays: "直近{n}日"
noEmailServerWarning: "メールサーバーの設定がされていません。"
thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。"
recommended: "推奨"
check: "チェック"
isSystemAccount: "システムにより自動で作成・管理されているアカウントです。"
typeToConfirm: "この操作を行うには {x} と入力してください"
deleteAccount: "アカウント削除"
_emailUnavailable: _emailUnavailable:
used: "既に使用されています" used: "既に使用されています"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "12.111.1", "version": "12.112.0-beta.7",
"codename": "indigo", "codename": "indigo",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -5,6 +5,6 @@
"loader=./test/loader.js" "loader=./test/loader.js"
], ],
"slow": 1000, "slow": 1000,
"timeout": 10000, "timeout": 30000,
"exit": true "exit": true
} }

View File

@ -0,0 +1,5 @@
Font Awesome Icons
-------------------------
Ⓒ Font Awesome
CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

View File

@ -53,6 +53,7 @@
"fluent-ffmpeg": "2.1.2", "fluent-ffmpeg": "2.1.2",
"got": "12.1.0", "got": "12.1.0",
"hpagent": "0.1.2", "hpagent": "0.1.2",
"ioredis": "4.28.5",
"ip-cidr": "3.0.10", "ip-cidr": "3.0.10",
"is-svg": "4.3.2", "is-svg": "4.3.2",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
@ -60,7 +61,7 @@
"json5": "2.2.1", "json5": "2.2.1",
"json5-loader": "4.0.1", "json5-loader": "4.0.1",
"jsonld": "6.0.0", "jsonld": "6.0.0",
"jsrsasign": "10.5.24", "jsrsasign": "10.5.25",
"koa": "2.13.4", "koa": "2.13.4",
"koa-bodyparser": "4.3.0", "koa-bodyparser": "4.3.0",
"koa-favicon": "2.1.0", "koa-favicon": "2.1.0",
@ -93,7 +94,6 @@
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.17.4", "re2": "1.17.4",
"redis": "3.1.2",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rename": "1.0.4", "rename": "1.0.4",
@ -107,7 +107,7 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"style-loader": "3.3.1", "style-loader": "3.3.1",
"summaly": "2.5.1", "summaly": "2.6.0",
"syslog-pro": "1.0.0", "syslog-pro": "1.0.0",
"systeminformation": "5.11.16", "systeminformation": "5.11.16",
"tinycolor2": "1.4.2", "tinycolor2": "1.4.2",

View File

@ -19,6 +19,7 @@ export type Source = {
redis: { redis: {
host: string; host: string;
port: number; port: number;
family?: number;
pass: string; pass: string;
db?: number; db?: number;
prefix?: string; prefix?: string;

View File

@ -192,12 +192,13 @@ export const db = new DataSource({
synchronize: process.env.NODE_ENV === 'test', synchronize: process.env.NODE_ENV === 'test',
dropSchema: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test',
cache: !config.db.disableCache ? { cache: !config.db.disableCache ? {
type: 'redis', type: 'ioredis',
options: { options: {
host: config.redis.host, host: config.redis.host,
port: config.redis.port, port: config.redis.port,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass, password: config.redis.pass,
prefix: `${config.redis.prefix}:query:`, keyPrefix: `${config.redis.prefix}:query:`,
db: config.redis.db || 0, db: config.redis.db || 0,
}, },
} : false, } : false,
@ -226,7 +227,7 @@ export async function initDb(force = false) {
export async function resetDb() { export async function resetDb() {
const reset = async () => { const reset = async () => {
await redisClient.FLUSHDB(); await redisClient.flushdb();
const tables = await db.query(`SELECT relname AS "table" const tables = await db.query(`SELECT relname AS "table"
FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
WHERE nspname NOT IN ('pg_catalog', 'information_schema') WHERE nspname NOT IN ('pg_catalog', 'information_schema')

View File

@ -1,16 +1,15 @@
import * as redis from 'redis'; import Redis from 'ioredis';
import config from '@/config/index.js'; import config from '@/config/index.js';
export function createConnection() { export function createConnection() {
return redis.createClient( return new Redis({
config.redis.port, port: config.redis.port,
config.redis.host, host: config.redis.host,
{ family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass, password: config.redis.pass,
prefix: config.redis.prefix, keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db || 0, db: config.redis.db || 0,
} });
);
} }
export const subsdcriber = createConnection(); export const subsdcriber = createConnection();

View File

@ -16,11 +16,13 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi
if (me && (note.userId === me.id)) return false; if (me && (note.userId === me.id)) return false;
if (mutedWords.length > 0) { if (mutedWords.length > 0) {
if (note.text == null) return false; const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
if (text === '') return false;
const matched = mutedWords.some(filter => { const matched = mutedWords.some(filter => {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
return filter.every(keyword => note.text!.includes(keyword)); return filter.every(keyword => text.includes(keyword));
} else { } else {
// represents RegExp // represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/); const regexp = filter.match(/^\/(.+)\/(.*)$/);
@ -29,7 +31,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi
if (!regexp) return false; if (!regexp) return false;
try { try {
return new RE2(regexp[1], regexp[2]).test(note.text!); return new RE2(regexp[1], regexp[2]).test(text);
} catch (err) { } catch (err) {
// This should never happen due to input sanitisation. // This should never happen due to input sanitisation.
return false; return false;

View File

@ -1,15 +0,0 @@
export function isBlockerUserRelated(note: any, blockerUserIds: Set<string>): boolean {
if (blockerUserIds.has(note.userId)) {
return true;
}
if (note.reply != null && blockerUserIds.has(note.reply.userId)) {
return true;
}
if (note.renote != null && blockerUserIds.has(note.renote.userId)) {
return true;
}
return false;
}

View File

@ -0,0 +1,8 @@
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'],
};
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);

View File

@ -1,15 +0,0 @@
export function isMutedUserRelated(note: any, mutedUserIds: Set<string>): boolean {
if (mutedUserIds.has(note.userId)) {
return true;
}
if (note.reply != null && mutedUserIds.has(note.reply.userId)) {
return true;
}
if (note.renote != null && mutedUserIds.has(note.renote.userId)) {
return true;
}
return false;
}

View File

@ -0,0 +1,15 @@
export function isUserRelated(note: any, userIds: Set<string>): boolean {
if (userIds.has(note.userId)) {
return true;
}
if (note.reply != null && userIds.has(note.reply.userId)) {
return true;
}
if (note.renote != null && userIds.has(note.renote.userId)) {
return true;
}
return false;
}

View File

@ -1,11 +1,13 @@
import { db } from '@/db/postgre.js'; import { db } from '@/db/postgre.js';
import { Instance } from '@/models/entities/instance.js'; import { Instance } from '@/models/entities/instance.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
export const InstanceRepository = db.getRepository(Instance).extend({ export const InstanceRepository = db.getRepository(Instance).extend({
async pack( async pack(
instance: Instance, instance: Instance,
): Promise<Packed<'FederationInstance'>> { ): Promise<Packed<'FederationInstance'>> {
const meta = await fetchMeta();
return { return {
id: instance.id, id: instance.id,
caughtAt: instance.caughtAt.toISOString(), caughtAt: instance.caughtAt.toISOString(),
@ -18,6 +20,7 @@ export const InstanceRepository = db.getRepository(Instance).extend({
lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(), lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(),
isNotResponding: instance.isNotResponding, isNotResponding: instance.isNotResponding,
isSuspended: instance.isSuspended, isSuspended: instance.isSuspended,
isBlocked: meta.blockedHosts.includes(instance.host),
softwareName: instance.softwareName, softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion, softwareVersion: instance.softwareVersion,
openRegistrations: instance.openRegistrations, openRegistrations: instance.openRegistrations,
@ -26,6 +29,8 @@ export const InstanceRepository = db.getRepository(Instance).extend({
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
}; };
}, },

View File

@ -52,6 +52,10 @@ export const packedFederationInstanceSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isBlocked: {
type: 'boolean',
optional: false, nullable: false,
},
softwareName: { softwareName: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -88,6 +92,15 @@ export const packedFederationInstanceSchema = {
optional: false, nullable: true, optional: false, nullable: true,
format: 'url', format: 'url',
}, },
faviconUrl: {
type: 'string',
optional: false, nullable: true,
format: 'url',
},
themeColor: {
type: 'string',
optional: false, nullable: true,
},
infoUpdatedAt: { infoUpdatedAt: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View File

@ -6,6 +6,7 @@ export function initialize<T>(name: string, limitPerSec = -1) {
redis: { redis: {
port: config.redis.port, port: config.redis.port,
host: config.redis.host, host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass, password: config.redis.pass,
db: config.redis.db || 0, db: config.redis.db || 0,
}, },

View File

@ -201,7 +201,7 @@ export interface IApMention extends IObject {
href: string; href: string;
} }
export const isMention = (object: IObject): object is IApMention=> export const isMention = (object: IObject): object is IApMention =>
getApType(object) === 'Mention' && getApType(object) === 'Mention' &&
typeof object.href === 'string'; typeof object.href === 'string';

View File

@ -6,7 +6,11 @@ import call from './call.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res) => { export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res) => {
const body = ctx.request.body; const body = ctx.is('multipart/form-data')
? (ctx.request as any).body
: ctx.method === 'GET'
? ctx.query
: ctx.request.body;
const reply = (x?: any, y?: ApiError) => { const reply = (x?: any, y?: ApiError) => {
if (x == null) { if (x == null) {
@ -33,6 +37,9 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res
authenticate(body['i']).then(([user, app]) => { authenticate(body['i']).then(([user, app]) => {
// API invoking // API invoking
call(endpoint.name, user, app, body, ctx).then((res: any) => { call(endpoint.name, user, app, body, ctx).then((res: any) => {
if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) {
ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
}
reply(res); reply(res);
}).catch((e: ApiError) => { }).catch((e: ApiError) => {
reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e);

View File

@ -94,7 +94,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
} }
// Cast non JSON input // Cast non JSON input
if (ep.meta.requireFile && ep.params.properties) { if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) { for (const k of Object.keys(ep.params.properties)) {
const param = ep.params.properties![k]; const param = ep.params.properties![k];
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {

View File

@ -1,40 +0,0 @@
import { User } from '@/models/entities/user.js';
import { id } from '@/models/id.js';
import { UserProfiles } from '@/models/index.js';
import { SelectQueryBuilder, Brackets } from 'typeorm';
function createMutesQuery(id: string) {
return UserProfiles.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: id });
}
export function generateMutedInstanceQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
const mutingQuery = createMutesQuery(me.id);
q
.andWhere(new Brackets(qb => { qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT((${ mutingQuery.getQuery() })::jsonb ? note.userHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where(`note.replyUserHost IS NULL`)
.orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.replyUserHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where(`note.renoteUserHost IS NULL`)
.orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
}
export function generateMutedInstanceNotificationQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
const mutingQuery = createMutesQuery(me.id);
q.andWhere(new Brackets(qb => { qb
.andWhere('notifier.host IS NULL')
.orWhere(`NOT (( ${mutingQuery.getQuery()} )::jsonb ? notifier.host)`);
}));
q.setParameters(mutingQuery.getParameters());
}

View File

@ -1,6 +1,6 @@
import { User } from '@/models/entities/user.js';
import { Mutings } from '@/models/index.js';
import { SelectQueryBuilder, Brackets } from 'typeorm'; import { SelectQueryBuilder, Brackets } from 'typeorm';
import { User } from '@/models/entities/user.js';
import { Mutings, UserProfiles } from '@/models/index.js';
export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User) { export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User) {
const mutingQuery = Mutings.createQueryBuilder('muting') const mutingQuery = Mutings.createQueryBuilder('muting')
@ -11,21 +11,39 @@ export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: Use
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
} }
const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
// 投稿の作者をミュートしていない かつ // 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
q q
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => { qb
.where(`note.replyUserId IS NULL`) .where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => { qb
.where(`note.renoteUserId IS NULL`) .where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances
.andWhere(new Brackets(qb => { qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
})); }));
q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
} }
export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
@ -33,8 +51,26 @@ export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: {
.select('muting.muteeId') .select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id }); .where('muting.muterId = :muterId', { muterId: me.id });
const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
q q
.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); .andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`)
// mute instances
.andWhere(new Brackets(qb => { qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
} }

View File

@ -21,7 +21,6 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, paramDef: Ps, cb: executor<T, Ps>) export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, paramDef: Ps, cb: executor<T, Ps>)
: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => Promise<any> { : (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => Promise<any> {
const validate = ajv.compile(paramDef); const validate = ajv.compile(paramDef);
return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => { return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => {

View File

@ -59,6 +59,7 @@ import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js';
import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; import * as ep___admin_vacuum from './endpoints/admin/vacuum.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@ -99,6 +100,7 @@ import * as ep___charts_user_notes from './endpoints/charts/user/notes.js';
import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js';
import * as ep___charts_users from './endpoints/charts/users.js'; import * as ep___charts_users from './endpoints/charts/users.js';
import * as ep___clips_addNote from './endpoints/clips/add-note.js'; import * as ep___clips_addNote from './endpoints/clips/add-note.js';
import * as ep___clips_removeNote from './endpoints/clips/remove-note.js';
import * as ep___clips_create from './endpoints/clips/create.js'; import * as ep___clips_create from './endpoints/clips/create.js';
import * as ep___clips_delete from './endpoints/clips/delete.js'; import * as ep___clips_delete from './endpoints/clips/delete.js';
import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_list from './endpoints/clips/list.js';
@ -133,6 +135,7 @@ import * as ep___federation_instances from './endpoints/federation/instances.js'
import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; import * as ep___federation_showInstance from './endpoints/federation/show-instance.js';
import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js';
import * as ep___federation_users from './endpoints/federation/users.js'; import * as ep___federation_users from './endpoints/federation/users.js';
import * as ep___federation_stats from './endpoints/federation/stats.js';
import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_create from './endpoints/following/create.js';
import * as ep___following_delete from './endpoints/following/delete.js'; import * as ep___following_delete from './endpoints/following/delete.js';
import * as ep___following_invalidate from './endpoints/following/invalidate.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js';
@ -369,6 +372,7 @@ const eps = [
['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/unsuspend-user', ep___admin_unsuspendUser],
['admin/update-meta', ep___admin_updateMeta], ['admin/update-meta', ep___admin_updateMeta],
['admin/vacuum', ep___admin_vacuum], ['admin/vacuum', ep___admin_vacuum],
['admin/delete-account', ep___admin_deleteAccount],
['announcements', ep___announcements], ['announcements', ep___announcements],
['antennas/create', ep___antennas_create], ['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete], ['antennas/delete', ep___antennas_delete],
@ -409,6 +413,7 @@ const eps = [
['charts/user/reactions', ep___charts_user_reactions], ['charts/user/reactions', ep___charts_user_reactions],
['charts/users', ep___charts_users], ['charts/users', ep___charts_users],
['clips/add-note', ep___clips_addNote], ['clips/add-note', ep___clips_addNote],
['clips/remove-note', ep___clips_removeNote],
['clips/create', ep___clips_create], ['clips/create', ep___clips_create],
['clips/delete', ep___clips_delete], ['clips/delete', ep___clips_delete],
['clips/list', ep___clips_list], ['clips/list', ep___clips_list],
@ -443,6 +448,7 @@ const eps = [
['federation/show-instance', ep___federation_showInstance], ['federation/show-instance', ep___federation_showInstance],
['federation/update-remote-user', ep___federation_updateRemoteUser], ['federation/update-remote-user', ep___federation_updateRemoteUser],
['federation/users', ep___federation_users], ['federation/users', ep___federation_users],
['federation/stats', ep___federation_stats],
['following/create', ep___following_create], ['following/create', ep___following_create],
['following/delete', ep___following_delete], ['following/delete', ep___following_delete],
['following/invalidate', ep___following_invalidate], ['following/invalidate', ep___following_invalidate],
@ -699,6 +705,16 @@ export interface IEndpointMeta {
readonly kind?: string; readonly kind?: string;
readonly description?: string; readonly description?: string;
/**
* GETでのリクエストを許容するか否か
*/
readonly allowGet?: boolean;
/**
* (Cache-Control: public)
*/
readonly cacheSec?: number;
} }
export interface IEndpoint { export interface IEndpoint {

View File

@ -0,0 +1,31 @@
import { Users } from '@/models/index.js';
import { deleteAccount } from '@/services/delete-account.js';
import define from '../../define.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireAdmin: true,
res: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => {
const user = await Users.findOneByOrFail({ id: ps.userId });
if (user.isDeleted) {
return;
}
await deleteAccount(user);
});

View File

@ -1,5 +1,5 @@
import define from '../../../define.js';
import { DriveFiles } from '@/models/index.js'; import { DriveFiles } from '@/models/index.js';
import define from '../../../define.js';
import { makePaginationQuery } from '../../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../../common/make-pagination-query.js';
export const meta = { export const meta = {
@ -25,8 +25,9 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id', nullable: true },
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z0-9\/\-*]+$/.toString().slice(1, -1) }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z0-9\/\-*]+$/.toString().slice(1, -1) },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "local" }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
hostname: { hostname: {
type: 'string', type: 'string',
nullable: true, nullable: true,
@ -41,6 +42,9 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId);
if (ps.userId) {
query.andWhere('file.userId = :userId', { userId: ps.userId });
} else {
if (ps.origin === 'local') { if (ps.origin === 'local') {
query.andWhere('file.userHost IS NULL'); query.andWhere('file.userHost IS NULL');
} else if (ps.origin === 'remote') { } else if (ps.origin === 'remote') {
@ -50,6 +54,7 @@ export default define(meta, paramDef, async (ps, me) => {
if (ps.hostname) { if (ps.hostname) {
query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); query.andWhere('file.userHost = :hostname', { hostname: ps.hostname });
} }
}
if (ps.type) { if (ps.type) {
if (ps.type.endsWith('/*')) { if (ps.type.endsWith('/*')) {

View File

@ -99,12 +99,16 @@ export default define(meta, paramDef, async () => {
const fsStats = await si.fsSize(); const fsStats = await si.fsSize();
const netInterface = await si.networkInterfaceDefault(); const netInterface = await si.networkInterfaceDefault();
const redisServerInfo = await redisClient.info('Server');
const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm'));
const redis_version = m?.[1];
return { return {
machine: os.hostname(), machine: os.hostname(),
os: os.platform(), os: os.platform(),
node: process.version, node: process.version,
psql: await db.query('SHOW server_version').then(x => x[0].server_version), psql: await db.query('SHOW server_version').then(x => x[0].server_version),
redis: redisClient.server_info.redis_version, redis: redis_version,
cpu: { cpu: {
model: os.cpus()[0].model, model: os.cpus()[0].model,
cores: os.cpus().length, cores: os.cpus().length,

View File

@ -2,12 +2,13 @@ import define from '../../define.js';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { createPerson } from '@/remote/activitypub/models/person.js'; import { createPerson } from '@/remote/activitypub/models/person.js';
import { createNote } from '@/remote/activitypub/models/note.js'; import { createNote } from '@/remote/activitypub/models/note.js';
import DbResolver from '@/remote/activitypub/db-resolver.js';
import Resolver from '@/remote/activitypub/resolver.js'; import Resolver from '@/remote/activitypub/resolver.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { extractDbHost } from '@/misc/convert-host.js'; import { extractDbHost } from '@/misc/convert-host.js';
import { Users, Notes } from '@/models/index.js'; import { Users, Notes } from '@/models/index.js';
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
import { User } from '@/models/entities/user.js'; import { CacheableLocalUser, User } from '@/models/entities/user.js';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { isActor, isPost, getApId } from '@/remote/activitypub/type.js'; import { isActor, isPost, getApId } from '@/remote/activitypub/type.js';
import ms from 'ms'; import ms from 'ms';
@ -77,8 +78,8 @@ export const paramDef = {
} as const; } as const;
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => { export default define(meta, paramDef, async (ps, me) => {
const object = await fetchAny(ps.uri); const object = await fetchAny(ps.uri, me);
if (object) { if (object) {
return object; return object;
} else { } else {
@ -89,48 +90,18 @@ export default define(meta, paramDef, async (ps) => {
/*** /***
* URIからUserかNoteを解決する * URIからUserかNoteを解決する
*/ */
async function fetchAny(uri: string): Promise<SchemaType<typeof meta['res']> | null> { async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// URIがこのサーバーを指しているなら、ローカルユーザーIDとしてDBからフェッチ
if (uri.startsWith(config.url + '/')) {
const parts = uri.split('/');
const id = parts.pop();
const type = parts.pop();
if (type === 'notes') {
const note = await Notes.findOneBy({ id });
if (note) {
return {
type: 'Note',
object: await Notes.pack(note, null, { detail: true }),
};
}
} else if (type === 'users') {
const user = await Users.findOneBy({ id });
if (user) {
return {
type: 'User',
object: await Users.pack(user, null, { detail: true }),
};
}
}
}
// ブロックしてたら中断 // ブロックしてたら中断
const fetchedMeta = await fetchMeta(); const fetchedMeta = await fetchMeta();
if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null; if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null;
// URI(AP Object id)としてDB検索 const dbResolver = new DbResolver();
{
const [user, note] = await Promise.all([
Users.findOneBy({ uri: uri }),
Notes.findOneBy({ uri: uri }),
]);
const packed = await mergePack(user, note); let local = await mergePack(me, ...await Promise.all([
if (packed !== null) return packed; dbResolver.getUserFromApId(uri),
} dbResolver.getNoteFromApId(uri),
]));
if (local != null) return local;
// リモートから一旦オブジェクトフェッチ // リモートから一旦オブジェクトフェッチ
const resolver = new Resolver(); const resolver = new Resolver();
@ -139,74 +110,37 @@ async function fetchAny(uri: string): Promise<SchemaType<typeof meta['res']> | n
// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
// これはDBに存在する可能性があるため再度DB検索 // これはDBに存在する可能性があるため再度DB検索
if (uri !== object.id) { if (uri !== object.id) {
if (object.id.startsWith(config.url + '/')) { local = await mergePack(me, ...await Promise.all([
const parts = object.id.split('/'); dbResolver.getUserFromApId(object.id),
const id = parts.pop(); dbResolver.getNoteFromApId(object.id),
const type = parts.pop(); ]));
if (local != null) return local;
if (type === 'notes') {
const note = await Notes.findOneBy({ id });
if (note) {
return {
type: 'Note',
object: await Notes.pack(note, null, { detail: true }),
};
}
} else if (type === 'users') {
const user = await Users.findOneBy({ id });
if (user) {
return {
type: 'User',
object: await Users.pack(user, null, { detail: true }),
};
}
}
} }
const [user, note] = await Promise.all([ return await mergePack(
Users.findOneBy({ uri: object.id }), me,
Notes.findOneBy({ uri: object.id }), isActor(object) ? await createPerson(getApId(object)) : null,
]); isPost(object) ? await createNote(getApId(object), undefined, true) : null,
);
const packed = await mergePack(user, note);
if (packed !== null) return packed;
}
// それでもみつからなければ新規であるため登録
if (isActor(object)) {
const user = await createPerson(getApId(object));
return {
type: 'User',
object: await Users.pack(user, null, { detail: true }),
};
}
if (isPost(object)) {
const note = await createNote(getApId(object), undefined, true);
return {
type: 'Note',
object: await Notes.pack(note!, null, { detail: true }),
};
}
return null;
} }
async function mergePack(user: User | null | undefined, note: Note | null | undefined): Promise<SchemaType<typeof meta.res> | null> { async function mergePack(me: CacheableLocalUser | null | undefined, user: User | null | undefined, note: Note | null | undefined): Promise<SchemaType<typeof meta.res> | null> {
if (user != null) { if (user != null) {
return { return {
type: 'User', type: 'User',
object: await Users.pack(user, null, { detail: true }), object: await Users.pack(user, me, { detail: true }),
}; };
} } else if (note != null) {
try {
const object = await Notes.pack(note, me, { detail: true });
if (note != null) {
return { return {
type: 'Note', type: 'Note',
object: await Notes.pack(note, null, { detail: true }), object,
}; };
} catch (e) {
return null;
}
} }
return null; return null;

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { activeUsersChart } from '@/services/chart/index.js'; import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users'], tags: ['charts', 'users'],
res: getJsonSchema(activeUsersChart.schema), res: getJsonSchema(activeUsersChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { apRequestChart } from '@/services/chart/index.js'; import { apRequestChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts'], tags: ['charts'],
res: getJsonSchema(apRequestChart.schema), res: getJsonSchema(apRequestChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { driveChart } from '@/services/chart/index.js'; import { driveChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'drive'], tags: ['charts', 'drive'],
res: getJsonSchema(driveChart.schema), res: getJsonSchema(driveChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { federationChart } from '@/services/chart/index.js'; import { federationChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts'], tags: ['charts'],
res: getJsonSchema(federationChart.schema), res: getJsonSchema(federationChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { hashtagChart } from '@/services/chart/index.js'; import { hashtagChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'hashtags'], tags: ['charts', 'hashtags'],
res: getJsonSchema(hashtagChart.schema), res: getJsonSchema(hashtagChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { instanceChart } from '@/services/chart/index.js'; import { instanceChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts'], tags: ['charts'],
res: getJsonSchema(instanceChart.schema), res: getJsonSchema(instanceChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { notesChart } from '@/services/chart/index.js'; import { notesChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'notes'], tags: ['charts', 'notes'],
res: getJsonSchema(notesChart.schema), res: getJsonSchema(notesChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { perUserDriveChart } from '@/services/chart/index.js'; import { perUserDriveChart } from '@/services/chart/index.js';
import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'drive', 'users'], tags: ['charts', 'drive', 'users'],
res: getJsonSchema(perUserDriveChart.schema), res: getJsonSchema(perUserDriveChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -6,6 +6,9 @@ export const meta = {
tags: ['charts', 'users', 'following'], tags: ['charts', 'users', 'following'],
res: getJsonSchema(perUserFollowingChart.schema), res: getJsonSchema(perUserFollowingChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { perUserNotesChart } from '@/services/chart/index.js'; import { perUserNotesChart } from '@/services/chart/index.js';
import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'notes'], tags: ['charts', 'users', 'notes'],
res: getJsonSchema(perUserNotesChart.schema), res: getJsonSchema(perUserNotesChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { perUserReactionsChart } from '@/services/chart/index.js'; import { perUserReactionsChart } from '@/services/chart/index.js';
import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'reactions'], tags: ['charts', 'users', 'reactions'],
res: getJsonSchema(perUserReactionsChart.schema), res: getJsonSchema(perUserReactionsChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -1,11 +1,14 @@
import define from '../../define.js';
import { getJsonSchema } from '@/services/chart/core.js'; import { getJsonSchema } from '@/services/chart/core.js';
import { usersChart } from '@/services/chart/index.js'; import { usersChart } from '@/services/chart/index.js';
import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users'], tags: ['charts', 'users'],
res: getJsonSchema(usersChart.schema), res: getJsonSchema(usersChart.schema),
allowGet: true,
cacheSec: 60 * 60,
} as const; } as const;
export const paramDef = { export const paramDef = {

View File

@ -0,0 +1,57 @@
import define from '../../define.js';
import { ClipNotes, Clips } from '@/models/index.js';
import { ApiError } from '../../error.js';
import { getNote } from '../../common/getters.js';
export const meta = {
tags: ['account', 'notes', 'clips'],
requireCredential: true,
kind: 'write:account',
errors: {
noSuchClip: {
message: 'No such clip.',
code: 'NO_SUCH_CLIP',
id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52',
},
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'aff017de-190e-434b-893e-33a9ff5049d8',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
clipId: { type: 'string', format: 'misskey:id' },
noteId: { type: 'string', format: 'misskey:id' },
},
required: ['clipId', 'noteId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const clip = await Clips.findOneBy({
id: ps.clipId,
userId: user.id,
});
if (clip == null) {
throw new ApiError(meta.errors.noSuchClip);
}
const note = await getNote(ps.noteId).catch(e => {
if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw e;
});
await ClipNotes.delete({
noteId: note.id,
clipId: clip.id,
});
});

View File

@ -0,0 +1,64 @@
import { IsNull, MoreThan, Not } from 'typeorm';
import { Followings, Instances } from '@/models/index.js';
import { awaitAll } from '@/prelude/await-all.js';
import define from '../../define.js';
export const meta = {
tags: ['federation'],
requireCredential: false,
allowGet: true,
cacheSec: 60 * 60,
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => {
const [topSubInstances, topPubInstances, allSubCount, allPubCount] = await Promise.all([
Instances.find({
where: {
followersCount: MoreThan(0),
},
order: {
followersCount: 'DESC',
},
take: 10,
}),
Instances.find({
where: {
followingCount: MoreThan(0),
},
order: {
followingCount: 'DESC',
},
take: 10,
}),
Followings.count({
where: {
followeeHost: Not(IsNull()),
},
}),
Followings.count({
where: {
followerHost: Not(IsNull()),
},
}),
]);
const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0);
const gotPubCount = topSubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0);
return await awaitAll({
topSubInstances: Instances.packMany(topSubInstances),
otherFollowersCount: Math.max(0, allSubCount - gotSubCount),
topPubInstances: Instances.packMany(topPubInstances),
otherFollowingCount: Math.max(0, allPubCount - gotPubCount),
});
});

View File

@ -1,9 +1,7 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import define from '../../define.js';
import { UserProfiles, Users } from '@/models/index.js'; import { UserProfiles, Users } from '@/models/index.js';
import { doPostSuspend } from '@/services/suspend-user.js'; import { deleteAccount } from '@/services/delete-account.js';
import { publishUserEvent } from '@/services/stream.js'; import define from '../../define.js';
import { createDeleteAccountJob } from '@/queue/index.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
@ -34,17 +32,5 @@ export default define(meta, paramDef, async (ps, user) => {
throw new Error('incorrect password'); throw new Error('incorrect password');
} }
// 物理削除する前にDelete activityを送信する await deleteAccount(user);
await doPostSuspend(user).catch(e => {});
createDeleteAccountJob(user, {
soft: false,
});
await Users.update(user.id, {
isDeleted: true,
});
// Terminate streaming
publishUserEvent(user.id, 'terminate', {});
}); });

View File

@ -1,11 +1,10 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Notifications, Followings, Mutings, Users } from '@/models/index.js'; import { Notifications, Followings, Mutings, Users, UserProfiles } from '@/models/index.js';
import { notificationTypes } from '@/types.js'; import { notificationTypes } from '@/types.js';
import read from '@/services/note/read.js'; import read from '@/services/note/read.js';
import { readNotification } from '../../common/read-notification.js'; import { readNotification } from '../../common/read-notification.js';
import define from '../../define.js'; import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateMutedInstanceNotificationQuery } from '../../common/generate-muted-instance-query.js';
export const meta = { export const meta = {
tags: ['account', 'notifications'], tags: ['account', 'notifications'],
@ -67,6 +66,10 @@ export default define(meta, paramDef, async (ps, user) => {
.select('muting.muteeId') .select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: user.id }); .where('muting.muterId = :muterId', { muterId: user.id });
const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: user.id });
const suspendedQuery = Users.createQueryBuilder('users') const suspendedQuery = Users.createQueryBuilder('users')
.select('users.id') .select('users.id')
.where('users.isSuspended = TRUE'); .where('users.isSuspended = TRUE');
@ -89,14 +92,21 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
// muted users
query.andWhere(new Brackets(qb => { qb query.andWhere(new Brackets(qb => { qb
.where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`)
.orWhere('notification.notifierId IS NULL'); .orWhere('notification.notifierId IS NULL');
})); }));
query.setParameters(mutingQuery.getParameters()); query.setParameters(mutingQuery.getParameters());
generateMutedInstanceNotificationQuery(query, user); // muted instances
query.andWhere(new Brackets(qb => { qb
.andWhere('notifier.host IS NULL')
.orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`);
}));
query.setParameters(mutingInstanceQuery.getParameters());
// suspended users
query.andWhere(new Brackets(qb => { qb query.andWhere(new Brackets(qb => { qb
.where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`)
.orWhere('notification.notifierId IS NULL'); .orWhere('notification.notifierId IS NULL');

View File

@ -5,7 +5,6 @@ import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -61,9 +60,10 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user); if (user) {
if (user) generateBlockedUserQuery(query, user); generateMutedUserQuery(query, user);
if (user) generateMutedInstanceQuery(query, user); generateBlockedUserQuery(query, user);
}
const notes = await query.take(ps.limit).getMany(); const notes = await query.take(ps.limit).getMany();

View File

@ -1,11 +1,10 @@
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes, Users } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { activeUsersChart } from '@/services/chart/index.js'; import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js'; import define from '../../define.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -76,10 +75,11 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
if (user) generateMutedUserQuery(query, user); if (user) {
if (user) generateMutedNoteQuery(query, user); generateMutedUserQuery(query, user);
if (user) generateBlockedUserQuery(query, user); generateMutedNoteQuery(query, user);
if (user) generateMutedInstanceQuery(query, user); generateBlockedUserQuery(query, user);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');

View File

@ -1,13 +1,12 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { Followings, Notes, Users } from '@/models/index.js'; import { Followings, Notes } from '@/models/index.js';
import { activeUsersChart } from '@/services/chart/index.js'; import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js'; import define from '../../define.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js';
@ -92,7 +91,6 @@ export default define(meta, paramDef, async (ps, user) => {
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user); generateMutedUserQuery(query, user);
generateMutedInstanceQuery(query, user);
generateMutedNoteQuery(query, user); generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user); generateBlockedUserQuery(query, user);
@ -134,9 +132,7 @@ export default define(meta, paramDef, async (ps, user) => {
const timeline = await query.take(ps.limit).getMany(); const timeline = await query.take(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
if (user) {
activeUsersChart.read(user); activeUsersChart.read(user);
}
}); });
return await Notes.packMany(timeline, user); return await Notes.packMany(timeline, user);

View File

@ -5,7 +5,6 @@ import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js';
@ -84,7 +83,6 @@ export default define(meta, paramDef, async (ps, user) => {
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user); generateMutedUserQuery(query, user);
generateMutedInstanceQuery(query, user);
generateMutedNoteQuery(query, user); generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user); generateBlockedUserQuery(query, user);
@ -126,9 +124,7 @@ export default define(meta, paramDef, async (ps, user) => {
const timeline = await query.take(ps.limit).getMany(); const timeline = await query.take(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
if (user) {
activeUsersChart.read(user); activeUsersChart.read(user);
}
}); });
return await Notes.packMany(timeline, user); return await Notes.packMany(timeline, user);

View File

@ -7,7 +7,6 @@ import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js';
export const meta = { export const meta = {
tags: ['users', 'notes'], tags: ['users', 'notes'],
@ -77,9 +76,10 @@ export default define(meta, paramDef, async (ps, me) => {
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, me); generateVisibilityQuery(query, me);
if (me) generateMutedUserQuery(query, me, user); if (me) {
if (me) generateBlockedUserQuery(query, me); generateMutedUserQuery(query, me, user);
if (me) generateMutedInstanceQuery(query, me); generateBlockedUserQuery(query, me);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');

View File

@ -8,6 +8,8 @@ import multer from '@koa/multer';
import bodyParser from 'koa-bodyparser'; import bodyParser from 'koa-bodyparser';
import cors from '@koa/cors'; import cors from '@koa/cors';
import { Instances, AccessTokens, Users } from '@/models/index.js';
import config from '@/config/index.js';
import endpoints from './endpoints.js'; import endpoints from './endpoints.js';
import handler from './api-handler.js'; import handler from './api-handler.js';
import signup from './private/signup.js'; import signup from './private/signup.js';
@ -16,8 +18,6 @@ import signupPending from './private/signup-pending.js';
import discord from './service/discord.js'; import discord from './service/discord.js';
import github from './service/github.js'; import github from './service/github.js';
import twitter from './service/twitter.js'; import twitter from './service/twitter.js';
import { Instances, AccessTokens, Users } from '@/models/index.js';
import config from '@/config/index.js';
// Init app // Init app
const app = new Koa(); const app = new Koa();
@ -56,11 +56,24 @@ for (const endpoint of endpoints) {
if (endpoint.meta.requireFile) { if (endpoint.meta.requireFile) {
router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint));
} else { } else {
if (endpoint.name.includes('-')) {
// 後方互換性のため // 後方互換性のため
if (endpoint.name.includes('-')) {
router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint));
if (endpoint.meta.allowGet) {
router.get(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint));
} else {
router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; });
} }
}
router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); router.post(`/${endpoint.name}`, handler.bind(null, endpoint));
if (endpoint.meta.allowGet) {
router.get(`/${endpoint.name}`, handler.bind(null, endpoint));
} else {
router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; });
}
} }
} }

View File

@ -7,6 +7,8 @@ import { IEndpointMeta } from './endpoints.js';
const logger = new Logger('limiter'); const logger = new Logger('limiter');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => { export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
if (process.env.NODE_ENV === 'test') ok();
const hasShortTermLimit = typeof limitation.minInterval === 'number'; const hasShortTermLimit = typeof limitation.minInterval === 'number';
const hasLongTermLimit = const hasLongTermLimit =

View File

@ -1,7 +1,6 @@
import Channel from '../channel.js'; import Channel from '../channel.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js';
import { StreamMessages } from '../types.js'; import { StreamMessages } from '../types.js';
export default class extends Channel { export default class extends Channel {
@ -27,9 +26,9 @@ export default class extends Channel {
const note = await Notes.pack(data.body.id, this.user, { detail: true }); const note = await Notes.pack(data.body.id, this.user, { detail: true });
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);

View File

@ -1,7 +1,6 @@
import Channel from '../channel.js'; import Channel from '../channel.js';
import { Notes, Users } from '@/models/index.js'; import { Notes, Users } from '@/models/index.js';
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { StreamMessages } from '../types.js'; import { StreamMessages } from '../types.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
@ -45,9 +44,9 @@ export default class extends Channel {
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);

View File

@ -1,10 +1,9 @@
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { checkWordMute } from '@/misc/check-word-mute.js'; import { checkWordMute } from '@/misc/check-word-mute.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
export default class extends Channel { export default class extends Channel {
@ -55,9 +54,9 @@ export default class extends Channel {
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View File

@ -1,8 +1,7 @@
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
export default class extends Channel { export default class extends Channel {
@ -38,9 +37,9 @@ export default class extends Channel {
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);

View File

@ -1,8 +1,7 @@
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { checkWordMute } from '@/misc/check-word-mute.js'; import { checkWordMute } from '@/misc/check-word-mute.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
@ -63,9 +62,9 @@ export default class extends Channel {
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View File

@ -1,9 +1,8 @@
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { checkWordMute } from '@/misc/check-word-mute.js'; import { checkWordMute } from '@/misc/check-word-mute.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
@ -71,9 +70,9 @@ export default class extends Channel {
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View File

@ -1,9 +1,8 @@
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { checkWordMute } from '@/misc/check-word-mute.js'; import { checkWordMute } from '@/misc/check-word-mute.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
export default class extends Channel { export default class extends Channel {
@ -52,9 +51,9 @@ export default class extends Channel {
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (iUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する // 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)

View File

@ -1,8 +1,7 @@
import Channel from '../channel.js'; import Channel from '../channel.js';
import { Notes, UserListJoinings, UserLists } from '@/models/index.js'; import { Notes, UserListJoinings, UserLists } from '@/models/index.js';
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
export default class extends Channel { export default class extends Channel {
@ -76,9 +75,9 @@ export default class extends Channel {
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isBlockerUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
this.send('note', note); this.send('note', note);
} }

View File

@ -1,13 +1,16 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import Koa from 'koa'; import Koa from 'koa';
import { serverLogger } from '../index.js'; import sharp from 'sharp';
import { IImage, convertToWebp } from '@/services/drive/image-processor.js'; import { IImage, convertToWebp } from '@/services/drive/image-processor.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js'; import { downloadUrl } from '@/misc/download-url.js';
import { detectType } from '@/misc/get-file-info.js'; import { detectType } from '@/misc/get-file-info.js';
import { StatusError } from '@/misc/fetch.js'; import { StatusError } from '@/misc/fetch.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { serverLogger } from '../index.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function proxyMedia(ctx: Koa.Context) { export async function proxyMedia(ctx: Koa.Context) {
const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url;
@ -23,14 +26,50 @@ export async function proxyMedia(ctx: Koa.Context) {
await downloadUrl(url, path); await downloadUrl(url, path);
const { mime, ext } = await detectType(path); const { mime, ext } = await detectType(path);
const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image');
let image: IImage; let image: IImage;
if ('static' in ctx.query && ['image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'].includes(mime)) { if ('static' in ctx.query && isConvertibleImage) {
image = await convertToWebp(path, 498, 280); image = await convertToWebp(path, 498, 280);
} else if ('preview' in ctx.query && ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/svg+xml'].includes(mime)) { } else if ('preview' in ctx.query && isConvertibleImage) {
image = await convertToWebp(path, 200, 200); image = await convertToWebp(path, 200, 200);
} else if (['image/svg+xml'].includes(mime)) { } else if ('badge' in ctx.query) {
if (!isConvertibleImage) {
// 画像でないなら404でお茶を濁す
throw new StatusError('Unexpected mime', 404);
}
const mask = sharp(path)
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
})
.greyscale()
.normalise()
.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
.flatten({ background: '#000' })
.toColorspace('b-w');
const stats = await mask.clone().stats();
if (stats.entropy < 0.1) {
// エントロピーがあまりない場合は404にする
throw new StatusError('Skip to provide badge', 404);
}
const data = sharp({
create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.pipelineColorspace('b-w')
.boolean(await mask.png().toBuffer(), 'eor');
image = {
data: await data.png().toBuffer(),
ext: 'png',
type: 'image/png',
};
} else if (mime === 'image/svg+xml') {
image = await convertToWebp(path, 2048, 2048, 1); image = await convertToWebp(path, 2048, 2048, 1);
} else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) {
throw new StatusError('Rejected type', 403, 'Rejected type'); throw new StatusError('Rejected type', 403, 'Rejected type');
@ -48,7 +87,7 @@ export async function proxyMedia(ctx: Koa.Context) {
} catch (e) { } catch (e) {
serverLogger.error(`${e}`); serverLogger.error(`${e}`);
if (e instanceof StatusError && e.isClientError) { if (e instanceof StatusError && (e.statusCode === 302 || e.isClientError)) {
ctx.status = e.statusCode; ctx.status = e.statusCode;
} else { } else {
ctx.status = 500; ctx.status = 500;

View File

@ -14,10 +14,10 @@
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => { (async () => {
window.onerror = (e) => { window.onerror = (e) => {
renderError('SOMETHING_HAPPENED', e.toString()); renderError('SOMETHING_HAPPENED', e);
}; };
window.onunhandledrejection = (e) => { window.onunhandledrejection = (e) => {
renderError('SOMETHING_HAPPENED_IN_PROMISE', e.toString()); renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
}; };
const v = localStorage.getItem('v') || VERSION; const v = localStorage.getItem('v') || VERSION;
@ -57,7 +57,7 @@
import(`/assets/${CLIENT_ENTRY}`) import(`/assets/${CLIENT_ENTRY}`)
.catch(async e => { .catch(async e => {
await checkUpdate(); await checkUpdate();
renderError('APP_FETCH_FAILED', JSON.stringify(e)); renderError('APP_FETCH_FAILED', e);
}) })
//#endregion //#endregion
@ -104,20 +104,27 @@
// eslint-disable-next-line no-inner-declarations // eslint-disable-next-line no-inner-declarations
function renderError(code, details) { function renderError(code, details) {
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
document.documentElement.innerHTML = ` document.documentElement.innerHTML = `
<h1>エラーが発生しました</h1> <h1> An error has occurred. </h1>
<p>問題が解決しない場合は管理者までお問い合わせください以下のオプションを試すこともできます:</p> <p>If the problem persists, please contact the administrator. You may also try the following options:</p>
<ul> <ul>
<li><a href="/cli">簡易クライアント</a></li> <li>Start <a href="/cli">the simple client</a></li>
<li><a href="/bios">BIOS</a></li> <li>Attempt to repair in <a href="/bios">BIOS</a></li>
<li><a href="/flush">キャッシュをクリア</a></li> <li><a href="/flush">Flush preferences and cache</a></li>
</ul> </ul>
<hr> <hr>
<code>ERROR CODE: ${code}</code> <div id="errors"></div>
<details>
${details}
</details>
`; `;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.innerHTML = `<summary><code>ERROR CODE: ${code}</code></summary>${JSON.stringify(details)}`;
errorsElement.appendChild(detailsElement);
} }
// eslint-disable-next-line no-inner-declarations // eslint-disable-next-line no-inner-declarations

View File

@ -11,6 +11,7 @@ import Router from '@koa/router';
import send from 'koa-send'; import send from 'koa-send';
import favicon from 'koa-favicon'; import favicon from 'koa-favicon';
import views from 'koa-views'; import views from 'koa-views';
import sharp from 'sharp';
import { createBullBoard } from '@bull-board/api'; import { createBullBoard } from '@bull-board/api';
import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { BullAdapter } from '@bull-board/api/bullAdapter.js';
import { KoaAdapter } from '@bull-board/koa'; import { KoaAdapter } from '@bull-board/koa';
@ -140,6 +141,49 @@ router.get('/twemoji/(.*)', async ctx => {
}); });
}); });
router.get('/twemoji-badge/(.*)', async ctx => {
const path = ctx.path.replace('/twemoji-badge/', '');
if (!path.match(/^[0-9a-f-]+\.png$/)) {
ctx.status = 404;
return;
}
const mask = await sharp(
`${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`,
{ density: 1000 },
)
.resize(488, 488)
.greyscale()
.normalise()
.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
.flatten({ background: '#000' })
.extend({
top: 12,
bottom: 12,
left: 12,
right: 12,
background: '#000',
})
.toColorspace('b-w')
.png()
.toBuffer();
const buffer = await sharp({
create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.pipelineColorspace('b-w')
.boolean(mask, 'eor')
.resize(96, 96)
.png()
.toBuffer();
ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
ctx.set('Cache-Control', 'max-age=2592000');
ctx.set('Content-Type', 'image/png');
ctx.body = buffer;
});
// ServiceWorker // ServiceWorker
router.get(`/sw.js`, async ctx => { router.get(`/sw.js`, async ctx => {
await send(ctx as any, `/sw.js`, { await send(ctx as any, `/sw.js`, {

View File

@ -2,7 +2,7 @@ import { Antenna } from '@/models/entities/antenna.js';
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
import { AntennaNotes, Mutings, Notes } from '@/models/index.js'; import { AntennaNotes, Mutings, Notes } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { publishAntennaStream, publishMainStream } from '@/services/stream.js'; import { publishAntennaStream, publishMainStream } from '@/services/stream.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
@ -39,7 +39,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: {
_note.renote = await Notes.findOneByOrFail({ id: note.renoteId }); _note.renote = await Notes.findOneByOrFail({ id: note.renoteId });
} }
if (isMutedUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) { if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) {
return; return;
} }

View File

@ -0,0 +1,23 @@
import { Users } from '@/models/index.js';
import { createDeleteAccountJob } from '@/queue/index.js';
import { publishUserEvent } from './stream.js';
import { doPostSuspend } from './suspend-user.js';
export async function deleteAccount(user: {
id: string;
host: string | null;
}): Promise<void> {
// 物理削除する前にDelete activityを送信する
await doPostSuspend(user).catch(e => {});
createDeleteAccountJob(user, {
soft: false,
});
await Users.update(user.id, {
isDeleted: true,
});
// Terminate streaming
publishUserEvent(user.id, 'terminate', {});
}

View File

@ -14,7 +14,6 @@ export async function deliverQuestionUpdate(noteId: Note['id']) {
if (user == null) throw new Error('note not found'); if (user == null) throw new Error('note not found');
if (Users.isLocalUser(user)) { if (Users.isLocalUser(user)) {
const content = renderActivity(renderUpdate(await renderNote(note, false), user)); const content = renderActivity(renderUpdate(await renderNote(note, false), user));
deliverToFollowers(user, content); deliverToFollowers(user, content);
deliverToRelays(user, content); deliverToRelays(user, content);

View File

@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils.js'; import { async, signup, request, post, react, startServer, shutdownServer, waitFire } from './utils.js';
describe('Mute', () => { describe('Mute', () => {
let p: childProcess.ChildProcess; let p: childProcess.ChildProcess;
@ -55,48 +55,24 @@ describe('Mute', () => {
assert.strictEqual(res.body.hasUnreadMentions, false); assert.strictEqual(res.body.hasUnreadMentions, false);
})); }));
it('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { it('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await request('/i/read-all-unread-notes', {}, alice);
let fired = false; const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention');
const ws = await connectStream(alice, 'main', ({ type }) => { assert.strictEqual(fired, false);
if (type == 'unreadMention') {
fired = true;
}
}); });
post(carol, { text: '@alice hi' }); it('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => {
setTimeout(() => {
assert.strictEqual(fired, false);
ws.close();
done();
}, 5000);
}));
it('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', () => new Promise(async done => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await request('/i/read-all-unread-notes', {}, alice);
await request('/notifications/mark-all-as-read', {}, alice); await request('/notifications/mark-all-as-read', {}, alice);
let fired = false; const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
const ws = await connectStream(alice, 'main', ({ type }) => {
if (type == 'unreadNotification') {
fired = true;
}
});
post(carol, { text: '@alice hi' });
setTimeout(() => {
assert.strictEqual(fired, false); assert.strictEqual(fired, false);
ws.close(); });
done();
}, 5000);
}));
describe('Timeline', () => { describe('Timeline', () => {
it('タイムラインにミュートしているユーザーの投稿が含まれない', async(async () => { it('タイムラインにミュートしているユーザーの投稿が含まれない', async(async () => {

View File

@ -3,7 +3,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import { Note } from '../src/models/entities/note.js'; import { Note } from '../src/models/entities/note.js';
import { async, signup, request, post, uploadFile, startServer, shutdownServer, initTestDb } from './utils.js'; import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from './utils.js';
describe('Note', () => { describe('Note', () => {
let p: childProcess.ChildProcess; let p: childProcess.ChildProcess;
@ -37,7 +37,7 @@ describe('Note', () => {
})); }));
it('ファイルを添付できる', async(async () => { it('ファイルを添付できる', async(async () => {
const file = await uploadFile(alice); const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
const res = await request('/notes/create', { const res = await request('/notes/create', {
fileIds: [file.id], fileIds: [file.id],
@ -49,7 +49,7 @@ describe('Note', () => {
})); }));
it('他人のファイルは無視', async(async () => { it('他人のファイルは無視', async(async () => {
const file = await uploadFile(bob); const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
const res = await request('/notes/create', { const res = await request('/notes/create', {
text: 'test', text: 'test',
@ -72,11 +72,13 @@ describe('Note', () => {
assert.deepStrictEqual(res.body.createdNote.fileIds, []); assert.deepStrictEqual(res.body.createdNote.fileIds, []);
})); }));
it('不正なファイルIDで怒られる', async(async () => { it('不正なファイルIDは無視', async(async () => {
const res = await request('/notes/create', { const res = await request('/notes/create', {
fileIds: ['kyoppie'], fileIds: ['kyoppie'],
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.deepStrictEqual(res.body.createdNote.fileIds, []);
})); }));
it('返信できる', async(async () => { it('返信できる', async(async () => {
@ -136,7 +138,7 @@ describe('Note', () => {
it('文字数ぎりぎりで怒られない', async(async () => { it('文字数ぎりぎりで怒られない', async(async () => {
const post = { const post = {
text: '!'.repeat(500), text: '!'.repeat(3000),
}; };
const res = await request('/notes/create', post, alice); const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -144,7 +146,7 @@ describe('Note', () => {
it('文字数オーバーで怒られる', async(async () => { it('文字数オーバーで怒られる', async(async () => {
const post = { const post = {
text: '!'.repeat(501), text: '!'.repeat(3001),
}; };
const res = await request('/notes/create', post, alice); const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
@ -207,7 +209,7 @@ describe('Note', () => {
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.text, post.text); assert.strictEqual(res.body.createdNote.text, post.text);
const noteDoc = await Notes.findOne(res.body.createdNote.id); const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id });
assert.deepStrictEqual(noteDoc.mentions, [bob.id]); assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
})); }));
@ -336,32 +338,32 @@ describe('Note', () => {
describe('notes/delete', () => { describe('notes/delete', () => {
it('delete a reply', async(async () => { it('delete a reply', async(async () => {
const mainNoteRes = await request('/notes/create', { const mainNoteRes = await api('notes/create', {
text: 'main post', text: 'main post',
}, alice); }, alice);
const replyOneRes = await request('/notes/create', { const replyOneRes = await api('notes/create', {
text: 'reply one', text: 'reply one',
replyId: mainNoteRes.body.createdNote.id, replyId: mainNoteRes.body.createdNote.id,
}, alice); }, alice);
const replyTwoRes = await request('/notes/create', { const replyTwoRes = await api('notes/create', {
text: 'reply two', text: 'reply two',
replyId: mainNoteRes.body.createdNote.id, replyId: mainNoteRes.body.createdNote.id,
}, alice); }, alice);
const deleteOneRes = await request('/notes/delete', { const deleteOneRes = await api('notes/delete', {
noteId: replyOneRes.body.createdNote.id, noteId: replyOneRes.body.createdNote.id,
}, alice); }, alice);
assert.strictEqual(deleteOneRes.status, 204); assert.strictEqual(deleteOneRes.status, 204);
let mainNote = await Notes.findOne({ id: mainNoteRes.body.createdNote.id }); let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id });
assert.strictEqual(mainNote.repliesCount, 1); assert.strictEqual(mainNote.repliesCount, 1);
const deleteTwoRes = await request('/notes/delete', { const deleteTwoRes = await api('notes/delete', {
noteId: replyTwoRes.body.createdNote.id, noteId: replyTwoRes.body.createdNote.id,
}, alice); }, alice);
assert.strictEqual(deleteTwoRes.status, 204); assert.strictEqual(deleteTwoRes.status, 204);
mainNote = await Notes.findOne({ id: mainNoteRes.body.createdNote.id }); mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id });
assert.strictEqual(mainNote.repliesCount, 0); assert.strictEqual(mainNote.repliesCount, 0);
})); }));
}); });

View File

@ -2,12 +2,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import { dirname } from 'node:path'; import { async, signup, request, post, uploadUrl, startServer, shutdownServer } from './utils.js';
import { fileURLToPath } from 'node:url';
import { async, signup, request, post, uploadFile, startServer, shutdownServer } from './utils.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
describe('users/notes', () => { describe('users/notes', () => {
let p: childProcess.ChildProcess; let p: childProcess.ChildProcess;
@ -20,8 +15,8 @@ describe('users/notes', () => {
before(async () => { before(async () => {
p = await startServer(); p = await startServer();
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
const jpg = await uploadFile(alice, _dirname + '/resources/Lenna.jpg'); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
const png = await uploadFile(alice, _dirname + '/resources/Lenna.png'); const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png');
jpgNote = await post(alice, { jpgNote = await post(alice, {
fileIds: [jpg.id], fileIds: [jpg.id],
}); });

View File

@ -1,16 +1,18 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import * as http from 'node:http'; import * as http from 'node:http';
import { SIGKILL } from 'constants'; import { SIGKILL } from 'constants';
import * as WebSocket from 'ws'; import WebSocket from 'ws';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import FormData from 'form-data'; import FormData from 'form-data';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import loadConfig from '../src/config/load.js'; import loadConfig from '../src/config/load.js';
import { entities } from '../src/db/postgre.js'; import { entities } from '../src/db/postgre.js';
import got from 'got';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -26,6 +28,42 @@ export const async = (fn: Function) => (done: Function) => {
}); });
}; };
export const api = async (endpoint: string, params: any, me?: any) => {
endpoint = endpoint.replace(/^\//, '');
const auth = me ? {
i: me.token
} : {};
const res = await got<string>(`http://localhost:${port}/api/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(Object.assign(auth, params)),
retry: {
limit: 0,
},
hooks: {
beforeError: [
error => {
const { response } = error;
if (response && response.body) console.warn(response.body);
return error;
}
]
},
});
const status = res.statusCode;
const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
return {
status,
body
};
};
export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => { export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
const auth = me ? { const auth = me ? {
i: me.token, i: me.token,
@ -53,7 +91,7 @@ export const signup = async (params?: any): Promise<any> => {
password: 'test', password: 'test',
}, params); }, params);
const res = await request('/signup', q); const res = await api('signup', q);
return res.body; return res.body;
}; };
@ -63,34 +101,62 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create']
text: 'test', text: 'test',
}, params); }, params);
const res = await request('/notes/create', q, user); const res = await api('notes/create', q, user);
return res.body ? res.body.createdNote : null; return res.body ? res.body.createdNote : null;
}; };
export const react = async (user: any, note: any, reaction: string): Promise<any> => { export const react = async (user: any, note: any, reaction: string): Promise<any> => {
await request('/notes/reactions/create', { await api('notes/reactions/create', {
noteId: note.id, noteId: note.id,
reaction: reaction, reaction: reaction,
}, user); }, user);
}; };
export const uploadFile = (user: any, path?: string): Promise<any> => { /**
const formData = new FormData(); * Upload file
formData.append('i', user.token); * @param user User
formData.append('file', fs.createReadStream(path || _dirname + '/resources/Lenna.png')); * @param _path Optional, absolute path or relative from ./resources/
*/
export const uploadFile = async (user: any, _path?: string): Promise<any> => {
const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`;
return fetch(`http://localhost:${port}/api/drive/files/create`, { const formData = new FormData() as any;
method: 'post', formData.append('i', user.token);
formData.append('file', fs.createReadStream(absPath));
formData.append('force', 'true');
const res = await got<string>(`http://localhost:${port}/api/drive/files/create`, {
method: 'POST',
body: formData, body: formData,
timeout: 30 * 1000, retry: {
}).then(res => { limit: 0,
if (!res.ok) { },
throw `${res.status} ${res.statusText}`; });
} else {
return res.json(); const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
return body;
};
export const uploadUrl = async (user: any, url: string) => {
let file: any;
const ws = await connectStream(user, 'main', (msg) => {
if (msg.type === 'driveFileCreated') {
file = msg.body;
} }
}); });
await api('drive/files/upload-from-url', {
url,
force: true,
}, user);
await sleep(5000);
ws.close();
return file;
}; };
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> { export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
@ -120,6 +186,40 @@ export function connectStream(user: any, channel: string, listener: (message: Re
}); });
} }
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean) => {
return new Promise<boolean>(async (res, rej) => {
let timer: NodeJS.Timeout;
let ws: WebSocket;
try {
ws = await connectStream(user, channel, msg => {
if (cond(msg)) {
ws.close();
if (timer) clearTimeout(timer);
res(true);
}
});
} catch (e) {
rej(e);
}
if (!ws!) return;
timer = setTimeout(() => {
ws.close();
res(false);
}, 5000);
try {
await trgr();
} catch (e) {
ws.close();
if (timer) clearTimeout(timer);
rej(e);
}
})
};
export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => {
// node-fetchだと3xxを取れない // node-fetchだと3xxを取れない
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
@ -176,7 +276,7 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
return db; return db;
} }
export function startServer(timeout = 30 * 1000): Promise<childProcess.ChildProcess> { export function startServer(timeout = 60 * 1000): Promise<childProcess.ChildProcess> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const t = setTimeout(() => { const t = setTimeout(() => {
p.kill(SIGKILL); p.kill(SIGKILL);
@ -214,3 +314,11 @@ export function shutdownServer(p: childProcess.ChildProcess, timeout = 20 * 1000
p.kill(); p.kill();
}); });
} }
export function sleep(msec: number) {
return new Promise<void>(res => {
setTimeout(() => {
res();
}, msec);
});
}

View File

@ -25,7 +25,6 @@ module.exports = {
// data の禁止理由: 抽象的すぎるため // data の禁止理由: 抽象的すぎるため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'data', 'e'], 'id-denylist': ['error', 'window', 'data', 'e'],
'eqeqeq': ['error', 'always', { 'null': 'ignore' }],
'no-shadow': ['warn'], 'no-shadow': ['warn'],
'vue/attributes-order': ['error', { 'vue/attributes-order': ['error', {
'alphabetical': false, 'alphabetical': false,

View File

@ -79,7 +79,6 @@
"vite": "2.9.10", "vite": "2.9.10",
"vue": "3.2.37", "vue": "3.2.37",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
"vue-router": "4.0.16",
"vuedraggable": "4.0.1", "vuedraggable": "4.0.1",
"websocket": "1.0.34", "websocket": "1.0.34",
"ws": "8.8.0" "ws": "8.8.0"

View File

@ -1,11 +1,11 @@
import { del, get, set } from '@/scripts/idb-proxy';
import { defineAsyncComponent, reactive } from 'vue'; import { defineAsyncComponent, reactive } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n';
import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config'; import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n';
// TODO: 他のタブと永続化されたstateを同期 // TODO: 他のタブと永続化されたstateを同期
@ -22,13 +22,9 @@ export async function signout() {
waiting(); waiting();
localStorage.removeItem('account'); localStorage.removeItem('account');
//#region Remove account await removeAccount($i.id);
const accounts = await getAccounts();
accounts.splice(accounts.findIndex(x => x.id === $i.id), 1);
if (accounts.length > 0) await set('accounts', accounts); const accounts = await getAccounts();
else await del('accounts');
//#endregion
//#region Remove service worker registration //#region Remove service worker registration
try { try {
@ -55,7 +51,7 @@ export async function signout() {
} catch (err) {} } catch (err) {}
//#endregion //#endregion
document.cookie = `igi=; path=/`; document.cookie = 'igi=; path=/';
if (accounts.length > 0) login(accounts[0].token); if (accounts.length > 0) login(accounts[0].token);
else unisonReload('/'); else unisonReload('/');
@ -72,14 +68,22 @@ export async function addAccount(id: Account['id'], token: Account['token']) {
} }
} }
export async function removeAccount(id: Account['id']) {
const accounts = await getAccounts();
accounts.splice(accounts.findIndex(x => x.id === id), 1);
if (accounts.length > 0) await set('accounts', accounts);
else await del('accounts');
}
function fetchAccount(token: string): Promise<Account> { function fetchAccount(token: string): Promise<Account> {
return new Promise((done, fail) => { return new Promise((done, fail) => {
// Fetch user // Fetch user
fetch(`${apiUrl}/i`, { fetch(`${apiUrl}/i`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
i: token i: token,
}) }),
}) })
.then(res => res.json()) .then(res => res.json())
.then(res => { .then(res => {
@ -216,13 +220,13 @@ export async function openAccountMenu(opts: {
type: 'link', type: 'link',
icon: 'fas fa-users', icon: 'fas fa-users',
text: i18n.ts.manageAccounts, text: i18n.ts.manageAccounts,
to: `/settings/accounts`, to: '/settings/accounts',
}]], ev.currentTarget ?? ev.target, { }]], ev.currentTarget ?? ev.target, {
align: 'left' align: 'left',
}); });
} else { } else {
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
align: 'left' align: 'left',
}); });
} }
} }

View File

@ -1,13 +1,19 @@
<template> <template>
<div class="bcekxzvu _card _gap"> <div class="bcekxzvu _gap _panel">
<div class="_content target"> <div class="target">
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> <MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`">
<MkA v-user-preview="report.targetUserId" class="info" :to="userPage(report.targetUser)"> <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/>
<div class="names">
<MkUserName class="name" :user="report.targetUser"/> <MkUserName class="name" :user="report.targetUser"/>
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/> <MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
</MkA>
</div> </div>
<div class="_content"> </MkA>
<MkKeyValue class="_formBlock">
<template #key>{{ $ts.registeredDate }}</template>
<template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
</MkKeyValue>
</div>
<div class="detail">
<div> <div>
<Mfm :text="report.comment"/> <Mfm :text="report.comment"/>
</div> </div>
@ -18,77 +24,71 @@
<MkAcct :user="report.assignee"/> <MkAcct :user="report.assignee"/>
</div> </div>
<div><MkTime :time="report.createdAt"/></div> <div><MkTime :time="report.createdAt"/></div>
</div> <div class="action">
<div class="_footer">
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
{{ $ts.forwardReport }} {{ $ts.forwardReport }}
<template #caption>{{ $ts.forwardReportIsAnonymous }}</template> <template #caption>{{ $ts.forwardReportIsAnonymous }}</template>
</MkSwitch> </MkSwitch>
<MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton>
</div> </div>
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import MkSwitch from '@/components/form/switch.vue'; import MkSwitch from '@/components/form/switch.vue';
import MkKeyValue from '@/components/key-value.vue';
import { acct, userPage } from '@/filters/user'; import { acct, userPage } from '@/filters/user';
import * as os from '@/os'; import * as os from '@/os';
export default defineComponent({ const props = defineProps<{
components: { report: any;
MkButton, }>();
MkSwitch,
},
props: { const emit = defineEmits<{
report: { (ev: 'resolved', reportId: string): void;
type: Object, }>();
required: true,
}
},
emits: ['resolved'], let forward = $ref(props.report.forwarded);
data() { function resolve() {
return {
forward: this.report.forwarded,
};
},
methods: {
acct,
userPage,
resolve() {
os.apiWithDialog('admin/resolve-abuse-user-report', { os.apiWithDialog('admin/resolve-abuse-user-report', {
forward: this.forward, forward: forward,
reportId: this.report.id, reportId: props.report.id,
}).then(() => { }).then(() => {
this.$emit('resolved', this.report.id); emit('resolved', props.report.id);
}); });
} }
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.bcekxzvu { .bcekxzvu {
> .target {
display: flex; display: flex;
width: 100%;
> .target {
width: 35%;
box-sizing: border-box; box-sizing: border-box;
text-align: left; text-align: left;
padding: 24px;
border-right: solid 1px var(--divider);
> .info {
display: flex;
box-sizing: border-box;
align-items: center; align-items: center;
padding: 14px;
border-radius: 8px;
--c: rgb(255 196 0 / 15%);
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
background-size: 16px 16px;
> .avatar { > .avatar {
width: 42px; width: 42px;
height: 42px; height: 42px;
} }
> .info { > .names {
margin-left: 0.3em; margin-left: 0.3em;
padding: 0 8px; padding: 0 8px;
flex: 1; flex: 1;
@ -98,5 +98,11 @@ export default defineComponent({
} }
} }
} }
}
> .detail {
flex: 1;
padding: 24px;
}
} }
</style> </style>

View File

@ -35,6 +35,7 @@
<script lang="ts"> <script lang="ts">
import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import contains from '@/scripts/contains'; import contains from '@/scripts/contains';
import { char2filePath } from '@/scripts/twemoji-base';
import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { acct } from '@/filters/user'; import { acct } from '@/filters/user';
import * as os from '@/os'; import * as os from '@/os';
@ -42,7 +43,6 @@ import { MFM_TAGS } from '@/scripts/mfm-tags';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { emojilist } from '@/scripts/emojilist'; import { emojilist } from '@/scripts/emojilist';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { twemojiSvgBase } from '@/scripts/twemoji-base';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
type EmojiDef = { type EmojiDef = {
@ -55,16 +55,10 @@ type EmojiDef = {
const lib = emojilist.filter(x => x.category !== 'flags'); const lib = emojilist.filter(x => x.category !== 'flags');
const char2file = (char: string) => {
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
return codes.filter(x => x && x.length).join('-');
};
const emjdb: EmojiDef[] = lib.map(x => ({ const emjdb: EmojiDef[] = lib.map(x => ({
emoji: x.char, emoji: x.char,
name: x.name, name: x.name,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg` url: char2filePath(x.char),
})); }));
for (const x of lib) { for (const x of lib) {
@ -74,7 +68,7 @@ for (const x of lib) {
emoji: x.char, emoji: x.char,
name: k, name: k,
aliasOf: x.name, aliasOf: x.name,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg` url: char2filePath(x.char),
}); });
} }
} }

View File

@ -1,11 +1,13 @@
<template> <template>
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'left'" :inner-margin="16" @closed="emit('closed')"> <MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')">
<div v-if="title" class="qpcyisrl"> <div v-if="title || series" class="qpcyisrl">
<div class="title">{{ title }}</div> <div v-if="title" class="title">{{ title }}</div>
<template v-if="series">
<div v-for="x in series" class="series"> <div v-for="x in series" class="series">
<span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span>
<span>{{ x.text }}</span> <span>{{ x.text }}</span>
</div> </div>
</template>
</div> </div>
</MkTooltip> </MkTooltip>
</template> </template>
@ -18,8 +20,8 @@ const props = defineProps<{
showing: boolean; showing: boolean;
x: number; x: number;
y: number; y: number;
title: string; title?: string;
series: { series?: {
backgroundColor: string; backgroundColor: string;
borderColor: string; borderColor: string;
text: string; text: string;

View File

@ -13,7 +13,7 @@
id-denylist violation when setting it. This is causing about 60+ lint issues. id-denylist violation when setting it. This is causing about 60+ lint issues.
As this is part of Chart.js's API it makes sense to disable the check here. As this is part of Chart.js's API it makes sense to disable the check here.
*/ */
import { defineProps, onMounted, ref, watch, PropType, onUnmounted } from 'vue'; import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
import { import {
Chart, Chart,
ArcElement, ArcElement,
@ -39,7 +39,7 @@ import zoomPlugin from 'chartjs-plugin-zoom';
//import gradient from 'chartjs-plugin-gradient'; //import gradient from 'chartjs-plugin-gradient';
import * as os from '@/os'; import * as os from '@/os';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import MkChartTooltip from '@/components/chart-tooltip.vue'; import { useChartTooltip } from '@/scripts/use-chart-tooltip';
const props = defineProps({ const props = defineProps({
src: { src: {
@ -53,7 +53,7 @@ const props = defineProps({
limit: { limit: {
type: Number, type: Number,
required: false, required: false,
default: 90 default: 90,
}, },
span: { span: {
type: String as PropType<'hour' | 'day'>, type: String as PropType<'hour' | 'day'>,
@ -62,22 +62,22 @@ const props = defineProps({
detailed: { detailed: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false,
}, },
stacked: { stacked: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false,
}, },
bar: { bar: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false,
}, },
aspectRatio: { aspectRatio: {
type: Number, type: Number,
required: false, required: false,
default: null default: null,
}, },
}); });
@ -156,46 +156,11 @@ const getDate = (ago: number) => {
const format = (arr) => { const format = (arr) => {
return arr.map((v, i) => ({ return arr.map((v, i) => ({
x: getDate(i).getTime(), x: getDate(i).getTime(),
y: v y: v,
})); }));
}; };
const tooltipShowing = ref(false); const { handler: externalTooltipHandler } = useChartTooltip();
const tooltipX = ref(0);
const tooltipY = ref(0);
const tooltipTitle = ref(null);
const tooltipSeries = ref(null);
let disposeTooltipComponent;
os.popup(MkChartTooltip, {
showing: tooltipShowing,
x: tooltipX,
y: tooltipY,
title: tooltipTitle,
series: tooltipSeries,
}, {}).then(({ dispose }) => {
disposeTooltipComponent = dispose;
});
function externalTooltipHandler(context) {
if (context.tooltip.opacity === 0) {
tooltipShowing.value = false;
return;
}
tooltipTitle.value = context.tooltip.title[0];
tooltipSeries.value = context.tooltip.body.map((b, i) => ({
backgroundColor: context.tooltip.labelColors[i].backgroundColor,
borderColor: context.tooltip.labelColors[i].borderColor,
text: b.lines[0],
}));
const rect = context.chart.canvas.getBoundingClientRect();
tooltipShowing.value = true;
tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
}
const render = () => { const render = () => {
if (chartInstance) { if (chartInstance) {
@ -343,7 +308,7 @@ const render = () => {
min: 'original', min: 'original',
max: 'original', max: 'original',
}, },
} },
} : undefined, } : undefined,
//gradient, //gradient,
}, },
@ -367,8 +332,8 @@ const render = () => {
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
} }
} },
}] }],
}); });
}; };
@ -377,7 +342,7 @@ const exportData = () => {
}; };
const fetchFederationChart = async (): Promise<typeof chartData> => { const fetchFederationChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Received', name: 'Received',
@ -427,36 +392,36 @@ const fetchFederationChart = async (): Promise<typeof chartData> => {
}; };
const fetchApRequestChart = async (): Promise<typeof chartData> => { const fetchApRequestChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/ap-request', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'In', name: 'In',
type: 'area', type: 'area',
color: '#008FFB', color: '#008FFB',
data: format(raw.inboxReceived) data: format(raw.inboxReceived),
}, { }, {
name: 'Out (succ)', name: 'Out (succ)',
type: 'area', type: 'area',
color: '#00E396', color: '#00E396',
data: format(raw.deliverSucceeded) data: format(raw.deliverSucceeded),
}, { }, {
name: 'Out (fail)', name: 'Out (fail)',
type: 'area', type: 'area',
color: '#FEB019', color: '#FEB019',
data: format(raw.deliverFailed) data: format(raw.deliverFailed),
}] }],
}; };
}; };
const fetchNotesChart = async (type: string): Promise<typeof chartData> => { const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'All', name: 'All',
type: 'line', type: 'line',
data: format(type === 'combined' data: format(type === 'combined'
? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
: sum(raw[type].inc, negate(raw[type].dec)) : sum(raw[type].inc, negate(raw[type].dec)),
), ),
color: '#888888', color: '#888888',
}, { }, {
@ -464,7 +429,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
type: 'area', type: 'area',
data: format(type === 'combined' data: format(type === 'combined'
? sum(raw.local.diffs.renote, raw.remote.diffs.renote) ? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
: raw[type].diffs.renote : raw[type].diffs.renote,
), ),
color: colors.green, color: colors.green,
}, { }, {
@ -472,7 +437,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
type: 'area', type: 'area',
data: format(type === 'combined' data: format(type === 'combined'
? sum(raw.local.diffs.reply, raw.remote.diffs.reply) ? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
: raw[type].diffs.reply : raw[type].diffs.reply,
), ),
color: colors.yellow, color: colors.yellow,
}, { }, {
@ -480,7 +445,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
type: 'area', type: 'area',
data: format(type === 'combined' data: format(type === 'combined'
? sum(raw.local.diffs.normal, raw.remote.diffs.normal) ? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
: raw[type].diffs.normal : raw[type].diffs.normal,
), ),
color: colors.blue, color: colors.blue,
}, { }, {
@ -488,7 +453,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
type: 'area', type: 'area',
data: format(type === 'combined' data: format(type === 'combined'
? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile) ? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
: raw[type].diffs.withFile : raw[type].diffs.withFile,
), ),
color: colors.purple, color: colors.purple,
}], }],
@ -496,7 +461,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
}; };
const fetchNotesTotalChart = async (): Promise<typeof chartData> => { const fetchNotesTotalChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Combined', name: 'Combined',
@ -515,35 +480,35 @@ const fetchNotesTotalChart = async (): Promise<typeof chartData> => {
}; };
const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Combined', name: 'Combined',
type: 'line', type: 'line',
data: format(total data: format(total
? sum(raw.local.total, raw.remote.total) ? sum(raw.local.total, raw.remote.total)
: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)),
), ),
}, { }, {
name: 'Local', name: 'Local',
type: 'area', type: 'area',
data: format(total data: format(total
? raw.local.total ? raw.local.total
: sum(raw.local.inc, negate(raw.local.dec)) : sum(raw.local.inc, negate(raw.local.dec)),
), ),
}, { }, {
name: 'Remote', name: 'Remote',
type: 'area', type: 'area',
data: format(total data: format(total
? raw.remote.total ? raw.remote.total
: sum(raw.remote.inc, negate(raw.remote.dec)) : sum(raw.remote.inc, negate(raw.remote.dec)),
), ),
}], }],
}; };
}; };
const fetchActiveUsersChart = async (): Promise<typeof chartData> => { const fetchActiveUsersChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Read & Write', name: 'Read & Write',
@ -595,7 +560,7 @@ const fetchActiveUsersChart = async (): Promise<typeof chartData> => {
}; };
const fetchDriveChart = async (): Promise<typeof chartData> => { const fetchDriveChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
return { return {
bytes: true, bytes: true,
series: [{ series: [{
@ -607,8 +572,8 @@ const fetchDriveChart = async (): Promise<typeof chartData> => {
raw.local.incSize, raw.local.incSize,
negate(raw.local.decSize), negate(raw.local.decSize),
raw.remote.incSize, raw.remote.incSize,
negate(raw.remote.decSize) negate(raw.remote.decSize),
) ),
), ),
}, { }, {
name: 'Local +', name: 'Local +',
@ -631,7 +596,7 @@ const fetchDriveChart = async (): Promise<typeof chartData> => {
}; };
const fetchDriveFilesChart = async (): Promise<typeof chartData> => { const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'All', name: 'All',
@ -642,8 +607,8 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
raw.local.incCount, raw.local.incCount,
negate(raw.local.decCount), negate(raw.local.decCount),
raw.remote.incCount, raw.remote.incCount,
negate(raw.remote.decCount) negate(raw.remote.decCount),
) ),
), ),
}, { }, {
name: 'Local +', name: 'Local +',
@ -666,29 +631,29 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
}; };
const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'In', name: 'In',
type: 'area', type: 'area',
color: '#008FFB', color: '#008FFB',
data: format(raw.requests.received) data: format(raw.requests.received),
}, { }, {
name: 'Out (succ)', name: 'Out (succ)',
type: 'area', type: 'area',
color: '#00E396', color: '#00E396',
data: format(raw.requests.succeeded) data: format(raw.requests.succeeded),
}, { }, {
name: 'Out (fail)', name: 'Out (fail)',
type: 'area', type: 'area',
color: '#FEB019', color: '#FEB019',
data: format(raw.requests.failed) data: format(raw.requests.failed),
}] }],
}; };
}; };
const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Users', name: 'Users',
@ -696,14 +661,14 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData
color: '#008FFB', color: '#008FFB',
data: format(total data: format(total
? raw.users.total ? raw.users.total
: sum(raw.users.inc, negate(raw.users.dec)) : sum(raw.users.inc, negate(raw.users.dec)),
) ),
}] }],
}; };
}; };
const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Notes', name: 'Notes',
@ -711,14 +676,14 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData
color: '#008FFB', color: '#008FFB',
data: format(total data: format(total
? raw.notes.total ? raw.notes.total
: sum(raw.notes.inc, negate(raw.notes.dec)) : sum(raw.notes.inc, negate(raw.notes.dec)),
) ),
}] }],
}; };
}; };
const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Following', name: 'Following',
@ -726,22 +691,22 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> =
color: '#008FFB', color: '#008FFB',
data: format(total data: format(total
? raw.following.total ? raw.following.total
: sum(raw.following.inc, negate(raw.following.dec)) : sum(raw.following.inc, negate(raw.following.dec)),
) ),
}, { }, {
name: 'Followers', name: 'Followers',
type: 'area', type: 'area',
color: '#00E396', color: '#00E396',
data: format(total data: format(total
? raw.followers.total ? raw.followers.total
: sum(raw.followers.inc, negate(raw.followers.dec)) : sum(raw.followers.inc, negate(raw.followers.dec)),
) ),
}] }],
}; };
}; };
const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return { return {
bytes: true, bytes: true,
series: [{ series: [{
@ -750,14 +715,14 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char
color: '#008FFB', color: '#008FFB',
data: format(total data: format(total
? raw.drive.totalUsage ? raw.drive.totalUsage
: sum(raw.drive.incUsage, negate(raw.drive.decUsage)) : sum(raw.drive.incUsage, negate(raw.drive.decUsage)),
) ),
}] }],
}; };
}; };
const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Drive files', name: 'Drive files',
@ -765,14 +730,14 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char
color: '#008FFB', color: '#008FFB',
data: format(total data: format(total
? raw.drive.totalFiles ? raw.drive.totalFiles
: sum(raw.drive.incFiles, negate(raw.drive.decFiles)) : sum(raw.drive.incFiles, negate(raw.drive.decFiles)),
) ),
}] }],
}; };
}; };
const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span });
return { return {
series: [...(props.args.withoutAll ? [] : [{ series: [...(props.args.withoutAll ? [] : [{
name: 'All', name: 'All',
@ -804,7 +769,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
}; };
const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Local', name: 'Local',
@ -819,7 +784,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
}; };
const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Local', name: 'Local',
@ -834,7 +799,7 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
}; };
const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { const fetchPerUserDriveChart = async (): Promise<typeof chartData> => {
const raw = await os.api('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span });
return { return {
series: [{ series: [{
name: 'Inc', name: 'Inc',
@ -891,10 +856,6 @@ watch(() => [props.src, props.span], fetchAndRender);
onMounted(() => { onMounted(() => {
fetchAndRender(); fetchAndRender();
}); });
onUnmounted(() => {
if (disposeTooltipComponent) disposeTooltipComponent();
});
/* eslint-enable id-denylist */ /* eslint-enable id-denylist */
</script> </script>

View File

@ -57,7 +57,7 @@ const isThumbnailAvailable = computed(() => {
.zdjebgpv { .zdjebgpv {
position: relative; position: relative;
display: flex; display: flex;
background: #e1e1e1; background: var(--panel);
border-radius: 8px; border-radius: 8px;
overflow: clip; overflow: clip;

View File

@ -0,0 +1,118 @@
<template>
<div>
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
<MkA
v-for="file in items"
:key="file.id"
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`"
:to="`/admin/file/${file.id}`"
class="file _button"
>
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
<div v-if="viewMode === 'list'" class="body">
<div>
<small style="opacity: 0.7;">{{ file.name }}</small>
</div>
<div>
<MkAcct v-if="file.user" :user="file.user"/>
<div v-else>{{ i18n.ts.system }}</div>
</div>
<div>
<span style="margin-right: 1em;">{{ file.type }}</span>
<span>{{ bytes(file.size) }}</span>
</div>
<div>
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
</div>
</div>
</MkA>
</MkPagination>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import * as Acct from 'misskey-js/built/acct';
import MkSwitch from '@/components/ui/switch.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
import { i18n } from '@/i18n';
const props = defineProps<{
pagination: any;
viewMode: 'grid' | 'list';
}>();
</script>
<style lang="scss" scoped>
@keyframes sensitive-blink {
0% { opacity: 1; }
50% { opacity: 0; }
}
.urempief {
margin-top: var(--margin);
&.list {
> .file {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
&:hover {
color: var(--accent);
}
> .thumbnail {
width: 128px;
height: 128px;
}
> .body {
margin-left: 0.3em;
padding: 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
}
}
}
}
&.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
> .file {
position: relative;
aspect-ratio: 1;
> .thumbnail {
width: 100%;
height: 100%;
}
> .sensitive-label {
position: absolute;
z-index: 10;
top: 8px;
left: 8px;
padding: 2px 4px;
background: #ff0000bf;
color: #fff;
border-radius: 4px;
font-size: 85%;
animation: sensitive-blink 1s infinite;
}
}
}
}
</style>

View File

@ -0,0 +1,143 @@
<template>
<div
class="ziffeoms"
:class="{ disabled, checked }"
>
<input
ref="input"
type="checkbox"
:disabled="disabled"
@keydown.enter="toggle"
>
<span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle">
<i class="check fas fa-check"></i>
</span>
<span class="label">
<!-- TODO: 無名slotの方は廃止 -->
<span @click="toggle"><slot name="label"></slot><slot></slot></span>
<p class="caption"><slot name="caption"></slot></p>
</span>
</div>
</template>
<script lang="ts" setup>
import { toRefs, Ref } from 'vue';
import * as os from '@/os';
import Ripple from '@/components/ripple.vue';
const props = defineProps<{
modelValue: boolean | Ref<boolean>;
disabled?: boolean;
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: boolean): void;
}>();
let button = $ref<HTMLElement>();
const checked = toRefs(props).modelValue;
const toggle = () => {
if (props.disabled) return;
emit('update:modelValue', !checked.value);
if (!checked.value) {
const rect = button.getBoundingClientRect();
const x = rect.left + (button.offsetWidth / 2);
const y = rect.top + (button.offsetHeight / 2);
os.popup(Ripple, { x, y, particle: false }, {}, 'end');
}
};
</script>
<style lang="scss" scoped>
.ziffeoms {
position: relative;
display: flex;
transition: all 0.2s ease;
> * {
user-select: none;
}
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
> .button {
position: relative;
display: inline-flex;
flex-shrink: 0;
margin: 0;
box-sizing: border-box;
width: 23px;
height: 23px;
outline: none;
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 4px;
cursor: pointer;
transition: inherit;
> .check {
margin: auto;
opacity: 0;
color: var(--fgOnAccent);
font-size: 13px;
transform: scale(0.5);
transition: all 0.2s ease;
}
}
&:hover {
> .button {
border-color: var(--inputBorderHover) !important;
}
}
> .label {
margin-left: 12px;
margin-top: 2px;
display: block;
transition: inherit;
color: var(--fg);
> span {
display: block;
line-height: 20px;
cursor: pointer;
transition: inherit;
}
> .caption {
margin: 8px 0 0 0;
color: var(--fgTransparentWeak);
font-size: 0.85em;
&:empty {
display: none;
}
}
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.checked {
> .button {
background-color: var(--accent) !important;
border-color: var(--accent) !important;
> .check {
opacity: 1;
transform: scale(1);
}
}
}
}
</style>

View File

@ -9,13 +9,13 @@
<i v-else class="fas fa-angle-down icon"></i> <i v-else class="fas fa-angle-down icon"></i>
</span> </span>
</div> </div>
<keep-alive> <KeepAlive>
<div v-if="openedAtLeastOnce" v-show="opened" class="body"> <div v-if="openedAtLeastOnce" v-show="opened" class="body">
<MkSpacer :margin-min="14" :margin-max="22"> <MkSpacer :margin-min="14" :margin-max="22">
<slot></slot> <slot></slot>
</MkSpacer> </MkSpacer>
</div> </div>
</keep-alive> </KeepAlive>
</div> </div>
</template> </template>

View File

@ -3,7 +3,8 @@
<div class="label" @click="focus"><slot name="label"></slot></div> <div class="label" @click="focus"><slot name="label"></slot></div>
<div class="input" :class="{ inline, disabled, focused }"> <div class="input" :class="{ inline, disabled, focused }">
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<input ref="inputEl" <input
ref="inputEl"
v-model="v" v-model="v"
v-adaptive-border v-adaptive-border
:type="type" :type="type"
@ -32,122 +33,83 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
import MkButton from '@/components/ui/button.vue';
import { debounce } from 'throttle-debounce'; import { debounce } from 'throttle-debounce';
import MkButton from '@/components/ui/button.vue';
import { useInterval } from '@/scripts/use-interval';
export default defineComponent({ const props = defineProps<{
components: { modelValue: string | number;
MkButton, type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time';
}, required?: boolean;
readonly?: boolean;
disabled?: boolean;
pattern?: string;
placeholder?: string;
autofocus?: boolean;
autocomplete?: boolean;
spellcheck?: boolean;
step?: any;
datalist?: string[];
inline?: boolean;
debounce?: boolean;
manualSave?: boolean;
small?: boolean;
large?: boolean;
}>();
props: { const emit = defineEmits<{
modelValue: { (ev: 'change', _ev: KeyboardEvent): void;
required: true (ev: 'keydown', _ev: KeyboardEvent): void;
}, (ev: 'enter'): void;
type: { (ev: 'update:modelValue', value: string | number): void;
type: String, }>();
required: false
},
required: {
type: Boolean,
required: false
},
readonly: {
type: Boolean,
required: false
},
disabled: {
type: Boolean,
required: false
},
pattern: {
type: String,
required: false
},
placeholder: {
type: String,
required: false
},
autofocus: {
type: Boolean,
required: false,
default: false
},
autocomplete: {
required: false
},
spellcheck: {
required: false
},
step: {
required: false
},
datalist: {
type: Array,
required: false,
},
inline: {
type: Boolean,
required: false,
default: false
},
debounce: {
type: Boolean,
required: false,
default: false
},
manualSave: {
type: Boolean,
required: false,
default: false
},
},
emits: ['change', 'keydown', 'enter', 'update:modelValue'], const { modelValue, type, autofocus } = toRefs(props);
const v = ref(modelValue.value);
const id = Math.random().toString(); // TODO: uuid?
const focused = ref(false);
const changed = ref(false);
const invalid = ref(false);
const filled = computed(() => v.value !== '' && v.value != null);
const inputEl = ref<HTMLElement>();
const prefixEl = ref<HTMLElement>();
const suffixEl = ref<HTMLElement>();
const height =
props.small ? 38 :
props.large ? 42 :
40;
setup(props, context) { const focus = () => inputEl.value.focus();
const { modelValue, type, autofocus } = toRefs(props); const onInput = (ev: KeyboardEvent) => {
const v = ref(modelValue.value);
const id = Math.random().toString(); // TODO: uuid?
const focused = ref(false);
const changed = ref(false);
const invalid = ref(false);
const filled = computed(() => v.value !== '' && v.value != null);
const inputEl = ref<HTMLElement>();
const prefixEl = ref<HTMLElement>();
const suffixEl = ref<HTMLElement>();
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true; changed.value = true;
context.emit('change', ev); emit('change', ev);
}; };
const onKeydown = (ev: KeyboardEvent) => { const onKeydown = (ev: KeyboardEvent) => {
context.emit('keydown', ev); emit('keydown', ev);
if (ev.code === 'Enter') { if (ev.code === 'Enter') {
context.emit('enter'); emit('enter');
} }
}; };
const updated = () => { const updated = () => {
changed.value = false; changed.value = false;
if (type?.value === 'number') { if (type.value === 'number') {
context.emit('update:modelValue', parseFloat(v.value)); emit('update:modelValue', parseFloat(v.value));
} else { } else {
context.emit('update:modelValue', v.value); emit('update:modelValue', v.value);
} }
}; };
const debouncedUpdated = debounce(1000, updated); const debouncedUpdated = debounce(1000, updated);
watch(modelValue, newValue => { watch(modelValue, newValue => {
v.value = newValue; v.value = newValue;
}); });
watch(v, newValue => { watch(v, newValue => {
if (!props.manualSave) { if (!props.manualSave) {
if (props.debounce) { if (props.debounce) {
debouncedUpdated(); debouncedUpdated();
@ -157,17 +119,11 @@ export default defineComponent({
} }
invalid.value = inputEl.value.validity.badInput; invalid.value = inputEl.value.validity.badInput;
}); });
onMounted(() => { //
nextTick(() => { // 0
if (autofocus.value) { useInterval(() => {
focus();
}
//
// 0
const clock = window.setInterval(() => {
if (prefixEl.value) { if (prefixEl.value) {
if (prefixEl.value.offsetWidth) { if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@ -178,30 +134,17 @@ export default defineComponent({
inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
} }
} }
}, 100); }, 100, {
immediate: true,
afterMounted: true,
});
onUnmounted(() => { onMounted(() => {
window.clearInterval(clock); nextTick(() => {
if (autofocus.value) {
focus();
}
}); });
});
});
return {
id,
v,
focused,
invalid,
changed,
filled,
inputEl,
prefixEl,
suffixEl,
focus,
onInput,
onKeydown,
updated,
};
},
}); });
</script> </script>
@ -228,14 +171,13 @@ export default defineComponent({
} }
> .input { > .input {
$height: 42px;
position: relative; position: relative;
> input { > input {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
display: block; display: block;
height: $height; height: v-bind("height + 'px'");
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0 12px; padding: 0 12px;
@ -265,7 +207,7 @@ export default defineComponent({
top: 0; top: 0;
padding: 0 12px; padding: 0 12px;
font-size: 1em; font-size: 1em;
height: $height; height: v-bind("height + 'px'");
pointer-events: none; pointer-events: none;
&:empty { &:empty {

View File

@ -7,7 +7,8 @@
:aria-disabled="disabled" :aria-disabled="disabled"
@click="toggle" @click="toggle"
> >
<input type="radio" <input
type="radio"
:disabled="disabled" :disabled="disabled"
> >
<span class="button"> <span class="button">
@ -23,27 +24,27 @@ import { defineComponent } from 'vue';
export default defineComponent({ export default defineComponent({
props: { props: {
modelValue: { modelValue: {
required: false required: false,
}, },
value: { value: {
required: false required: false,
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}, },
computed: { computed: {
checked(): boolean { checked(): boolean {
return this.modelValue === this.value; return this.modelValue === this.value;
} },
}, },
methods: { methods: {
toggle() { toggle() {
if (this.disabled) return; if (this.disabled) return;
this.$emit('update:modelValue', this.value); this.$emit('update:modelValue', this.value);
} },
} },
}); });
</script> </script>
@ -53,7 +54,8 @@ export default defineComponent({
display: inline-block; display: inline-block;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
padding: 10px 12px; padding: 9px 12px;
min-width: 60px;
background-color: var(--panel); background-color: var(--panel);
background-clip: padding-box !important; background-clip: padding-box !important;
border: solid 1px var(--panel); border: solid 1px var(--panel);

View File

@ -4,11 +4,11 @@ import MkRadio from './radio.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
MkRadio MkRadio,
}, },
props: { props: {
modelValue: { modelValue: {
required: false required: false,
}, },
}, },
data() { data() {
@ -19,7 +19,7 @@ export default defineComponent({
watch: { watch: {
value() { value() {
this.$emit('update:modelValue', this.value); this.$emit('update:modelValue', this.value);
} },
}, },
render() { render() {
let options = this.$slots.default(); let options = this.$slots.default();
@ -30,13 +30,13 @@ export default defineComponent({
if (options.length === 1 && options[0].props == null) options = options[0].children; if (options.length === 1 && options[0].props == null) options = options[0].children;
return h('div', { return h('div', {
class: 'novjtcto' class: 'novjtcto',
}, [ }, [
...(label ? [h('div', { ...(label ? [h('div', {
class: 'label' class: 'label',
}, [label])] : []), }, [label])] : []),
h('div', { h('div', {
class: 'body' class: 'body',
}, options.map(option => h(MkRadio, { }, options.map(option => h(MkRadio, {
key: option.key, key: option.key,
value: option.props.value, value: option.props.value,
@ -45,10 +45,10 @@ export default defineComponent({
}, option.children)), }, option.children)),
), ),
...(caption ? [h('div', { ...(caption ? [h('div', {
class: 'caption' class: 'caption',
}, [caption])] : []), }, [caption])] : []),
]); ]);
} },
}); });
</script> </script>
@ -65,9 +65,9 @@ export default defineComponent({
} }
> .body { > .body {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px;
grid-gap: 12px; flex-wrap: wrap;
} }
> .caption { > .caption {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="timctyfi" :class="{ disabled }"> <div class="timctyfi" :class="{ disabled }">
<div class="label"><slot name="label"></slot></div> <div class="label"><slot name="label"></slot></div>
<div v-panel class="body"> <div v-adaptive-border class="body">
<div ref="containerEl" class="container"> <div ref="containerEl" class="container">
<div class="track"> <div class="track">
<div class="highlight" :style="{ width: (steppedValue * 100) + '%' }"></div> <div class="highlight" :style="{ width: (steppedValue * 100) + '%' }"></div>
@ -24,31 +24,31 @@ export default defineComponent({
modelValue: { modelValue: {
type: Number, type: Number,
required: false, required: false,
default: 0 default: 0,
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false,
}, },
min: { min: {
type: Number, type: Number,
required: false, required: false,
default: 0 default: 0,
}, },
max: { max: {
type: Number, type: Number,
required: false, required: false,
default: 100 default: 100,
}, },
step: { step: {
type: Number, type: Number,
required: false, required: false,
default: 1 default: 1,
}, },
autofocus: { autofocus: {
type: Boolean, type: Boolean,
required: false required: false,
}, },
textConverter: { textConverter: {
type: Function, type: Function,
@ -90,14 +90,18 @@ export default defineComponent({
} }
}; };
watch([steppedValue, containerEl], calcThumbPosition); watch([steppedValue, containerEl], calcThumbPosition);
let ro: ResizeObserver | undefined;
onMounted(() => { onMounted(() => {
const ro = new ResizeObserver((entries, observer) => { ro = new ResizeObserver((entries, observer) => {
calcThumbPosition(); calcThumbPosition();
}); });
ro.observe(containerEl.value); ro.observe(containerEl.value);
onUnmounted(() => {
ro.disconnect();
}); });
onUnmounted(() => {
if (ro) ro.disconnect();
}); });
const steps = computed(() => { const steps = computed(() => {
@ -191,7 +195,9 @@ export default defineComponent({
$thumbWidth: 20px; $thumbWidth: 20px;
> .body { > .body {
padding: 12px; padding: 10px 12px;
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 6px; border-radius: 6px;
> .container { > .container {

View File

@ -3,7 +3,8 @@
<div class="label" @click="focus"><slot name="label"></slot></div> <div class="label" @click="focus"><slot name="label"></slot></div>
<div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick"> <div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick">
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<select ref="inputEl" <select
ref="inputEl"
v-model="v" v-model="v"
v-adaptive-border v-adaptive-border
class="select" class="select"
@ -25,99 +26,73 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import * as os from '@/os'; import * as os from '@/os';
import { useInterval } from '@/scripts/use-interval';
export default defineComponent({ const props = defineProps<{
components: { modelValue: string;
MkButton, required?: boolean;
}, readonly?: boolean;
disabled?: boolean;
placeholder?: string;
autofocus?: boolean;
inline?: boolean;
manualSave?: boolean;
small?: boolean;
large?: boolean;
}>();
props: { const emit = defineEmits<{
modelValue: { (ev: 'change', _ev: KeyboardEvent): void;
required: true (ev: 'update:modelValue', value: string): void;
}, }>();
required: {
type: Boolean,
required: false
},
readonly: {
type: Boolean,
required: false
},
disabled: {
type: Boolean,
required: false
},
placeholder: {
type: String,
required: false
},
autofocus: {
type: Boolean,
required: false,
default: false
},
inline: {
type: Boolean,
required: false,
default: false
},
manualSave: {
type: Boolean,
required: false,
default: false
},
},
emits: ['change', 'update:modelValue'], const slots = useSlots();
setup(props, context) { const { modelValue, autofocus } = toRefs(props);
const { modelValue, autofocus } = toRefs(props); const v = ref(modelValue.value);
const v = ref(modelValue.value); const focused = ref(false);
const focused = ref(false); const changed = ref(false);
const changed = ref(false); const invalid = ref(false);
const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null);
const filled = computed(() => v.value !== '' && v.value != null); const inputEl = ref(null);
const inputEl = ref(null); const prefixEl = ref(null);
const prefixEl = ref(null); const suffixEl = ref(null);
const suffixEl = ref(null); const container = ref(null);
const container = ref(null); const height =
props.small ? 38 :
props.large ? 42 :
40;
const focus = () => inputEl.value.focus(); const focus = () => inputEl.value.focus();
const onInput = (ev) => { const onInput = (ev) => {
changed.value = true; changed.value = true;
context.emit('change', ev); emit('change', ev);
}; };
const updated = () => { const updated = () => {
changed.value = false; changed.value = false;
context.emit('update:modelValue', v.value); emit('update:modelValue', v.value);
}; };
watch(modelValue, newValue => { watch(modelValue, newValue => {
v.value = newValue; v.value = newValue;
}); });
watch(v, newValue => { watch(v, newValue => {
if (!props.manualSave) { if (!props.manualSave) {
updated(); updated();
} }
invalid.value = inputEl.value.validity.badInput; invalid.value = inputEl.value.validity.badInput;
}); });
onMounted(() => { //
nextTick(() => { // 0
if (autofocus.value) { useInterval(() => {
focus();
}
//
// 0
const clock = window.setInterval(() => {
if (prefixEl.value) { if (prefixEl.value) {
if (prefixEl.value.offsetWidth) { if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@ -128,19 +103,24 @@ export default defineComponent({
inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
} }
} }
}, 100); }, 100, {
immediate: true,
afterMounted: true,
});
onUnmounted(() => { onMounted(() => {
window.clearInterval(clock); nextTick(() => {
}); if (autofocus.value) {
}); focus();
}
}); });
});
const onClick = (ev: MouseEvent) => { const onClick = (ev: MouseEvent) => {
focused.value = true; focused.value = true;
const menu = []; const menu = [];
let options = context.slots.default(); let options = slots.default!();
const pushOption = (option: VNode) => { const pushOption = (option: VNode) => {
menu.push({ menu.push({
@ -178,25 +158,7 @@ export default defineComponent({
}).then(() => { }).then(() => {
focused.value = false; focused.value = false;
}); });
}; };
return {
v,
focused,
invalid,
changed,
filled,
inputEl,
prefixEl,
suffixEl,
container,
focus,
onInput,
onClick,
updated,
};
},
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -222,7 +184,6 @@ export default defineComponent({
} }
> .input { > .input {
$height: 42px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@ -236,7 +197,7 @@ export default defineComponent({
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
display: block; display: block;
height: $height; height: v-bind("height + 'px'");
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0 12px; padding: 0 12px;
@ -264,7 +225,7 @@ export default defineComponent({
top: 0; top: 0;
padding: 0 12px; padding: 0 12px;
font-size: 1em; font-size: 1em;
height: $height; height: v-bind("height + 'px'");
pointer-events: none; pointer-events: none;
&:empty { &:empty {

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="ziffeoms" class="ziffeomt"
:class="{ disabled, checked }" :class="{ disabled, checked }"
> >
<input <input
@ -9,8 +9,8 @@
:disabled="disabled" :disabled="disabled"
@keydown.enter="toggle" @keydown.enter="toggle"
> >
<span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> <span ref="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle">
<i class="check fas fa-check"></i> <div class="knob"></div>
</span> </span>
<span class="label"> <span class="label">
<!-- TODO: 無名slotの方は廃止 --> <!-- TODO: 無名slotの方は廃止 -->
@ -23,7 +23,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { toRefs, Ref } from 'vue'; import { toRefs, Ref } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import Ripple from '@/components/ripple.vue';
const props = defineProps<{ const props = defineProps<{
modelValue: boolean | Ref<boolean>; modelValue: boolean | Ref<boolean>;
@ -41,16 +40,13 @@ const toggle = () => {
emit('update:modelValue', !checked.value); emit('update:modelValue', !checked.value);
if (!checked.value) { if (!checked.value) {
const rect = button.getBoundingClientRect();
const x = rect.left + (button.offsetWidth / 2);
const y = rect.top + (button.offsetHeight / 2);
os.popup(Ripple, { x, y, particle: false }, {}, 'end');
} }
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.ziffeoms { .ziffeomt {
position: relative; position: relative;
display: flex; display: flex;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -73,21 +69,25 @@ const toggle = () => {
flex-shrink: 0; flex-shrink: 0;
margin: 0; margin: 0;
box-sizing: border-box; box-sizing: border-box;
width: 23px; width: 32px;
height: 23px; height: 23px;
outline: none; outline: none;
background: var(--panel); background: var(--swutchOffBg);
border: solid 1px var(--panel); background-clip: content-box;
border-radius: 4px; border: solid 1px var(--swutchOffBg);
border-radius: 999px;
cursor: pointer; cursor: pointer;
transition: inherit; transition: inherit;
user-select: none;
> .check { > .knob {
margin: auto; position: absolute;
opacity: 0; top: 3px;
color: var(--fgOnAccent); left: 3px;
font-size: 13px; width: 15px;
transform: scale(0.5); height: 15px;
background: var(--swutchOffFg);
border-radius: 999px;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
} }
@ -130,12 +130,12 @@ const toggle = () => {
&.checked { &.checked {
> .button { > .button {
background-color: var(--accent) !important; background-color: var(--swutchOnBg) !important;
border-color: var(--accent) !important; border-color: var(--swutchOnBg) !important;
> .check { > .knob {
opacity: 1; left: 12px;
transform: scale(1); background: var(--swutchOnFg);
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More