Compare commits
17 commits
cherrypick
...
main
Author | SHA1 | Date | |
---|---|---|---|
598b88b142 | |||
e90e9e016f | |||
125bf6fe93 | |||
32fc7644fc | |||
04c1c0e1e6 | |||
cfba325ae7 | |||
ed302618f2 | |||
12a9dab275 | |||
55a2b82330 | |||
f0de9000a0 | |||
71606c5507 | |||
8165088886 | |||
140daecf2f | |||
884caa8c1e | |||
9f4a76a1d9 | |||
44edae531a | |||
93dba136ab |
34 changed files with 435 additions and 86 deletions
|
@ -175,6 +175,8 @@ flagAsBot: "Mark this account as a bot"
|
|||
flagAsBotDescription: "Enable this option if this account is controlled by a program. If enabled, it will act as a flag for other developers to prevent endless interaction chains with other bots and adjust Misskey's internal systems to treat this account as a bot."
|
||||
flagAsCat: "Mark this account as a cat"
|
||||
flagAsCatDescription: "Enable this option to mark this account as a cat."
|
||||
flagSpeakAsCat: "Speak as a cat"
|
||||
flagSpeakAsCatDescription: "Your posts will get nyanified when in cat mode. If this isn't working, then please check that you dont have 'Disable cat speak' on under General/Note Display"
|
||||
flagShowTimelineReplies: "Show replies in timeline"
|
||||
flagShowTimelineRepliesDescription: "Shows replies of users to notes of other users in the timeline if turned on."
|
||||
autoAcceptFollowed: "Automatically approve follow requests from users you're following"
|
||||
|
@ -762,6 +764,7 @@ noCrawleDescription: "Ask search engines to not index your profile page, notes,
|
|||
lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", your notes will be visible to anyone, even if you require followers to be manually approved."
|
||||
alwaysMarkSensitive: "Mark as sensitive by default"
|
||||
loadRawImages: "Load original images instead of showing thumbnails"
|
||||
disableCatSpeak: "Disable cat speak"
|
||||
disableShowingAnimatedImages: "Don't play animated images"
|
||||
highlightSensitiveMedia: "Highlight sensitive media"
|
||||
verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification."
|
||||
|
@ -2240,6 +2243,7 @@ _permissions:
|
|||
"read:clip-favorite": "View favorited clips"
|
||||
"read:federation": "Get federation data"
|
||||
"write:report-abuse": "Report violation"
|
||||
"write:push-notification": "Receive push notifications"
|
||||
_auth:
|
||||
shareAccessTitle: "Granting application permissions"
|
||||
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
||||
|
|
12
locales/index.d.ts
vendored
12
locales/index.d.ts
vendored
|
@ -724,6 +724,14 @@ export interface Locale extends ILocale {
|
|||
* にゃにゃにゃ??
|
||||
*/
|
||||
"flagAsCatDescription": string;
|
||||
/**
|
||||
* 猫語で話す
|
||||
*/
|
||||
"flagSpeakAsCat": string;
|
||||
/**
|
||||
* 有効にすると、あなたの投稿の 「な」を「にゃ」にします。
|
||||
*/
|
||||
"flagSpeakAsCatDescription": string;
|
||||
/**
|
||||
* タイムラインにノートへの返信を表示する
|
||||
*/
|
||||
|
@ -3064,6 +3072,10 @@ export interface Locale extends ILocale {
|
|||
* 添付画像のサムネイルをオリジナル画質にする
|
||||
*/
|
||||
"loadRawImages": string;
|
||||
/**
|
||||
* 猫の話し方を無効にする
|
||||
*/
|
||||
"disableCatSpeak": string;
|
||||
/**
|
||||
* アニメーション画像を再生しない
|
||||
*/
|
||||
|
|
|
@ -72,7 +72,7 @@ export function build() {
|
|||
.reduce((a, [k, v]) => (a[k] = (() => {
|
||||
const [lang] = k.split('-');
|
||||
switch (k) {
|
||||
case 'ja-JP': return v;
|
||||
case 'ja-JP': return merge(locales['en-US'], v);
|
||||
case 'ja-KS':
|
||||
case 'en-US': return merge(locales['ja-JP'], v);
|
||||
default: return merge(
|
||||
|
|
|
@ -177,6 +177,8 @@ flagAsBot: "Botとして設定"
|
|||
flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。"
|
||||
flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!"
|
||||
flagAsCatDescription: "にゃにゃにゃ??"
|
||||
flagSpeakAsCat: "猫語で話す"
|
||||
flagSpeakAsCatDescription: "有効にすると、あなたの投稿の 「な」を「にゃ」にします。"
|
||||
flagShowTimelineReplies: "タイムラインにノートへの返信を表示する"
|
||||
flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。"
|
||||
autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
|
||||
|
@ -762,6 +764,7 @@ noCrawleDescription: "外部の検索エンジンにあなたのユーザーペ
|
|||
lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。"
|
||||
alwaysMarkSensitive: "デフォルトでメディアをセンシティブ設定にする"
|
||||
loadRawImages: "添付画像のサムネイルをオリジナル画質にする"
|
||||
disableCatSpeak: "猫の話し方を無効にする"
|
||||
disableShowingAnimatedImages: "アニメーション画像を再生しない"
|
||||
highlightSensitiveMedia: "メディアがセンシティブであることを分かりやすく表示"
|
||||
verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。"
|
||||
|
|
12
packages/backend/migration/1696386694000-speakAsCat.js
Normal file
12
packages/backend/migration/1696386694000-speakAsCat.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
export class SpeakAsCat1696386694000 {
|
||||
name = "SpeakAsCat1696386694000";
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "speakAsCat" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."speakAsCat" IS 'Whether to speak as a cat if chosen.'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "speakAsCat"`);
|
||||
}
|
||||
}
|
18
packages/backend/migration/1736599563231-MastodonApp.js
Normal file
18
packages/backend/migration/1736599563231-MastodonApp.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
export class MastodonOauth1736599563231 {
|
||||
name = 'MastodonOauth1736599563231'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "app" ADD "website" character varying(128)`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "app"."website" IS 'Application website.'`);
|
||||
await queryRunner.query(`ALTER TABLE "app" ADD "mastodonScopes" character varying(64) array`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "app"."mastodonScopes" IS 'Mastodon app scopes, only set for apps created with Mastodon API.'`);
|
||||
await queryRunner.query(`ALTER TABLE "app" ADD "redirectUris" character varying(512) array DEFAULT '{}' NOT NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "app"."redirectUris" IS 'Redirect URIs.'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "website"`);
|
||||
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "mastodonScopes"`);
|
||||
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "redirectUris"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export class NoteUserIdIdIndex1736888704471 {
|
||||
name = 'NoteUserIdIdIndex1736888704471'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_note_userId_id" ON "note" ("userId", "id") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_note_userId_id"`);
|
||||
}
|
||||
}
|
|
@ -518,6 +518,7 @@ export class ApRendererService {
|
|||
discoverable: user.isExplorable,
|
||||
publicKey: this.renderKey(user, keypair, '#main-key'),
|
||||
isCat: user.isCat,
|
||||
speakAsCat: user.speakAsCat,
|
||||
attachment: attachment.length ? attachment : undefined,
|
||||
};
|
||||
|
||||
|
|
|
@ -553,6 +553,9 @@ export const CONTEXTS: (string | Context)[] = [
|
|||
'_misskey_votes': 'misskey:_misskey_votes',
|
||||
'_misskey_summary': 'misskey:_misskey_summary',
|
||||
'isCat': 'misskey:isCat',
|
||||
// Firefish
|
||||
firefish: "https://joinfirefish.org/ns#",
|
||||
speakAsCat: "firefish:speakAsCat",
|
||||
// vcard
|
||||
vcard: 'http://www.w3.org/2006/vcard/ns#',
|
||||
} satisfies Context,
|
||||
|
|
|
@ -389,6 +389,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true,
|
||||
emojis,
|
||||
})) as MiRemoteUser;
|
||||
|
||||
|
@ -562,12 +563,13 @@ export class ApPersonService implements OnModuleInit {
|
|||
tags,
|
||||
isBot: getApType(object) === 'Service' || getApType(object) === 'Application',
|
||||
isCat: (person as any).isCat === true,
|
||||
speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true,
|
||||
isLocked: person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo ?? null,
|
||||
alsoKnownAs: person.alsoKnownAs ?? null,
|
||||
isExplorable: person.discoverable,
|
||||
...((policy.canUpdateAvatar || policy.canUpdateBanner) ? await this.resolveAvatarAndBanner(exist, policy.canUpdateAvatar ? person.icon : exist.avatarUrl, policy.canUpdateBanner ? person.image : exist.bannerUrl).catch(() => ({})) : {}),
|
||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
|
||||
const moving = ((): boolean => {
|
||||
// 移行先がない→ある
|
||||
|
|
|
@ -489,6 +489,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
}))) : [],
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
speakAsCat: user.speakAsCat,
|
||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
||||
name: instance.name,
|
||||
softwareName: instance.softwareName,
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import { permissions } from 'misskey-js';
|
||||
|
||||
const mastodonToMisskeyScopes: Map<string, (typeof permissions)[number][]> = new Map([
|
||||
['profile', ['read:account']],
|
||||
['follow', ['read:following', 'write:following', 'read:blocks', 'write:blocks', 'read:mutes', 'write:mutes']],
|
||||
['push', ['write:push-notification']],
|
||||
['read:accounts', ['read:account']],
|
||||
['read:blocks', ['read:blocks']],
|
||||
['read:bookmarks', ['read:favorites']],
|
||||
['read:favourites', ['read:reactions']],
|
||||
['read:filters', ['read:account']],
|
||||
['read:follows', ['read:following']],
|
||||
['read:lists', ['read:account']],
|
||||
['read:mutes', ['read:mutes']],
|
||||
['read:notifications', ['read:notifications']],
|
||||
['read:search', []],
|
||||
['read:statuses', []],
|
||||
['write:accounts', ['write:account']],
|
||||
['write:blocks', ['write:blocks']],
|
||||
['write:bookmarks', ['write:favorites']],
|
||||
['write:conversations', ['write:notes']],
|
||||
['write:favourites', ['write:reactions']],
|
||||
['write:filters', ['write:account']],
|
||||
['write:follows', ['write:following']],
|
||||
['write:lists', ['write:account']],
|
||||
['write:media', ['read:drive', 'write:drive']],
|
||||
['write:mutes', ['write:mutes']],
|
||||
['write:notifications', ['write:notifications']],
|
||||
['write:reports', ['write:report-abuse']],
|
||||
['write:statuses', ['write:notes']],
|
||||
]);
|
||||
|
||||
function setHighLevelScope(scopeName: string) {
|
||||
const granularScopes = Array.from(mastodonToMisskeyScopes)
|
||||
.flatMap(([key, value]) => key.startsWith(scopeName + ':') ? value : []);
|
||||
mastodonToMisskeyScopes.set(scopeName, Array.from(new Set(granularScopes)));
|
||||
}
|
||||
|
||||
setHighLevelScope('read');
|
||||
setHighLevelScope('write');
|
||||
|
||||
export { mastodonToMisskeyScopes };
|
|
@ -63,4 +63,22 @@ export class MiApp {
|
|||
comment: 'The callbackUrl of the App.',
|
||||
})
|
||||
public callbackUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: 'Application website.',
|
||||
})
|
||||
public website: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 64, array: true, nullable: true,
|
||||
comment: 'Mastodon app scopes, only set for apps created with Mastodon API.',
|
||||
})
|
||||
public mastodonScopes: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, array: true, nullable: true,
|
||||
comment: 'Redirect URIs.',
|
||||
})
|
||||
public redirectUris: string[];
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import type { MiDriveFile } from './DriveFile.js';
|
|||
@Index('IDX_NOTE_MENTIONS', { synchronize: false })
|
||||
@Index('IDX_NOTE_FILE_IDS', { synchronize: false })
|
||||
@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false })
|
||||
@Index('IDX_note_userId_id', ['userId', 'id'])
|
||||
export class MiNote {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
|
|
@ -186,6 +186,12 @@ export class MiUser {
|
|||
})
|
||||
public isCat: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the User speaks in nya.',
|
||||
})
|
||||
public speakAsCat: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the User is the root.',
|
||||
|
|
|
@ -115,6 +115,10 @@ export const packedUserLiteSchema = {
|
|||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
speakAsCat: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
instance: {
|
||||
type: 'object',
|
||||
nullable: false, optional: true,
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import cors from '@fastify/cors';
|
||||
import multipart from '@fastify/multipart';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyFormbody from '@fastify/formbody';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
|
||||
|
@ -63,6 +64,13 @@ export class ApiServerService {
|
|||
done();
|
||||
});
|
||||
|
||||
fastify.register(this.createMisskeyServer);
|
||||
fastify.register(this.createMastodonServer);
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private createMisskeyServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
for (const endpoint of endpoints) {
|
||||
const ep = {
|
||||
name: endpoint.name,
|
||||
|
@ -106,32 +114,6 @@ export class ApiServerService {
|
|||
}
|
||||
}
|
||||
|
||||
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
|
||||
name: endpoint.name,
|
||||
method: endpoint.method,
|
||||
meta: endpoint.meta,
|
||||
params: endpoint.params,
|
||||
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
|
||||
});
|
||||
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
|
||||
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
|
||||
for (const { name, endpoints } of groupedMastodonEndpoints) {
|
||||
fastify.all<{
|
||||
Params: { endpoint: string; },
|
||||
Body: Record<string, unknown>,
|
||||
Querystring: Record<string, unknown>,
|
||||
}>('/' + name, async (request, reply) => {
|
||||
const ep = endpoints.get(request.method);
|
||||
if (!ep) {
|
||||
reply.code(405);
|
||||
reply.send();
|
||||
return;
|
||||
}
|
||||
await this.apiCallService.handleMastodonRequest(ep, request, reply);
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
username: string;
|
||||
|
@ -212,4 +194,39 @@ export class ApiServerService {
|
|||
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private createMastodonServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.register(fastifyFormbody);
|
||||
|
||||
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
|
||||
name: endpoint.name,
|
||||
method: endpoint.method,
|
||||
meta: endpoint.meta,
|
||||
params: endpoint.params,
|
||||
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
|
||||
});
|
||||
|
||||
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
|
||||
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
|
||||
|
||||
for (const { name, endpoints } of groupedMastodonEndpoints) {
|
||||
fastify.all<{
|
||||
Params: { endpoint: string; },
|
||||
Body: Record<string, unknown>,
|
||||
Querystring: Record<string, unknown>,
|
||||
}>('/' + name, async (request, reply) => {
|
||||
const ep = endpoints.get(request.method);
|
||||
if (!ep) {
|
||||
reply.code(405);
|
||||
reply.send();
|
||||
return;
|
||||
}
|
||||
await this.apiCallService.handleMastodonRequest(ep, request, reply);
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,10 @@ import { Module, Provider } from '@nestjs/common';
|
|||
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
||||
import * as mep___apps_v1_post from './mastodon/apps/v1/post.js';
|
||||
|
||||
const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup', useClass: mep___accounts_lookup_v1_get.default };
|
||||
const $apps_v1_post: Provider = { provide: 'mep:POST:v1/apps', useClass: mep___apps_v1_post.default };
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
@ -11,9 +13,11 @@ const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup
|
|||
],
|
||||
providers: [
|
||||
$accounts_lookup_v1_get,
|
||||
$apps_v1_post,
|
||||
],
|
||||
exports: [
|
||||
$accounts_lookup_v1_get,
|
||||
$apps_v1_post,
|
||||
],
|
||||
})
|
||||
export class MastodonEndpointsModule {}
|
||||
|
|
|
@ -187,6 +187,7 @@ export const paramDef = {
|
|||
preventAiLearning: { type: 'boolean' },
|
||||
isBot: { type: 'boolean' },
|
||||
isCat: { type: 'boolean' },
|
||||
speakAsCat: { type: 'boolean' },
|
||||
injectFeaturedNote: { type: 'boolean' },
|
||||
receiveAnnouncementEmail: { type: 'boolean' },
|
||||
alwaysMarkNsfw: { type: 'boolean' },
|
||||
|
@ -341,6 +342,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
|
||||
if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning;
|
||||
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
|
||||
if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat;
|
||||
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
|
||||
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
|
||||
if (typeof ps.alwaysMarkNsfw === 'boolean') {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { Schema } from '@/misc/json-schema.js';
|
||||
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
||||
import * as mep___apps_v1_post from './mastodon/apps/v1/post.js';
|
||||
|
||||
const eps = [
|
||||
['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get],
|
||||
['POST', 'v1/apps', mep___apps_v1_post],
|
||||
];
|
||||
|
||||
export interface IMastodonEndpointMeta {
|
||||
|
|
80
packages/backend/src/server/api/mastodon/apps/v1/post.ts
Normal file
80
packages/backend/src/server/api/mastodon/apps/v1/post.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MastodonEndpoint } from '@/server/api/mastodon-endpoint-base.js';
|
||||
import { MastodonApiError } from '@/server/api/error.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import type { AppsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { mastodonToMisskeyScopes } from '@/misc/mastodon/mastodon-to-misskey-scopes.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: false,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
client_name: { type: 'string', minLength: 1 },
|
||||
redirect_uri: { type: 'string', minLength: 1 },
|
||||
redirect_uris: {
|
||||
anyOf: [
|
||||
{ type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
|
||||
{ type: 'string', minLength: 1 },
|
||||
],
|
||||
},
|
||||
scopes: { type: 'string', minLength: 1 },
|
||||
website: { type: 'string' },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['redirect_uri'] },
|
||||
{ required: ['redirect_uris'] },
|
||||
],
|
||||
required: ['client_name'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends MastodonEndpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.appsRepository)
|
||||
private appsRepository: AppsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const redirectUrlsRawValue = ps.redirect_uris ?? ps.redirect_uri ?? [];
|
||||
const redirectUris = typeof redirectUrlsRawValue === 'string' ? redirectUrlsRawValue.split('\n') : redirectUrlsRawValue;
|
||||
const secret = secureRndstr(32);
|
||||
const mastodonScopes = (ps.scopes ?? 'read').split(' ');
|
||||
const scopes = mastodonScopes.flatMap(scope => {
|
||||
const misskeyScopes = mastodonToMisskeyScopes.get(scope);
|
||||
if (!misskeyScopes) {
|
||||
throw new MastodonApiError('Scopes doesn\'t match configured on the server.', 400);
|
||||
}
|
||||
return misskeyScopes;
|
||||
});
|
||||
const clientId = this.idService.gen();
|
||||
await this.appsRepository.insert({
|
||||
id: clientId,
|
||||
userId: me ? me.id : null,
|
||||
name: ps.client_name,
|
||||
description: ps.website ?? '',
|
||||
permission: scopes,
|
||||
callbackUrl: null,
|
||||
secret: secret,
|
||||
website: ps.website,
|
||||
mastodonScopes: mastodonScopes,
|
||||
redirectUris: redirectUris,
|
||||
});
|
||||
return {
|
||||
id: clientId,
|
||||
name: ps.client_name,
|
||||
website: ps.website,
|
||||
scopes: mastodonScopes,
|
||||
redirect_uri: redirectUris.join('\n'),
|
||||
redirect_uris: redirectUris,
|
||||
client_id: `mastodon:${clientId}`,
|
||||
client_secret: secret,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -34,6 +34,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type {
|
||||
AccessTokensRepository,
|
||||
AppsRepository,
|
||||
IndieAuthClientsRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
|
@ -45,9 +46,15 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import Logger from '@/logger.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { normalizeEmailAddress } from '@/misc/normalize-email-address.js';
|
||||
import { mastodonToMisskeyScopes } from '@/misc/mastodon/mastodon-to-misskey-scopes.js';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
function extractMastodonAppId(clientId: string): string | null {
|
||||
const MASTODON_CLIENT_ID_PREFIX = 'mastodon:';
|
||||
return clientId.startsWith(MASTODON_CLIENT_ID_PREFIX) ? clientId.substring(MASTODON_CLIENT_ID_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
// TODO: Consider migrating to @node-oauth/oauth2-server once
|
||||
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
|
||||
// Upstream the various validations and RFC9207 implementation in that case.
|
||||
|
@ -258,6 +265,8 @@ export class OAuth2ProviderService {
|
|||
private usersRepository: UsersRepository,
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
@Inject(DI.appsRepository)
|
||||
private appsRepository: AppsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
|
@ -328,13 +337,25 @@ export class OAuth2ProviderService {
|
|||
if (body.client_id !== granted.clientId) return;
|
||||
if (redirectUri !== granted.redirectUri) return;
|
||||
|
||||
const mastodonAppId = extractMastodonAppId(granted.clientId);
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
|
||||
if (!body.code_verifier) return;
|
||||
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
|
||||
// For Mastodon API, code verifier isn't necessary (but if code challenge was provided, then it should be verified)
|
||||
if (!mastodonAppId || granted.codeChallenge || body.code_verifier) {
|
||||
if (!body.code_verifier) return;
|
||||
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
|
||||
}
|
||||
|
||||
const accessToken = secureRndstr(128);
|
||||
const now = new Date();
|
||||
|
||||
let scopes = granted.scopes;
|
||||
let name = granted.clientId;
|
||||
if (mastodonAppId) {
|
||||
scopes = [...new Set(granted.scopes.flatMap((scope: string) => mastodonToMisskeyScopes.get(scope)))];
|
||||
name = (await this.appsRepository.findOneBy({ id: mastodonAppId }))?.name ?? name;
|
||||
}
|
||||
|
||||
// NOTE: we don't have a setup for automatic token expiration
|
||||
await accessTokensRepository.insert({
|
||||
id: idService.gen(now.getTime()),
|
||||
|
@ -342,8 +363,9 @@ export class OAuth2ProviderService {
|
|||
userId: granted.userId,
|
||||
token: accessToken,
|
||||
hash: accessToken,
|
||||
name: granted.clientId,
|
||||
permission: granted.scopes,
|
||||
name: name,
|
||||
permission: scopes,
|
||||
appId: mastodonAppId,
|
||||
});
|
||||
|
||||
grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`);
|
||||
|
@ -354,7 +376,7 @@ export class OAuth2ProviderService {
|
|||
}
|
||||
|
||||
granted.grantedToken = accessToken;
|
||||
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
|
||||
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${scopes}]`);
|
||||
await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify(granted), 'EX', 60 * 5);
|
||||
|
||||
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
|
||||
|
@ -418,50 +440,75 @@ export class OAuth2ProviderService {
|
|||
|
||||
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
|
||||
|
||||
const clientUrl = validateClientId(clientID);
|
||||
let clientInfo: ClientInformation;
|
||||
const mastodonAppId = extractMastodonAppId(clientID);
|
||||
if (mastodonAppId) {
|
||||
const app = await this.appsRepository.findOneBy({ id: mastodonAppId });
|
||||
if (!app) {
|
||||
throw new AuthorizationError('unrecognized client id', 'invalid_request');
|
||||
}
|
||||
clientInfo = {
|
||||
id: clientID,
|
||||
name: app.name,
|
||||
redirectUris: app.redirectUris,
|
||||
};
|
||||
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
||||
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
|
||||
}
|
||||
try {
|
||||
const scopes = [...new Set(scope)].filter(s => mastodonToMisskeyScopes.has(s));
|
||||
if (!scopes.length) {
|
||||
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
|
||||
}
|
||||
areq.scope = scopes;
|
||||
} catch (err) {
|
||||
return [err as Error, clientInfo, redirectURI];
|
||||
}
|
||||
} else {
|
||||
const clientUrl = validateClientId(clientID);
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
// "the server may want to resolve the domain name first and avoid fetching the document
|
||||
// if the IP address is within the loopback range defined by [RFC5735]
|
||||
// or any other implementation-specific internal IP address."
|
||||
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
|
||||
const lookup = await dns.lookup(clientUrl.hostname);
|
||||
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
|
||||
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
// "the server may want to resolve the domain name first and avoid fetching the document
|
||||
// if the IP address is within the loopback range defined by [RFC5735]
|
||||
// or any other implementation-specific internal IP address."
|
||||
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
|
||||
const lookup = await dns.lookup(clientUrl.hostname);
|
||||
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
|
||||
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
|
||||
}
|
||||
}
|
||||
|
||||
// Find client information from the database.
|
||||
const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null;
|
||||
// Find client information from the remote.
|
||||
clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
|
||||
|
||||
try {
|
||||
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
|
||||
if (!scopes.length) {
|
||||
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
|
||||
}
|
||||
areq.scope = scopes;
|
||||
|
||||
// Require PKCE parameters. This requirement is skipped for Mastodon clients, as Mastodon API doesn't require it.
|
||||
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
|
||||
if (typeof codeChallenge !== 'string') {
|
||||
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
|
||||
}
|
||||
if (codeChallengeMethod !== 'S256') {
|
||||
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
|
||||
}
|
||||
} catch (err) {
|
||||
return [err as Error, clientInfo, redirectURI];
|
||||
}
|
||||
}
|
||||
|
||||
// Find client information from the database.
|
||||
const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null;
|
||||
// Find client information from the remote.
|
||||
const clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
|
||||
|
||||
// Require the redirect URI to be included in an explicit list, per
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
|
||||
if (!clientInfo.redirectUris.includes(redirectURI)) {
|
||||
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
||||
}
|
||||
|
||||
try {
|
||||
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
|
||||
if (!scopes.length) {
|
||||
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
|
||||
}
|
||||
areq.scope = scopes;
|
||||
|
||||
// Require PKCE parameters.
|
||||
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
|
||||
if (typeof codeChallenge !== 'string') {
|
||||
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
|
||||
}
|
||||
if (codeChallengeMethod !== 'S256') {
|
||||
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
|
||||
}
|
||||
} catch (err) {
|
||||
return [err as Error, clientInfo, redirectURI];
|
||||
}
|
||||
|
||||
return [null, clientInfo, redirectURI];
|
||||
})().then(args => done(...args), err => done(err));
|
||||
}) as ValidateFunctionArity2));
|
||||
|
|
|
@ -40,6 +40,7 @@ describe('ユーザー', () => {
|
|||
avatarDecorations: user.avatarDecorations,
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
speakAsCat: user.speakAsCat,
|
||||
instance: user.instance,
|
||||
emojis: user.emojis,
|
||||
onlineStatus: user.onlineStatus,
|
||||
|
@ -311,6 +312,7 @@ describe('ユーザー', () => {
|
|||
assert.deepStrictEqual(response.avatarDecorations, []);
|
||||
assert.strictEqual(response.isBot, false);
|
||||
assert.strictEqual(response.isCat, false);
|
||||
assert.strictEqual(response.speakAsCat, false);
|
||||
assert.strictEqual(response.instance, undefined);
|
||||
assert.deepStrictEqual(response.emojis, {});
|
||||
assert.strictEqual(response.onlineStatus, 'unknown');
|
||||
|
@ -446,6 +448,8 @@ describe('ユーザー', () => {
|
|||
{ parameters: () => ({ isBot: false }) },
|
||||
{ parameters: () => ({ isCat: true }) },
|
||||
{ parameters: () => ({ isCat: false }) },
|
||||
{ parameters: () => ({ speakAsCat: true }) },
|
||||
{ parameters: () => ({ speakAsCat: false }) },
|
||||
{ parameters: () => ({ injectFeaturedNote: true }) },
|
||||
{ parameters: () => ({ injectFeaturedNote: false }) },
|
||||
{ parameters: () => ({ receiveAnnouncementEmail: true }) },
|
||||
|
|
|
@ -100,6 +100,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
|||
isBlocking: false,
|
||||
isBot: false,
|
||||
isCat: false,
|
||||
speakAsCat: false,
|
||||
isFollowed: false,
|
||||
isFollowing: false,
|
||||
isLocked: false,
|
||||
|
|
|
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { calcPopupPosition } from '@/scripts/popup-position.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
@ -70,7 +70,7 @@ function setPosition() {
|
|||
el.value.style.top = data.top + 'px';
|
||||
}
|
||||
|
||||
let loopHandler;
|
||||
let loopHandler: number | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
|
@ -81,12 +81,23 @@ onMounted(() => {
|
|||
loopHandler = window.requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
loop();
|
||||
watch(() => props.showing, show => {
|
||||
if (show) {
|
||||
if (!loopHandler) {
|
||||
loop();
|
||||
}
|
||||
} else if (loopHandler) {
|
||||
window.cancelAnimationFrame(loopHandler);
|
||||
loopHandler = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.cancelAnimationFrame(loopHandler);
|
||||
if (loopHandler) {
|
||||
window.cancelAnimationFrame(loopHandler);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
provide('linkNavigationBehavior', props.linkNavigationBehavior);
|
||||
|
||||
const isNote = props.isNote ?? true;
|
||||
const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
|
||||
const shouldNyaize = props.nyaize === 'respect' && props.author?.isCat && props.author?.speakAsCat && !defaultStore.state.disableCatSpeak;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (props.text == null || props.text === '') return;
|
||||
|
@ -85,7 +85,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
switch (token.type) {
|
||||
case 'text': {
|
||||
let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
if (!disableNyaize && shouldNyaize) {
|
||||
if (!disableNyaize && shouldNyaize) {
|
||||
text = doNyaize(text);
|
||||
}
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="showReactionsCount">{{ i18n.ts.showReactionsCount }}</MkSwitch>
|
||||
<MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch>
|
||||
<MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch>
|
||||
<MkSwitch v-model="disableCatSpeak">{{ i18n.ts.disableCatSpeak }}</MkSwitch>
|
||||
<MkRadios v-model="reactionsDisplaySize">
|
||||
<template #label>{{ i18n.ts.reactionsDisplaySize }}</template>
|
||||
<option value="small">{{ i18n.ts.small }}</option>
|
||||
|
@ -299,6 +300,7 @@ const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
|
|||
const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
|
||||
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
|
||||
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
||||
const disableCatSpeak = computed(defaultStore.makeGetterSetter('disableCatSpeak'));
|
||||
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
|
||||
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
||||
const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText'));
|
||||
|
|
|
@ -78,6 +78,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
|
|||
'warnMissingAltText',
|
||||
'imageNewTab',
|
||||
'dataSaver',
|
||||
'disableCatSpeak',
|
||||
'disableShowingAnimatedImages',
|
||||
'emojiStyle',
|
||||
'disableDrawer',
|
||||
|
|
|
@ -171,6 +171,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="profile.isCat">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></MkSwitch>
|
||||
<MkSwitch v-if="profile.isCat" v-model="profile.speakAsCat">{{ i18n.ts.flagSpeakAsCat }}<template #caption>{{ i18n.ts.flagSpeakAsCatDescription }}</template></MkSwitch>
|
||||
<MkSwitch v-model="profile.isBot">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
@ -220,6 +221,7 @@ const profile = reactive({
|
|||
lang: $i.lang,
|
||||
isBot: $i.isBot ?? false,
|
||||
isCat: $i.isCat ?? false,
|
||||
speakAsCat: $i.speakAsCat ?? false,
|
||||
});
|
||||
|
||||
watch(() => profile, () => {
|
||||
|
@ -304,6 +306,7 @@ function save() {
|
|||
lang: profile.lang || null,
|
||||
isBot: !!profile.isBot,
|
||||
isCat: !!profile.isCat,
|
||||
speakAsCat: !!profile.speakAsCat,
|
||||
});
|
||||
globalEvents.emit('requestClearPageCache');
|
||||
claimAchievement('profileFilled');
|
||||
|
|
|
@ -225,7 +225,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
|||
import MkOmit from '@/components/MkOmit.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { getScrollPosition } from '@/scripts/scroll.js';
|
||||
import { getScrollContainer } from '@/scripts/scroll.js';
|
||||
import { getUserMenu } from '@/scripts/get-user-menu.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
|
@ -282,6 +282,7 @@ const isEditingMemo = ref(false);
|
|||
const moderationNote = ref(props.user.moderationNote);
|
||||
const editModerationNote = ref(false);
|
||||
const movedFromLog = ref<null | {movedFromId:string;}[]>(null);
|
||||
let scrollEl: null | HTMLElement = null;
|
||||
|
||||
watch(moderationNote, async () => {
|
||||
await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value });
|
||||
|
@ -313,15 +314,19 @@ async function fetchMovedFromLog() {
|
|||
}
|
||||
|
||||
function parallaxLoop() {
|
||||
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
|
||||
requestNextParallaxFrame();
|
||||
parallax();
|
||||
}
|
||||
|
||||
function requestNextParallaxFrame() {
|
||||
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
|
||||
}
|
||||
|
||||
function parallax() {
|
||||
const banner = bannerEl.value as any;
|
||||
if (banner == null) return;
|
||||
|
||||
const top = getScrollPosition(rootEl.value);
|
||||
const top = scrollEl?.scrollTop ?? scrollY;
|
||||
|
||||
if (top < 0) return;
|
||||
|
||||
|
@ -330,6 +335,23 @@ function parallax() {
|
|||
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
|
||||
}
|
||||
|
||||
function startParallax() {
|
||||
(scrollEl ?? window).removeEventListener('scroll', startParallax);
|
||||
requestNextParallaxFrame();
|
||||
}
|
||||
|
||||
function addScrollEvent() {
|
||||
(scrollEl ?? window).addEventListener('scroll', startParallax);
|
||||
}
|
||||
|
||||
function cancelParallax() {
|
||||
if (parallaxAnimationId.value) {
|
||||
window.cancelAnimationFrame(parallaxAnimationId.value);
|
||||
parallaxAnimationId.value = null;
|
||||
addScrollEvent();
|
||||
}
|
||||
}
|
||||
|
||||
function showMemoTextarea() {
|
||||
isEditingMemo.value = true;
|
||||
nextTick(() => {
|
||||
|
@ -394,7 +416,10 @@ watch([props.user], () => {
|
|||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.requestAnimationFrame(parallaxLoop);
|
||||
scrollEl = getScrollContainer(rootEl.value);
|
||||
addScrollEvent();
|
||||
(scrollEl ?? window).addEventListener('scrollend', cancelParallax);
|
||||
parallax();
|
||||
narrow.value = rootEl.value!.clientWidth < 1000;
|
||||
|
||||
if (props.user.birthday) {
|
||||
|
@ -417,11 +442,7 @@ onMounted(() => {
|
|||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (parallaxAnimationId.value) {
|
||||
window.cancelAnimationFrame(parallaxAnimationId.value);
|
||||
}
|
||||
});
|
||||
onUnmounted(cancelParallax);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -259,6 +259,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
where: 'device',
|
||||
default: window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
},
|
||||
disableCatSpeak: {
|
||||
where: 'account',
|
||||
default: false,
|
||||
},
|
||||
emojiStyle: {
|
||||
where: 'device',
|
||||
default: 'twemoji', // twemoji / fluentEmoji / native
|
||||
|
|
|
@ -2820,7 +2820,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
|
|||
function parse(acct: string): Acct;
|
||||
|
||||
// @public (undocumented)
|
||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
|
||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:push-notification"];
|
||||
|
||||
// @public (undocumented)
|
||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||
|
|
|
@ -3853,6 +3853,7 @@ export type components = {
|
|||
}[];
|
||||
isBot?: boolean;
|
||||
isCat?: boolean;
|
||||
speakAsCat?: boolean;
|
||||
instance?: {
|
||||
name: string | null;
|
||||
softwareName: string | null;
|
||||
|
@ -4702,7 +4703,7 @@ export type components = {
|
|||
blockee: components['schemas']['UserDetailedNotMe'];
|
||||
};
|
||||
Hashtag: {
|
||||
/** @example misskey */
|
||||
/** @example forkey */
|
||||
tag: string;
|
||||
mentionedUsersCount: number;
|
||||
mentionedLocalUsersCount: number;
|
||||
|
@ -4885,7 +4886,7 @@ export type components = {
|
|||
isNotResponding: boolean;
|
||||
isSuspended: boolean;
|
||||
isBlocked: boolean;
|
||||
/** @example misskey */
|
||||
/** @example forkey */
|
||||
softwareName: string | null;
|
||||
softwareVersion: string | null;
|
||||
/** @example true */
|
||||
|
@ -5239,6 +5240,8 @@ export type components = {
|
|||
enableTurnstile: boolean;
|
||||
turnstileSiteKey: string | null;
|
||||
googleAnalyticsId: string | null;
|
||||
enableFC: boolean;
|
||||
fcSiteKey: string | null;
|
||||
swPublickey: string | null;
|
||||
/** @default /assets/ai.png */
|
||||
mascotImageUrl: string;
|
||||
|
@ -5388,6 +5391,8 @@ export type operations = {
|
|||
enableTurnstile: boolean;
|
||||
turnstileSiteKey: string | null;
|
||||
googleAnalyticsId: string | null;
|
||||
enableFC: boolean;
|
||||
fcSiteKey: string | null;
|
||||
swPublickey: string | null;
|
||||
/** @default /assets/ai.png */
|
||||
mascotImageUrl: string | null;
|
||||
|
@ -5414,6 +5419,7 @@ export type operations = {
|
|||
mcaptchaSecretKey: string | null;
|
||||
recaptchaSecretKey: string | null;
|
||||
turnstileSecretKey: string | null;
|
||||
fcSecretKey: string | null;
|
||||
sensitiveMediaDetection: string;
|
||||
sensitiveMediaDetectionSensitivity: string;
|
||||
setSensitiveFlagAutomatically: boolean;
|
||||
|
@ -10319,6 +10325,9 @@ export type operations = {
|
|||
enableTurnstile?: boolean;
|
||||
turnstileSiteKey?: string | null;
|
||||
turnstileSecretKey?: string | null;
|
||||
enableFC?: boolean;
|
||||
fcSiteKey?: string | null;
|
||||
fcSecretKey?: string | null;
|
||||
googleAnalyticsId?: string | null;
|
||||
/** @enum {string} */
|
||||
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
|
||||
|
@ -21747,6 +21756,7 @@ export type operations = {
|
|||
preventAiLearning?: boolean;
|
||||
isBot?: boolean;
|
||||
isCat?: boolean;
|
||||
speakAsCat?: boolean;
|
||||
injectFeaturedNote?: boolean;
|
||||
receiveAnnouncementEmail?: boolean;
|
||||
alwaysMarkNsfw?: boolean;
|
||||
|
@ -30841,3 +30851,4 @@ export type operations = {
|
|||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -103,6 +103,7 @@ export const permissions = [
|
|||
'read:clip-favorite',
|
||||
'read:federation',
|
||||
'write:report-abuse',
|
||||
'write:push-notification', // Mastodon permission
|
||||
] as const;
|
||||
|
||||
export const moderationLogTypes = [
|
||||
|
|
Loading…
Reference in a new issue