Compare commits

...

12 commits

Author SHA1 Message Date
598b88b142 Merge pull request 'Replace notes(userId) index with (userId, id)' (#28) from sugar/forkey:replace-notes-user-id-index-with-user-id-id into main
All checks were successful
Lint / pnpm_install (push) Successful in 31s
Test (production install and build) / production (22.x) (push) Successful in 1m1s
Lint / lint (misskey-js) (push) Successful in 33s
Lint / lint (backend) (push) Successful in 1m17s
Test (backend) / validate-api-json (22.x) (push) Successful in 1m27s
Lint / lint (sw) (push) Successful in 30s
Lint / typecheck (misskey-js) (push) Successful in 31s
Test (backend) / unit (22.x) (push) Successful in 2m50s
Lint / typecheck (backend) (push) Successful in 1m22s
Test (backend) / e2e (22.x) (push) Successful in 6m49s
Lint / lint (frontend) (push) Successful in 7m49s
Reviewed-on: #28
Reviewed-by: leah <leah@noreply.woem.men>
2025-02-23 13:05:54 +00:00
e90e9e016f Merge pull request 'implement mastodon oauth authorization' (#22) from sugar/forkey:implement-mastodon-oauth into main
All checks were successful
Lint / pnpm_install (push) Successful in 33s
API report (misskey.js) / report (push) Successful in 47s
Test (misskey.js) / test (22.x) (push) Successful in 58s
Test (production install and build) / production (22.x) (push) Successful in 1m12s
Lint / lint (misskey-js) (push) Successful in 36s
Test (backend) / validate-api-json (22.x) (push) Successful in 1m28s
Lint / lint (backend) (push) Successful in 1m17s
Lint / lint (sw) (push) Successful in 34s
Test (backend) / unit (22.x) (push) Successful in 2m38s
Lint / typecheck (misskey-js) (push) Successful in 36s
Lint / typecheck (backend) (push) Successful in 1m26s
Test (backend) / e2e (22.x) (push) Successful in 6m37s
Lint / lint (frontend) (push) Successful in 7m38s
Reviewed-on: #22
Reviewed-by: leah <leah@noreply.woem.men>
2025-02-08 22:01:20 +00:00
125bf6fe93 implement mastodon push permission
All checks were successful
Lint / pnpm_install (pull_request) Successful in 30s
API report (misskey.js) / report (pull_request) Successful in 47s
Test (misskey.js) / test (22.x) (pull_request) Successful in 48s
Test (frontend) / vitest (22.x) (pull_request) Successful in 1m19s
Test (backend) / unit (22.x) (pull_request) Successful in 2m44s
Test (production install and build) / production (22.x) (pull_request) Successful in 1m7s
Lint / lint (backend) (pull_request) Successful in 1m7s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 1m24s
Lint / lint (misskey-js) (pull_request) Successful in 36s
Lint / lint (sw) (pull_request) Successful in 32s
Lint / typecheck (misskey-js) (pull_request) Successful in 33s
Lint / typecheck (backend) (pull_request) Successful in 1m24s
Test (backend) / e2e (22.x) (pull_request) Successful in 6m28s
Lint / lint (frontend) (pull_request) Successful in 7m22s
currently doesn't do anything, but it's necessary for masto-fe to be
able to sign in
2025-02-07 21:53:22 +01:00
32fc7644fc add support for form bodies for mastodon apis
this separates registration of mastodon endpoints to another register
scope so that form body parsing wouldn't affect misskey apis
2025-02-07 21:53:22 +01:00
04c1c0e1e6 implement mastodon oauth authorization 2025-02-07 21:42:36 +01:00
cfba325ae7 Merge pull request 'frontend performance improvements' (#59) from sugar/forkey:frontend-performance-improvements into main
All checks were successful
Lint / pnpm_install (push) Successful in 34s
Test (production install and build) / production (22.x) (push) Successful in 1m4s
Lint / lint (misskey-js) (push) Successful in 36s
Lint / lint (sw) (push) Successful in 38s
Lint / lint (backend) (push) Successful in 1m4s
Lint / typecheck (misskey-js) (push) Successful in 35s
Lint / typecheck (backend) (push) Successful in 1m24s
Lint / lint (frontend) (push) Successful in 7m16s
Reviewed-on: #59
Reviewed-by: leah <leah@noreply.woem.men>
2025-02-07 18:36:39 +00:00
71606c5507 avoid computing tooltip position every frame
All checks were successful
Lint / pnpm_install (pull_request) Successful in 2m59s
Test (frontend) / vitest (22.x) (pull_request) Successful in 2m56s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m16s
Lint / lint (backend) (pull_request) Successful in 2m26s
Lint / lint (frontend) (pull_request) Successful in 9m0s
Lint / lint (misskey-js) (pull_request) Successful in 1m59s
Lint / lint (sw) (pull_request) Successful in 2m13s
Lint / typecheck (backend) (pull_request) Successful in 2m41s
Lint / typecheck (misskey-js) (pull_request) Successful in 1m57s
this fixes an issue causing misskey to constantly recompute tooltip
position when any kind of chart is being displayed (such as the one
on user profile page, or any page for unauthenticated users)
2025-02-02 17:33:33 +01:00
8165088886 only run parallax effect when scrolling user profile
running animation handler every frame is slow
2025-02-02 16:58:14 +01:00
884caa8c1e Merge branch 'main' into replace-notes-user-id-index-with-user-id-id
All checks were successful
Lint / pnpm_install (pull_request) Successful in 3m13s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m17s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m42s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 4m7s
Lint / lint (backend) (pull_request) Successful in 3m11s
Lint / lint (misskey-js) (pull_request) Successful in 2m32s
Lint / lint (sw) (pull_request) Successful in 2m10s
Lint / lint (frontend) (pull_request) Successful in 9m52s
Lint / typecheck (backend) (pull_request) Successful in 3m38s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m57s
Test (backend) / unit (22.x) (pull_request) Successful in 2m44s
Test (backend) / e2e (22.x) (pull_request) Successful in 12m44s
2025-01-19 07:56:10 +00:00
9f4a76a1d9 Merge branch 'main' into replace-notes-user-id-index-with-user-id-id
All checks were successful
Lint / pnpm_install (pull_request) Successful in 2m29s
Test (backend) / unit (22.x) (pull_request) Successful in 8m50s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m38s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m30s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 3m2s
Lint / lint (backend) (pull_request) Successful in 2m58s
Lint / lint (misskey-js) (pull_request) Successful in 2m33s
Lint / lint (sw) (pull_request) Successful in 2m45s
Lint / typecheck (backend) (pull_request) Successful in 3m22s
Lint / lint (frontend) (pull_request) Successful in 9m16s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m40s
Test (backend) / e2e (22.x) (pull_request) Successful in 9m36s
2025-01-19 06:06:19 +00:00
44edae531a Merge branch 'main' into replace-notes-user-id-index-with-user-id-id
Some checks failed
Lint / pnpm_install (pull_request) Successful in 2m44s
Test (backend) / unit (22.x) (pull_request) Successful in 8m16s
Test (backend) / e2e (22.x) (pull_request) Successful in 9m44s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m46s
Test (production install and build) / production (22.x) (pull_request) Successful in 3m5s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 5m21s
Lint / lint (backend) (pull_request) Successful in 4m9s
Lint / lint (misskey-js) (pull_request) Successful in 3m59s
Lint / lint (frontend) (pull_request) Failing after 9m38s
Lint / lint (sw) (pull_request) Successful in 4m49s
Lint / typecheck (backend) (pull_request) Successful in 3m41s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m59s
2025-01-18 20:38:54 -08:00
93dba136ab Replace notes(userId) index with (userId, id)
Some checks failed
Lint / pnpm_install (pull_request) Successful in 2m14s
Test (backend) / unit (22.x) (pull_request) Successful in 7m8s
Test (backend) / e2e (22.x) (pull_request) Failing after 9m26s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m0s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m37s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 3m16s
Lint / lint (backend) (pull_request) Successful in 3m1s
Lint / lint (frontend) (pull_request) Successful in 9m53s
Lint / lint (misskey-js) (pull_request) Successful in 2m22s
Lint / lint (sw) (pull_request) Successful in 3m24s
Lint / typecheck (backend) (pull_request) Successful in 3m17s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m22s
This improves performance of queries searching posts by a given user,
optionally with a provided range of dates, and then using ORDER BY to
sort posts by date.

Examples of such queries include: viewing posts by a given user, as
well as checking when the last post by a given user was written for
Mastodon API.
2025-01-17 15:29:08 +00:00
16 changed files with 355 additions and 81 deletions

View file

@ -2243,6 +2243,7 @@ _permissions:
"read:clip-favorite": "View favorited clips" "read:clip-favorite": "View favorited clips"
"read:federation": "Get federation data" "read:federation": "Get federation data"
"write:report-abuse": "Report violation" "write:report-abuse": "Report violation"
"write:push-notification": "Receive push notifications"
_auth: _auth:
shareAccessTitle: "Granting application permissions" shareAccessTitle: "Granting application permissions"
shareAccess: "Would you like to authorize \"{name}\" to access this account?" shareAccess: "Would you like to authorize \"{name}\" to access this account?"

View file

@ -72,7 +72,7 @@ export function build() {
.reduce((a, [k, v]) => (a[k] = (() => { .reduce((a, [k, v]) => (a[k] = (() => {
const [lang] = k.split('-'); const [lang] = k.split('-');
switch (k) { switch (k) {
case 'ja-JP': return v; case 'ja-JP': return merge(locales['en-US'], v);
case 'ja-KS': case 'ja-KS':
case 'en-US': return merge(locales['ja-JP'], v); case 'en-US': return merge(locales['ja-JP'], v);
default: return merge( default: return merge(

View 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"`);
}
}

View file

@ -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"`);
}
}

View file

@ -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 };

View file

@ -63,4 +63,22 @@ export class MiApp {
comment: 'The callbackUrl of the App.', comment: 'The callbackUrl of the App.',
}) })
public callbackUrl: string | null; 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[];
} }

View file

@ -15,6 +15,7 @@ import type { MiDriveFile } from './DriveFile.js';
@Index('IDX_NOTE_MENTIONS', { synchronize: false }) @Index('IDX_NOTE_MENTIONS', { synchronize: false })
@Index('IDX_NOTE_FILE_IDS', { synchronize: false }) @Index('IDX_NOTE_FILE_IDS', { synchronize: false })
@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false }) @Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false })
@Index('IDX_note_userId_id', ['userId', 'id'])
export class MiNote { export class MiNote {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import fastifyFormbody from '@fastify/formbody';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js'; import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
@ -63,6 +64,13 @@ export class ApiServerService {
done(); 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) { for (const endpoint of endpoints) {
const ep = { const ep = {
name: endpoint.name, 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<{ fastify.post<{
Body: { Body: {
username: string; username: string;
@ -212,4 +194,39 @@ export class ApiServerService {
done(); 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();
}
} }

View file

@ -2,8 +2,10 @@ import { Module, Provider } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.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 $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({ @Module({
imports: [ imports: [
@ -11,9 +13,11 @@ const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup
], ],
providers: [ providers: [
$accounts_lookup_v1_get, $accounts_lookup_v1_get,
$apps_v1_post,
], ],
exports: [ exports: [
$accounts_lookup_v1_get, $accounts_lookup_v1_get,
$apps_v1_post,
], ],
}) })
export class MastodonEndpointsModule {} export class MastodonEndpointsModule {}

View file

@ -1,8 +1,10 @@
import { Schema } from '@/misc/json-schema.js'; import { Schema } from '@/misc/json-schema.js';
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.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 = [ const eps = [
['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get], ['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get],
['POST', 'v1/apps', mep___apps_v1_post],
]; ];
export interface IMastodonEndpointMeta { export interface IMastodonEndpointMeta {

View 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,
};
});
}
}

View file

@ -34,6 +34,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { import type {
AccessTokensRepository, AccessTokensRepository,
AppsRepository,
IndieAuthClientsRepository, IndieAuthClientsRepository,
UserProfilesRepository, UserProfilesRepository,
UsersRepository, UsersRepository,
@ -45,9 +46,15 @@ import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { normalizeEmailAddress } from '@/misc/normalize-email-address.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 { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify'; 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 // TODO: Consider migrating to @node-oauth/oauth2-server once
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
// Upstream the various validations and RFC9207 implementation in that case. // Upstream the various validations and RFC9207 implementation in that case.
@ -258,6 +265,8 @@ export class OAuth2ProviderService {
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.appsRepository)
private appsRepository: AppsRepository,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
@ -328,13 +337,25 @@ export class OAuth2ProviderService {
if (body.client_id !== granted.clientId) return; if (body.client_id !== granted.clientId) return;
if (redirectUri !== granted.redirectUri) return; if (redirectUri !== granted.redirectUri) return;
const mastodonAppId = extractMastodonAppId(granted.clientId);
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6 // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
if (!body.code_verifier) return; // For Mastodon API, code verifier isn't necessary (but if code challenge was provided, then it should be verified)
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return; 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 accessToken = secureRndstr(128);
const now = new Date(); 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 // NOTE: we don't have a setup for automatic token expiration
await accessTokensRepository.insert({ await accessTokensRepository.insert({
id: idService.gen(now.getTime()), id: idService.gen(now.getTime()),
@ -342,8 +363,9 @@ export class OAuth2ProviderService {
userId: granted.userId, userId: granted.userId,
token: accessToken, token: accessToken,
hash: accessToken, hash: accessToken,
name: granted.clientId, name: name,
permission: granted.scopes, permission: scopes,
appId: mastodonAppId,
}); });
grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`); grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`);
@ -354,7 +376,7 @@ export class OAuth2ProviderService {
} }
granted.grantedToken = accessToken; 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); await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify(granted), 'EX', 60 * 5);
return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; 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}`); 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 // https://indieauth.spec.indieweb.org/#client-information-discovery
// "the server may want to resolve the domain name first and avoid fetching the document // "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] // if the IP address is within the loopback range defined by [RFC5735]
// or any other implementation-specific internal IP address." // or any other implementation-specific internal IP address."
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') { if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
const lookup = await dns.lookup(clientUrl.hostname); const lookup = await dns.lookup(clientUrl.hostname);
if (ipaddr.parse(lookup.address).range() !== 'unicast') { if (ipaddr.parse(lookup.address).range() !== 'unicast') {
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request'); 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 // 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 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
if (!clientInfo.redirectUris.includes(redirectURI)) { if (!clientInfo.redirectUris.includes(redirectURI)) {
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request'); 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]; return [null, clientInfo, redirectURI];
})().then(args => done(...args), err => done(err)); })().then(args => done(...args), err => done(err));
}) as ValidateFunctionArity2)); }) as ValidateFunctionArity2));

View file

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <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 * as os from '@/os.js';
import { calcPopupPosition } from '@/scripts/popup-position.js'; import { calcPopupPosition } from '@/scripts/popup-position.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -70,7 +70,7 @@ function setPosition() {
el.value.style.top = data.top + 'px'; el.value.style.top = data.top + 'px';
} }
let loopHandler; let loopHandler: number | undefined;
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
@ -81,12 +81,23 @@ onMounted(() => {
loopHandler = window.requestAnimationFrame(loop); loopHandler = window.requestAnimationFrame(loop);
}; };
loop(); watch(() => props.showing, show => {
if (show) {
if (!loopHandler) {
loop();
}
} else if (loopHandler) {
window.cancelAnimationFrame(loopHandler);
loopHandler = undefined;
}
});
}); });
}); });
onUnmounted(() => { onUnmounted(() => {
window.cancelAnimationFrame(loopHandler); if (loopHandler) {
window.cancelAnimationFrame(loopHandler);
}
}); });
</script> </script>

View file

@ -225,7 +225,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkOmit from '@/components/MkOmit.vue'; import MkOmit from '@/components/MkOmit.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkButton from '@/components/MkButton.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 { getUserMenu } from '@/scripts/get-user-menu.js';
import number from '@/filters/number.js'; import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
@ -282,6 +282,7 @@ const isEditingMemo = ref(false);
const moderationNote = ref(props.user.moderationNote); const moderationNote = ref(props.user.moderationNote);
const editModerationNote = ref(false); const editModerationNote = ref(false);
const movedFromLog = ref<null | {movedFromId:string;}[]>(null); const movedFromLog = ref<null | {movedFromId:string;}[]>(null);
let scrollEl: null | HTMLElement = null;
watch(moderationNote, async () => { watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value }); await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value });
@ -313,15 +314,19 @@ async function fetchMovedFromLog() {
} }
function parallaxLoop() { function parallaxLoop() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop); requestNextParallaxFrame();
parallax(); parallax();
} }
function requestNextParallaxFrame() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
}
function parallax() { function parallax() {
const banner = bannerEl.value as any; const banner = bannerEl.value as any;
if (banner == null) return; if (banner == null) return;
const top = getScrollPosition(rootEl.value); const top = scrollEl?.scrollTop ?? scrollY;
if (top < 0) return; if (top < 0) return;
@ -330,6 +335,23 @@ function parallax() {
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; 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() { function showMemoTextarea() {
isEditingMemo.value = true; isEditingMemo.value = true;
nextTick(() => { nextTick(() => {
@ -394,7 +416,10 @@ watch([props.user], () => {
}); });
onMounted(() => { onMounted(() => {
window.requestAnimationFrame(parallaxLoop); scrollEl = getScrollContainer(rootEl.value);
addScrollEvent();
(scrollEl ?? window).addEventListener('scrollend', cancelParallax);
parallax();
narrow.value = rootEl.value!.clientWidth < 1000; narrow.value = rootEl.value!.clientWidth < 1000;
if (props.user.birthday) { if (props.user.birthday) {
@ -417,11 +442,7 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => { onUnmounted(cancelParallax);
if (parallaxAnimationId.value) {
window.cancelAnimationFrame(parallaxAnimationId.value);
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -2820,7 +2820,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
function parse(acct: string): Acct; function parse(acct: string): Acct;
// @public (undocumented) // @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) // @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json']; type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -103,6 +103,7 @@ export const permissions = [
'read:clip-favorite', 'read:clip-favorite',
'read:federation', 'read:federation',
'write:report-abuse', 'write:report-abuse',
'write:push-notification', // Mastodon permission
] as const; ] as const;
export const moderationLogTypes = [ export const moderationLogTypes = [