Compare commits

..

23 commits
oauth ... main

Author SHA1 Message Date
9f6be8d557
server: refactor meta caching
This removes the "caching" that re-fetches the instance meta information
from the database every 10 seconds.
2022-11-14 22:12:32 +01:00
9d9b2da6cc
fix parameter for cache fetcher 2022-11-13 20:31:24 +01:00
d1ec058d5c
server: refactor Cache to hold fetcher as attribute
Instead of having to pass the fetcher every time you want to fetch
something, the fetcher is stored in an attribute of the Cache.
2022-11-13 19:39:30 +01:00
131c12a30b
server: refactor prefetchEmojis
Exiting earlier might slightly improve performance.
2022-11-13 18:24:15 +01:00
8d6476af2a
server: remove localUserByIdCache
The same data is stored in userByIdCache. Whether a user is local or not
can easily be determined from the cached object.
2022-11-13 18:03:22 +01:00
57299f0df6
server: simplify caching for instance actor 2022-11-13 17:14:33 +01:00
b0489abd7f
translate japanese comments 2022-11-13 13:47:22 +01:00
26f1b66c6a
client: update API error dialog to error refactoring 2022-11-13 12:59:45 +01:00
1d877e97f0
client: fix maxlength for profile description
Changelog: Fixed
2022-11-13 11:58:11 +01:00
0571a0843c
client: improve suspend toggle 2022-11-13 01:12:05 +01:00
56033c26f0
service worker: remove dead code 2022-11-12 22:36:03 +01:00
80af8a143e
service worker: don't trigger "push notifications have been updated"
closes FoundKeyGang/FoundKey#121

Changelog: Fixed
2022-11-12 22:35:37 +01:00
a3468491a7
fix import 2022-11-12 18:51:57 +01:00
486be564e8
server: improve comments 2022-11-12 17:39:36 +01:00
c49f529ccb
server: use DeliverManager for user deletion 2022-11-12 15:23:49 +01:00
8979e779da
server: optimise follower inboxes query
Use the distinct query thingy so we don't have to make the Set work
so hard. This is also uniform code with the "everyone" above so should
hopefully be easier to understand.
2022-11-12 15:09:50 +01:00
Volpeon
b1bb5b28c5
client: remove wrong content type header 2022-11-12 09:43:24 +01:00
f3c38ad5c8
server: only add unique cascade-delete notes 2022-11-11 18:08:57 +01:00
899b01a031
remove unnecessary checks
These checks were made obsolete by commit
6df2f7c55c.
2022-11-11 18:07:49 +01:00
a27a29b371
server: redirect browsers to human readable page
Also added/translated more comments.
2022-11-11 17:54:11 +01:00
66a9d27ab1
server: increase user description length to 2048
Changelog: Changed
2022-11-11 12:28:57 +01:00
ed14fe8e79
client: remove hostname from signup & signin form
Long hostnames can obscure the username being entered. And the hostname
should already be known to the user anyway or they can find out by
looking at the current URL.

fixes <FoundKeyGang/FoundKey#231>

Changelog: Changed
2022-11-11 12:20:48 +01:00
d411ea6281
backend: make removeAds migration plain JS 2022-11-10 12:56:39 -05:00
52 changed files with 443 additions and 942 deletions

View file

@ -190,7 +190,9 @@ charts: "Charts"
perHour: "Per Hour"
perDay: "Per Day"
stopActivityDelivery: "Stop sending activities"
stopActivityDeliveryDescription: "Local activities will not be sent to this instance. Receiving activities works as before."
blockThisInstance: "Block this instance"
blockThisInstanceDescription: "Local activites will not be sent to this instance. Activites from this instance will be discarded."
operations: "Operations"
software: "Software"
version: "Version"
@ -929,10 +931,6 @@ setTag: "Set tag"
addTag: "Add tag"
removeTag: "Remove tag"
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:
used: "This email address is already being used"
format: "The format of this email address is invalid"
@ -1251,37 +1249,38 @@ _2fa:
\ authentication via hardware security keys that support FIDO2 to further secure\
\ your account."
_permissions:
"read:account": "Read account information"
"write:account": "Edit account information"
"read:blocks": "Read which users are blocked"
"write:blocks": "Block and unblock users"
"read:drive": "List files and folders in the drive"
"write:drive": "Create, change and delete files in the drive"
"read:favorites": "List favourited notes"
"write:favorites": "Favorite and unfavorite notes"
"read:following": "List followed and following users"
"write:following": "Follow and unfollow other users"
"read:messaging": "View chat messages and history"
"write:messaging": "Create and delete chat messages"
"read:mutes": "List users which are muted or whose renotes are muted"
"write:mutes": "Mute and unmute users or their renotes"
"write:notes": "Create and delete notes"
"read:notifications": "Read notifications"
"write:notifications": "Mark notifications as read and create custom notifications"
"write:reactions": "Create and delete reactions"
"write:votes": "Vote in polls"
"read:pages": "List and read pages"
"write:pages": "Create, change and delete pages"
"read:page-likes": "List and read page likes"
"write:page-likes": "Like and unlike pages"
"read:user-groups": "List and view joined, owned and invited to groups"
"write:user-groups": "Create, modify, delete, transfer, join and leave groups. Invite and ban others from groups. Accept and reject group invitations."
"read:channels": "List and read followed and joined channels"
"write:channels": "Create, modify, follow and unfollow channels"
"read:gallery": "List and read gallery posts"
"write:gallery": "Create, modify and delete gallery posts"
"read:gallery-likes": "List and read gallery post likes"
"write:gallery-likes": "Like and unlike gallery posts"
"read:account": "View your account information"
"write:account": "Edit your account information"
"read:blocks": "View your list of blocked users"
"write:blocks": "Edit your list of blocked users"
"read:drive": "Access your Drive files and folders"
"write:drive": "Edit or delete your Drive files and folders"
"read:favorites": "View your list of favorites"
"write:favorites": "Edit your list of favorites"
"read:following": "View information on who you follow"
"write:following": "Follow or unfollow other accounts"
"read:messaging": "View your chats"
"write:messaging": "Compose or delete chat messages"
"read:mutes": "View your list of muted users"
"write:mutes": "Edit your list of muted users"
"write:notes": "Compose or delete notes"
"read:notifications": "View your notifications"
"write:notifications": "Manage your notifications"
"read:reactions": "View your reactions"
"write:reactions": "Edit your reactions"
"write:votes": "Vote on a poll"
"read:pages": "View your pages"
"write:pages": "Edit or delete your pages"
"read:page-likes": "View your likes on pages"
"write:page-likes": "Edit your likes on pages"
"read:user-groups": "View your user groups"
"write:user-groups": "Edit or delete your user groups"
"read:channels": "View your channels"
"write:channels": "Edit your channels"
"read:gallery": "View your gallery"
"write:gallery": "Edit your gallery"
"read:gallery-likes": "View your list of liked gallery posts"
"write:gallery-likes": "Edit your list of liked gallery posts"
_auth:
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
shareAccessAsk: "Are you sure you want to authorize this application to access your\

View file

@ -1,5 +1,5 @@
export class removeAds1657570176749 {
name = 'removeAds1657570176749'
name = 'removeAds1657570176749';
async up(queryRunner) {
await queryRunner.query(`DROP TABLE "ad"`);

View file

@ -1,26 +0,0 @@
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`);
}
}

View file

@ -1,12 +0,0 @@
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"`);
}
}

View file

@ -62,22 +62,21 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
// ハッシュタグ
// hashtags
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt;
// メンション
// mentions
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
// restore the host name part
const acct = `${txt}@${(new URL(href.value)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
text += txt;
}
// その他
// other
} else {
const generateLink = () => {
if (!href && !txt) {

View file

@ -1,10 +1,12 @@
export class Cache<T> {
public cache: Map<string | null, { date: number; value: T; }>;
private lifetime: number;
public fetcher: (key: string | null) => Promise<T | undefined>;
constructor(lifetime: Cache<never>['lifetime']) {
constructor(lifetime: number, fetcher: Cache<T>['fetcher']) {
this.cache = new Map();
this.lifetime = lifetime;
this.fetcher = fetcher;
}
public set(key: string | null, value: T): void {
@ -17,10 +19,13 @@ export class Cache<T> {
public get(key: string | null): T | undefined {
const cached = this.cache.get(key);
if (cached == null) return undefined;
// discard if past the cache lifetime
if ((Date.now() - cached.date) > this.lifetime) {
this.cache.delete(key);
return undefined;
}
return cached.value;
}
@ -29,52 +34,22 @@ export class Cache<T> {
}
/**
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
* If the value is cached, it is returned. Otherwise the fetcher is
* run to get the value. If the fetcher returns undefined, it is
* returned but not cached.
*/
public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
public async fetch(key: string | null): Promise<T | undefined> {
const cached = this.get(key);
if (cached !== undefined) {
return cached;
} else {
const value = await this.fetcher(key);
// Cache MISS
const value = await fetcher();
this.set(key, value);
return value;
}
// don't cache undefined
if (value !== undefined)
this.set(key, 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;
}
return value;
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
this.set(key, value);
}
return value;
}
}

View file

@ -3,22 +3,26 @@ import { Note } from '@/models/entities/note.js';
import { User } from '@/models/entities/user.js';
import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js';
import * as Acct from '@/misc/acct.js';
import { MINUTE } from '@/const.js';
import { getFullApAccount } from './convert-host.js';
import { Packed } from './schema.js';
import { Cache } from './cache.js';
const blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
const blockingCache = new Cache<User['id'][]>(
5 * MINUTE,
(blockerId) => Blockings.findBy({ blockerId }).then(res => res.map(x => x.blockeeId)),
);
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
// designation for users you follow, list users and groups is disabled for performance reasons
/**
* noteUserFollowers / antennaUserFollowing
* either noteUserFollowers or antennaUserFollowing must be specified
*/
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;
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
// skip if the antenna creator is blocked by the note author
const blockings = await blockingCache.fetch(noteUser.id);
if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (note.visibility === 'followers') {

View file

@ -1,44 +1,44 @@
import push from 'web-push';
import { db } from '@/db/postgre.js';
import { Meta } from '@/models/entities/meta.js';
import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js';
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> {
if (!noCache && cache) return cache;
return await db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
await getMeta();
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;
}
});
return cache;
}
setInterval(() => {
fetchMeta(true).then(meta => {
cache = meta;
});
}, 1000 * 10);

View file

@ -3,8 +3,11 @@ import { User } from '@/models/entities/user.js';
import { UserKeypair } from '@/models/entities/user-keypair.js';
import { Cache } from './cache.js';
const cache = new Cache<UserKeypair>(Infinity);
const cache = new Cache<UserKeypair>(
Infinity,
(userId) => UserKeypairs.findOneByOrFail({ userId }),
);
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
return await cache.fetch(userId);
}

View file

@ -4,14 +4,27 @@ import { Emojis } from '@/models/index.js';
import { Emoji } from '@/models/entities/emoji.js';
import { Note } from '@/models/entities/note.js';
import { query } from '@/prelude/url.js';
import { HOUR } from '@/const.js';
import { Cache } from './cache.js';
import { isSelfHost, toPunyNullable } from './convert-host.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 = {
name: string;
@ -36,28 +49,22 @@ function parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const name = match[1];
// ホスト正規化
const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
return { name, host };
}
/**
*
* @param emojiName (:, @. (decodeReactionで可能))
* @param noteUserHost
* @returns , nullは未マッチを意味する
* 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 noteUserHost host that the content is from, to default to
* @returns emoji information. `null` means not found.
*/
export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null;
const queryOrNull = async () => (await Emojis.findOneBy({
name,
host: host ?? IsNull(),
})) || null;
const emoji = await cache.fetch(`${name} ${host}`, queryOrNull);
const emoji = await cache.fetch(`${host ?? ''}:${name}`);
if (emoji == null) return null;
@ -72,7 +79,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[]> {
const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
@ -103,11 +110,20 @@ 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> {
const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null);
const notCachedEmojis = emojis.filter(emoji => {
// 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[] = [];
// group by hosts to try to reduce query size
const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) {
emojisQuery.push({
@ -115,11 +131,14 @@ export async function prefetchEmojis(emojis: { name: string; host: string | null
host: host ?? IsNull(),
});
}
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
await Emojis.find({
where: emojisQuery,
select: ['name', 'host', 'originalUrl', 'publicUrl'],
}) : [];
for (const emoji of _emojis) {
cache.set(`${emoji.name} ${emoji.host}`, emoji);
}
}).then(emojis => {
// store all emojis into the cache
emojis.forEach(emoji => {
cache.set(`${emoji.host ?? ''}:${emoji.name}`, emoji);
});
});
}

View file

@ -1,6 +1,6 @@
import { Entity, PrimaryColumn, Index, Column, ManyToOne, OneToOne, JoinColumn } from 'typeorm';
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
import { id } from '../id.js';
import { AccessToken } from './access-token.js';
import { User } from './user.js';
import { App } from './app.js';
@Entity()
@ -23,27 +23,21 @@ export class AuthSession {
...id(),
nullable: true,
})
public accessTokenId: AccessToken['id'] | null;
public userId: User['id'] | null;
@ManyToOne(() => AccessToken, {
@ManyToOne(type => User, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn()
public accessToken: AccessToken | null;
public user: User | null;
@Column(id())
public appId: App['id'];
@ManyToOne(() => App, {
@ManyToOne(type => App, {
onDelete: 'CASCADE',
})
@JoinColumn()
public app: App | null;
@Column('text', {
nullable: true,
comment: 'PKCE code_challenge value, if provided (OAuth only)',
})
pkceChallenge: string | null;
}

View file

@ -6,13 +6,16 @@ import { Packed } from '@/misc/schema.js';
import { awaitAll, Promiseable } from '@/prelude/await-all.js';
import { populateEmojis } from '@/misc/populate-emojis.js';
import { getAntennas } from '@/misc/antenna-cache.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD, HOUR } from '@/const.js';
import { Cache } from '@/misc/cache.js';
import { db } from '@/db/postgre.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';
const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
const userInstanceCache = new Cache<Instance | null>(
3 * HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
@ -27,7 +30,7 @@ const ajv = new Ajv();
const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
const passwordSchema = { type: 'string', minLength: 1 } as const;
const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 2048 } 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;
@ -309,17 +312,15 @@ export const UserRepository = db.getRepository(User).extend({
isModerator: user.isModerator || falsy,
isBot: user.isBot || falsy,
isCat: user.isCat || falsy,
instance: user.host ? userInstanceCache.fetch(user.host,
() => Instances.findOneBy({ host: user.host! }),
v => v != null,
).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
} : undefined) : undefined,
instance: !user.host ? undefined : userInstanceCache.fetch(user.host)
.then(instance => !instance ? undefined : {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
}),
emojis: populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),

View file

@ -1,6 +1,6 @@
import Bull from 'bull';
import { In, LessThan } from 'typeorm';
import { AttestationChallenges, AuthSessions, Mutings, PasswordResetRequests, Signins } from '@/models/index.js';
import { AttestationChallenges, Mutings, PasswordResetRequests, Signins } from '@/models/index.js';
import { publishUserEvent } from '@/services/stream.js';
import { MINUTE, DAY } from '@/const.js';
import { queueLogger } from '@/queue/logger.js';
@ -40,11 +40,7 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)),
});
await AuthSessions.delete({
createdAt: LessThan(new Date(new Date().getTime() - 15 * MINUTE)),
});
logger.succ('Deleted expired data.');
logger.succ('Deleted expired mutes, signins and attestation challenges.');
done();
}

View file

@ -10,8 +10,14 @@ import { uriPersonCache, userByIdCache } from '@/services/user-cache.js';
import { IObject, getApId } from './type.js';
import { resolvePerson } from './models/person.js';
const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
const publicKeyCache = new Cache<UserPublickey>(
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 = {
/** wether the URI was generated by us */
@ -99,13 +105,9 @@ export default class DbResolver {
if (parsed.local) {
if (parsed.type !== 'users') return null;
return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({
id: parsed.id,
}).then(x => x ?? undefined)) ?? null;
return await userByIdCache.fetch(parsed.id) ?? null;
} else {
return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({
uri: parsed.uri,
}));
return await uriPersonCache.fetch(parsed.uri) ?? null;
}
}
@ -116,20 +118,12 @@ export default class DbResolver {
user: CacheableRemoteUser;
key: UserPublickey;
} | null> {
const key = await publicKeyCache.fetch(keyId, async () => {
const key = await UserPublickeys.findOneBy({
keyId,
});
if (key == null) return null;
return key;
}, key => key != null);
const key = await publicKeyCache.fetch(keyId);
if (key == null) return null;
return {
user: await userByIdCache.fetch(key.userId, () => Users.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser,
user: await userByIdCache.fetch(key.userId) as CacheableRemoteUser,
key,
};
}
@ -145,7 +139,7 @@ export default class DbResolver {
if (user == null) return null;
const key = await publicKeyByUserIdCache.fetch(user.id, () => UserPublickeys.findOneBy({ userId: user.id }), v => v != null);
const key = await publicKeyByUserIdCache.fetch(user.id);
return {
user,

View file

@ -119,26 +119,18 @@ export default class DeliverManager {
if (this.recipes.some(r => isFollowers(r))) {
// followers deliver
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう
const followers = await Followings.find({
where: {
followeeId: this.actor.id,
followerHost: Not(IsNull()),
},
select: {
followerSharedInbox: true,
followerInbox: true,
},
}) as {
followerSharedInbox: string | null;
followerInbox: string;
}[];
const followers = await Followings.createQueryBuilder('followings')
// return either the shared inbox (if available) or the individual inbox
.select('COALESCE(followings.followerSharedInbox, followings.followerInbox)', 'inbox')
// so we don't have to make our inboxes Set work as hard
.distinct(true)
// ...for the specific actors followers
.where('followings.followeeId = :actorId', { actorId: this.actor.id })
// don't deliver to ourselves
.andWhere('followings.followerHost IS NOT NULL')
.getRawMany();
for (const following of followers) {
const inbox = following.followerSharedInbox || following.followerInbox;
inboxes.add(inbox);
}
followers.forEach(({ inbox }) => inboxes.add(inbox));
}
this.recipes.filter((recipe): recipe is IDirectRecipe =>

View file

@ -23,8 +23,6 @@ import Featured from './activitypub/featured.js';
// Init router
const router = new Router();
//#region Routing
function inbox(ctx: Router.RouterContext) {
let signature;
@ -45,6 +43,8 @@ const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystr
function isActivityPubReq(ctx: Router.RouterContext) {
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);
return typeof accepted === 'string' && !accepted.match(/html/);
}
@ -77,7 +77,7 @@ router.get('/notes/:note', async (ctx, next) => {
return;
}
// リモートだったらリダイレクト
// redirect if remote
if (note.userHost != null) {
if (note.uri == null || isSelfHost(note.userHost)) {
ctx.status = 500;
@ -94,6 +94,15 @@ router.get('/notes/:note', async (ctx, next) => {
// note activity
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({
id: ctx.params.note,
userHost: IsNull(),
@ -185,7 +194,6 @@ router.get('/@:user', async (ctx, next) => {
await userInfo(ctx, user);
});
//#endregion
// emoji
router.get('/emojis/:emoji', async ctx => {

View file

@ -3,9 +3,14 @@ import { Users, AccessTokens, Apps } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js';
import { Cache } from '@/misc/cache.js';
import { App } from '@/models/entities/app.js';
import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>(
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
@ -37,8 +42,7 @@ export default async (authorization: string | null | undefined, bodyToken: strin
const token: string = maybeToken;
if (isNativeToken(token)) {
const user = await localUserByNativeTokenCache.fetch(token,
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>);
const user = await localUserByNativeTokenCache.fetch(token);
if (user == null) {
throw new AuthenticationError('unknown token');
@ -62,11 +66,20 @@ export default async (authorization: string | null | undefined, bodyToken: strin
lastUsedAt: new Date(),
});
const user = await localUserByIdCache.fetch(accessToken.userId,
() => Users.findOneBy({
id: accessToken.userId,
}) as Promise<ILocalUser>);
const user = await userByIdCache.fetch(accessToken.userId);
return [user, accessToken];
// can't authorize remote users
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];
}
}
};

View file

@ -1,42 +0,0 @@
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;
}

View file

@ -1,131 +0,0 @@
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(' '),
};
};

View file

@ -67,7 +67,6 @@ import * as ep___ap_show from './endpoints/ap/show.js';
import * as ep___app_create from './endpoints/app/create.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_deny from './endpoints/auth/deny.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_userkey from './endpoints/auth/session/userkey.js';
@ -376,7 +375,6 @@ const eps = [
['app/create', ep___app_create],
['app/show', ep___app_show],
['auth/accept', ep___auth_accept],
['auth/deny', ep___auth_deny],
['auth/session/generate', ep___auth_session_generate],
['auth/session/show', ep___auth_session_show],
['auth/session/userkey', ep___auth_session_userkey],

View file

@ -1,6 +1,5 @@
import { Meta } from '@/models/entities/meta.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { db } from '@/db/postgre.js';
import { fetchMeta, setMeta } from '@/misc/fetch-meta.js';
import define from '../../define.js';
export const meta = {
@ -375,20 +374,10 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = ps.deeplIsPro;
}
await db.transaction(async transactionalEntityManager => {
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
await transactionalEntityManager.update(Meta, meta.id, set);
} else {
await transactionalEntityManager.save(Meta, set);
}
const meta = await fetchMeta();
await setMeta({
...meta,
...set,
});
insertModerationLog(me, 'updateMeta');

View file

@ -2,7 +2,6 @@ import * as crypto from 'node:crypto';
import { AuthSessions, AccessTokens, Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
@ -20,17 +19,6 @@ export const paramDef = {
type: 'object',
properties: {
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'],
} as const;
@ -46,35 +34,37 @@ export default define(meta, paramDef, async (ps, user) => {
// Generate access token
const accessToken = secureRndstr(32, true);
// Check for existing access token.
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,
// Fetch exist access token
const exist = await AccessTokens.findOneBy({
appId: session.appId,
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
await AuthSessions.update(session.id, { accessTokenId });
await AuthSessions.update(session.id, {
userId: user.id,
});
});

View file

@ -1,38 +0,0 @@
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);
}
});

View file

@ -2,7 +2,6 @@ import { v4 as uuid } from 'uuid';
import config from '@/config/index.js';
import { Apps, AuthSessions } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { compareUrl } from '@/server/api/common/compare-url.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
@ -24,19 +23,6 @@ export const meta = {
optional: false, nullable: false,
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',
},
},
},
@ -45,33 +31,16 @@ export const meta = {
export const paramDef = {
type: 'object',
oneOf: [{
properties: {
clientId: { type: 'string' },
callbackUrl: {
type: 'string',
minLength: 1,
},
pkceChallenge: {
type: 'string',
minLength: 1,
},
},
required: ['clientId']
}, {
properties: {
appSecret: { type: 'string' },
},
required: ['appSecret'],
}],
properties: {
appSecret: { type: 'string' },
},
required: ['appSecret'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => {
// Lookup app
const app = await Apps.findOneBy(ps.clientId ? {
id: ps.clientId,
} : {
const app = await Apps.findOneBy({
secret: ps.appSecret,
});
@ -79,31 +48,19 @@ export default define(meta, paramDef, async (ps) => {
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
const token = uuid();
const id = genId();
// Create session token document
const doc = await AuthSessions.insert({
id,
id: genId(),
createdAt: new Date(),
appId: app.id,
token,
pkceChallenge: ps.pkceChallenge,
}).then(x => AuthSessions.findOneByOrFail(x.identifiers[0]));
return {
token: doc.token,
url: `${config.authUrl}/${doc.token}`,
id,
app: await Apps.pack(app),
};
});

View file

@ -1,5 +0,0 @@
/*
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.
*/

View file

@ -46,26 +46,27 @@ export default define(meta, paramDef, async (ps) => {
if (app == null) throw new ApiError('NO_SUCH_APP');
// Fetch token
const session = await AuthSessions.findOne({
where: {
token: ps.token,
appId: app.id,
},
relations: {
accessToken: true,
},
const session = await AuthSessions.findOneBy({
token: ps.token,
appId: app.id,
});
if (session == null) throw new ApiError('NO_SUCH_SESSION');
if (session.accessTokenId == null) throw new ApiError('PENDING_SESSION');
if (session.userId == null) throw new ApiError('PENDING_SESSION');
// Lookup access token
const accessToken = await AccessTokens.findOneByOrFail({
appId: app.id,
userId: session.userId,
});
// Delete session
AuthSessions.delete(session.id);
return {
accessToken: session.accessToken.token,
user: await Users.pack(session.accessToken.userId, null, {
accessToken: accessToken.token,
user: await Users.pack(session.userId, null, {
detail: true,
}),
};

View file

@ -15,7 +15,6 @@ import { handler } from './api-handler.js';
import signup from './private/signup.js';
import signin from './private/signin.js';
import signupPending from './private/signup-pending.js';
import { oauth } from './common/oauth.js';
import discord from './service/discord.js';
import github from './service/github.js';
import twitter from './service/twitter.js';
@ -75,9 +74,6 @@ 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('/signin', signin);
router.post('/signup-pending', signupPending);

View file

@ -3,10 +3,6 @@ import { errors as errorDefinitions } from '../error.js';
import endpoints from '../endpoints.js';
import { schemas, convertSchemaToOpenApiSchema } from './schemas.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() {
const spec = {
@ -38,18 +34,10 @@ export function genOpenapiSpec() {
in: 'body',
name: 'i',
},
OAuth: {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: `${config.url}/auth`,
tokenUrl: `${config.apiUrl}/auth/session/oauth`,
scopes: kinds.reduce((acc, kind) => {
acc[kind] = i18n.ts['_permissions'][kind];
return acc;
}, {}),
},
},
// TODO: change this to oauth2 when the remaining oauth stuff is set up
Bearer: {
type: 'http',
scheme: 'bearer',
},
},
},
@ -149,16 +137,10 @@ export function genOpenapiSpec() {
{
ApiKeyAuth: [],
},
{
Bearer: [],
},
];
if (endpoint.meta.kind) {
security.push({
OAuth: [endpoint.meta.kind],
});
} else {
security.push({
OAuth: [],
});
}
if (!endpoint.meta.requireCredential) {
// add this to make authentication optional
security.push({});

View file

@ -1,16 +0,0 @@
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'],
});

View file

@ -7,7 +7,6 @@ import { escapeAttribute, escapeValue } from '@/prelude/xml.js';
import { Users } from '@/models/index.js';
import { User } from '@/models/entities/user.js';
import { links } from './nodeinfo.js';
import { oauthMeta } from './oauth.js';
// Init router
const router = new Router();
@ -63,21 +62,10 @@ router.get('/.well-known/nodeinfo', async ctx => {
ctx.body = { links };
});
function oauth(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);
/* TODO
router.get('/.well-known/change-password', async ctx => {
});
*/
router.get(webFingerPath, async ctx => {
const fromId = (id: User['id']): FindOptionsWhere<User> => ({

View file

@ -1,28 +1,20 @@
import { IsNull } from 'typeorm';
import { ILocalUser } from '@/models/entities/user.js';
import { Users } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { createSystemUser } from './create-system-user.js';
const ACTOR_USERNAME = 'instance.actor' as const;
const cache = new Cache<ILocalUser>(Infinity);
let instanceActor = await Users.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as ILocalUser | undefined;
export async function getInstanceActor(): Promise<ILocalUser> {
const cached = cache.get(null);
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;
if (instanceActor) {
return instanceActor;
} else {
const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
cache.set(null, created);
instanceActor = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
return created;
}
}

View file

@ -36,13 +36,22 @@ import { Cache } from '@/misc/cache.js';
import { UserProfile } from '@/models/entities/user-profile.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { IActivity } from '@/remote/activitypub/type.js';
import { MINUTE } from '@/const.js';
import { updateHashtags } from '../update-hashtag.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import { createNotification } from '../create-notification.js';
import { addNoteToAntenna } from '../add-note-to-antenna.js';
import { deliverToRelays } from '../relay.js';
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(
5 * MINUTE,
() => UserProfiles.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
}),
);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -257,12 +266,7 @@ export default async (user: { id: User['id']; username: User['username']; host:
incNotesCountOfUser(user);
// Word mute
mutedWordsCache.fetch(null, () => UserProfiles.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
mutedWordsCache.fetch(null).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {

View file

@ -53,11 +53,9 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
deliverToConcerned(user, note, content);
}
// also deliever delete activity to cascaded notes
// also deliver delete activity to cascaded notes
const cascadingNotes = await findCascadingNotes(note);
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));
deliverToConcerned(cascadingNote.user, cascadingNote, content);
}
@ -107,6 +105,9 @@ async function findCascadingNotes(note: Note): Promise<Note[]> {
});
await Promise.all(replies.map(reply => {
// only add unique notes
if (cascadingNotes.find((x) => x.id == reply.id) != null) return;
cascadingNotes.push(reply);
return recursive(reply.id);
}));

View file

@ -15,20 +15,21 @@ type pushNotificationsTypes = {
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
};
// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
// Reduce the content of the push message because of the character limit
function truncateNotification(notification: Packed<'Notification'>): any {
if (notification.note) {
return {
...notification,
note: {
...notification.note,
// textをgetNoteSummaryしたものに置き換える
// replace text with getNoteSummary
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
cw: undefined,
reply: undefined,
renote: undefined,
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
// unnecessary, since usually the user who is receiving the notification knows who they are
user: undefined as any,
},
};
}
@ -41,7 +42,7 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
// Register key pair information
push.setVapidDetails(config.url,
meta.swPublicKey,
meta.swPrivateKey);
@ -65,10 +66,6 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
}), {
proxy: config.proxy,
}).catch((err: any) => {
//swLogger.info(err.statusCode);
//swLogger.info(err.headers);
//swLogger.info(err.body);
if (err.statusCode === 410) {
SwSubscriptions.delete({
userId,

View file

@ -3,29 +3,27 @@ import { Instances } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { toPuny } from '@/misc/convert-host.js';
import { Cache } from '@/misc/cache.js';
import { HOUR } from '@/const.js';
const cache = new Cache<Instance>(1000 * 60 * 60);
const cache = new Cache<Instance>(
HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
export async function registerOrFetchInstanceDoc(idnHost: string): Promise<Instance> {
const host = toPuny(idnHost);
const cached = cache.get(host);
const cached = cache.fetch(host);
if (cached) return cached;
const index = await Instances.findOneBy({ host });
// apparently a new instance
const i = await Instances.insert({
id: genId(),
host,
caughtAt: new Date(),
lastCommunicatedAt: new Date(),
}).then(x => Instances.findOneByOrFail(x.identifiers[0]));
if (index == null) {
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;
}
cache.set(host, i);
return i;
}

View file

@ -8,11 +8,21 @@ import { Users, Relays } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { Cache } from '@/misc/cache.js';
import { Relay } from '@/models/entities/relay.js';
import { MINUTE } from '@/const.js';
import { createSystemUser } from './create-system-user.js';
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> {
const user = await Users.findOneBy({
@ -83,9 +93,7 @@ export async function relayRejected(id: string) {
export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) {
if (activity == null) return;
const relays = await relaysCache.fetch(null, () => Relays.findBy({
status: 'accepted',
}));
const relays = await relaysCache.fetch(null);
if (relays.length === 0) return;
// TODO

View file

@ -1,7 +1,7 @@
import { Not, IsNull } from 'typeorm';
import renderDelete from '@/remote/activitypub/renderer/delete.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import { deliver } from '@/queue/index.js';
import DeliverManager from '@/remote/activitypub/deliver-manager.js';
import config from '@/config/index.js';
import { User } from '@/models/entities/user.js';
import { Users, Followings } from '@/models/index.js';
@ -11,27 +11,11 @@ export async function doPostSuspend(user: { id: User['id']; host: User['host'] }
publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (Users.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user));
const queue: string[] = [];
const followings = await Followings.find({
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);
}
// deliver to all of known network
const dm = new DeliverManager(user, content);
dm.addEveryone();
await dm.execute();
}
}

View file

@ -3,10 +3,18 @@ import { Users } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { subscriber } from '@/db/redis.js';
export const userByIdCache = new Cache<CacheableUser>(Infinity);
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity);
export const localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
export const uriPersonCache = new Cache<CacheableUser | null>(Infinity);
export const userByIdCache = new Cache<CacheableUser>(
Infinity,
(id) => Users.findOneBy({ id }).then(x => x ?? undefined),
);
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) => {
const obj = JSON.parse(data);
@ -27,7 +35,6 @@ subscriber.on('message', async (_, data) => {
}
if (Users.isLocalUser(user)) {
localUserByNativeTokenCache.set(user.token, user);
localUserByIdCache.set(user.id, user);
}
break;
}

View file

@ -71,7 +71,6 @@ const ok = async () => {
method: 'POST',
body: formData,
headers: {
'content-type': 'multipart/form-data',
authorization: `Bearer ${$i.token}`,
},
})

View file

@ -17,6 +17,7 @@
:spellcheck="spellcheck"
:step="step"
:list="id"
:maxlength="max"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@ -58,6 +59,7 @@ const props = defineProps<{
manualSave?: boolean;
small?: boolean;
large?: boolean;
max?: number;
}>();
const emit = defineEmits<{

View file

@ -14,6 +14,7 @@
:pattern="pattern"
:autocomplete="autocomplete ? 'on' : 'off'"
:spellcheck="spellcheck"
:maxlength="max"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@ -54,6 +55,7 @@ const props = withDefaults(defineProps<{
pre?: boolean;
debounce?: boolean;
manualSave?: boolean;
max?: number;
}>(), {
pattern: undefined,
placeholder: '',

View file

@ -8,7 +8,6 @@
<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">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</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>
<template #prefix><i class="fas fa-lock"></i></template>
@ -55,7 +54,7 @@ import { showSuspendedDialog } from '@/scripts/show-suspended-dialog';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkInfo from '@/components/ui/info.vue';
import { apiUrl, host as configHost } from '@/config';
import { apiUrl } from '@/config';
import { byteify, hexify } from '@/scripts/2fa';
import * as os from '@/os';
import { login } from '@/account';
@ -68,7 +67,6 @@ let user = $ref(null);
let username = $ref('');
let password = $ref('');
let token = $ref('');
let host = $ref(toUnicode(configHost));
let totpLogin = $ref(false);
let challengeData = $ref(null);
let queryingKey = $ref(false);

View file

@ -7,7 +7,6 @@
<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 #prefix>@</template>
<template #suffix>@{{ host }}</template>
<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-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.available }}</span>
@ -70,7 +69,6 @@ import MkButton from './ui/button.vue';
import MkCaptcha from './captcha.vue';
import MkInput from './form/input.vue';
import MkSwitch from './form/switch.vue';
import * as config from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { instance } from '@/instance';
@ -87,8 +85,6 @@ const emit = defineEmits<{
(ev: 'signupEmailPending'): void;
}>();
const host = toUnicode(config.host);
let hcaptcha = $ref();
let recaptcha = $ref();

View file

@ -103,7 +103,7 @@ export const apiWithDialog = ((
promiseDialog(promise, null, (err) => {
alert({
type: 'error',
text: err.message + '\n' + (err as any).id,
text: (err.message + '\n' + (err?.endpoint ?? '') + (err?.code ?? '')).trim(),
});
});
@ -141,7 +141,7 @@ export function promiseDialog<T extends Promise<any>>(
}
});
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
// NOTE: dynamic import results in strange behaviour (showing is not reactive)
popup(MkWaitingDialog, {
success,
showing,

View file

@ -3,16 +3,14 @@
<div class="_title">{{ i18n.t('_auth.shareAccess', { name: app.name }) }}</div>
<div class="_content">
<h2>{{ app.name }}</h2>
<p class="id">{{ app.id }}</p>
<p class="description">{{ app.description }}</p>
</div>
<div class="_content">
<h2>{{ i18n.ts._auth.permissionAsk }}</h2>
<ul v-if="permission.length > 0">
<li v-for="p in permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
<ul>
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul>
<template v-else>
{{ i18n.ts.noPermissionRequested }}
</template>
</div>
<div class="_footer">
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
@ -32,12 +30,12 @@ const emit = defineEmits<{
}>();
const props = defineProps<{
// TODO: allow user to deselect some permissions
permission: string[];
session: {
app: {
name: string;
id: string;
description: string;
permission: string[];
};
token: string;
};
@ -56,7 +54,6 @@ function cancel(): void {
function accept(): void {
os.api('auth/accept', {
token: props.session.token,
permission: props.permission,
}).then(() => {
emit('accepted');
});

View file

@ -1,38 +1,29 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :max-content="700">
<div v-if="$i">
<MkLoading v-if="state == 'fetching'"/>
<XForm
v-else-if="state == 'waiting'"
ref="form"
class="form"
:session="session"
:permission="permission"
@denied="denied"
@accepted="accepted"
/>
<div v-else-if="state == 'denied'" class="denied">
<h1>{{ i18n.ts._auth.denied }}</h1>
</div>
<div v-else-if="state == 'accepted'" class="accepted">
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
</div>
<div v-else-if="state == 'fetch-session-error'" class="error">
<p>{{ i18n.ts.somethingHappened }}</p>
</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>
<div v-if="$i">
<MkLoading v-if="state == 'fetching'"/>
<XForm
v-else-if="state == 'waiting'"
ref="form"
class="form"
:session="session"
@denied="state = 'denied'"
@accepted="accepted"
/>
<div v-else-if="state == 'denied'" class="denied">
<h1>{{ i18n.ts._auth.denied }}</h1>
</div>
<div v-else-if="state == 'accepted'" class="accepted">
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
</div>
<div v-else-if="state == 'fetch-session-error'" class="error">
<p>{{ i18n.ts.somethingHappened }}</p>
</div>
</div>
<div v-else class="signin">
<MkSignin @login="onLogin"/>
</div>
</template>
<script lang="ts" setup>
@ -42,155 +33,48 @@ import MkSignin from '@/components/signin.vue';
import * as os from '@/os';
import { login , $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { query, appendQuery } from '@/scripts/url';
const props = defineProps<{
token?: string;
token: string;
}>();
let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' | 'oauth-error' = $ref('fetching');
let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' = $ref('fetching');
let session = $ref(null);
let permission: string[] = $ref([]);
// if this is an OAuth request, will contain the respective parameters
let oauth: { state: string | null, callback: string } | null = null;
onMounted(async () => {
onMounted(() => {
if (!$i) return;
// detect whether this is actual OAuth or "legacy" auth
const params = new URLSearchParams(location.search);
if (params.get('response_type') === 'code') {
// OAuth request detected!
// Fetch session
os.api('auth/session/show', {
token: props.token,
}).then(fetchedSession => {
session = fetchedSession;
// if PKCE is used, check that it is a supported method
// the default value for code_challenge_method if not supplied is 'plain', which is not supported.
if (params.has('code_challenge') && params.get('code_challenge_method') !== 'S256') {
if (params.has('redirect_uri')) {
location.href = appendQuery(params.get('redirect_uri'), query({
error: 'invalid_request',
error_description: 'unsupported code_challenge_method, only "S256" is supported',
}));
} else {
state = 'oauth-error';
}
return;
}
// 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));
//
if (session.app.isAuthorized) {
os.api('auth/accept', {
token: session.token,
}).then(() => {
this.accepted();
});
} else {
// Default to all permissions of this app.
permission = session.app.permission;
state = 'waiting';
}
} else if (!props.token) {
}).catch(() => {
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 {
state = 'accepted';
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
if (session.app.callbackUrl) {
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 {
login(res.i);
}
definePageMetadata({
title: i18n.ts.appAuthorization,
icon: 'fas fa-shield',
});
</script>

View file

@ -26,8 +26,27 @@
<FormSection v-if="iAmModerator">
<template #label>Moderation</template>
<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch>
<FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch>
<FormSwitch
:model-value="suspended || isBlocked"
@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>
</FormSection>
@ -152,7 +171,7 @@ const usersPagination = {
offsetMode: true,
};
async function fetch() {
async function fetch(): Promise<void> {
instance = await os.api('federation/show-instance', {
host: props.host,
});
@ -160,21 +179,21 @@ async function fetch() {
isBlocked = instance.isBlocked;
}
async function toggleBlock(ev) {
async function toggleBlock(): Promise<void> {
if (meta == null) return;
await os.api('admin/update-meta', {
blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
});
}
async function toggleSuspend(v) {
async function toggleSuspend(): Promise<void> {
await os.api('admin/federation/update-instance', {
host: instance.host,
isSuspended: suspended,
});
}
function refreshMetadata() {
function refreshMetadata(): void {
os.api('admin/federation/refresh-remote-instance-metadata', {
host: instance.host,
});

View file

@ -12,7 +12,7 @@
<template #label>{{ i18n.ts._profile.name }}</template>
</FormInput>
<FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
<FormTextarea v-model="profile.description" :max="2048" tall manual-save class="_formBlock">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</FormTextarea>

View file

@ -6,7 +6,6 @@
<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 #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
<MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock">
<template #label>{{ i18n.ts.password }}</template>
@ -24,7 +23,6 @@
<script lang="ts" setup>
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import { host } from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { i18n } from '@/i18n';

View file

@ -98,9 +98,6 @@ export const routes = [{
}, {
path: '/preview',
component: page(() => import('./pages/preview.vue')),
}, {
path: '/auth',
component: page(() => import('./pages/auth.vue')),
}, {
path: '/auth/:token',
component: page(() => import('./pages/auth.vue')),

View file

@ -30,14 +30,6 @@ async function composeNotification<K extends keyof pushNotificationDataMap>(data
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n;
switch (data.type) {
/*
case 'driveFileCreated': // TODO (Server Side)
return [t('_notification.fileUploaded'), {
body: body.name,
icon: body.url,
data
}];
*/
case 'notification':
switch (data.body.type) {
case 'follow':

View file

@ -1,6 +1,6 @@
declare var self: ServiceWorkerGlobalScope;
import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
import { createNotification } from '@/scripts/create-notification';
import { swLang } from '@/scripts/lang';
import { swNotificationRead } from '@/scripts/notification-read';
import { pushNotificationDataMap } from '@/types';
@ -67,8 +67,6 @@ self.addEventListener('push', ev => {
}
break;
}
return createEmptyNotification();
}));
});