Compare commits

...

40 commits

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
5d23aa9e69
translate some comments to english 2022-11-10 00:36:39 +01:00
5b61941e4c
server: skip instances that proclaimed themself dead via HTTP 410
Changelog: Fixed
2022-11-10 00:23:30 +01:00
ca90cedba0
server: reduce dead instance detection to 7 days 2022-11-09 18:47:28 +01:00
2496b385ce
fix login
This is a fixup commit to b2c800e654.
2022-11-08 21:59:13 +01:00
54075789cd
server: remove content type bodge
Now that the client should send the proper content type, this should not be
necessary any more.
2022-11-08 20:57:38 +01:00
b2c800e654
client: properly set content-type header 2022-11-08 20:57:09 +01:00
5713f329ca
client: remove unnecessary ref 2022-11-08 20:57:08 +01:00
609312bb82
server: refactor errors in signin endpoint 2022-11-08 20:57:08 +01:00
7939d130aa backend: update sharp to 0.31.2
Changelog: Fixed
Fixes: FoundKeyGang/FoundKey#226
2022-11-08 01:16:55 -05:00
489eea0c67
server: improve API validation for creating apps
Resolves a FIXME comment.
2022-11-05 10:43:34 +01:00
6f65326b32
chore: synchronize code and database schema 2022-11-03 21:50:55 +01:00
408c5c3c65
improve description of generating migrations 2022-11-03 21:50:37 +01:00
e79d7879c6 docs/migrating: Make yarn instructions version-agnostic
This means we don't have to update the yarn version here in case we update the version of Yarn used.
2022-11-02 22:58:02 +00:00
e8ecd71f8a backend: refactor server/nodeinfo.ts (#221)
This fixes a few type errors like removing `software.respository` in
NodeInfo 2.0 and updating `metadata.repositoryUrl` to not use the
now removed meta `repositoryUrl` field.

Co-authored-by: Francis Dinh <normandy@biribiri.dev>
Reviewed-on: FoundKeyGang/FoundKey#221
2022-11-02 21:42:51 +00:00
0db0db9a87
backend: fix types in getRedisFamily 2022-10-31 18:39:05 -04:00
6df2f7c55c
server: refactor finding delete-cascaded notes
Remove the several filter functions in different places by filtering
directly in the database.

Instead of a QueryBuilder, use the plain find function.

Refactor a for loop awaiting several promises individually, use
Array.map and await Promise.all to make better use of promises.
2022-10-31 20:57:45 +01:00
ac240eb58d
server: translate/add comments 2022-10-31 20:57:18 +01:00
48 changed files with 494 additions and 438 deletions

View file

@ -297,8 +297,11 @@ PostgreSQL array indices **start at 1**.
When `IN` is performed on a column that may contain `NULL` values, use `OR` or similar to handle `NULL` values.
### creating migrations
In `packages/backend`, run:
First make changes to the entity files in `packages/backend/src/models/entities/`.
Then, in `packages/backend`, run:
```sh
yarn build
npx typeorm migration:generate -d ormconfig.js -o <migration name>
```

View file

@ -41,7 +41,7 @@ git merge tags/v13.0.0-preview2 --squash
```
## Making sure modern Yarn works
Foundkey uses Yarn 3.2.3 instead of 1.x. To make sure the `yarn` command will work going forward, run `corepack enable`.
Foundkey uses Modern Yarn instead of Classic (1.x). To make sure the `yarn` command will work going forward, run `corepack enable`.
## Rebuilding and running database migrations
This will be pretty much the same as a regular update of Misskey. Note that `yarn install` may take a while since dependency versions have been updated or removed and we use a newer version of Yarn.

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"

View file

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

View file

@ -0,0 +1,44 @@
export class sync1667503570994 {
name = 'sync1667503570994'
async up(queryRunner) {
await Promise.all([
// the migration for renote mutes added the index to the wrong table
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_createdAt"`),
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muteeId"`),
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muterId"`),
queryRunner.query(`CREATE INDEX "IDX_d1259a2c2b7bb413ff449e8711" ON "renote_muting" ("createdAt") `),
queryRunner.query(`CREATE INDEX "IDX_7eac97594bcac5ffcf2068089b" ON "renote_muting" ("muteeId") `),
queryRunner.query(`CREATE INDEX "IDX_7aa72a5fe76019bfe8e5e0e8b7" ON "renote_muting" ("muterId") `),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS 'The created date of the Muting.'`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS 'The mutee user ID.'`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS 'The muter user ID.'`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" SET NOT NULL`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" SET DEFAULT ''`),
queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d801c609cec4e9eb4b6b4490c" ON "renote_muting" ("muterId", "muteeId") `),
queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`),
queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`),
]);
}
async down(queryRunner) {
await Promise.all([
queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d"`),
queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6"`),
queryRunner.query(`DROP INDEX "public"."IDX_0d801c609cec4e9eb4b6b4490c"`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" DROP DEFAULT`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" DROP NOT NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS NULL`),
queryRunner.query(`DROP INDEX "public"."IDX_7aa72a5fe76019bfe8e5e0e8b7"`),
queryRunner.query(`DROP INDEX "public"."IDX_7eac97594bcac5ffcf2068089b"`),
queryRunner.query(`DROP INDEX "public"."IDX_d1259a2c2b7bb413ff449e8711"`),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `),
]);
}
}

View file

@ -96,7 +96,7 @@
"rss-parser": "3.12.0",
"sanitize-html": "2.7.0",
"semver": "7.3.7",
"sharp": "0.30.7",
"sharp": "0.31.2",
"speakeasy": "2.0.0",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",

View file

@ -10,7 +10,7 @@ function getRedisFamily(family?: string | number): number {
dual: 0,
};
if (typeof family === 'string' && family in familyMap) {
return familyMap[family];
return familyMap[family as keyof typeof familyMap];
} else if (typeof family === 'number' && Object.values(familyMap).includes(family)) {
return family;
}

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

@ -6,7 +6,7 @@ import { DAY } from '@/const.js';
// Threshold from last contact after which an instance will be considered
// "dead" and should no longer get activities delivered to it.
const deadThreshold = 30 * DAY;
const deadThreshold = 7 * DAY;
/**
* Returns the subset of hosts which should be skipped.
@ -33,7 +33,8 @@ export async function skippedInstances(hosts: Array<Instace['host']>): Array<Ins
})
.andWhere(new Brackets(qb => { qb
.where('instance.isSuspended')
.orWhere('instance.lastCommunicatedAt < :deadTime', { deadTime });
.orWhere('instance.lastCommunicatedAt < :deadTime', { deadTime })
.orWhere('instance.latestStatus = 410');
}))
.select('host')
.getRawMany()

View file

@ -62,7 +62,8 @@ export class DriveFile {
public size: number;
@Column('varchar', {
length: 512, nullable: true,
length: 2048,
nullable: true,
comment: 'The comment of the DriveFile.',
})
public comment: string | null;

View file

@ -7,7 +7,7 @@ export class Instance {
public id: string;
/**
*
* Date and time this instance was first seen.
*/
@Index()
@Column('timestamp with time zone', {
@ -16,7 +16,7 @@ export class Instance {
public caughtAt: Date;
/**
*
* Hostname
*/
@Index({ unique: true })
@Column('varchar', {
@ -26,7 +26,7 @@ export class Instance {
public host: string;
/**
*
* Number of users on this instance.
*/
@Column('integer', {
default: 0,
@ -35,7 +35,7 @@ export class Instance {
public usersCount: number;
/**
* 稿
* Number of notes on this instance.
*/
@Column('integer', {
default: 0,
@ -44,7 +44,7 @@ export class Instance {
public notesCount: number;
/**
*
* Number of local users who are followed by users from this instance.
*/
@Column('integer', {
default: 0,
@ -52,7 +52,7 @@ export class Instance {
public followingCount: number;
/**
*
* Number of users from this instance who are followed by local users.
*/
@Column('integer', {
default: 0,
@ -60,7 +60,7 @@ export class Instance {
public followersCount: number;
/**
*
* Timestamp of the latest outgoing HTTP request.
*/
@Column('timestamp with time zone', {
nullable: true,
@ -68,7 +68,7 @@ export class Instance {
public latestRequestSentAt: Date | null;
/**
* HTTPステータスコード
* HTTP status code that was received for the last outgoing HTTP request.
*/
@Column('integer', {
nullable: true,
@ -76,7 +76,7 @@ export class Instance {
public latestStatus: number | null;
/**
*
* Timestamp of the latest incoming HTTP request.
*/
@Column('timestamp with time zone', {
nullable: true,
@ -84,13 +84,13 @@ export class Instance {
public latestRequestReceivedAt: Date | null;
/**
*
* Timestamp of last communication with this instance (incoming or outgoing).
*/
@Column('timestamp with time zone')
public lastCommunicatedAt: Date;
/**
*
* Whether this instance seems unresponsive.
*/
@Column('boolean', {
default: false,
@ -98,7 +98,7 @@ export class Instance {
public isNotResponding: boolean;
/**
*
* Whether sending activities to this instance has been suspended.
*/
@Index()
@Column('boolean', {

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

@ -62,8 +62,8 @@ export default async (job: Bull.Job<DeliverJobData>) => {
if (res instanceof StatusError) {
// 4xx
if (res.isClientError) {
// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
// 何回再送しても成功することはないということなのでエラーにはしないでおく
// A client error means that something is wrong with the request we are making,
// which means that retrying it makes no sense.
return `${res.statusCode} ${res.statusMessage}`;
}

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,10 +3,13 @@ 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);
const appCache = new Cache<App>(
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
export class AuthenticationError extends Error {
constructor(message: string) {
@ -39,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');
@ -64,14 +66,13 @@ 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);
// can't authorize remote users
if (!Users.isLocalUser(user)) return [null, null];
if (accessToken.appId) {
const app = await appCache.fetch(accessToken.appId,
() => Apps.findOneByOrFail({ id: accessToken.appId! }));
const app = await appCache.fetch(accessToken.appId);
return [user, {
id: accessToken.id,

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,6 +2,7 @@ import { Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { unique } from '@/prelude/array.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js';
export const meta = {
@ -21,10 +22,14 @@ export const paramDef = {
properties: {
name: { type: 'string' },
description: { type: 'string' },
permission: { type: 'array', uniqueItems: true, items: {
type: 'string',
// FIXME: add enum of possible permissions
} },
permission: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: kinds,
},
},
callbackUrl: { type: 'string', nullable: true },
},
required: ['name', 'description', 'permission'],

View file

@ -32,10 +32,7 @@ app.use(async (ctx, next) => {
await next();
});
app.use(bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: ctx => !ctx.is('multipart/form-data'),
}));
app.use(bodyParser());
// Init multer instance
const upload = multer({

View file

@ -11,48 +11,51 @@ import { getIpHash } from '@/misc/get-ip-hash.js';
import signin from '../common/signin.js';
import { verifyLogin, hash } from '../2fa.js';
import { limiter } from '../limiter.js';
import { ApiError } from '../error.js';
export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url);
ctx.set('Access-Control-Allow-Credentials', 'true');
const body = ctx.request.body as any;
const username = body['username'];
const password = body['password'];
const token = body['token'];
const { username, password, token } = body;
function error(status: number, error: { id: string }) {
ctx.status = status;
ctx.body = { error };
// taken from @server/api/api-handler.ts
function error (e: ApiError): void {
ctx.status = e.httpStatusCode;
if (e.httpStatusCode === 401) {
ctx.response.set('WWW-Authenticate', 'Bearer');
}
ctx.body = {
error: {
message: e!.message,
code: e!.code,
...(e!.info ? { info: e!.info } : {}),
endpoint: endpoint.name,
},
};
}
try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip));
} catch (err) {
ctx.status = 429;
ctx.body = {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
error(new ApiError('RATE_LIMIT_EXCEEDED'));
return;
}
if (typeof username !== 'string') {
ctx.status = 400;
error(new ApiError('INVALID_PARAM', { param: 'username', reason: 'not a string' }));
return;
}
if (typeof password !== 'string') {
ctx.status = 400;
error(new ApiError('INVALID_PARAM', { param: 'password', reason: 'not a string' }));
return;
}
if (token != null && typeof token !== 'string') {
ctx.status = 400;
error(new ApiError('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' }));
return;
}
@ -63,16 +66,12 @@ export default async (ctx: Koa.Context) => {
}) as ILocalUser;
if (user == null) {
error(404, {
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
});
error(new ApiError('NO_SUCH_USER'));
return;
}
if (user.isSuspended) {
error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
});
error(new ApiError('SUSPENDED'));
return;
}
@ -81,7 +80,7 @@ export default async (ctx: Koa.Context) => {
// Compare password
const same = await bcrypt.compare(password, profile.password!);
async function fail(status?: number, failure?: { id: string }) {
async function fail(): void {
// Append signin history
await Signins.insert({
id: genId(),
@ -92,7 +91,7 @@ export default async (ctx: Koa.Context) => {
success: false,
});
error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
error(new ApiError('ACCESS_DENIED'));
}
if (!profile.twoFactorEnabled) {
@ -100,18 +99,14 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
await fail();
return;
}
}
if (token) {
if (!same) {
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
await fail();
return;
}
@ -126,16 +121,12 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
});
await fail();
return;
}
} else if (body.credentialId) {
if (!same && !profile.usePasswordLessLogin) {
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
await fail();
return;
}
@ -149,9 +140,7 @@ export default async (ctx: Koa.Context) => {
});
if (!challenge) {
await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
await fail();
return;
}
@ -161,9 +150,7 @@ export default async (ctx: Koa.Context) => {
});
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
await fail();
return;
}
@ -177,9 +164,7 @@ export default async (ctx: Koa.Context) => {
});
if (!securityKey) {
await fail(403, {
id: '66269679-aeaf-4474-862b-eb761197e046',
});
await fail();
return;
}
@ -196,16 +181,12 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail(403, {
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
});
await fail();
return;
}
} else {
if (!same && !profile.usePasswordLessLogin) {
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
await fail();
return;
}
@ -214,9 +195,7 @@ export default async (ctx: Koa.Context) => {
});
if (keys.length === 0) {
await fail(403, {
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
});
await fail();
return;
}

View file

@ -3,7 +3,6 @@ import { IsNull, MoreThan } from 'typeorm';
import config from '@/config/index.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Users, Notes } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
const router = new Router();
@ -18,7 +17,33 @@ export const links = [{
href: config.url + nodeinfo2_0path,
}];
const nodeinfo2 = async () => {
const repository = 'https://akkoma.dev/FoundKeyGang/FoundKey';
type NodeInfo2Base = {
software: {
name: string;
version: string;
repository?: string; // Not used in NodeInfo 2.0; used in 2.1
};
protocols: string[];
services: {
inbound: string[];
outbound: string[];
};
openRegistrations: boolean;
usage: {
users: {
total: number;
activeHalfyear: number;
activeMonth: number;
};
localPosts: number;
localComments: number;
};
metadata: Record<string, any>;
};
const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
const now = Date.now();
const [
meta,
@ -40,7 +65,7 @@ const nodeinfo2 = async () => {
software: {
name: 'foundkey',
version: config.version,
repository: 'https://akkoma.dev/FoundKeyGang/FoundKey',
repository,
},
protocols: ['activitypub'],
services: {
@ -62,7 +87,7 @@ const nodeinfo2 = async () => {
},
langs: meta.langs,
tosUrl: meta.ToSUrl,
repositoryUrl: meta.repositoryUrl,
repositoryUrl: repository,
feedbackUrl: 'ircs://irc.akkoma.dev/foundkey',
disableRegistration: meta.disableRegistration,
disableLocalTimeline: meta.disableLocalTimeline,
@ -82,17 +107,15 @@ const nodeinfo2 = async () => {
};
};
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
router.get(nodeinfo2_1path, async ctx => {
const base = await cache.fetch(null, () => nodeinfo2());
const base = await nodeinfo2();
ctx.body = { version: '2.1', ...base };
ctx.set('Cache-Control', 'public, max-age=600');
});
router.get(nodeinfo2_0path, async ctx => {
const base = await cache.fetch(null, () => nodeinfo2());
const base = await nodeinfo2();
delete base.software.repository;

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

@ -17,14 +17,14 @@ import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js
import { deliverToRelays } from '../relay.js';
/**
* 稿
* @param user 稿
* @param note 稿
* Delete your note.
* @param user author
* @param note note to be deleted
*/
export default async function(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false): Promise<void> {
const deletedAt = new Date();
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
// If this is the only renote of this note by this user
if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
Notes.decrement({ id: note.renoteId }, 'renoteCount', 1);
Notes.decrement({ id: note.renoteId }, 'score', 1);
@ -37,7 +37,7 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
if (!quiet) {
publishNoteStream(note.id, 'deleted', { deletedAt });
//#region ローカルの投稿なら削除アクティビティを配送
// deliver delete activity of note itself for local posts
if (Users.isLocalUser(user) && !note.localOnly) {
let renote: Note | null = null;
@ -53,17 +53,14 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
deliverToConcerned(user, note, content);
}
// also deliever delete activity to cascaded notes
const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only 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);
}
//#endregion
// 統計を更新
// update statistics
notesChart.update(note, false);
perUserNotesChart.update(user, note, false);
@ -81,26 +78,43 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
});
}
/**
* Search for notes that will be affected by ON CASCADE DELETE.
* However, only notes for which it is relevant to deliver delete activities are searched.
* This means only local notes that are not local-only are searched.
*/
async function findCascadingNotes(note: Note): Promise<Note[]> {
const cascadingNotes: Note[] = [];
const recursive = async (noteId: string): Promise<void> => {
const query = Notes.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
q.where('note.renoteId = :noteId', { noteId })
.andWhere('note.text IS NOT NULL');
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
for (const reply of replies) {
// FIXME: use note_replies SQL function? Unclear what to do with 2nd and 3rd parameter, maybe rewrite the function.
const replies = await Notes.find({
where: [{
replyId: noteId,
localOnly: false,
userHost: IsNull(),
}, {
renoteId: noteId,
text: Not(IsNull()),
localOnly: false,
userHost: IsNull(),
}],
relations: {
user: true,
},
});
await Promise.all(replies.map(reply => {
// only add unique notes
if (cascadingNotes.find((x) => x.id == reply.id) != null) return;
cascadingNotes.push(reply);
await recursive(reply.id);
}
return recursive(reply.id);
}));
};
await recursive(note.id);
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
return cascadingNotes;
}
async function getMentionedRemoteUsers(note: Note): Promise<IRemoteUser[]> {

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

@ -32,12 +32,8 @@ export async function signout() {
const registration = await navigator.serviceWorker.ready;
const push = await registration.pushManager.getSubscription();
if (push) {
await fetch(`${apiUrl}/sw/unregister`, {
method: 'POST',
body: JSON.stringify({
i: $i.token,
endpoint: push.endpoint,
}),
await api('sw/unregister', {
endpoint: push.endpoint,
});
}
}
@ -79,13 +75,7 @@ export async function removeAccount(id: Account['id']) {
function fetchAccount(token: string): Promise<Account> {
return new Promise((done, fail) => {
// Fetch user
fetch(`${apiUrl}/i`, {
method: 'POST',
body: JSON.stringify({
i: token,
}),
})
.then(res => res.json())
api('i', {}, token)
.then(res => {
if (res.error) {
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {

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

@ -32,7 +32,10 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s
body: JSON.stringify(data),
credentials: 'omit',
cache: 'no-cache',
headers: authorization ? { authorization } : {},
headers: {
'content-type': 'application/json',
...(authorization ? { authorization } : {}),
},
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -69,7 +72,10 @@ export const apiGet = ((endpoint: string, data: Record<string, any> = {}, token?
method: 'GET',
credentials: 'omit',
cache: 'default',
headers: authorization ? { authorization } : {},
headers: {
'content-type': 'application/json',
...(authorization ? { authorization } : {}),
},
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -97,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(),
});
});
@ -135,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

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

@ -7,14 +7,14 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import { defineAsyncComponent } from 'vue';
import FormLink from '@/components/form/link.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const isDesktop = ref(window.innerWidth >= 1100);
const isDesktop = window.innerWidth >= 1100;
function generateToken() {
os.popup(defineAsyncComponent(() => import('@/components/token-generate-window.vue')), {}, {

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

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

View file

@ -3743,7 +3743,7 @@ __metadata:
rss-parser: 3.12.0
sanitize-html: 2.7.0
semver: 7.3.7
sharp: 0.30.7
sharp: 0.31.2
speakeasy: 2.0.0
strict-event-emitter-types: 2.0.0
stringz: 2.1.0
@ -15189,6 +15189,17 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.3.8":
version: 7.3.8
resolution: "semver@npm:7.3.8"
dependencies:
lru-cache: ^6.0.0
bin:
semver: bin/semver.js
checksum: ba9c7cbbf2b7884696523450a61fee1a09930d888b7a8d7579025ad93d459b2d1949ee5bbfeb188b2be5f4ac163544c5e98491ad6152df34154feebc2cc337c1
languageName: node
linkType: hard
"serialize-javascript@npm:6.0.0":
version: 6.0.0
resolution: "serialize-javascript@npm:6.0.0"
@ -15250,20 +15261,20 @@ __metadata:
languageName: node
linkType: hard
"sharp@npm:0.30.7":
version: 0.30.7
resolution: "sharp@npm:0.30.7"
"sharp@npm:0.31.2":
version: 0.31.2
resolution: "sharp@npm:0.31.2"
dependencies:
color: ^4.2.3
detect-libc: ^2.0.1
node-addon-api: ^5.0.0
node-gyp: latest
prebuild-install: ^7.1.1
semver: ^7.3.7
semver: ^7.3.8
simple-get: ^4.0.1
tar-fs: ^2.1.1
tunnel-agent: ^0.6.0
checksum: bbc63ca3c7ea8a5bff32cd77022cfea30e25a03f5bd031e935924bf6cf0e11e3388e8b0e22b3137bf8816aa73407f1e4fbeb190f3a35605c27ffca9f32b91601
checksum: 076717b7a073ea47bb522ff2931b74b6608daeb6f7ae334e4848d47fdf4d23bcb18cd49044fd5fb27ef27a1a4aa87d141894d67d1c4bb15a6e2e63cf4dbe329e
languageName: node
linkType: hard