forked from FoundKeyGang/FoundKey
Compare commits
12 commits
Author | SHA1 | Date | |
---|---|---|---|
4912fb286c | |||
1d14ed013a | |||
f110cbedc5 | |||
029870ef01 | |||
cc4e9f9071 | |||
181a2c4217 | |||
c6ac67a3f7 | |||
741ceb82df | |||
3939f0f37b | |||
6ec3d61753 | |||
3b295f8ce1 | |||
8ccc4f51d9 |
52 changed files with 945 additions and 446 deletions
|
@ -190,9 +190,7 @@ charts: "Charts"
|
||||||
perHour: "Per Hour"
|
perHour: "Per Hour"
|
||||||
perDay: "Per Day"
|
perDay: "Per Day"
|
||||||
stopActivityDelivery: "Stop sending activities"
|
stopActivityDelivery: "Stop sending activities"
|
||||||
stopActivityDeliveryDescription: "Local activities will not be sent to this instance. Receiving activities works as before."
|
|
||||||
blockThisInstance: "Block this instance"
|
blockThisInstance: "Block this instance"
|
||||||
blockThisInstanceDescription: "Local activites will not be sent to this instance. Activites from this instance will be discarded."
|
|
||||||
operations: "Operations"
|
operations: "Operations"
|
||||||
software: "Software"
|
software: "Software"
|
||||||
version: "Version"
|
version: "Version"
|
||||||
|
@ -931,6 +929,10 @@ setTag: "Set tag"
|
||||||
addTag: "Add tag"
|
addTag: "Add tag"
|
||||||
removeTag: "Remove tag"
|
removeTag: "Remove tag"
|
||||||
externalCssSnippets: "Some CSS snippets for your inspiration (not managed by FoundKey)"
|
externalCssSnippets: "Some CSS snippets for your inspiration (not managed by FoundKey)"
|
||||||
|
oauthErrorGoBack: "An error happened while trying to authenticate a 3rd party app.\
|
||||||
|
\ Please go back and try again."
|
||||||
|
appAuthorization: "App authorization"
|
||||||
|
noPermissionsRequested: "(No permissions requested.)"
|
||||||
_emailUnavailable:
|
_emailUnavailable:
|
||||||
used: "This email address is already being used"
|
used: "This email address is already being used"
|
||||||
format: "The format of this email address is invalid"
|
format: "The format of this email address is invalid"
|
||||||
|
@ -1249,38 +1251,37 @@ _2fa:
|
||||||
\ authentication via hardware security keys that support FIDO2 to further secure\
|
\ authentication via hardware security keys that support FIDO2 to further secure\
|
||||||
\ your account."
|
\ your account."
|
||||||
_permissions:
|
_permissions:
|
||||||
"read:account": "View your account information"
|
"read:account": "Read account information"
|
||||||
"write:account": "Edit your account information"
|
"write:account": "Edit account information"
|
||||||
"read:blocks": "View your list of blocked users"
|
"read:blocks": "Read which users are blocked"
|
||||||
"write:blocks": "Edit your list of blocked users"
|
"write:blocks": "Block and unblock users"
|
||||||
"read:drive": "Access your Drive files and folders"
|
"read:drive": "List files and folders in the drive"
|
||||||
"write:drive": "Edit or delete your Drive files and folders"
|
"write:drive": "Create, change and delete files in the drive"
|
||||||
"read:favorites": "View your list of favorites"
|
"read:favorites": "List favourited notes"
|
||||||
"write:favorites": "Edit your list of favorites"
|
"write:favorites": "Favorite and unfavorite notes"
|
||||||
"read:following": "View information on who you follow"
|
"read:following": "List followed and following users"
|
||||||
"write:following": "Follow or unfollow other accounts"
|
"write:following": "Follow and unfollow other users"
|
||||||
"read:messaging": "View your chats"
|
"read:messaging": "View chat messages and history"
|
||||||
"write:messaging": "Compose or delete chat messages"
|
"write:messaging": "Create and delete chat messages"
|
||||||
"read:mutes": "View your list of muted users"
|
"read:mutes": "List users which are muted or whose renotes are muted"
|
||||||
"write:mutes": "Edit your list of muted users"
|
"write:mutes": "Mute and unmute users or their renotes"
|
||||||
"write:notes": "Compose or delete notes"
|
"write:notes": "Create and delete notes"
|
||||||
"read:notifications": "View your notifications"
|
"read:notifications": "Read notifications"
|
||||||
"write:notifications": "Manage your notifications"
|
"write:notifications": "Mark notifications as read and create custom notifications"
|
||||||
"read:reactions": "View your reactions"
|
"write:reactions": "Create and delete reactions"
|
||||||
"write:reactions": "Edit your reactions"
|
"write:votes": "Vote in polls"
|
||||||
"write:votes": "Vote on a poll"
|
"read:pages": "List and read pages"
|
||||||
"read:pages": "View your pages"
|
"write:pages": "Create, change and delete pages"
|
||||||
"write:pages": "Edit or delete your pages"
|
"read:page-likes": "List and read page likes"
|
||||||
"read:page-likes": "View your likes on pages"
|
"write:page-likes": "Like and unlike pages"
|
||||||
"write:page-likes": "Edit your likes on pages"
|
"read:user-groups": "List and view joined, owned and invited to groups"
|
||||||
"read:user-groups": "View your user groups"
|
"write:user-groups": "Create, modify, delete, transfer, join and leave groups. Invite and ban others from groups. Accept and reject group invitations."
|
||||||
"write:user-groups": "Edit or delete your user groups"
|
"read:channels": "List and read followed and joined channels"
|
||||||
"read:channels": "View your channels"
|
"write:channels": "Create, modify, follow and unfollow channels"
|
||||||
"write:channels": "Edit your channels"
|
"read:gallery": "List and read gallery posts"
|
||||||
"read:gallery": "View your gallery"
|
"write:gallery": "Create, modify and delete gallery posts"
|
||||||
"write:gallery": "Edit your gallery"
|
"read:gallery-likes": "List and read gallery post likes"
|
||||||
"read:gallery-likes": "View your list of liked gallery posts"
|
"write:gallery-likes": "Like and unlike gallery posts"
|
||||||
"write:gallery-likes": "Edit your list of liked gallery posts"
|
|
||||||
_auth:
|
_auth:
|
||||||
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
||||||
shareAccessAsk: "Are you sure you want to authorize this application to access your\
|
shareAccessAsk: "Are you sure you want to authorize this application to access your\
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export class removeAds1657570176749 {
|
export class removeAds1657570176749 {
|
||||||
name = 'removeAds1657570176749';
|
name = 'removeAds1657570176749'
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`DROP TABLE "ad"`);
|
await queryRunner.query(`DROP TABLE "ad"`);
|
|
@ -0,0 +1,26 @@
|
||||||
|
export class tokenPermissions1667653936442 {
|
||||||
|
name = 'tokenPermissions1667653936442'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
// Carry over the permissions from the app for tokens that have an associated app.
|
||||||
|
await queryRunner.query(`UPDATE "access_token" SET permission = (SELECT permission FROM "app" WHERE "app"."id" = "access_token"."appId") WHERE "appId" IS NOT NULL AND CARDINALITY("permission") = 0`);
|
||||||
|
// The permission column should now always be set explicitly, so the default is not needed any more.
|
||||||
|
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`);
|
||||||
|
// Refactor scheme to allow multiple access tokens per app.
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_c072b729d71697f959bde66ade0"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "userId" TO "accessTokenId"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66" UNIQUE ("accessTokenId")`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66" FOREIGN KEY ("accessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "accessTokenId" TO "userId"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_c072b729d71697f959bde66ade0" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" SET DEFAULT '{}'::varchar[]`);
|
||||||
|
await queryRunner.query(`UPDATE "access_token" SET permission = '{}'::varchar[] WHERE "appId" IS NOT NULL`);
|
||||||
|
}
|
||||||
|
}
|
12
packages/backend/migration/1667738304733-pkce.js
Normal file
12
packages/backend/migration/1667738304733-pkce.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export class pkce1667738304733 {
|
||||||
|
name = 'pkce1667738304733'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" ADD "pkceChallenge" text`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "auth_session"."pkceChallenge" IS 'PKCE code_challenge value, if provided (OAuth only)'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" DROP COLUMN "pkceChallenge"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -62,21 +62,22 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
|
||||||
const rel = node.attrs.find(x => x.name === 'rel');
|
const rel = node.attrs.find(x => x.name === 'rel');
|
||||||
const href = node.attrs.find(x => x.name === 'href');
|
const href = node.attrs.find(x => x.name === 'href');
|
||||||
|
|
||||||
// hashtags
|
// ハッシュタグ
|
||||||
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
|
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
|
||||||
text += txt;
|
text += txt;
|
||||||
// mentions
|
// メンション
|
||||||
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
|
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
|
||||||
const part = txt.split('@');
|
const part = txt.split('@');
|
||||||
|
|
||||||
if (part.length === 2 && href) {
|
if (part.length === 2 && href) {
|
||||||
// restore the host name part
|
//#region ホスト名部分が省略されているので復元する
|
||||||
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
||||||
text += acct;
|
text += acct;
|
||||||
|
//#endregion
|
||||||
} else if (part.length === 3) {
|
} else if (part.length === 3) {
|
||||||
text += txt;
|
text += txt;
|
||||||
}
|
}
|
||||||
// other
|
// その他
|
||||||
} else {
|
} else {
|
||||||
const generateLink = () => {
|
const generateLink = () => {
|
||||||
if (!href && !txt) {
|
if (!href && !txt) {
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
export class Cache<T> {
|
export class Cache<T> {
|
||||||
public cache: Map<string | null, { date: number; value: T; }>;
|
public cache: Map<string | null, { date: number; value: T; }>;
|
||||||
private lifetime: number;
|
private lifetime: number;
|
||||||
public fetcher: (key: string | null) => Promise<T | undefined>;
|
|
||||||
|
|
||||||
constructor(lifetime: number, fetcher: Cache<T>['fetcher']) {
|
constructor(lifetime: Cache<never>['lifetime']) {
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.lifetime = lifetime;
|
this.lifetime = lifetime;
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public set(key: string | null, value: T): void {
|
public set(key: string | null, value: T): void {
|
||||||
|
@ -19,13 +17,10 @@ export class Cache<T> {
|
||||||
public get(key: string | null): T | undefined {
|
public get(key: string | null): T | undefined {
|
||||||
const cached = this.cache.get(key);
|
const cached = this.cache.get(key);
|
||||||
if (cached == null) return undefined;
|
if (cached == null) return undefined;
|
||||||
|
|
||||||
// discard if past the cache lifetime
|
|
||||||
if ((Date.now() - cached.date) > this.lifetime) {
|
if ((Date.now() - cached.date) > this.lifetime) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,22 +29,52 @@ export class Cache<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the value is cached, it is returned. Otherwise the fetcher is
|
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||||
* run to get the value. If the fetcher returns undefined, it is
|
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||||
* returned but not cached.
|
|
||||||
*/
|
*/
|
||||||
public async fetch(key: string | null): Promise<T | undefined> {
|
public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
|
||||||
const cached = this.get(key);
|
const cachedValue = this.get(key);
|
||||||
if (cached !== undefined) {
|
if (cachedValue !== undefined) {
|
||||||
return cached;
|
if (validator) {
|
||||||
} else {
|
if (validator(cachedValue)) {
|
||||||
const value = await this.fetcher(key);
|
// Cache HIT
|
||||||
|
return cachedValue;
|
||||||
// don't cache undefined
|
}
|
||||||
if (value !== undefined)
|
} else {
|
||||||
this.set(key, value);
|
// Cache HIT
|
||||||
|
return cachedValue;
|
||||||
return value;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache MISS
|
||||||
|
const value = await fetcher();
|
||||||
|
this.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||||
|
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
|
||||||
|
*/
|
||||||
|
public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
|
||||||
|
const cachedValue = this.get(key);
|
||||||
|
if (cachedValue !== undefined) {
|
||||||
|
if (validator) {
|
||||||
|
if (validator(cachedValue)) {
|
||||||
|
// Cache HIT
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cache HIT
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache MISS
|
||||||
|
const value = await fetcher();
|
||||||
|
if (value !== undefined) {
|
||||||
|
this.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,26 +3,22 @@ import { Note } from '@/models/entities/note.js';
|
||||||
import { User } from '@/models/entities/user.js';
|
import { User } from '@/models/entities/user.js';
|
||||||
import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js';
|
import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js';
|
||||||
import * as Acct from '@/misc/acct.js';
|
import * as Acct from '@/misc/acct.js';
|
||||||
import { MINUTE } from '@/const.js';
|
|
||||||
import { getFullApAccount } from './convert-host.js';
|
import { getFullApAccount } from './convert-host.js';
|
||||||
import { Packed } from './schema.js';
|
import { Packed } from './schema.js';
|
||||||
import { Cache } from './cache.js';
|
import { Cache } from './cache.js';
|
||||||
|
|
||||||
const blockingCache = new Cache<User['id'][]>(
|
const blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
|
||||||
5 * MINUTE,
|
|
||||||
(blockerId) => Blockings.findBy({ blockerId }).then(res => res.map(x => x.blockeeId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// designation for users you follow, list users and groups is disabled for performance reasons
|
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* either noteUserFollowers or antennaUserFollowing must be specified
|
* noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい
|
||||||
*/
|
*/
|
||||||
export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
|
export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
|
||||||
if (note.visibility === 'specified') return false;
|
if (note.visibility === 'specified') return false;
|
||||||
|
|
||||||
// skip if the antenna creator is blocked by the note author
|
// アンテナ作成者がノート作成者にブロックされていたらスキップ
|
||||||
const blockings = await blockingCache.fetch(noteUser.id);
|
const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
|
||||||
if (blockings.some(blocking => blocking === antenna.userId)) return false;
|
if (blockings.some(blocking => blocking === antenna.userId)) return false;
|
||||||
|
|
||||||
if (note.visibility === 'followers') {
|
if (note.visibility === 'followers') {
|
||||||
|
|
|
@ -1,44 +1,44 @@
|
||||||
import push from 'web-push';
|
|
||||||
import { db } from '@/db/postgre.js';
|
import { db } from '@/db/postgre.js';
|
||||||
import { Meta } from '@/models/entities/meta.js';
|
import { Meta } from '@/models/entities/meta.js';
|
||||||
import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js';
|
|
||||||
|
|
||||||
let cache: Meta;
|
let cache: Meta;
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs the primitive database operation to set the server configuration
|
|
||||||
*/
|
|
||||||
export async function setMeta(meta: Meta): Promise<void> {
|
|
||||||
const unlock = await getFetchInstanceMetadataLock('localhost');
|
|
||||||
|
|
||||||
// try to mitigate older bugs where multiple meta entries may have been created
|
|
||||||
db.manager.clear(Meta);
|
|
||||||
db.manager.insert(Meta, meta);
|
|
||||||
|
|
||||||
unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs the primitive database operation to fetch server configuration.
|
|
||||||
* Writes to `cache` instead of returning.
|
|
||||||
*/
|
|
||||||
async function getMeta(): Promise<void> {
|
|
||||||
const unlock = await getFetchInstanceMetadataLock('localhost');
|
|
||||||
|
|
||||||
// new IDs are prioritised because multiple records may have been created due to past bugs
|
|
||||||
cache = db.manager.findOne(Meta, {
|
|
||||||
order: {
|
|
||||||
id: 'DESC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMeta(noCache = false): Promise<Meta> {
|
export async function fetchMeta(noCache = false): Promise<Meta> {
|
||||||
if (!noCache && cache) return cache;
|
if (!noCache && cache) return cache;
|
||||||
|
|
||||||
await getMeta();
|
return await db.transaction(async transactionalEntityManager => {
|
||||||
|
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||||
|
const metas = await transactionalEntityManager.find(Meta, {
|
||||||
|
order: {
|
||||||
|
id: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return cache;
|
const meta = metas[0];
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
cache = meta;
|
||||||
|
return meta;
|
||||||
|
} else {
|
||||||
|
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
||||||
|
const saved = await transactionalEntityManager
|
||||||
|
.upsert(
|
||||||
|
Meta,
|
||||||
|
{
|
||||||
|
id: 'x',
|
||||||
|
},
|
||||||
|
['id'],
|
||||||
|
)
|
||||||
|
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
|
||||||
|
|
||||||
|
cache = saved;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
fetchMeta(true).then(meta => {
|
||||||
|
cache = meta;
|
||||||
|
});
|
||||||
|
}, 1000 * 10);
|
||||||
|
|
|
@ -3,11 +3,8 @@ import { User } from '@/models/entities/user.js';
|
||||||
import { UserKeypair } from '@/models/entities/user-keypair.js';
|
import { UserKeypair } from '@/models/entities/user-keypair.js';
|
||||||
import { Cache } from './cache.js';
|
import { Cache } from './cache.js';
|
||||||
|
|
||||||
const cache = new Cache<UserKeypair>(
|
const cache = new Cache<UserKeypair>(Infinity);
|
||||||
Infinity,
|
|
||||||
(userId) => UserKeypairs.findOneByOrFail({ userId }),
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
||||||
return await cache.fetch(userId);
|
return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,27 +4,14 @@ import { Emojis } from '@/models/index.js';
|
||||||
import { Emoji } from '@/models/entities/emoji.js';
|
import { Emoji } from '@/models/entities/emoji.js';
|
||||||
import { Note } from '@/models/entities/note.js';
|
import { Note } from '@/models/entities/note.js';
|
||||||
import { query } from '@/prelude/url.js';
|
import { query } from '@/prelude/url.js';
|
||||||
import { HOUR } from '@/const.js';
|
|
||||||
import { Cache } from './cache.js';
|
import { Cache } from './cache.js';
|
||||||
import { isSelfHost, toPunyNullable } from './convert-host.js';
|
import { isSelfHost, toPunyNullable } from './convert-host.js';
|
||||||
import { decodeReaction } from './reaction-lib.js';
|
import { decodeReaction } from './reaction-lib.js';
|
||||||
|
|
||||||
/**
|
const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||||
* composite cache key: `${host ?? ''}:${name}`
|
|
||||||
*/
|
|
||||||
const cache = new Cache<Emoji | null>(
|
|
||||||
12 * HOUR,
|
|
||||||
async (key) => {
|
|
||||||
const [host, name] = key.split(':');
|
|
||||||
return (await Emojis.findOneBy({
|
|
||||||
name,
|
|
||||||
host: host || IsNull(),
|
|
||||||
})) || null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information needed to attach in ActivityPub
|
* 添付用絵文字情報
|
||||||
*/
|
*/
|
||||||
type PopulatedEmoji = {
|
type PopulatedEmoji = {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -49,22 +36,28 @@ function parseEmojiStr(emojiName: string, noteUserHost: string | null) {
|
||||||
|
|
||||||
const name = match[1];
|
const name = match[1];
|
||||||
|
|
||||||
|
// ホスト正規化
|
||||||
const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
|
const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
|
||||||
|
|
||||||
return { name, host };
|
return { name, host };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve emoji information from ActivityPub attachment.
|
* 添付用絵文字情報を解決する
|
||||||
* @param emojiName custom emoji names attached to notes, user profiles or in rections. Colons should not be included. Localhost is denote by @. (see also `decodeReaction`)
|
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
|
||||||
* @param noteUserHost host that the content is from, to default to
|
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
|
||||||
* @returns emoji information. `null` means not found.
|
* @returns 絵文字情報, nullは未マッチを意味する
|
||||||
*/
|
*/
|
||||||
export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
|
export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
|
||||||
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
|
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
|
||||||
if (name == null) return null;
|
if (name == null) return null;
|
||||||
|
|
||||||
const emoji = await cache.fetch(`${host ?? ''}:${name}`);
|
const queryOrNull = async () => (await Emojis.findOneBy({
|
||||||
|
name,
|
||||||
|
host: host ?? IsNull(),
|
||||||
|
})) || null;
|
||||||
|
|
||||||
|
const emoji = await cache.fetch(`${name} ${host}`, queryOrNull);
|
||||||
|
|
||||||
if (emoji == null) return null;
|
if (emoji == null) return null;
|
||||||
|
|
||||||
|
@ -79,7 +72,7 @@ export async function populateEmoji(emojiName: string, noteUserHost: string | nu
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve list of emojis from the cache. Uncached emoji are dropped.
|
* 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される)
|
||||||
*/
|
*/
|
||||||
export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
|
export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
|
||||||
const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
|
const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
|
||||||
|
@ -110,20 +103,11 @@ export function aggregateNoteEmojis(notes: Note[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query list of emojis in bulk and add them to the cache.
|
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
|
||||||
*/
|
*/
|
||||||
export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
||||||
const notCachedEmojis = emojis.filter(emoji => {
|
const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||||
// check if the cache has this emoji
|
|
||||||
return cache.get(`${emoji.host ?? ''}:${emoji.name}`) == null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// check if there even are any uncached emoji to handle
|
|
||||||
if (notCachedEmojis.length === 0) return;
|
|
||||||
|
|
||||||
// query all uncached emoji
|
|
||||||
const emojisQuery: any[] = [];
|
const emojisQuery: any[] = [];
|
||||||
// group by hosts to try to reduce query size
|
|
||||||
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
emojisQuery.push({
|
emojisQuery.push({
|
||||||
|
@ -131,14 +115,11 @@ export async function prefetchEmojis(emojis: { name: string; host: string | null
|
||||||
host: host ?? IsNull(),
|
host: host ?? IsNull(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
|
||||||
await Emojis.find({
|
|
||||||
where: emojisQuery,
|
where: emojisQuery,
|
||||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||||
}).then(emojis => {
|
}) : [];
|
||||||
// store all emojis into the cache
|
for (const emoji of _emojis) {
|
||||||
emojis.forEach(emoji => {
|
cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||||
cache.set(`${emoji.host ?? ''}:${emoji.name}`, emoji);
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
|
import { Entity, PrimaryColumn, Index, Column, ManyToOne, OneToOne, JoinColumn } from 'typeorm';
|
||||||
import { id } from '../id.js';
|
import { id } from '../id.js';
|
||||||
import { User } from './user.js';
|
import { AccessToken } from './access-token.js';
|
||||||
import { App } from './app.js';
|
import { App } from './app.js';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@ -23,21 +23,27 @@ export class AuthSession {
|
||||||
...id(),
|
...id(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
public userId: User['id'] | null;
|
public accessTokenId: AccessToken['id'] | null;
|
||||||
|
|
||||||
@ManyToOne(type => User, {
|
@ManyToOne(() => AccessToken, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public user: User | null;
|
public accessToken: AccessToken | null;
|
||||||
|
|
||||||
@Column(id())
|
@Column(id())
|
||||||
public appId: App['id'];
|
public appId: App['id'];
|
||||||
|
|
||||||
@ManyToOne(type => App, {
|
@ManyToOne(() => App, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public app: App | null;
|
public app: App | null;
|
||||||
|
|
||||||
|
@Column('text', {
|
||||||
|
nullable: true,
|
||||||
|
comment: 'PKCE code_challenge value, if provided (OAuth only)',
|
||||||
|
})
|
||||||
|
pkceChallenge: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,16 +6,13 @@ import { Packed } from '@/misc/schema.js';
|
||||||
import { awaitAll, Promiseable } from '@/prelude/await-all.js';
|
import { awaitAll, Promiseable } from '@/prelude/await-all.js';
|
||||||
import { populateEmojis } from '@/misc/populate-emojis.js';
|
import { populateEmojis } from '@/misc/populate-emojis.js';
|
||||||
import { getAntennas } from '@/misc/antenna-cache.js';
|
import { getAntennas } from '@/misc/antenna-cache.js';
|
||||||
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD, HOUR } from '@/const.js';
|
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
||||||
import { Cache } from '@/misc/cache.js';
|
import { Cache } from '@/misc/cache.js';
|
||||||
import { db } from '@/db/postgre.js';
|
import { db } from '@/db/postgre.js';
|
||||||
import { Instance } from '../entities/instance.js';
|
import { Instance } from '../entities/instance.js';
|
||||||
import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js';
|
import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js';
|
||||||
|
|
||||||
const userInstanceCache = new Cache<Instance | null>(
|
const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
|
||||||
3 * HOUR,
|
|
||||||
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
|
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
|
||||||
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
|
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
|
||||||
|
@ -30,7 +27,7 @@ const ajv = new Ajv();
|
||||||
const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
|
const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
|
||||||
const passwordSchema = { type: 'string', minLength: 1 } as const;
|
const passwordSchema = { type: 'string', minLength: 1 } as const;
|
||||||
const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
||||||
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 2048 } as const;
|
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const;
|
||||||
const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
||||||
const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
|
const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
|
||||||
|
|
||||||
|
@ -312,15 +309,17 @@ export const UserRepository = db.getRepository(User).extend({
|
||||||
isModerator: user.isModerator || falsy,
|
isModerator: user.isModerator || falsy,
|
||||||
isBot: user.isBot || falsy,
|
isBot: user.isBot || falsy,
|
||||||
isCat: user.isCat || falsy,
|
isCat: user.isCat || falsy,
|
||||||
instance: !user.host ? undefined : userInstanceCache.fetch(user.host)
|
instance: user.host ? userInstanceCache.fetch(user.host,
|
||||||
.then(instance => !instance ? undefined : {
|
() => Instances.findOneBy({ host: user.host! }),
|
||||||
name: instance.name,
|
v => v != null,
|
||||||
softwareName: instance.softwareName,
|
).then(instance => instance ? {
|
||||||
softwareVersion: instance.softwareVersion,
|
name: instance.name,
|
||||||
iconUrl: instance.iconUrl,
|
softwareName: instance.softwareName,
|
||||||
faviconUrl: instance.faviconUrl,
|
softwareVersion: instance.softwareVersion,
|
||||||
themeColor: instance.themeColor,
|
iconUrl: instance.iconUrl,
|
||||||
}),
|
faviconUrl: instance.faviconUrl,
|
||||||
|
themeColor: instance.themeColor,
|
||||||
|
} : undefined) : undefined,
|
||||||
emojis: populateEmojis(user.emojis, user.host),
|
emojis: populateEmojis(user.emojis, user.host),
|
||||||
onlineStatus: this.getOnlineStatus(user),
|
onlineStatus: this.getOnlineStatus(user),
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Bull from 'bull';
|
import Bull from 'bull';
|
||||||
import { In, LessThan } from 'typeorm';
|
import { In, LessThan } from 'typeorm';
|
||||||
import { AttestationChallenges, Mutings, PasswordResetRequests, Signins } from '@/models/index.js';
|
import { AttestationChallenges, AuthSessions, Mutings, PasswordResetRequests, Signins } from '@/models/index.js';
|
||||||
import { publishUserEvent } from '@/services/stream.js';
|
import { publishUserEvent } from '@/services/stream.js';
|
||||||
import { MINUTE, DAY } from '@/const.js';
|
import { MINUTE, DAY } from '@/const.js';
|
||||||
import { queueLogger } from '@/queue/logger.js';
|
import { queueLogger } from '@/queue/logger.js';
|
||||||
|
@ -40,7 +40,11 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
|
||||||
createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)),
|
createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)),
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.succ('Deleted expired mutes, signins and attestation challenges.');
|
await AuthSessions.delete({
|
||||||
|
createdAt: LessThan(new Date(new Date().getTime() - 15 * MINUTE)),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.succ('Deleted expired data.');
|
||||||
|
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,14 +10,8 @@ import { uriPersonCache, userByIdCache } from '@/services/user-cache.js';
|
||||||
import { IObject, getApId } from './type.js';
|
import { IObject, getApId } from './type.js';
|
||||||
import { resolvePerson } from './models/person.js';
|
import { resolvePerson } from './models/person.js';
|
||||||
|
|
||||||
const publicKeyCache = new Cache<UserPublickey>(
|
const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
|
||||||
Infinity,
|
const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
|
||||||
(keyId) => UserPublickeys.findOneBy({ keyId }).then(x => x ?? undefined),
|
|
||||||
);
|
|
||||||
const publicKeyByUserIdCache = new Cache<UserPublickey>(
|
|
||||||
Infinity,
|
|
||||||
(userId) => UserPublickeys.findOneBy({ userId }).then(x => x ?? undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type UriParseResult = {
|
export type UriParseResult = {
|
||||||
/** wether the URI was generated by us */
|
/** wether the URI was generated by us */
|
||||||
|
@ -105,9 +99,13 @@ export default class DbResolver {
|
||||||
if (parsed.local) {
|
if (parsed.local) {
|
||||||
if (parsed.type !== 'users') return null;
|
if (parsed.type !== 'users') return null;
|
||||||
|
|
||||||
return await userByIdCache.fetch(parsed.id) ?? null;
|
return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({
|
||||||
|
id: parsed.id,
|
||||||
|
}).then(x => x ?? undefined)) ?? null;
|
||||||
} else {
|
} else {
|
||||||
return await uriPersonCache.fetch(parsed.uri) ?? null;
|
return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({
|
||||||
|
uri: parsed.uri,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,12 +116,20 @@ export default class DbResolver {
|
||||||
user: CacheableRemoteUser;
|
user: CacheableRemoteUser;
|
||||||
key: UserPublickey;
|
key: UserPublickey;
|
||||||
} | null> {
|
} | null> {
|
||||||
const key = await publicKeyCache.fetch(keyId);
|
const key = await publicKeyCache.fetch(keyId, async () => {
|
||||||
|
const key = await UserPublickeys.findOneBy({
|
||||||
|
keyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (key == null) return null;
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}, key => key != null);
|
||||||
|
|
||||||
if (key == null) return null;
|
if (key == null) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: await userByIdCache.fetch(key.userId) as CacheableRemoteUser,
|
user: await userByIdCache.fetch(key.userId, () => Users.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser,
|
||||||
key,
|
key,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -139,7 +145,7 @@ export default class DbResolver {
|
||||||
|
|
||||||
if (user == null) return null;
|
if (user == null) return null;
|
||||||
|
|
||||||
const key = await publicKeyByUserIdCache.fetch(user.id);
|
const key = await publicKeyByUserIdCache.fetch(user.id, () => UserPublickeys.findOneBy({ userId: user.id }), v => v != null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
|
|
|
@ -119,18 +119,26 @@ export default class DeliverManager {
|
||||||
|
|
||||||
if (this.recipes.some(r => isFollowers(r))) {
|
if (this.recipes.some(r => isFollowers(r))) {
|
||||||
// followers deliver
|
// followers deliver
|
||||||
const followers = await Followings.createQueryBuilder('followings')
|
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
|
||||||
// return either the shared inbox (if available) or the individual inbox
|
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう?
|
||||||
.select('COALESCE(followings.followerSharedInbox, followings.followerInbox)', 'inbox')
|
const followers = await Followings.find({
|
||||||
// so we don't have to make our inboxes Set work as hard
|
where: {
|
||||||
.distinct(true)
|
followeeId: this.actor.id,
|
||||||
// ...for the specific actors followers
|
followerHost: Not(IsNull()),
|
||||||
.where('followings.followeeId = :actorId', { actorId: this.actor.id })
|
},
|
||||||
// don't deliver to ourselves
|
select: {
|
||||||
.andWhere('followings.followerHost IS NOT NULL')
|
followerSharedInbox: true,
|
||||||
.getRawMany();
|
followerInbox: true,
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
followerSharedInbox: string | null;
|
||||||
|
followerInbox: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
followers.forEach(({ inbox }) => inboxes.add(inbox));
|
for (const following of followers) {
|
||||||
|
const inbox = following.followerSharedInbox || following.followerInbox;
|
||||||
|
inboxes.add(inbox);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recipes.filter((recipe): recipe is IDirectRecipe =>
|
this.recipes.filter((recipe): recipe is IDirectRecipe =>
|
||||||
|
|
|
@ -23,6 +23,8 @@ import Featured from './activitypub/featured.js';
|
||||||
// Init router
|
// Init router
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
|
//#region Routing
|
||||||
|
|
||||||
function inbox(ctx: Router.RouterContext) {
|
function inbox(ctx: Router.RouterContext) {
|
||||||
let signature;
|
let signature;
|
||||||
|
|
||||||
|
@ -43,8 +45,6 @@ const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystr
|
||||||
|
|
||||||
function isActivityPubReq(ctx: Router.RouterContext) {
|
function isActivityPubReq(ctx: Router.RouterContext) {
|
||||||
ctx.response.vary('Accept');
|
ctx.response.vary('Accept');
|
||||||
// if no accept header is supplied, koa returns the 1st, so html is used as a dummy
|
|
||||||
// i.e. activitypub requests must be explicit
|
|
||||||
const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
|
const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
|
||||||
return typeof accepted === 'string' && !accepted.match(/html/);
|
return typeof accepted === 'string' && !accepted.match(/html/);
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,7 @@ router.get('/notes/:note', async (ctx, next) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirect if remote
|
// リモートだったらリダイレクト
|
||||||
if (note.userHost != null) {
|
if (note.userHost != null) {
|
||||||
if (note.uri == null || isSelfHost(note.userHost)) {
|
if (note.uri == null || isSelfHost(note.userHost)) {
|
||||||
ctx.status = 500;
|
ctx.status = 500;
|
||||||
|
@ -94,15 +94,6 @@ router.get('/notes/:note', async (ctx, next) => {
|
||||||
|
|
||||||
// note activity
|
// note activity
|
||||||
router.get('/notes/:note/activity', async ctx => {
|
router.get('/notes/:note/activity', async ctx => {
|
||||||
if (!isActivityPubReq(ctx)) {
|
|
||||||
/*
|
|
||||||
Redirect to the human readable page. in this case using next is not possible,
|
|
||||||
since there is no human readable page explicitly for the activity.
|
|
||||||
*/
|
|
||||||
ctx.redirect(`/notes/${ctx.params.note}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const note = await Notes.findOneBy({
|
const note = await Notes.findOneBy({
|
||||||
id: ctx.params.note,
|
id: ctx.params.note,
|
||||||
userHost: IsNull(),
|
userHost: IsNull(),
|
||||||
|
@ -194,6 +185,7 @@ router.get('/@:user', async (ctx, next) => {
|
||||||
|
|
||||||
await userInfo(ctx, user);
|
await userInfo(ctx, user);
|
||||||
});
|
});
|
||||||
|
//#endregion
|
||||||
|
|
||||||
// emoji
|
// emoji
|
||||||
router.get('/emojis/:emoji', async ctx => {
|
router.get('/emojis/:emoji', async ctx => {
|
||||||
|
|
|
@ -3,14 +3,9 @@ import { Users, AccessTokens, Apps } from '@/models/index.js';
|
||||||
import { AccessToken } from '@/models/entities/access-token.js';
|
import { AccessToken } from '@/models/entities/access-token.js';
|
||||||
import { Cache } from '@/misc/cache.js';
|
import { Cache } from '@/misc/cache.js';
|
||||||
import { App } from '@/models/entities/app.js';
|
import { App } from '@/models/entities/app.js';
|
||||||
import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
|
import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
|
||||||
import isNativeToken from './common/is-native-token.js';
|
import isNativeToken from './common/is-native-token.js';
|
||||||
|
|
||||||
const appCache = new Cache<App>(
|
|
||||||
Infinity,
|
|
||||||
(id) => Apps.findOneByOrFail({ id }),
|
|
||||||
);
|
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
@ -42,7 +37,8 @@ export default async (authorization: string | null | undefined, bodyToken: strin
|
||||||
const token: string = maybeToken;
|
const token: string = maybeToken;
|
||||||
|
|
||||||
if (isNativeToken(token)) {
|
if (isNativeToken(token)) {
|
||||||
const user = await localUserByNativeTokenCache.fetch(token);
|
const user = await localUserByNativeTokenCache.fetch(token,
|
||||||
|
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new AuthenticationError('unknown token');
|
throw new AuthenticationError('unknown token');
|
||||||
|
@ -66,20 +62,11 @@ export default async (authorization: string | null | undefined, bodyToken: strin
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await userByIdCache.fetch(accessToken.userId);
|
const user = await localUserByIdCache.fetch(accessToken.userId,
|
||||||
|
() => Users.findOneBy({
|
||||||
|
id: accessToken.userId,
|
||||||
|
}) as Promise<ILocalUser>);
|
||||||
|
|
||||||
// can't authorize remote users
|
return [user, accessToken];
|
||||||
if (!Users.isLocalUser(user)) return [null, null];
|
|
||||||
|
|
||||||
if (accessToken.appId) {
|
|
||||||
const app = await appCache.fetch(accessToken.appId);
|
|
||||||
|
|
||||||
return [user, {
|
|
||||||
id: accessToken.id,
|
|
||||||
permission: app.permission,
|
|
||||||
} as AccessToken];
|
|
||||||
} else {
|
|
||||||
return [user, accessToken];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
42
packages/backend/src/server/api/common/compare-url.ts
Normal file
42
packages/backend/src/server/api/common/compare-url.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two URLs for OAuth. The first parameter is the trusted URL
|
||||||
|
* which decides how the comparison is conducted.
|
||||||
|
*
|
||||||
|
* Invalid URLs are never equal.
|
||||||
|
*
|
||||||
|
* Implements the current draft-ietf-oauth-security-topics-21 § 4.1.3
|
||||||
|
* (published 2022-09-27)
|
||||||
|
*/
|
||||||
|
export function compareUrl(trusted: string, untrusted: string): boolean {
|
||||||
|
let trustedUrl, untrustedUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
trustedUrl = new URL(trusted);
|
||||||
|
untrustedUrl = new URL(untrusted);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excerpt from RFC 8252:
|
||||||
|
//> Loopback redirect URIs use the "http" scheme and are constructed with
|
||||||
|
//> the loopback IP literal and whatever port the client is listening on.
|
||||||
|
//> That is, "http://127.0.0.1:{port}/{path}" for IPv4, and
|
||||||
|
//> "http://[::1]:{port}/{path}" for IPv6.
|
||||||
|
//
|
||||||
|
// To be nice we also include the "localhost" name, since it is required
|
||||||
|
// to resolve to one of the other two.
|
||||||
|
if (trustedUrl.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(trustedUrl.host)) {
|
||||||
|
// localhost comparisons should ignore port number
|
||||||
|
trustedUrl.port = '';
|
||||||
|
untrustedUrl.port = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// security recommendation is to just compare the (normalized) string
|
||||||
|
//> This document therefore advises to simplify the required logic and configuration
|
||||||
|
//> by using exact redirect URI matching. This means the authorization server MUST
|
||||||
|
//> compare the two URIs using simple string comparison as defined in [RFC3986],
|
||||||
|
//> Section 6.2.1.
|
||||||
|
return trustedUrl.href === untrustedUrl.href;
|
||||||
|
}
|
131
packages/backend/src/server/api/common/oauth.ts
Normal file
131
packages/backend/src/server/api/common/oauth.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import Koa from 'koa';
|
||||||
|
import { IsNull, Not } from 'typeorm';
|
||||||
|
import { Apps, AuthSessions, AccessTokens } from '@/models/index.js';
|
||||||
|
import config from '@/config/index.js';
|
||||||
|
import { compareUrl } from './compare-url.js';
|
||||||
|
|
||||||
|
export async function oauth(ctx: Koa.Context): void {
|
||||||
|
const {
|
||||||
|
grant_type,
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
} = ctx.request.body;
|
||||||
|
|
||||||
|
// check if any of the parameters are null or empty string
|
||||||
|
if ([grant_type, code].some(x => !x)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_request',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grant_type !== 'authorization_code') {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'unsupported_grant_type',
|
||||||
|
error_description: 'only authorization_code grants are supported',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = ctx.headers.authorization;
|
||||||
|
if (!authHeader?.toLowerCase().startsWith('basic ')) {
|
||||||
|
ctx.response.status = 401;
|
||||||
|
ctx.response.set('WWW-Authenticate', 'Basic');
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_client',
|
||||||
|
error_description: 'HTTP Basic Authentication required',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client_id, client_secret] = new Buffer(authHeader.slice(6), 'base64')
|
||||||
|
.toString('ascii')
|
||||||
|
.split(':', 2);
|
||||||
|
|
||||||
|
const [app, session] = await Promise.all([
|
||||||
|
Apps.findOneBy({
|
||||||
|
id: client_id,
|
||||||
|
secret: client_secret,
|
||||||
|
}),
|
||||||
|
AuthSessions.findOne({
|
||||||
|
where: {
|
||||||
|
appId: client_id,
|
||||||
|
token: code,
|
||||||
|
// only check for approved auth sessions
|
||||||
|
accessTokenId: Not(IsNull()),
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
accessToken: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (app == null) {
|
||||||
|
ctx.response.status = 401;
|
||||||
|
ctx.response.set('WWW-Authenticate', 'Basic');
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_client',
|
||||||
|
error_description: 'authentication failed',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session == null) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check PKCE challenge, if provided before
|
||||||
|
if (session.pkceChallenge) {
|
||||||
|
// Also checking the client's homework, the RFC says:
|
||||||
|
//> minimum length of 43 characters and a maximum length of 128 characters
|
||||||
|
if (!code_verifier || code_verifier.length < 43 || code_verifier.length > 128) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'invalid or missing PKCE code_verifier',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// verify that (from RFC 7636):
|
||||||
|
//> BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
hash.update(code_verifier);
|
||||||
|
|
||||||
|
if (hash.digest('base64url') !== code_challenge) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'invalid PKCE code_verifier',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check redirect URI
|
||||||
|
if (!compareUrl(app.callbackUrl, redirect_uri)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'Mismatched redirect_uri',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// session is single use
|
||||||
|
await AuthSessions.delete(session.id),
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
access_token: session.accessToken.token,
|
||||||
|
token_type: 'bearer',
|
||||||
|
scope: session.accessToken.permission.join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
|
@ -67,6 +67,7 @@ import * as ep___ap_show from './endpoints/ap/show.js';
|
||||||
import * as ep___app_create from './endpoints/app/create.js';
|
import * as ep___app_create from './endpoints/app/create.js';
|
||||||
import * as ep___app_show from './endpoints/app/show.js';
|
import * as ep___app_show from './endpoints/app/show.js';
|
||||||
import * as ep___auth_accept from './endpoints/auth/accept.js';
|
import * as ep___auth_accept from './endpoints/auth/accept.js';
|
||||||
|
import * as ep___auth_deny from './endpoints/auth/deny.js';
|
||||||
import * as ep___auth_session_generate from './endpoints/auth/session/generate.js';
|
import * as ep___auth_session_generate from './endpoints/auth/session/generate.js';
|
||||||
import * as ep___auth_session_show from './endpoints/auth/session/show.js';
|
import * as ep___auth_session_show from './endpoints/auth/session/show.js';
|
||||||
import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js';
|
import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js';
|
||||||
|
@ -375,6 +376,7 @@ const eps = [
|
||||||
['app/create', ep___app_create],
|
['app/create', ep___app_create],
|
||||||
['app/show', ep___app_show],
|
['app/show', ep___app_show],
|
||||||
['auth/accept', ep___auth_accept],
|
['auth/accept', ep___auth_accept],
|
||||||
|
['auth/deny', ep___auth_deny],
|
||||||
['auth/session/generate', ep___auth_session_generate],
|
['auth/session/generate', ep___auth_session_generate],
|
||||||
['auth/session/show', ep___auth_session_show],
|
['auth/session/show', ep___auth_session_show],
|
||||||
['auth/session/userkey', ep___auth_session_userkey],
|
['auth/session/userkey', ep___auth_session_userkey],
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { Meta } from '@/models/entities/meta.js';
|
||||||
import { insertModerationLog } from '@/services/insert-moderation-log.js';
|
import { insertModerationLog } from '@/services/insert-moderation-log.js';
|
||||||
import { fetchMeta, setMeta } from '@/misc/fetch-meta.js';
|
import { db } from '@/db/postgre.js';
|
||||||
import define from '../../define.js';
|
import define from '../../define.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -374,10 +375,20 @@ export default define(meta, paramDef, async (ps, me) => {
|
||||||
set.deeplIsPro = ps.deeplIsPro;
|
set.deeplIsPro = ps.deeplIsPro;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await fetchMeta();
|
await db.transaction(async transactionalEntityManager => {
|
||||||
await setMeta({
|
const metas = await transactionalEntityManager.find(Meta, {
|
||||||
...meta,
|
order: {
|
||||||
...set,
|
id: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = metas[0];
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
await transactionalEntityManager.update(Meta, meta.id, set);
|
||||||
|
} else {
|
||||||
|
await transactionalEntityManager.save(Meta, set);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
insertModerationLog(me, 'updateMeta');
|
insertModerationLog(me, 'updateMeta');
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as crypto from 'node:crypto';
|
||||||
import { AuthSessions, AccessTokens, Apps } from '@/models/index.js';
|
import { AuthSessions, AccessTokens, Apps } from '@/models/index.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
import { kinds } from '@/misc/api-permissions.js';
|
||||||
import define from '../../define.js';
|
import define from '../../define.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
|
@ -19,6 +20,17 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
token: { type: 'string' },
|
token: { type: 'string' },
|
||||||
|
permission: {
|
||||||
|
description: 'The permissions which the user wishes to grant in this token. '
|
||||||
|
+ 'Permissions that the app has not registered before will be removed. '
|
||||||
|
+ 'Defaults to all permissions the app was registered with if not provided.',
|
||||||
|
type: 'array',
|
||||||
|
uniqueItems: true,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
enum: kinds,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['token'],
|
required: ['token'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -34,37 +46,35 @@ export default define(meta, paramDef, async (ps, user) => {
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = secureRndstr(32, true);
|
const accessToken = secureRndstr(32, true);
|
||||||
|
|
||||||
// Fetch exist access token
|
// Check for existing access token.
|
||||||
const exist = await AccessTokens.findOneBy({
|
const app = await Apps.findOneByOrFail({ id: session.appId });
|
||||||
|
|
||||||
|
// Generate Hash
|
||||||
|
const sha256 = crypto.createHash('sha256');
|
||||||
|
sha256.update(accessToken + app.secret);
|
||||||
|
const hash = sha256.digest('hex');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Calculate the set intersection between requested permissions and
|
||||||
|
// permissions that the app registered with. If no specific permissions
|
||||||
|
// are given, grant all permissions the app registered with.
|
||||||
|
const permission = ps.permission?.filter(x => app.permission.includes(x)) ?? app.permission;
|
||||||
|
|
||||||
|
const accessTokenId = genId();
|
||||||
|
|
||||||
|
// Insert access token doc
|
||||||
|
await AccessTokens.insert({
|
||||||
|
id: accessTokenId,
|
||||||
|
createdAt: now,
|
||||||
|
lastUsedAt: now,
|
||||||
appId: session.appId,
|
appId: session.appId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
token: accessToken,
|
||||||
|
hash,
|
||||||
|
permission,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exist == null) {
|
|
||||||
// Lookup app
|
|
||||||
const app = await Apps.findOneByOrFail({ id: session.appId });
|
|
||||||
|
|
||||||
// Generate Hash
|
|
||||||
const sha256 = crypto.createHash('sha256');
|
|
||||||
sha256.update(accessToken + app.secret);
|
|
||||||
const hash = sha256.digest('hex');
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Insert access token doc
|
|
||||||
await AccessTokens.insert({
|
|
||||||
id: genId(),
|
|
||||||
createdAt: now,
|
|
||||||
lastUsedAt: now,
|
|
||||||
appId: session.appId,
|
|
||||||
userId: user.id,
|
|
||||||
token: accessToken,
|
|
||||||
hash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update session
|
// Update session
|
||||||
await AuthSessions.update(session.id, {
|
await AuthSessions.update(session.id, { accessTokenId });
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
38
packages/backend/src/server/api/endpoints/auth/deny.ts
Normal file
38
packages/backend/src/server/api/endpoints/auth/deny.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { AuthSessions } from '@/models/index.js';
|
||||||
|
import define from '../../define.js';
|
||||||
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['auth'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
secure: true,
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchSession: {
|
||||||
|
message: 'No such session.',
|
||||||
|
code: 'NO_SUCH_SESSION',
|
||||||
|
id: '9c72d8de-391a-43c1-9d06-08d29efde8df',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
token: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['token'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default define(meta, paramDef, async (ps, user) => {
|
||||||
|
const result = await AuthSessions.delete({
|
||||||
|
token: ps.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.affected == 0) {
|
||||||
|
throw new ApiError(meta.errors.noSuchSession);
|
||||||
|
}
|
||||||
|
});
|
|
@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid';
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
import { Apps, AuthSessions } from '@/models/index.js';
|
import { Apps, AuthSessions } from '@/models/index.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
|
import { compareUrl } from '@/server/api/common/compare-url.js';
|
||||||
import define from '../../../define.js';
|
import define from '../../../define.js';
|
||||||
import { ApiError } from '../../../error.js';
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
|
@ -23,6 +24,19 @@ export const meta = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
format: 'url',
|
format: 'url',
|
||||||
},
|
},
|
||||||
|
// stuff that auth/session/show would respond with
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The ID of the authentication session. Same as returned by `auth/session/show`.',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
format: 'id',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'The App requesting permissions. Same as returned by `auth/session/show`.',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
ref: 'App',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -31,16 +45,33 @@ export const meta = {
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
oneOf: [{
|
||||||
appSecret: { type: 'string' },
|
properties: {
|
||||||
},
|
clientId: { type: 'string' },
|
||||||
required: ['appSecret'],
|
callbackUrl: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
pkceChallenge: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['clientId']
|
||||||
|
}, {
|
||||||
|
properties: {
|
||||||
|
appSecret: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['appSecret'],
|
||||||
|
}],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
export default define(meta, paramDef, async (ps) => {
|
export default define(meta, paramDef, async (ps) => {
|
||||||
// Lookup app
|
// Lookup app
|
||||||
const app = await Apps.findOneBy({
|
const app = await Apps.findOneBy(ps.clientId ? {
|
||||||
|
id: ps.clientId,
|
||||||
|
} : {
|
||||||
secret: ps.appSecret,
|
secret: ps.appSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -48,19 +79,31 @@ export default define(meta, paramDef, async (ps) => {
|
||||||
throw new ApiError('NO_SUCH_APP');
|
throw new ApiError('NO_SUCH_APP');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check URL if provided
|
||||||
|
// technically the OAuth specification says that the redirect URI has to be
|
||||||
|
// bound with the token request, but since an app may only register one
|
||||||
|
// redirect URI, we don't actually have to store that.
|
||||||
|
if (ps.callbackUrl && !compareUrl(app.callbackUrl, ps.callbackUrl)) {
|
||||||
|
throw new ApiError('NO_SUCH_APP', 'redirect URI mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
// Generate token
|
// Generate token
|
||||||
const token = uuid();
|
const token = uuid();
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
// Create session token document
|
// Create session token document
|
||||||
const doc = await AuthSessions.insert({
|
const doc = await AuthSessions.insert({
|
||||||
id: genId(),
|
id,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
token,
|
token,
|
||||||
|
pkceChallenge: ps.pkceChallenge,
|
||||||
}).then(x => AuthSessions.findOneByOrFail(x.identifiers[0]));
|
}).then(x => AuthSessions.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: doc.token,
|
token: doc.token,
|
||||||
url: `${config.authUrl}/${doc.token}`,
|
url: `${config.authUrl}/${doc.token}`,
|
||||||
|
id,
|
||||||
|
app: await Apps.pack(app),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
/*
|
||||||
|
This route is already in use, but the functionality is provided
|
||||||
|
by '@/server/api/common/oauth.ts'. The route is not here because
|
||||||
|
that route requires more deep level access to HTTP data.
|
||||||
|
*/
|
|
@ -46,27 +46,26 @@ export default define(meta, paramDef, async (ps) => {
|
||||||
if (app == null) throw new ApiError('NO_SUCH_APP');
|
if (app == null) throw new ApiError('NO_SUCH_APP');
|
||||||
|
|
||||||
// Fetch token
|
// Fetch token
|
||||||
const session = await AuthSessions.findOneBy({
|
const session = await AuthSessions.findOne({
|
||||||
token: ps.token,
|
where: {
|
||||||
appId: app.id,
|
token: ps.token,
|
||||||
|
appId: app.id,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
accessToken: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (session == null) throw new ApiError('NO_SUCH_SESSION');
|
if (session == null) throw new ApiError('NO_SUCH_SESSION');
|
||||||
|
|
||||||
if (session.userId == null) throw new ApiError('PENDING_SESSION');
|
if (session.accessTokenId == null) throw new ApiError('PENDING_SESSION');
|
||||||
|
|
||||||
// Lookup access token
|
|
||||||
const accessToken = await AccessTokens.findOneByOrFail({
|
|
||||||
appId: app.id,
|
|
||||||
userId: session.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete session
|
// Delete session
|
||||||
AuthSessions.delete(session.id);
|
AuthSessions.delete(session.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: accessToken.token,
|
accessToken: session.accessToken.token,
|
||||||
user: await Users.pack(session.userId, null, {
|
user: await Users.pack(session.accessToken.userId, null, {
|
||||||
detail: true,
|
detail: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { handler } from './api-handler.js';
|
||||||
import signup from './private/signup.js';
|
import signup from './private/signup.js';
|
||||||
import signin from './private/signin.js';
|
import signin from './private/signin.js';
|
||||||
import signupPending from './private/signup-pending.js';
|
import signupPending from './private/signup-pending.js';
|
||||||
|
import { oauth } from './common/oauth.js';
|
||||||
import discord from './service/discord.js';
|
import discord from './service/discord.js';
|
||||||
import github from './service/github.js';
|
import github from './service/github.js';
|
||||||
import twitter from './service/twitter.js';
|
import twitter from './service/twitter.js';
|
||||||
|
@ -74,6 +75,9 @@ for (const endpoint of endpoints) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the OAuth endpoint does some shenanigans and can not use the normal API handler
|
||||||
|
router.post('/auth/session/oauth', oauth);
|
||||||
|
|
||||||
router.post('/signup', signup);
|
router.post('/signup', signup);
|
||||||
router.post('/signin', signin);
|
router.post('/signin', signin);
|
||||||
router.post('/signup-pending', signupPending);
|
router.post('/signup-pending', signupPending);
|
||||||
|
|
|
@ -3,6 +3,10 @@ import { errors as errorDefinitions } from '../error.js';
|
||||||
import endpoints from '../endpoints.js';
|
import endpoints from '../endpoints.js';
|
||||||
import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
|
import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
|
||||||
import { httpCodes } from './http-codes.js';
|
import { httpCodes } from './http-codes.js';
|
||||||
|
import { kinds } from '@/misc/api-permissions.js';
|
||||||
|
import { I18n } from '@/misc/i18n.js';
|
||||||
|
|
||||||
|
const i18n = new I18n('en-US');
|
||||||
|
|
||||||
export function genOpenapiSpec() {
|
export function genOpenapiSpec() {
|
||||||
const spec = {
|
const spec = {
|
||||||
|
@ -34,10 +38,18 @@ export function genOpenapiSpec() {
|
||||||
in: 'body',
|
in: 'body',
|
||||||
name: 'i',
|
name: 'i',
|
||||||
},
|
},
|
||||||
// TODO: change this to oauth2 when the remaining oauth stuff is set up
|
OAuth: {
|
||||||
Bearer: {
|
type: 'oauth2',
|
||||||
type: 'http',
|
flows: {
|
||||||
scheme: 'bearer',
|
authorizationCode: {
|
||||||
|
authorizationUrl: `${config.url}/auth`,
|
||||||
|
tokenUrl: `${config.apiUrl}/auth/session/oauth`,
|
||||||
|
scopes: kinds.reduce((acc, kind) => {
|
||||||
|
acc[kind] = i18n.ts['_permissions'][kind];
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -137,10 +149,16 @@ export function genOpenapiSpec() {
|
||||||
{
|
{
|
||||||
ApiKeyAuth: [],
|
ApiKeyAuth: [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Bearer: [],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
if (endpoint.meta.kind) {
|
||||||
|
security.push({
|
||||||
|
OAuth: [endpoint.meta.kind],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
security.push({
|
||||||
|
OAuth: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!endpoint.meta.requireCredential) {
|
if (!endpoint.meta.requireCredential) {
|
||||||
// add this to make authentication optional
|
// add this to make authentication optional
|
||||||
security.push({});
|
security.push({});
|
||||||
|
|
16
packages/backend/src/server/oauth.ts
Normal file
16
packages/backend/src/server/oauth.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { kinds } from '@/misc/api-permissions.js';
|
||||||
|
import config from '@/config/index.js';
|
||||||
|
|
||||||
|
// Since it cannot change while the server is running, we can serialize it once
|
||||||
|
// instead of having to serialize it every time it is requested.
|
||||||
|
export const oauthMeta = JSON.stringify({
|
||||||
|
issuer: config.url,
|
||||||
|
authorization_endpoint: `${config.url}/auth`,
|
||||||
|
token_endpoint: `${config.apiUrl}/auth/session/oauth`,
|
||||||
|
scopes_supported: kinds,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
grant_types_supported: ['authorization_code'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||||
|
service_documentation: `${config.url}/api-doc`,
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
});
|
|
@ -7,6 +7,7 @@ import { escapeAttribute, escapeValue } from '@/prelude/xml.js';
|
||||||
import { Users } from '@/models/index.js';
|
import { Users } from '@/models/index.js';
|
||||||
import { User } from '@/models/entities/user.js';
|
import { User } from '@/models/entities/user.js';
|
||||||
import { links } from './nodeinfo.js';
|
import { links } from './nodeinfo.js';
|
||||||
|
import { oauthMeta } from './oauth.js';
|
||||||
|
|
||||||
// Init router
|
// Init router
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
@ -62,10 +63,21 @@ router.get('/.well-known/nodeinfo', async ctx => {
|
||||||
ctx.body = { links };
|
ctx.body = { links };
|
||||||
});
|
});
|
||||||
|
|
||||||
/* TODO
|
function oauth(ctx) {
|
||||||
router.get('/.well-known/change-password', async ctx => {
|
ctx.body = oauthMeta;
|
||||||
});
|
ctx.type = 'application/json';
|
||||||
*/
|
ctx.set('Cache-Control', 'max-age=31536000, immutable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// implements RFC 8414
|
||||||
|
router.get('/.well-known/oauth-authorization-server', oauth);
|
||||||
|
// From the above RFC:
|
||||||
|
//> The identifiers "/.well-known/openid-configuration" [...] contain strings
|
||||||
|
//> referring to the OpenID Connect family of specifications [...]. Despite the reuse
|
||||||
|
//> of these identifiers that appear to be OpenID specific, their usage in this
|
||||||
|
//> specification is actually referring to general OAuth 2.0 features that are not
|
||||||
|
//> specific to OpenID Connect.
|
||||||
|
router.get('/.well-known/openid-configuration', oauth);
|
||||||
|
|
||||||
router.get(webFingerPath, async ctx => {
|
router.get(webFingerPath, async ctx => {
|
||||||
const fromId = (id: User['id']): FindOptionsWhere<User> => ({
|
const fromId = (id: User['id']): FindOptionsWhere<User> => ({
|
||||||
|
|
|
@ -1,20 +1,28 @@
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import { ILocalUser } from '@/models/entities/user.js';
|
import { ILocalUser } from '@/models/entities/user.js';
|
||||||
import { Users } from '@/models/index.js';
|
import { Users } from '@/models/index.js';
|
||||||
|
import { Cache } from '@/misc/cache.js';
|
||||||
import { createSystemUser } from './create-system-user.js';
|
import { createSystemUser } from './create-system-user.js';
|
||||||
|
|
||||||
const ACTOR_USERNAME = 'instance.actor' as const;
|
const ACTOR_USERNAME = 'instance.actor' as const;
|
||||||
|
|
||||||
let instanceActor = await Users.findOneBy({
|
const cache = new Cache<ILocalUser>(Infinity);
|
||||||
host: IsNull(),
|
|
||||||
username: ACTOR_USERNAME,
|
|
||||||
}) as ILocalUser | undefined;
|
|
||||||
|
|
||||||
export async function getInstanceActor(): Promise<ILocalUser> {
|
export async function getInstanceActor(): Promise<ILocalUser> {
|
||||||
if (instanceActor) {
|
const cached = cache.get(null);
|
||||||
return instanceActor;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const user = await Users.findOneBy({
|
||||||
|
host: IsNull(),
|
||||||
|
username: ACTOR_USERNAME,
|
||||||
|
}) as ILocalUser | undefined;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
cache.set(null, user);
|
||||||
|
return user;
|
||||||
} else {
|
} else {
|
||||||
instanceActor = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
|
const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
|
||||||
|
cache.set(null, created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,22 +36,13 @@ import { Cache } from '@/misc/cache.js';
|
||||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
import { UserProfile } from '@/models/entities/user-profile.js';
|
||||||
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
||||||
import { IActivity } from '@/remote/activitypub/type.js';
|
import { IActivity } from '@/remote/activitypub/type.js';
|
||||||
import { MINUTE } from '@/const.js';
|
|
||||||
import { updateHashtags } from '../update-hashtag.js';
|
import { updateHashtags } from '../update-hashtag.js';
|
||||||
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
||||||
import { createNotification } from '../create-notification.js';
|
import { createNotification } from '../create-notification.js';
|
||||||
import { addNoteToAntenna } from '../add-note-to-antenna.js';
|
import { addNoteToAntenna } from '../add-note-to-antenna.js';
|
||||||
import { deliverToRelays } from '../relay.js';
|
import { deliverToRelays } from '../relay.js';
|
||||||
|
|
||||||
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(
|
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||||
5 * MINUTE,
|
|
||||||
() => UserProfiles.find({
|
|
||||||
where: {
|
|
||||||
enableWordMute: true,
|
|
||||||
},
|
|
||||||
select: ['userId', 'mutedWords'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
|
@ -266,7 +257,12 @@ export default async (user: { id: User['id']; username: User['username']; host:
|
||||||
incNotesCountOfUser(user);
|
incNotesCountOfUser(user);
|
||||||
|
|
||||||
// Word mute
|
// Word mute
|
||||||
mutedWordsCache.fetch(null).then(us => {
|
mutedWordsCache.fetch(null, () => UserProfiles.find({
|
||||||
|
where: {
|
||||||
|
enableWordMute: true,
|
||||||
|
},
|
||||||
|
select: ['userId', 'mutedWords'],
|
||||||
|
})).then(us => {
|
||||||
for (const u of us) {
|
for (const u of us) {
|
||||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||||
if (shouldMute) {
|
if (shouldMute) {
|
||||||
|
|
|
@ -53,9 +53,11 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
|
||||||
deliverToConcerned(user, note, content);
|
deliverToConcerned(user, note, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// also deliver delete activity to cascaded notes
|
// also deliever delete activity to cascaded notes
|
||||||
const cascadingNotes = await findCascadingNotes(note);
|
const cascadingNotes = await findCascadingNotes(note);
|
||||||
for (const cascadingNote of cascadingNotes) {
|
for (const cascadingNote of cascadingNotes) {
|
||||||
|
if (!cascadingNote.user) continue;
|
||||||
|
if (!Users.isLocalUser(cascadingNote.user)) continue;
|
||||||
const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
||||||
deliverToConcerned(cascadingNote.user, cascadingNote, content);
|
deliverToConcerned(cascadingNote.user, cascadingNote, content);
|
||||||
}
|
}
|
||||||
|
@ -105,9 +107,6 @@ async function findCascadingNotes(note: Note): Promise<Note[]> {
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(replies.map(reply => {
|
await Promise.all(replies.map(reply => {
|
||||||
// only add unique notes
|
|
||||||
if (cascadingNotes.find((x) => x.id == reply.id) != null) return;
|
|
||||||
|
|
||||||
cascadingNotes.push(reply);
|
cascadingNotes.push(reply);
|
||||||
return recursive(reply.id);
|
return recursive(reply.id);
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -15,21 +15,20 @@ type pushNotificationsTypes = {
|
||||||
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
|
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reduce the content of the push message because of the character limit
|
// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
|
||||||
function truncateNotification(notification: Packed<'Notification'>): any {
|
function truncateNotification(notification: Packed<'Notification'>): any {
|
||||||
if (notification.note) {
|
if (notification.note) {
|
||||||
return {
|
return {
|
||||||
...notification,
|
...notification,
|
||||||
note: {
|
note: {
|
||||||
...notification.note,
|
...notification.note,
|
||||||
// replace text with getNoteSummary
|
// textをgetNoteSummaryしたものに置き換える
|
||||||
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
|
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
|
||||||
|
|
||||||
cw: undefined,
|
cw: undefined,
|
||||||
reply: undefined,
|
reply: undefined,
|
||||||
renote: undefined,
|
renote: undefined,
|
||||||
// unnecessary, since usually the user who is receiving the notification knows who they are
|
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
|
||||||
user: undefined as any,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -42,7 +41,7 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
|
||||||
|
|
||||||
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
||||||
|
|
||||||
// Register key pair information
|
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
|
||||||
push.setVapidDetails(config.url,
|
push.setVapidDetails(config.url,
|
||||||
meta.swPublicKey,
|
meta.swPublicKey,
|
||||||
meta.swPrivateKey);
|
meta.swPrivateKey);
|
||||||
|
@ -66,6 +65,10 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
|
||||||
}), {
|
}), {
|
||||||
proxy: config.proxy,
|
proxy: config.proxy,
|
||||||
}).catch((err: any) => {
|
}).catch((err: any) => {
|
||||||
|
//swLogger.info(err.statusCode);
|
||||||
|
//swLogger.info(err.headers);
|
||||||
|
//swLogger.info(err.body);
|
||||||
|
|
||||||
if (err.statusCode === 410) {
|
if (err.statusCode === 410) {
|
||||||
SwSubscriptions.delete({
|
SwSubscriptions.delete({
|
||||||
userId,
|
userId,
|
||||||
|
|
|
@ -3,27 +3,29 @@ import { Instances } from '@/models/index.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
import { toPuny } from '@/misc/convert-host.js';
|
import { toPuny } from '@/misc/convert-host.js';
|
||||||
import { Cache } from '@/misc/cache.js';
|
import { Cache } from '@/misc/cache.js';
|
||||||
import { HOUR } from '@/const.js';
|
|
||||||
|
|
||||||
const cache = new Cache<Instance>(
|
const cache = new Cache<Instance>(1000 * 60 * 60);
|
||||||
HOUR,
|
|
||||||
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function registerOrFetchInstanceDoc(idnHost: string): Promise<Instance> {
|
export async function registerOrFetchInstanceDoc(idnHost: string): Promise<Instance> {
|
||||||
const host = toPuny(idnHost);
|
const host = toPuny(idnHost);
|
||||||
|
|
||||||
const cached = cache.fetch(host);
|
const cached = cache.get(host);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// apparently a new instance
|
const index = await Instances.findOneBy({ host });
|
||||||
const i = await Instances.insert({
|
|
||||||
id: genId(),
|
|
||||||
host,
|
|
||||||
caughtAt: new Date(),
|
|
||||||
lastCommunicatedAt: new Date(),
|
|
||||||
}).then(x => Instances.findOneByOrFail(x.identifiers[0]));
|
|
||||||
|
|
||||||
cache.set(host, i);
|
if (index == null) {
|
||||||
return i;
|
const i = await Instances.insert({
|
||||||
|
id: genId(),
|
||||||
|
host,
|
||||||
|
caughtAt: new Date(),
|
||||||
|
lastCommunicatedAt: new Date(),
|
||||||
|
}).then(x => Instances.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
|
cache.set(host, i);
|
||||||
|
return i;
|
||||||
|
} else {
|
||||||
|
cache.set(host, index);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,21 +8,11 @@ import { Users, Relays } from '@/models/index.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
import { Cache } from '@/misc/cache.js';
|
import { Cache } from '@/misc/cache.js';
|
||||||
import { Relay } from '@/models/entities/relay.js';
|
import { Relay } from '@/models/entities/relay.js';
|
||||||
import { MINUTE } from '@/const.js';
|
|
||||||
import { createSystemUser } from './create-system-user.js';
|
import { createSystemUser } from './create-system-user.js';
|
||||||
|
|
||||||
const ACTOR_USERNAME = 'relay.actor' as const;
|
const ACTOR_USERNAME = 'relay.actor' as const;
|
||||||
|
|
||||||
/**
|
const relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
|
||||||
* There is only one cache key: null.
|
|
||||||
* A cache is only used here to have expiring storage.
|
|
||||||
*/
|
|
||||||
const relaysCache = new Cache<Relay[]>(
|
|
||||||
10 * MINUTE,
|
|
||||||
() => Relays.findBy({
|
|
||||||
status: 'accepted',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function getRelayActor(): Promise<ILocalUser> {
|
export async function getRelayActor(): Promise<ILocalUser> {
|
||||||
const user = await Users.findOneBy({
|
const user = await Users.findOneBy({
|
||||||
|
@ -93,7 +83,9 @@ export async function relayRejected(id: string) {
|
||||||
export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) {
|
export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) {
|
||||||
if (activity == null) return;
|
if (activity == null) return;
|
||||||
|
|
||||||
const relays = await relaysCache.fetch(null);
|
const relays = await relaysCache.fetch(null, () => Relays.findBy({
|
||||||
|
status: 'accepted',
|
||||||
|
}));
|
||||||
if (relays.length === 0) return;
|
if (relays.length === 0) return;
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Not, IsNull } from 'typeorm';
|
import { Not, IsNull } from 'typeorm';
|
||||||
import renderDelete from '@/remote/activitypub/renderer/delete.js';
|
import renderDelete from '@/remote/activitypub/renderer/delete.js';
|
||||||
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
|
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
|
||||||
import DeliverManager from '@/remote/activitypub/deliver-manager.js';
|
import { deliver } from '@/queue/index.js';
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
import { User } from '@/models/entities/user.js';
|
import { User } from '@/models/entities/user.js';
|
||||||
import { Users, Followings } from '@/models/index.js';
|
import { Users, Followings } from '@/models/index.js';
|
||||||
|
@ -11,11 +11,27 @@ export async function doPostSuspend(user: { id: User['id']; host: User['host'] }
|
||||||
publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||||
|
|
||||||
if (Users.isLocalUser(user)) {
|
if (Users.isLocalUser(user)) {
|
||||||
|
// 知り得る全SharedInboxにDelete配信
|
||||||
const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user));
|
const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user));
|
||||||
|
|
||||||
// deliver to all of known network
|
const queue: string[] = [];
|
||||||
const dm = new DeliverManager(user, content);
|
|
||||||
dm.addEveryone();
|
const followings = await Followings.find({
|
||||||
await dm.execute();
|
where: [
|
||||||
|
{ followerSharedInbox: Not(IsNull()) },
|
||||||
|
{ followeeSharedInbox: Not(IsNull()) },
|
||||||
|
],
|
||||||
|
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox);
|
||||||
|
|
||||||
|
for (const inbox of inboxes) {
|
||||||
|
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inbox of queue) {
|
||||||
|
deliver(user, content, inbox);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,18 +3,10 @@ import { Users } from '@/models/index.js';
|
||||||
import { Cache } from '@/misc/cache.js';
|
import { Cache } from '@/misc/cache.js';
|
||||||
import { subscriber } from '@/db/redis.js';
|
import { subscriber } from '@/db/redis.js';
|
||||||
|
|
||||||
export const userByIdCache = new Cache<CacheableUser>(
|
export const userByIdCache = new Cache<CacheableUser>(Infinity);
|
||||||
Infinity,
|
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity);
|
||||||
(id) => Users.findOneBy({ id }).then(x => x ?? undefined),
|
export const localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
|
||||||
);
|
export const uriPersonCache = new Cache<CacheableUser | null>(Infinity);
|
||||||
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser>(
|
|
||||||
Infinity,
|
|
||||||
(token) => Users.findOneBy({ token }).then(x => x ?? undefined),
|
|
||||||
);
|
|
||||||
export const uriPersonCache = new Cache<CacheableUser>(
|
|
||||||
Infinity,
|
|
||||||
(uri) => Users.findOneBy({ uri }).then(x => x ?? undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriber.on('message', async (_, data) => {
|
subscriber.on('message', async (_, data) => {
|
||||||
const obj = JSON.parse(data);
|
const obj = JSON.parse(data);
|
||||||
|
@ -35,6 +27,7 @@ subscriber.on('message', async (_, data) => {
|
||||||
}
|
}
|
||||||
if (Users.isLocalUser(user)) {
|
if (Users.isLocalUser(user)) {
|
||||||
localUserByNativeTokenCache.set(user.token, user);
|
localUserByNativeTokenCache.set(user.token, user);
|
||||||
|
localUserByIdCache.set(user.id, user);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,6 +71,7 @@ const ok = async () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: {
|
headers: {
|
||||||
|
'content-type': 'multipart/form-data',
|
||||||
authorization: `Bearer ${$i.token}`,
|
authorization: `Bearer ${$i.token}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
:spellcheck="spellcheck"
|
:spellcheck="spellcheck"
|
||||||
:step="step"
|
:step="step"
|
||||||
:list="id"
|
:list="id"
|
||||||
:maxlength="max"
|
|
||||||
@focus="focused = true"
|
@focus="focused = true"
|
||||||
@blur="focused = false"
|
@blur="focused = false"
|
||||||
@keydown="onKeydown($event)"
|
@keydown="onKeydown($event)"
|
||||||
|
@ -59,7 +58,6 @@ const props = defineProps<{
|
||||||
manualSave?: boolean;
|
manualSave?: boolean;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
max?: number;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
:pattern="pattern"
|
:pattern="pattern"
|
||||||
:autocomplete="autocomplete ? 'on' : 'off'"
|
:autocomplete="autocomplete ? 'on' : 'off'"
|
||||||
:spellcheck="spellcheck"
|
:spellcheck="spellcheck"
|
||||||
:maxlength="max"
|
|
||||||
@focus="focused = true"
|
@focus="focused = true"
|
||||||
@blur="focused = false"
|
@blur="focused = false"
|
||||||
@keydown="onKeydown($event)"
|
@keydown="onKeydown($event)"
|
||||||
|
@ -55,7 +54,6 @@ const props = withDefaults(defineProps<{
|
||||||
pre?: boolean;
|
pre?: boolean;
|
||||||
debounce?: boolean;
|
debounce?: boolean;
|
||||||
manualSave?: boolean;
|
manualSave?: boolean;
|
||||||
max?: number;
|
|
||||||
}>(), {
|
}>(), {
|
||||||
pattern: undefined,
|
pattern: undefined,
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
<div v-if="!totpLogin" class="normal-signin">
|
<div v-if="!totpLogin" class="normal-signin">
|
||||||
<MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
|
<MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
|
||||||
<template #prefix>@</template>
|
<template #prefix>@</template>
|
||||||
|
<template #suffix>@{{ host }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
|
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
|
||||||
<template #prefix><i class="fas fa-lock"></i></template>
|
<template #prefix><i class="fas fa-lock"></i></template>
|
||||||
|
@ -54,7 +55,7 @@ import { showSuspendedDialog } from '@/scripts/show-suspended-dialog';
|
||||||
import MkButton from '@/components/ui/button.vue';
|
import MkButton from '@/components/ui/button.vue';
|
||||||
import MkInput from '@/components/form/input.vue';
|
import MkInput from '@/components/form/input.vue';
|
||||||
import MkInfo from '@/components/ui/info.vue';
|
import MkInfo from '@/components/ui/info.vue';
|
||||||
import { apiUrl } from '@/config';
|
import { apiUrl, host as configHost } from '@/config';
|
||||||
import { byteify, hexify } from '@/scripts/2fa';
|
import { byteify, hexify } from '@/scripts/2fa';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { login } from '@/account';
|
import { login } from '@/account';
|
||||||
|
@ -67,6 +68,7 @@ let user = $ref(null);
|
||||||
let username = $ref('');
|
let username = $ref('');
|
||||||
let password = $ref('');
|
let password = $ref('');
|
||||||
let token = $ref('');
|
let token = $ref('');
|
||||||
|
let host = $ref(toUnicode(configHost));
|
||||||
let totpLogin = $ref(false);
|
let totpLogin = $ref(false);
|
||||||
let challengeData = $ref(null);
|
let challengeData = $ref(null);
|
||||||
let queryingKey = $ref(false);
|
let queryingKey = $ref(false);
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername">
|
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername">
|
||||||
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
|
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
|
||||||
<template #prefix>@</template>
|
<template #prefix>@</template>
|
||||||
|
<template #suffix>@{{ host }}</template>
|
||||||
<template #caption>
|
<template #caption>
|
||||||
<span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ i18n.ts.checking }}</span>
|
<span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ i18n.ts.checking }}</span>
|
||||||
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.available }}</span>
|
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.available }}</span>
|
||||||
|
@ -69,6 +70,7 @@ import MkButton from './ui/button.vue';
|
||||||
import MkCaptcha from './captcha.vue';
|
import MkCaptcha from './captcha.vue';
|
||||||
import MkInput from './form/input.vue';
|
import MkInput from './form/input.vue';
|
||||||
import MkSwitch from './form/switch.vue';
|
import MkSwitch from './form/switch.vue';
|
||||||
|
import * as config from '@/config';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { login } from '@/account';
|
import { login } from '@/account';
|
||||||
import { instance } from '@/instance';
|
import { instance } from '@/instance';
|
||||||
|
@ -85,6 +87,8 @@ const emit = defineEmits<{
|
||||||
(ev: 'signupEmailPending'): void;
|
(ev: 'signupEmailPending'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const host = toUnicode(config.host);
|
||||||
|
|
||||||
let hcaptcha = $ref();
|
let hcaptcha = $ref();
|
||||||
let recaptcha = $ref();
|
let recaptcha = $ref();
|
||||||
|
|
||||||
|
|
|
@ -103,7 +103,7 @@ export const apiWithDialog = ((
|
||||||
promiseDialog(promise, null, (err) => {
|
promiseDialog(promise, null, (err) => {
|
||||||
alert({
|
alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: (err.message + '\n' + (err?.endpoint ?? '') + (err?.code ?? '')).trim(),
|
text: err.message + '\n' + (err as any).id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -141,7 +141,7 @@ export function promiseDialog<T extends Promise<any>>(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: dynamic import results in strange behaviour (showing is not reactive)
|
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
|
||||||
popup(MkWaitingDialog, {
|
popup(MkWaitingDialog, {
|
||||||
success,
|
success,
|
||||||
showing,
|
showing,
|
||||||
|
|
|
@ -3,14 +3,16 @@
|
||||||
<div class="_title">{{ i18n.t('_auth.shareAccess', { name: app.name }) }}</div>
|
<div class="_title">{{ i18n.t('_auth.shareAccess', { name: app.name }) }}</div>
|
||||||
<div class="_content">
|
<div class="_content">
|
||||||
<h2>{{ app.name }}</h2>
|
<h2>{{ app.name }}</h2>
|
||||||
<p class="id">{{ app.id }}</p>
|
|
||||||
<p class="description">{{ app.description }}</p>
|
<p class="description">{{ app.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="_content">
|
<div class="_content">
|
||||||
<h2>{{ i18n.ts._auth.permissionAsk }}</h2>
|
<h2>{{ i18n.ts._auth.permissionAsk }}</h2>
|
||||||
<ul>
|
<ul v-if="permission.length > 0">
|
||||||
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
|
<li v-for="p in permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<template v-else>
|
||||||
|
{{ i18n.ts.noPermissionRequested }}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="_footer">
|
<div class="_footer">
|
||||||
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||||
|
@ -30,12 +32,12 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
// TODO: allow user to deselect some permissions
|
||||||
|
permission: string[];
|
||||||
session: {
|
session: {
|
||||||
app: {
|
app: {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
permission: string[];
|
|
||||||
};
|
};
|
||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
@ -54,6 +56,7 @@ function cancel(): void {
|
||||||
function accept(): void {
|
function accept(): void {
|
||||||
os.api('auth/accept', {
|
os.api('auth/accept', {
|
||||||
token: props.session.token,
|
token: props.session.token,
|
||||||
|
permission: props.permission,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
emit('accepted');
|
emit('accepted');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,29 +1,38 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="$i">
|
<MkStickyContainer>
|
||||||
<MkLoading v-if="state == 'fetching'"/>
|
<template #header><MkPageHeader/></template>
|
||||||
<XForm
|
<MkSpacer :max-content="700">
|
||||||
v-else-if="state == 'waiting'"
|
<div v-if="$i">
|
||||||
ref="form"
|
<MkLoading v-if="state == 'fetching'"/>
|
||||||
class="form"
|
<XForm
|
||||||
:session="session"
|
v-else-if="state == 'waiting'"
|
||||||
@denied="state = 'denied'"
|
ref="form"
|
||||||
@accepted="accepted"
|
class="form"
|
||||||
/>
|
:session="session"
|
||||||
<div v-else-if="state == 'denied'" class="denied">
|
:permission="permission"
|
||||||
<h1>{{ i18n.ts._auth.denied }}</h1>
|
@denied="denied"
|
||||||
</div>
|
@accepted="accepted"
|
||||||
<div v-else-if="state == 'accepted'" class="accepted">
|
/>
|
||||||
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
|
<div v-else-if="state == 'denied'" class="denied">
|
||||||
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
|
<h1>{{ i18n.ts._auth.denied }}</h1>
|
||||||
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
|
</div>
|
||||||
</div>
|
<div v-else-if="state == 'accepted'" class="accepted">
|
||||||
<div v-else-if="state == 'fetch-session-error'" class="error">
|
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
|
||||||
<p>{{ i18n.ts.somethingHappened }}</p>
|
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
|
||||||
</div>
|
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="signin">
|
<div v-else-if="state == 'fetch-session-error'" class="error">
|
||||||
<MkSignin @login="onLogin"/>
|
<p>{{ i18n.ts.somethingHappened }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="state == 'oauth-error'" class="error">
|
||||||
|
<p>{{ i18n.ts.oauthErrorGoBack }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="signin">
|
||||||
|
<MkSignin @login="onLogin"/>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</MkStickyContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
@ -33,48 +42,155 @@ import MkSignin from '@/components/signin.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { login , $i } from '@/account';
|
import { login , $i } from '@/account';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||||
import { query, appendQuery } from '@/scripts/url';
|
import { query, appendQuery } from '@/scripts/url';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
token: string;
|
token?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' = $ref('fetching');
|
let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' | 'oauth-error' = $ref('fetching');
|
||||||
let session = $ref(null);
|
let session = $ref(null);
|
||||||
|
let permission: string[] = $ref([]);
|
||||||
|
|
||||||
onMounted(() => {
|
// if this is an OAuth request, will contain the respective parameters
|
||||||
|
let oauth: { state: string | null, callback: string } | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
if (!$i) return;
|
if (!$i) return;
|
||||||
|
|
||||||
// Fetch session
|
// detect whether this is actual OAuth or "legacy" auth
|
||||||
os.api('auth/session/show', {
|
const params = new URLSearchParams(location.search);
|
||||||
token: props.token,
|
if (params.get('response_type') === 'code') {
|
||||||
}).then(fetchedSession => {
|
// OAuth request detected!
|
||||||
session = fetchedSession;
|
|
||||||
|
|
||||||
// 既に連携していた場合
|
// if PKCE is used, check that it is a supported method
|
||||||
if (session.app.isAuthorized) {
|
// the default value for code_challenge_method if not supplied is 'plain', which is not supported.
|
||||||
os.api('auth/accept', {
|
if (params.has('code_challenge') && params.get('code_challenge_method') !== 'S256') {
|
||||||
token: session.token,
|
if (params.has('redirect_uri')) {
|
||||||
}).then(() => {
|
location.href = appendQuery(params.get('redirect_uri'), query({
|
||||||
this.accepted();
|
error: 'invalid_request',
|
||||||
});
|
error_description: 'unsupported code_challenge_method, only "S256" is supported',
|
||||||
} else {
|
}));
|
||||||
state = 'waiting';
|
} else {
|
||||||
|
state = 'oauth-error';
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
|
||||||
|
// as a kind of hack, we first have to start the session for the OAuth client
|
||||||
|
const clientId = params.get('client_id');
|
||||||
|
if (!clientId) {
|
||||||
|
state = 'fetch-session-error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session = await os.api('auth/session/generate', {
|
||||||
|
clientId,
|
||||||
|
// make the server check the redirect, if provided
|
||||||
|
callbackUrl: params.get('redirect_uri') ?? undefined,
|
||||||
|
pkceChallenge: params.get('code_challenge') ?? undefined,
|
||||||
|
}).catch(e => {
|
||||||
|
const response = {
|
||||||
|
error: 'server_error',
|
||||||
|
...(oauth.state ? { state: oauth.state } : {}),
|
||||||
|
};
|
||||||
|
// try to determine the cause of the error
|
||||||
|
if (e.code === 'NO_SUCH_APP') {
|
||||||
|
response.error = 'invalid_request';
|
||||||
|
response.error_description = 'unknown client_id';
|
||||||
|
} else if (e.message) {
|
||||||
|
response.error_description = e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.has('redirect_uri')) {
|
||||||
|
location.href = appendQuery(params.get('redirect_uri'), query(response));
|
||||||
|
} else {
|
||||||
|
state = 'oauth-error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
oauth = {
|
||||||
|
state: params.get('state'),
|
||||||
|
callback: params.get('redirect_uri') ?? session.app.callbackUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.has('scope')) {
|
||||||
|
// If there are specific permissions requested, they have to be a subset of the apps permissions.
|
||||||
|
permission = params.get('scope')
|
||||||
|
.split(' ')
|
||||||
|
.filter(scope => session.app.permission.includes(scope));
|
||||||
|
} else {
|
||||||
|
// Default to all permissions of this app.
|
||||||
|
permission = session.app.permission;
|
||||||
|
}
|
||||||
|
} else if (!props.token) {
|
||||||
state = 'fetch-session-error';
|
state = 'fetch-session-error';
|
||||||
});
|
} else {
|
||||||
|
session = await os.api('auth/session/show', {
|
||||||
|
token: props.token,
|
||||||
|
}).catch(() => {
|
||||||
|
state = 'fetch-session-error';
|
||||||
|
});
|
||||||
|
permission = session?.app.permission ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// abort if an error occurred
|
||||||
|
if (['fetch-session-error', 'oauth-error'].includes(state)) return;
|
||||||
|
|
||||||
|
// check whether the user already authorized the app earlier
|
||||||
|
if (session.app.isAuthorized) {
|
||||||
|
// already authorized, move on through!
|
||||||
|
os.api('auth/accept', {
|
||||||
|
token: session.token,
|
||||||
|
permission,
|
||||||
|
}).then(() => {
|
||||||
|
accepted();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// user still has to give consent
|
||||||
|
state = 'waiting';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function accepted(): void {
|
function accepted(): void {
|
||||||
state = 'accepted';
|
state = 'accepted';
|
||||||
if (session.app.callbackUrl) {
|
if (oauth) {
|
||||||
|
// redirect with authorization token
|
||||||
|
const params = {
|
||||||
|
code: session.token,
|
||||||
|
...(oauth.state ? { state: oauth.state } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
location.href = appendQuery(oauth.callback, query(params));
|
||||||
|
} else if (session.app.callbackUrl) {
|
||||||
|
// do whatever the legacy auth did
|
||||||
location.href = appendQuery(session.app.callbackUrl, query({ token: session.token }));
|
location.href = appendQuery(session.app.callbackUrl, query({ token: session.token }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function denied(): void {
|
||||||
|
state = 'denied';
|
||||||
|
if (oauth) {
|
||||||
|
// redirect with error code
|
||||||
|
const params = {
|
||||||
|
error: 'access_denied',
|
||||||
|
error_description: 'The user denied permission.',
|
||||||
|
...(oauth.state ? { state: oauth.state } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
location.href = appendQuery(oauth.callback, query(params));
|
||||||
|
} else {
|
||||||
|
// legacy auth didn't do anything in this case...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onLogin(res): void {
|
function onLogin(res): void {
|
||||||
login(res.i);
|
login(res.i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
definePageMetadata({
|
||||||
|
title: i18n.ts.appAuthorization,
|
||||||
|
icon: 'fas fa-shield',
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -26,27 +26,8 @@
|
||||||
|
|
||||||
<FormSection v-if="iAmModerator">
|
<FormSection v-if="iAmModerator">
|
||||||
<template #label>Moderation</template>
|
<template #label>Moderation</template>
|
||||||
<FormSwitch
|
<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch>
|
||||||
:model-value="suspended || isBlocked"
|
<FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch>
|
||||||
@update:model-value="newValue => {suspended = newValue; toggleSuspend() }"
|
|
||||||
:disabled="isBlocked"
|
|
||||||
class="_formBlock"
|
|
||||||
>
|
|
||||||
{{ i18n.ts.stopActivityDelivery }}
|
|
||||||
<template #caption>
|
|
||||||
{{ i18n.ts.stopActivityDeliveryDescription }}
|
|
||||||
</template>
|
|
||||||
</FormSwitch>
|
|
||||||
<FormSwitch
|
|
||||||
v-model="isBlocked"
|
|
||||||
@update:modelValue="toggleBlock"
|
|
||||||
class="_formBlock"
|
|
||||||
>
|
|
||||||
{{ i18n.ts.blockThisInstance }}
|
|
||||||
<template #caption>
|
|
||||||
{{ i18n.ts.blockThisInstanceDescription }}
|
|
||||||
</template>
|
|
||||||
</FormSwitch>
|
|
||||||
<MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton>
|
<MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
@ -171,7 +152,7 @@ const usersPagination = {
|
||||||
offsetMode: true,
|
offsetMode: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetch(): Promise<void> {
|
async function fetch() {
|
||||||
instance = await os.api('federation/show-instance', {
|
instance = await os.api('federation/show-instance', {
|
||||||
host: props.host,
|
host: props.host,
|
||||||
});
|
});
|
||||||
|
@ -179,21 +160,21 @@ async function fetch(): Promise<void> {
|
||||||
isBlocked = instance.isBlocked;
|
isBlocked = instance.isBlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBlock(): Promise<void> {
|
async function toggleBlock(ev) {
|
||||||
if (meta == null) return;
|
if (meta == null) return;
|
||||||
await os.api('admin/update-meta', {
|
await os.api('admin/update-meta', {
|
||||||
blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
|
blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSuspend(): Promise<void> {
|
async function toggleSuspend(v) {
|
||||||
await os.api('admin/federation/update-instance', {
|
await os.api('admin/federation/update-instance', {
|
||||||
host: instance.host,
|
host: instance.host,
|
||||||
isSuspended: suspended,
|
isSuspended: suspended,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshMetadata(): void {
|
function refreshMetadata() {
|
||||||
os.api('admin/federation/refresh-remote-instance-metadata', {
|
os.api('admin/federation/refresh-remote-instance-metadata', {
|
||||||
host: instance.host,
|
host: instance.host,
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<template #label>{{ i18n.ts._profile.name }}</template>
|
<template #label>{{ i18n.ts._profile.name }}</template>
|
||||||
</FormInput>
|
</FormInput>
|
||||||
|
|
||||||
<FormTextarea v-model="profile.description" :max="2048" tall manual-save class="_formBlock">
|
<FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
|
||||||
<template #label>{{ i18n.ts._profile.description }}</template>
|
<template #label>{{ i18n.ts._profile.description }}</template>
|
||||||
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
|
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
|
||||||
</FormTextarea>
|
</FormTextarea>
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock">
|
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock">
|
||||||
<template #label>{{ i18n.ts.username }}</template>
|
<template #label>{{ i18n.ts.username }}</template>
|
||||||
<template #prefix>@</template>
|
<template #prefix>@</template>
|
||||||
|
<template #suffix>@{{ host }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock">
|
<MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock">
|
||||||
<template #label>{{ i18n.ts.password }}</template>
|
<template #label>{{ i18n.ts.password }}</template>
|
||||||
|
@ -23,6 +24,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import MkButton from '@/components/ui/button.vue';
|
import MkButton from '@/components/ui/button.vue';
|
||||||
import MkInput from '@/components/form/input.vue';
|
import MkInput from '@/components/form/input.vue';
|
||||||
|
import { host } from '@/config';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { login } from '@/account';
|
import { login } from '@/account';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
|
@ -98,6 +98,9 @@ export const routes = [{
|
||||||
}, {
|
}, {
|
||||||
path: '/preview',
|
path: '/preview',
|
||||||
component: page(() => import('./pages/preview.vue')),
|
component: page(() => import('./pages/preview.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/auth',
|
||||||
|
component: page(() => import('./pages/auth.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/auth/:token',
|
path: '/auth/:token',
|
||||||
component: page(() => import('./pages/auth.vue')),
|
component: page(() => import('./pages/auth.vue')),
|
||||||
|
|
|
@ -30,6 +30,14 @@ async function composeNotification<K extends keyof pushNotificationDataMap>(data
|
||||||
const i18n = await swLang.i18n as I18n<any>;
|
const i18n = await swLang.i18n as I18n<any>;
|
||||||
const { t } = i18n;
|
const { t } = i18n;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
|
/*
|
||||||
|
case 'driveFileCreated': // TODO (Server Side)
|
||||||
|
return [t('_notification.fileUploaded'), {
|
||||||
|
body: body.name,
|
||||||
|
icon: body.url,
|
||||||
|
data
|
||||||
|
}];
|
||||||
|
*/
|
||||||
case 'notification':
|
case 'notification':
|
||||||
switch (data.body.type) {
|
switch (data.body.type) {
|
||||||
case 'follow':
|
case 'follow':
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
declare var self: ServiceWorkerGlobalScope;
|
declare var self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
import { createNotification } from '@/scripts/create-notification';
|
import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
|
||||||
import { swLang } from '@/scripts/lang';
|
import { swLang } from '@/scripts/lang';
|
||||||
import { swNotificationRead } from '@/scripts/notification-read';
|
import { swNotificationRead } from '@/scripts/notification-read';
|
||||||
import { pushNotificationDataMap } from '@/types';
|
import { pushNotificationDataMap } from '@/types';
|
||||||
|
@ -67,6 +67,8 @@ self.addEventListener('push', ev => {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return createEmptyNotification();
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue