Compare commits

..

2 commits

Author SHA1 Message Date
8a90dfa60b
fix: perform visibility query in second stage 2022-10-28 23:39:39 +02:00
f5ea7b6d5b
handle note visibility in SQL
This allows to check visibility recursively, which should hopefully
solve problems with timelines not showing up properly.
2022-10-28 23:37:15 +02:00
86 changed files with 767 additions and 841 deletions

View file

@ -297,11 +297,8 @@ 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
First make changes to the entity files in `packages/backend/src/models/entities/`.
Then, in `packages/backend`, run:
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 Modern Yarn instead of Classic (1.x). To make sure the `yarn` command will work going forward, run `corepack enable`.
Foundkey uses Yarn 3.2.3 instead of 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,9 +190,7 @@ 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,53 @@
export class noteVisibilityFunction1662132062000 {
name = 'noteVisibilityFunction1662132062000';
async up(queryRunner) {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION note_visible(note_id varchar, user_id varchar) RETURNS BOOLEAN
LANGUAGE SQL
STABLE
CALLED ON NULL INPUT
AS $$
SELECT CASE
WHEN note_id IS NULL THEN TRUE
WHEN NOT EXISTS (SELECT 1 FROM note WHERE id = note_id) THEN FALSE
WHEN user_id IS NULL THEN (
-- simplified check without logged in user
SELECT
visibility IN ('public', 'home')
-- check reply / renote recursively
AND note_visible("replyId", NULL)
AND note_visible("renoteId", NULL)
FROM note WHERE note.id = note_id
) ELSE (
SELECT
(
visibility IN ('public', 'home')
OR
user_id = "userId"
OR
user_id = ANY("visibleUserIds")
OR
user_id = ANY("mentions")
OR (
visibility = 'followers'
AND
EXISTS (
SELECT 1 FROM following WHERE "followeeId" = "userId" AND "followerId" = user_id
)
)
)
-- check reply / renote recursively
AND note_visible("replyId", user_id)
AND note_visible("renoteId", user_id)
FROM note WHERE note.id = note_id
)
END;
$$;
`);
}
async down(queryRunner) {
await queryRunner.query('DROP FUNCTION note_visible');
}
}

View file

@ -1,44 +0,0 @@
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

@ -15,8 +15,8 @@
"test": "npm run mocha"
},
"dependencies": {
"@bull-board/api": "^4.3.1",
"@bull-board/koa": "^4.3.1",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "4.0.0",
"@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.11.0",
"@koa/cors": "3.1.0",
@ -96,7 +96,7 @@
"rss-parser": "3.12.0",
"sanitize-html": "2.7.0",
"semver": "7.3.7",
"sharp": "0.31.2",
"sharp": "0.30.7",
"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 as keyof typeof familyMap];
return familyMap[family];
} else if (typeof family === 'number' && Object.values(familyMap).includes(family)) {
return family;
}

View file

@ -62,21 +62,22 @@ 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) {
// restore the host name part
//#region ホスト名部分が省略されているので復元する
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,12 +1,10 @@
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: number, fetcher: Cache<T>['fetcher']) {
constructor(lifetime: Cache<never>['lifetime']) {
this.cache = new Map();
this.lifetime = lifetime;
this.fetcher = fetcher;
}
public set(key: string | null, value: T): void {
@ -19,13 +17,10 @@ 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;
}
@ -34,22 +29,52 @@ export class Cache<T> {
}
/**
* 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.
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
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);
// don't cache undefined
if (value !== undefined)
this.set(key, value);
return value;
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;
}
}
// Cache MISS
const value = await fetcher();
this.set(key, value);
return value;
}
/**
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
this.set(key, value);
}
return value;
}
}

View file

@ -3,26 +3,22 @@ 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'][]>(
5 * MINUTE,
(blockerId) => Blockings.findBy({ blockerId }).then(res => res.map(x => x.blockeeId)),
);
const blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
// designation for users you follow, list users and groups is disabled for performance reasons
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
/**
* either noteUserFollowers or antennaUserFollowing must be specified
* noteUserFollowers / antennaUserFollowing
*/
export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
if (note.visibility === 'specified') return false;
// skip if the antenna creator is blocked by the note author
const blockings = await blockingCache.fetch(noteUser.id);
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (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;
await getMeta();
return await db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
return cache;
const meta = metas[0];
if (meta) {
cache = meta;
return meta;
} else {
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
const saved = await transactionalEntityManager
.upsert(
Meta,
{
id: 'x',
},
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
cache = saved;
return saved;
}
});
}
setInterval(() => {
fetchMeta(true).then(meta => {
cache = meta;
});
}, 1000 * 10);

View file

@ -3,11 +3,8 @@ 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,
(userId) => UserKeypairs.findOneByOrFail({ userId }),
);
const cache = new Cache<UserKeypair>(Infinity);
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await cache.fetch(userId);
return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
}

View file

@ -4,27 +4,14 @@ 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';
/**
* 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;
},
);
const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
/**
* Information needed to attach in ActivityPub
*
*/
type PopulatedEmoji = {
name: string;
@ -49,22 +36,28 @@ function parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const name = match[1];
// ホスト正規化
const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
return { name, host };
}
/**
* Resolve emoji information from ActivityPub attachment.
* @param emojiName custom emoji names attached to notes, user profiles or in rections. Colons should not be included. Localhost is denote by @. (see also `decodeReaction`)
* @param noteUserHost host that the content is from, to default to
* @returns emoji information. `null` means not found.
*
* @param emojiName (:, @. (decodeReactionで可能))
* @param noteUserHost
* @returns , nullは未マッチを意味する
*/
export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null;
const emoji = await cache.fetch(`${host ?? ''}:${name}`);
const queryOrNull = async () => (await Emojis.findOneBy({
name,
host: host ?? IsNull(),
})) || null;
const emoji = await cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null;
@ -79,7 +72,7 @@ export async function populateEmoji(emojiName: string, noteUserHost: string | nu
}
/**
* Retrieve list of emojis from the cache. Uncached emoji are dropped.
* (, )
*/
export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
@ -110,20 +103,11 @@ export function aggregateNoteEmojis(notes: Note[]) {
}
/**
* Query list of emojis in bulk and add them to the cache.
*
*/
export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
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 notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null);
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({
@ -131,14 +115,11 @@ export async function prefetchEmojis(emojis: { name: string; host: string | null
host: host ?? IsNull(),
});
}
await Emojis.find({
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
where: emojisQuery,
select: ['name', 'host', 'originalUrl', 'publicUrl'],
}).then(emojis => {
// store all emojis into the cache
emojis.forEach(emoji => {
cache.set(`${emoji.host ?? ''}:${emoji.name}`, emoji);
});
});
}) : [];
for (const emoji of _emojis) {
cache.set(`${emoji.name} ${emoji.host}`, emoji);
}
}

View file

@ -1,5 +1,5 @@
import { Note } from '@/models/entities/note.js';
export function isPureRenote(note: Note): note is Note & { renoteId: string, text: null, fileIds: null | never[], hasPoll: false } {
export function isPureRenote(note: Note): boolean {
return note.renoteId != null && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !note.hasPoll;
}

View file

@ -1,55 +0,0 @@
import { Brackets } from 'typeorm';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Instances } from '@/models/index.js';
import { Instance } from '@/models/entities/instance.js';
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 = 7 * DAY;
/**
* Returns the subset of hosts which should be skipped.
*
* @param hosts array of punycoded instance hosts
* @returns array of punycoed instance hosts that should be skipped (subset of hosts parameter)
*/
export async function skippedInstances(hosts: Array<Instace['host']>): Array<Instance['host']> {
// first check for blocked instances since that info may already be in memory
const { blockedHosts } = await fetchMeta();
const skipped = hosts.filter(host => blockedHosts.includes(host));
// if possible return early and skip accessing the database
if (skipped.length === hosts.length) return hosts;
const deadTime = new Date(Date.now() - deadThreshold);
return skipped.concat(
await Instances.createQueryBuilder('instance')
.where('instance.host in (:...hosts)', {
// don't check hosts again that we already know are suspended
// also avoids adding duplicates to the list
hosts: hosts.filter(host => !skipped.includes(host)),
})
.andWhere(new Brackets(qb => { qb
.where('instance.isSuspended')
.orWhere('instance.lastCommunicatedAt < :deadTime', { deadTime })
.orWhere('instance.latestStatus = 410');
}))
.select('host')
.getRawMany()
);
}
/**
* Returns whether a specific host (punycoded) should be skipped.
* Convenience wrapper around skippedInstances which should only be used if there is a single host to check.
* If you have multiple hosts, consider using skippedInstances instead to do a bulk check.
*
* @param host punycoded instance host
* @returns whether the given host should be skipped
*/
export async function shouldSkipInstance(host: Instance['host']): boolean {
const skipped = await skippedInstances([host]);
return skipped.length > 0;
}

View file

@ -62,8 +62,7 @@ export class DriveFile {
public size: number;
@Column('varchar', {
length: 2048,
nullable: true,
length: 512, 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 status code that was received for the last outgoing HTTP request.
* HTTPステータスコード
*/
@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

@ -77,7 +77,7 @@ async function populateMyReaction(note: Note, meId: User['id'], _hint_?: {
export const NoteRepository = db.getRepository(Note).extend({
async isVisibleForMe(note: Note, meId: User['id'] | null): Promise<boolean> {
// This code must always be synchronized with the checks in generateVisibilityQuery.
// This code must always be synchronized with the `note_visible` SQL function.
// visibility が specified かつ自分が指定されていなかったら非表示
if (note.visibility === 'specified') {
if (meId == null) {

View file

@ -6,16 +6,13 @@ 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, HOUR } from '@/const.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } 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>(
3 * HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
@ -30,7 +27,7 @@ const ajv = new Ajv();
const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
const passwordSchema = { type: 'string', minLength: 1 } as const;
const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 2048 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const;
const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
@ -312,15 +309,17 @@ export const UserRepository = db.getRepository(User).extend({
isModerator: user.isModerator || falsy,
isBot: user.isBot || falsy,
isCat: user.isCat || falsy,
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,
}),
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,
emojis: populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),

View file

@ -6,20 +6,39 @@ import Logger from '@/services/logger.js';
import { Instances } from '@/models/index.js';
import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js';
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { toPuny } from '@/misc/convert-host.js';
import { Cache } from '@/misc/cache.js';
import { Instance } from '@/models/entities/instance.js';
import { StatusError } from '@/misc/fetch.js';
import { shouldSkipInstance } from '@/misc/skipped-instances.js';
import { DeliverJobData } from '@/queue/types.js';
import { LessThan } from 'typeorm';
import { DAY } from '@/const.js';
const logger = new Logger('deliver');
let latest: string | null = null;
const deadThreshold = 30 * DAY;
export default async (job: Bull.Job<DeliverJobData>) => {
const { host } = new URL(job.data.to);
const puny = toPuny(host);
if (await shouldSkipInstance(puny)) return 'skip';
// ブロックしてたら中断
const meta = await fetchMeta();
if (meta.blockedHosts.includes(puny)) {
return 'skip (blocked)';
}
const deadTime = new Date(Date.now() - deadThreshold);
const isSuspendedOrDead = await Instances.countBy([
{ host: puny, isSuspended: true },
{ host: puny, lastCommunicatedAt: LessThan(deadTime) },
]);
if (isSuspendedOrDead) {
return 'skip (suspended or dead)';
}
try {
if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) {
@ -62,8 +81,8 @@ export default async (job: Bull.Job<DeliverJobData>) => {
if (res instanceof StatusError) {
// 4xx
if (res.isClientError) {
// A client error means that something is wrong with the request we are making,
// which means that retrying it makes no sense.
// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
// 何回再送しても成功することはないということなのでエラーにはしないでおく
return `${res.statusCode} ${res.statusMessage}`;
}

View file

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

View file

@ -2,7 +2,6 @@ import { IsNull, Not } from 'typeorm';
import { ILocalUser, IRemoteUser, User } from '@/models/entities/user.js';
import { Users, Followings } from '@/models/index.js';
import { deliver } from '@/queue/index.js';
import { skippedInstances } from '@/misc/skipped-instances.js';
//#region types
interface IRecipe {
@ -119,18 +118,26 @@ export default class DeliverManager {
if (this.recipes.some(r => isFollowers(r))) {
// followers deliver
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();
// 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;
}[];
followers.forEach(({ inbox }) => inboxes.add(inbox));
for (const following of followers) {
const inbox = following.followerSharedInbox || following.followerInbox;
inboxes.add(inbox);
}
}
this.recipes.filter((recipe): recipe is IDirectRecipe =>
@ -143,19 +150,8 @@ export default class DeliverManager {
)
.forEach(recipe => inboxes.add(recipe.to.inbox!));
const instancesToSkip = await skippedInstances(
// get (unique) list of hosts
Array.from(new Set(
Array.from(inboxes)
.map(inbox => new URL(inbox).host)
))
);
// deliver
for (const inbox of inboxes) {
// skip instances as indicated
if (instancesToSkip.includes(new URL(inbox).host)) continue;
deliver(this.actor, this.activity, inbox);
}
}

View file

@ -34,7 +34,7 @@ export default async (user: { id: User['id'] }, url: string, object: any) => {
* @param user http-signature user
* @param url URL to fetch
*/
export async function signedGet(url: string, user: { id: User['id'] }): Promise<any> {
export async function signedGet(url: string, user: { id: User['id'] }) {
const keypair = await getUserKeypair(user.id);
const req = createSignedGet({

View file

@ -23,6 +23,8 @@ import Featured from './activitypub/featured.js';
// Init router
const router = new Router();
//#region Routing
function inbox(ctx: Router.RouterContext) {
let signature;
@ -43,8 +45,6 @@ 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,15 +94,6 @@ 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(),
@ -194,6 +185,7 @@ router.get('/@:user', async (ctx, next) => {
await userInfo(ctx, user);
});
//#endregion
// emoji
router.get('/emojis/:emoji', async ctx => {

View file

@ -3,13 +3,10 @@ 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 { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>(
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
const appCache = new Cache<App>(Infinity);
export class AuthenticationError extends Error {
constructor(message: string) {
@ -18,8 +15,8 @@ export class AuthenticationError extends Error {
}
}
export default async (authorization: string | null | undefined, bodyToken: string | null | undefined): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => {
let maybeToken: string | null = null;
export default async (authorization: string | null | undefined, bodyToken: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => {
let token: string | null = null;
// check if there is an authorization header set
if (authorization != null) {
@ -30,19 +27,19 @@ export default async (authorization: string | null | undefined, bodyToken: strin
// check if OAuth 2.0 Bearer tokens are being used
// Authorization schemes are case insensitive
if (authorization.substring(0, 7).toLowerCase() === 'bearer ') {
maybeToken = authorization.substring(7);
token = authorization.substring(7);
} else {
throw new AuthenticationError('unsupported authentication scheme');
}
} else if (bodyToken != null) {
maybeToken = bodyToken;
token = bodyToken;
} else {
return [null, null];
}
const token: string = maybeToken;
if (isNativeToken(token)) {
const user = await localUserByNativeTokenCache.fetch(token);
const user = await localUserByNativeTokenCache.fetch(token,
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>);
if (user == null) {
throw new AuthenticationError('unknown token');
@ -66,13 +63,14 @@ export default async (authorization: string | null | undefined, bodyToken: strin
lastUsedAt: new Date(),
});
const user = await userByIdCache.fetch(accessToken.userId);
// can't authorize remote users
if (!Users.isLocalUser(user)) return [null, null];
const user = await localUserByIdCache.fetch(accessToken.userId,
() => Users.findOneBy({
id: accessToken.userId,
}) as Promise<ILocalUser>);
if (accessToken.appId) {
const app = await appCache.fetch(accessToken.appId);
const app = await appCache.fetch(accessToken.appId,
() => Apps.findOneByOrFail({ id: accessToken.appId! }));
return [user, {
id: accessToken.id,

View file

@ -1,42 +1,17 @@
import { Brackets, SelectQueryBuilder } from 'typeorm';
import { SelectQueryBuilder } from 'typeorm';
import { User } from '@/models/entities/user.js';
import { Followings } from '@/models/index.js';
import { Note } from '@/models/entities/note.js';
import { Notes } from '@/models/index.js';
export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where("note.visibility = 'public'")
.orWhere("note.visibility = 'home'");
}));
export function visibilityQuery(q: SelectQueryBuilder<Note>, meId?: User['id'] | null = null): SelectQueryBuilder<Note> {
const superQuery = Notes.createQueryBuilder()
.from(() => q, 'note');
if (meId == null) {
superQuery.where('note_visible(note.id, null);');
} else {
const followingQuery = Followings.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { qb
// 公開投稿である
.where(new Brackets(qb => { qb
.where("note.visibility = 'public'")
.orWhere("note.visibility = 'home'");
}))
// または 自分自身
.orWhere('note.userId = :meId')
// または 自分宛て
.orWhere(':meId = ANY(note.visibleUserIds)')
.orWhere(':meId = ANY(note.mentions)')
.orWhere(new Brackets(qb => { qb
// または フォロワー宛ての投稿であり、
.where("note.visibility = 'followers'")
.andWhere(new Brackets(qb => { qb
// 自分がフォロワーである
.where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ
.orWhere('note.replyUserId = :meId');
}));
}));
}));
q.setParameters({ meId: me.id });
superQuery.where('note_visible(note.id, :meId)', { meId });
}
return q;
}

View file

@ -2,7 +2,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
import { User } from '@/models/entities/user.js';
import { Note } from '@/models/entities/note.js';
import { Notes, Users } from '@/models/index.js';
import { generateVisibilityQuery } from './generate-visibility-query.js';
import { visibilityQuery } from './generate-visibility-query.js';
/**
* Get note for API processing, taking into account visibility.
@ -13,9 +13,7 @@ export async function getNote(noteId: Note['id'], me: { id: User['id'] } | null)
id: noteId,
});
generateVisibilityQuery(query, me);
const note = await query.getOne();
const note = await visibilityQuery(query, me).getOne();
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');

View file

@ -622,7 +622,7 @@ export interface IEndpointMeta {
readonly tags?: ReadonlyArray<string>;
readonly errors?: ReadonlyArray<keyof typeof errors>;
readonly errors?: Array<keyof errors>;
readonly res?: Schema;

View file

@ -1,5 +1,6 @@
import { Meta } from '@/models/entities/meta.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { fetchMeta, setMeta } from '@/misc/fetch-meta.js';
import { db } from '@/db/postgre.js';
import define from '../../define.js';
export const meta = {
@ -374,10 +375,20 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = ps.deeplIsPro;
}
const meta = await fetchMeta();
await setMeta({
...meta,
...set,
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);
}
});
insertModerationLog(me, 'updateMeta');

View file

@ -1,7 +1,7 @@
import { readNote } from '@/services/note/read.js';
import { Antennas, Notes, AntennaNotes } from '@/models/index.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
import define from '../../define.js';
@ -65,11 +65,10 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id });
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
const notes = await query
const notes = await visibilityQuery(query, user)
.take(ps.limit)
.getMany();

View file

@ -2,7 +2,6 @@ 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 = {
@ -22,14 +21,10 @@ export const paramDef = {
properties: {
name: { type: 'string' },
description: { type: 'string' },
permission: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: kinds,
},
},
permission: { type: 'array', uniqueItems: true, items: {
type: 'string',
// FIXME: add enum of possible permissions
} },
callbackUrl: { type: 'string', nullable: true },
},
required: ['name', 'description', 'permission'],

View file

@ -1,7 +1,7 @@
import { ClipNotes, Clips, Notes } from '@/models/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { ApiError } from '../../error.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -65,12 +65,11 @@ export default define(meta, paramDef, async (ps, user) => {
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
if (user) {
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
const notes = await query
const notes = await visibilityQuery(query, user)
.take(ps.limit)
.getMany();

View file

@ -1,7 +1,7 @@
import { Notes } from '@/models/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -55,13 +55,12 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner');
generateVisibilityQuery(query, user);
if (user) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
const notes = await query.getMany();
const notes = await visibilityQuery(query, user).getMany();
return await Notes.packMany(notes, user, { detail: false });
});

View file

@ -5,7 +5,7 @@ import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
@ -84,7 +84,6 @@ export default define(meta, paramDef, async (ps, user) => {
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
@ -125,7 +124,7 @@ export default define(meta, paramDef, async (ps, user) => {
}
//#endregion
const timeline = await query.take(ps.limit).getMany();
const timeline = await visibilityQuery(query, user).take(ps.limit).getMany();
process.nextTick(() => {
activeUsersChart.read(user);

View file

@ -6,7 +6,7 @@ import define from '../../define.js';
import { ApiError } from '../../error.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js';
@ -77,7 +77,6 @@ export default define(meta, paramDef, async (ps, user) => {
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (user) generateBlockedUserQuery(query, user);
@ -103,7 +102,7 @@ export default define(meta, paramDef, async (ps, user) => {
}
//#endregion
const timeline = await query.take(ps.limit).getMany();
const timeline = await visibilityQuery(query, user).take(ps.limit).getMany();
process.nextTick(() => {
if (user) {

View file

@ -3,7 +3,7 @@ import { noteVisibilities } from 'foundkey-js';
import { readNote } from '@/services/note/read.js';
import { Notes, Followings } from '@/models/index.js';
import define from '../../define.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -63,7 +63,6 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteThreadQuery(query, user);
generateBlockedUserQuery(query, user);
@ -77,7 +76,7 @@ export default define(meta, paramDef, async (ps, user) => {
query.setParameters(followingQuery.getParameters());
}
const mentions = await query.take(ps.limit).getMany();
const mentions = await visibilityQuery(query, user).take(ps.limit).getMany();
readNote(user.id, mentions);

View file

@ -2,7 +2,7 @@ import { Notes } from '@/models/index.js';
import define from '../../define.js';
import { getNote } from '../../common/getters.js';
import { ApiError } from '../../error.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -57,11 +57,10 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);
if (user) generateBlockedUserQuery(query, user);
const renotes = await query.take(ps.limit).getMany();
const renotes = await visibilityQuery(query, user).take(ps.limit).getMany();
return await Notes.packMany(renotes, user);
});

View file

@ -1,7 +1,7 @@
import { Notes } from '@/models/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -48,11 +48,10 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);
if (user) generateBlockedUserQuery(query, user);
const timeline = await query.take(ps.limit).getMany();
const timeline = await visibilityQuery(query, user).take(ps.limit).getMany();
return await Notes.packMany(timeline, user);
});

View file

@ -5,7 +5,7 @@ import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
export const meta = {
@ -80,7 +80,6 @@ export default define(meta, paramDef, async (ps, me) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, me);
if (me) generateMutedUserQuery(query, me);
if (me) generateBlockedUserQuery(query, me);
@ -134,7 +133,7 @@ export default define(meta, paramDef, async (ps, me) => {
}
// Search notes
const notes = await query.take(ps.limit).getMany();
const notes = await visibilityQuery(query, me).take(ps.limit).getMany();
return await Notes.packMany(notes, me);
});

View file

@ -4,7 +4,7 @@ import config from '@/config/index.js';
import es from '@/db/elasticsearch.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -68,11 +68,10 @@ export default define(meta, paramDef, async (ps, me) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, me);
if (me) generateMutedUserQuery(query, me);
if (me) generateBlockedUserQuery(query, me);
const notes = await query.take(ps.limit).getMany();
const notes = await visibilityQuery(query, me).take(ps.limit).getMany();
return await Notes.packMany(notes, me);
} else {

View file

@ -3,7 +3,7 @@ import { Notes, Followings } from '@/models/index.js';
import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
@ -82,7 +82,6 @@ export default define(meta, paramDef, async (ps, user) => {
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
@ -123,7 +122,7 @@ export default define(meta, paramDef, async (ps, user) => {
}
//#endregion
const timeline = await query.take(ps.limit).getMany();
const timeline = await visibilityQuery(query, user).take(ps.limit).getMany();
process.nextTick(() => {
activeUsersChart.read(user);

View file

@ -4,7 +4,7 @@ import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
export const meta = {
tags: ['notes', 'lists'],
@ -70,8 +70,6 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
generateVisibilityQuery(query, user);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: user.id });
@ -107,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
}
//#endregion
const timeline = await query.take(ps.limit).getMany();
const timeline = await visibilityQuery(query, user).take(ps.limit).getMany();
activeUsersChart.read(user);

View file

@ -4,7 +4,7 @@ import define from '../../define.js';
import { ApiError } from '../../error.js';
import { getUser } from '../../common/getters.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
@ -69,7 +69,6 @@ export default define(meta, paramDef, async (ps, me) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateVisibilityQuery(query, me);
if (me) {
generateMutedUserQuery(query, me);
generateBlockedUserQuery(query, me);
@ -110,7 +109,7 @@ export default define(meta, paramDef, async (ps, me) => {
//#endregion
const timeline = await query.take(ps.limit).getMany();
const timeline = await visibilityQuery(query, me).take(ps.limit).getMany();
return await Notes.packMany(timeline, me);
});

View file

@ -1,7 +1,7 @@
import { NoteReactions, UserProfiles } from '@/models/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { visibilityQuery } from '../../common/generate-visibility-query.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -50,9 +50,7 @@ export default define(meta, paramDef, async (ps, me) => {
.andWhere('reaction.userId = :userId', { userId: ps.userId })
.leftJoinAndSelect('reaction.note', 'note');
generateVisibilityQuery(query, me);
const reactions = await query
const reactions = await visibilityQuery(query, me)
.take(ps.limit)
.getMany();

View file

@ -9,14 +9,15 @@ export class ApiError extends Error {
info?: any | null,
) {
if (!(code in errors)) {
info = `Unknown error "${code}" occurred.`;
code = 'INTERNAL_ERROR';
this.info = `Unknown error "${code}" occurred.`;
this.code = 'INTERNAL_ERROR';
} else {
this.info = info;
this.code = code;
}
const { message, httpStatusCode } = errors[code];
const { message, httpStatusCode } = errors[this.code];
super(message);
this.code = code;
this.info = info;
this.message = message;
this.httpStatusCode = httpStatusCode;
}

View file

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

View file

@ -39,7 +39,7 @@ export const httpCodes: Record<string, string> = {
'415': 'Unsupported Media Type',
'416': 'Range Not Satisfiable',
'417': 'Expectation Failed',
'418': 'I\'m a Teapot',
'418': 'I'm a Teapot',
'421': 'Misdirected Request',
'422': 'Unprocessable Content',
'423': 'Locked',

View file

@ -11,51 +11,48 @@ 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, password, token } = body;
const username = body['username'];
const password = body['password'];
const token = body['token'];
// 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,
},
};
function error(status: number, error: { id: string }) {
ctx.status = status;
ctx.body = { error };
}
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) {
error(new ApiError('RATE_LIMIT_EXCEEDED'));
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',
},
};
return;
}
if (typeof username !== 'string') {
error(new ApiError('INVALID_PARAM', { param: 'username', reason: 'not a string' }));
ctx.status = 400;
return;
}
if (typeof password !== 'string') {
error(new ApiError('INVALID_PARAM', { param: 'password', reason: 'not a string' }));
ctx.status = 400;
return;
}
if (token != null && typeof token !== 'string') {
error(new ApiError('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' }));
ctx.status = 400;
return;
}
@ -66,12 +63,16 @@ export default async (ctx: Koa.Context) => {
}) as ILocalUser;
if (user == null) {
error(new ApiError('NO_SUCH_USER'));
error(404, {
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
});
return;
}
if (user.isSuspended) {
error(new ApiError('SUSPENDED'));
error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
});
return;
}
@ -80,7 +81,7 @@ export default async (ctx: Koa.Context) => {
// Compare password
const same = await bcrypt.compare(password, profile.password!);
async function fail(): void {
async function fail(status?: number, failure?: { id: string }) {
// Append signin history
await Signins.insert({
id: genId(),
@ -91,7 +92,7 @@ export default async (ctx: Koa.Context) => {
success: false,
});
error(new ApiError('ACCESS_DENIED'));
error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
}
if (!profile.twoFactorEnabled) {
@ -99,14 +100,18 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
}
if (token) {
if (!same) {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
@ -121,12 +126,16 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail();
await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
});
return;
}
} else if (body.credentialId) {
if (!same && !profile.usePasswordLessLogin) {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
@ -140,7 +149,9 @@ export default async (ctx: Koa.Context) => {
});
if (!challenge) {
await fail();
await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
return;
}
@ -150,7 +161,9 @@ export default async (ctx: Koa.Context) => {
});
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
await fail();
await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
return;
}
@ -164,7 +177,9 @@ export default async (ctx: Koa.Context) => {
});
if (!securityKey) {
await fail();
await fail(403, {
id: '66269679-aeaf-4474-862b-eb761197e046',
});
return;
}
@ -181,12 +196,16 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail();
await fail(403, {
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
});
return;
}
} else {
if (!same && !profile.usePasswordLessLogin) {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
@ -195,7 +214,9 @@ export default async (ctx: Koa.Context) => {
});
if (keys.length === 0) {
await fail();
await fail(403, {
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
});
return;
}

View file

@ -62,21 +62,21 @@ export default abstract class Channel {
});
}
protected withPackedNote(callback: (note: Packed<'Note'>) => Promise<void>): (note: Note) => Promise<void> {
protected withPackedNote(callback: (note: Packed<'Note'>) => void): (Note) => void {
return async (note: Note) => {
try {
// because `note` was previously JSON.stringify'ed, the fields that
// were objects before are now strings and have to be restored or
// removed from the object
note.createdAt = new Date(note.createdAt);
note.reply = null;
note.renote = null;
note.user = null;
note.channel = null;
delete note.reply;
delete note.renote;
delete note.user;
delete note.channel;
const packed = await Notes.pack(note, this.user, { detail: true });
await callback(packed);
callback(packed);
} catch (err) {
if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') {
// skip: note not visible to user

View file

@ -2,7 +2,6 @@ import { Users } from '@/models/index.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { User } from '@/models/entities/user.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import { StreamMessages } from '../types.js';
import Channel from '../channel.js';
@ -13,11 +12,10 @@ export default class extends Channel {
private channelId: string;
private typers: Record<User['id'], Date> = {};
private emitTypersIntervalId: ReturnType<typeof setInterval>;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -29,7 +27,7 @@ export default class extends Channel {
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
if (note.channelId !== this.channelId) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -3,18 +3,16 @@ import { checkWordMute } from '@/misc/check-word-mute.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = true;
public static requireCredential = false;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -27,7 +25,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
if (note.visibility !== 'public') return;
if (note.channelId != null) return;

View file

@ -1,7 +1,6 @@
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
@ -9,11 +8,10 @@ export default class extends Channel {
public static shouldShare = false;
public static requireCredential = false;
private q: string[][];
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -25,7 +23,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : [];
const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag))));
if (!matched) return;

View file

@ -2,18 +2,16 @@ import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = true;
public static requireCredential = true;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -21,7 +19,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
if (note.channelId) {
if (!this.followingChannels.has(note.channelId)) return;
} else {

View file

@ -3,18 +3,16 @@ import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = true;
public static requireCredential = true;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -25,7 +23,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
// チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または

View file

@ -2,18 +2,16 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = true;
public static requireCredential = false;
onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -26,7 +24,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
if (note.user.host !== null) return;
if (note.visibility !== 'public') return;
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;

View file

@ -2,7 +2,6 @@ import { UserListJoinings, UserLists } from '@/models/index.js';
import { User } from '@/models/entities/user.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
@ -12,12 +11,11 @@ export default class extends Channel {
private listId: string;
public listUsers: User['id'][] = [];
private listUsersClock: NodeJS.Timer;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.updateListUsers = this.updateListUsers.bind(this);
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
@ -50,7 +48,7 @@ export default class extends Channel {
this.listUsers = users.map(x => x.userId);
}
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
private async onNote(note: Packed<'Note'>) {
if (!this.listUsers.includes(note.userId)) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import Emitter from 'strict-event-emitter-types';
import { Channel } from '@/models/entities/channel.js';
import { User } from '@/models/entities/user.js';
import { UserProfile } from '@/models/entities/user-profile.js';
@ -14,7 +15,6 @@ import { Signin } from '@/models/entities/signin.js';
import { Page } from '@/models/entities/page.js';
import { Packed } from '@/misc/schema.js';
import { Webhook } from '@/models/entities/webhook.js';
import type { StrictEventEmitter as Emitter } from 'strict-event-emitter-types';
//#region Stream type-body definitions
export interface InternalStreamTypes {

View file

@ -138,13 +138,13 @@ export const startServer = () => {
return server;
};
export default (): Promise<void> => new Promise(resolve => {
export default () => new Promise(resolve => {
const server = createServer();
initializeStreamingServer(server);
server.on('error', e => {
switch ((e as NodeJS.ErrnoException).code) {
switch ((e as any).code) {
case 'EACCES':
serverLogger.error(`You do not have permission to listen on port ${config.port}.`);
break;
@ -164,5 +164,5 @@ export default (): Promise<void> => new Promise(resolve => {
}
});
server.listen(config.port, () => resolve());
server.listen(config.port, resolve);
});

View file

@ -3,6 +3,7 @@ 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();
@ -17,33 +18,7 @@ export const links = [{
href: config.url + nodeinfo2_0path,
}];
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 nodeinfo2 = async () => {
const now = Date.now();
const [
meta,
@ -65,7 +40,7 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
software: {
name: 'foundkey',
version: config.version,
repository,
repository: 'https://akkoma.dev/FoundKeyGang/FoundKey',
},
protocols: ['activitypub'],
services: {
@ -87,7 +62,7 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
},
langs: meta.langs,
tosUrl: meta.ToSUrl,
repositoryUrl: repository,
repositoryUrl: meta.repositoryUrl,
feedbackUrl: 'ircs://irc.akkoma.dev/foundkey',
disableRegistration: meta.disableRegistration,
disableLocalTimeline: meta.disableLocalTimeline,
@ -107,15 +82,17 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
};
};
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
router.get(nodeinfo2_1path, async ctx => {
const base = await nodeinfo2();
const base = await cache.fetch(null, () => nodeinfo2());
ctx.body = { version: '2.1', ...base };
ctx.set('Cache-Control', 'public, max-age=600');
});
router.get(nodeinfo2_0path, async ctx => {
const base = await nodeinfo2();
const base = await cache.fetch(null, () => nodeinfo2());
delete base.software.repository;

View file

@ -1,20 +1,28 @@
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;
let instanceActor = await Users.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as ILocalUser | undefined;
const cache = new Cache<ILocalUser>(Infinity);
export async function getInstanceActor(): Promise<ILocalUser> {
if (instanceActor) {
return instanceActor;
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;
} else {
instanceActor = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
cache.set(null, created);
return created;
}
}

View file

@ -28,9 +28,9 @@ export default class Logger {
if (config.syslog) {
this.syslogClient = new SyslogPro.RFC5424({
applicationName: 'FoundKey',
applacationName: 'FoundKey',
timestamp: true,
includeStructuredData: true,
encludeStructuredData: true,
color: true,
extendedColor: true,
server: {

View file

@ -36,22 +36,13 @@ 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']; }[]>(
5 * MINUTE,
() => UserProfiles.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
}),
);
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -266,7 +257,12 @@ export default async (user: { id: User['id']; username: User['username']; host:
incNotesCountOfUser(user);
// Word mute
mutedWordsCache.fetch(null).then(us => {
mutedWordsCache.fetch(null, () => UserProfiles.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
for (const u of us) {
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';
/**
* Delete your note.
* @param user author
* @param note note to be deleted
* 稿
* @param user 稿
* @param note 稿
*/
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,13 +37,17 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
if (!quiet) {
publishNoteStream(note.id, 'deleted', { deletedAt });
// deliver delete activity of note itself for local posts
//#region ローカルの投稿なら削除アクティビティを配送
if (Users.isLocalUser(user) && !note.localOnly) {
let renote: Note | null = null;
// if deleted note is renote
if (isPureRenote(note)) {
renote = await Notes.findOneBy({ id: note.renoteId });
renote = await Notes.findOneBy({
// isPureRenote checks if note.renoteId is null already, so renoteId should be non-null.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: note.renoteId!,
});
}
const content = renderActivity(renote
@ -53,14 +57,17 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
deliverToConcerned(user, note, content);
}
// also deliver delete activity to cascaded notes
const cascadingNotes = await findCascadingNotes(note);
// also deliever delete activity to cascaded notes
const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
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);
@ -78,43 +85,26 @@ 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> => {
// 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;
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) {
cascadingNotes.push(reply);
return recursive(reply.id);
}));
await recursive(reply.id);
}
};
await recursive(note.id);
return cascadingNotes;
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
}
async function getMentionedRemoteUsers(note: Note): Promise<IRemoteUser[]> {

View file

@ -15,21 +15,20 @@ 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,
// replace text with getNoteSummary
// textをgetNoteSummaryしたものに置き換える
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
cw: undefined,
reply: undefined,
renote: undefined,
// unnecessary, since usually the user who is receiving the notification knows who they are
user: undefined as any,
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
},
};
}
@ -42,7 +41,7 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
// Register key pair information
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
push.setVapidDetails(config.url,
meta.swPublicKey,
meta.swPrivateKey);
@ -66,6 +65,10 @@ 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,27 +3,29 @@ 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>(
HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
const cache = new Cache<Instance>(1000 * 60 * 60);
export async function registerOrFetchInstanceDoc(idnHost: string): Promise<Instance> {
const host = toPuny(idnHost);
const cached = cache.fetch(host);
const cached = cache.get(host);
if (cached) return cached;
// 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]));
const index = await Instances.findOneBy({ host });
cache.set(host, i);
return i;
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;
}
}

View file

@ -8,21 +8,11 @@ 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;
/**
* 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',
}),
);
const relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
export async function getRelayActor(): Promise<ILocalUser> {
const user = await Users.findOneBy({
@ -93,7 +83,9 @@ export async function relayRejected(id: string) {
export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) {
if (activity == null) return;
const relays = await relaysCache.fetch(null);
const relays = await relaysCache.fetch(null, () => Relays.findBy({
status: 'accepted',
}));
if (relays.length === 0) return;
// 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 DeliverManager from '@/remote/activitypub/deliver-manager.js';
import { deliver } from '@/queue/index.js';
import config from '@/config/index.js';
import { User } from '@/models/entities/user.js';
import { Users, Followings } from '@/models/index.js';
@ -11,11 +11,27 @@ export async function doPostSuspend(user: { id: User['id']; host: User['host'] }
publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (Users.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user));
// deliver to all of known network
const dm = new DeliverManager(user, content);
dm.addEveryone();
await dm.execute();
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);
}
}
}

View file

@ -3,18 +3,10 @@ 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,
(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),
);
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);
subscriber.on('message', async (_, data) => {
const obj = JSON.parse(data);
@ -35,6 +27,7 @@ subscriber.on('message', async (_, data) => {
}
if (Users.isLocalUser(user)) {
localUserByNativeTokenCache.set(user.token, user);
localUserByIdCache.set(user.id, user);
}
break;
}

View file

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

View file

@ -5,17 +5,26 @@
:disabled="wait"
@click="onClick"
>
<template v-if="wait">
<span v-if="full">{{ i18n.ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
</template>
<template v-else-if="hasPendingFollowRequestFromYou">
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i>
</template>
<template v-else-if="isFollowing">
<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="fas fa-minus"></i>
<template v-if="!wait">
<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i>
</template>
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
<!-- つまりリモートフォローの場合 -->
<span v-if="full">{{ i18n.ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i>
</template>
<template v-else-if="isFollowing">
<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="fas fa-minus"></i>
</template>
<template v-else-if="!isFollowing && user.isLocked">
<span v-if="full">{{ i18n.ts.followRequest }}</span><i class="fas fa-plus"></i>
</template>
<template v-else-if="!isFollowing && !user.isLocked">
<span v-if="full">{{ i18n.ts.follow }}</span><i class="fas fa-plus"></i>
</template>
</template>
<template v-else>
<span v-if="full">{{ user.isLocked ? i18n.ts.followRequest : i18n.ts.follow }}</span><i class="fas fa-plus"></i>
<span v-if="full">{{ i18n.ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
</template>
</button>
</template>

View file

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

View file

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

View file

@ -31,7 +31,7 @@ const buttonRef = ref<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
const toggleReaction = (): void => {
const toggleReaction = () => {
if (!canToggle.value) return;
const oldReaction = props.note.myReaction;

View file

@ -8,6 +8,7 @@
<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>
@ -54,7 +55,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 } from '@/config';
import { apiUrl, host as configHost } from '@/config';
import { byteify, hexify } from '@/scripts/2fa';
import * as os from '@/os';
import { login } from '@/account';
@ -67,6 +68,7 @@ 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,6 +7,7 @@
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername">
<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>
@ -69,6 +70,7 @@ 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';
@ -85,6 +87,8 @@ const emit = defineEmits<{
(ev: 'signupEmailPending'): void;
}>();
const host = toUnicode(config.host);
let hcaptcha = $ref();
let recaptcha = $ref();

View file

@ -15,14 +15,14 @@
<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore(true)">
<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="items"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore()">
<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
@ -32,7 +32,7 @@
</template>
<script lang="ts" setup>
import { ComputedRef, isRef, onActivated, onDeactivated, watch } from 'vue';
import { computed, ComputedRef, isRef, onActivated, onDeactivated, ref, watch } from 'vue';
import * as foundkey from 'foundkey-js';
import * as os from '@/os';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
@ -45,13 +45,13 @@ export type Paging<E extends keyof foundkey.Endpoints = keyof foundkey.Endpoints
params?: foundkey.Endpoints[E]['req'] | ComputedRef<foundkey.Endpoints[E]['req']>;
/**
* When using non-pageable endpoints, such as the search API.
* (though it is slightly inconsistent to use such an API with this function)
* 検索APIのようなページング不可なエンドポイントを利用する場合
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
*/
noPaging?: boolean;
/**
* items Array contents in reverse order (newest first, last)
* items 配列の中身を逆順にする(新しい方が最後)
*/
reversed?: boolean;
@ -76,179 +76,202 @@ const emit = defineEmits<{
type Item = { id: string; [another: string]: unknown; };
let rootEl: HTMLElement | null = $ref(null);
let items: Item[] = $ref([]);
let queue: Item[] = $ref([]);
let offset: number = $ref(0);
let fetching: boolean = $ref(true);
let moreFetching: boolean = $ref(false);
let more: boolean = $ref(false);
let backed: boolean = $ref(false); //
let isBackTop: boolean = $ref(false);
const empty = $computed(() => items.length === 0);
let error: boolean = $ref(false);
const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]);
const queue = ref<Item[]>([]);
const offset = ref(0);
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
const backed = ref(false); //
const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0);
const error = ref(false);
const init = async (): Promise<void> => {
queue = [];
fetching = true;
const params = props.pagination.params
? isRef(props.pagination.params)
? props.pagination.params.value as Record<string, any>
: props.pagination.params
: {};
queue.value = [];
fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
}).then((res: Item[]) => {
}).then(res => {
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
res.pop();
more = true;
items.value = props.pagination.reversed ? [...res].reverse() : res;
more.value = true;
} else {
more = false;
items.value = props.pagination.reversed ? [...res].reverse() : res;
more.value = false;
}
items = props.pagination.reversed ? [...res].reverse() : res;
offset = res.length;
error = false;
fetching = false;
offset.value = res.length;
error.value = false;
fetching.value = false;
emit('loaded');
}).catch(() => {
error = true;
fetching = false;
}, () => {
error.value = true;
fetching.value = false;
emit('error');
});
};
const reload = (): void => {
items = [];
items.value = [];
init();
};
const fetchMore = async (ahead?: boolean): Promise<void> => {
if (!more || fetching || moreFetching || items.length === 0) return;
moreFetching = true;
if (!ahead) {
backed = true;
}
const params = props.pagination.params
? isRef(props.pagination.params)
? props.pagination.params.value as Record<string, any>
: props.pagination.params
: {};
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
moreFetching.value = true;
backed.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? {
offset,
} : ahead ? (
props.pagination.reversed ? {
untilId: items[0].id,
} : {
sinceId: items[items.length - 1].id,
}
) : (
props.pagination.reversed ? {
sinceId: items[0].id,
} : {
untilId: items[items.length - 1].id,
}
)),
offset: offset.value,
} : props.pagination.reversed ? {
sinceId: items.value[0].id,
} : {
untilId: items.value[items.value.length - 1].id,
}),
}).then(res => {
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
more = true;
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = true;
} else {
more = false;
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = false;
}
items = props.pagination.reversed ? [...res].reverse().concat(items) : items.concat(res);
offset += res.length;
moreFetching = false;
offset.value += res.length;
moreFetching.value = false;
}, () => {
moreFetching = false;
moreFetching.value = false;
});
};
const fetchMoreAhead = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : props.pagination.reversed ? {
untilId: items.value[0].id,
} : {
sinceId: items.value[items.value.length - 1].id,
}),
}).then(res => {
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = true;
} else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = false;
}
offset.value += res.length;
moreFetching.value = false;
}, () => {
moreFetching.value = false;
});
};
const prepend = (item: Item): void => {
if (props.pagination.reversed) {
if (rootEl) {
const container = getScrollContainer(rootEl);
if (rootEl.value) {
const container = getScrollContainer(rootEl.value);
if (container == null) {
// TODO?
} else {
const pos = getScrollPosition(rootEl);
const pos = getScrollPosition(rootEl.value);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
// Discard old items if they overflow.
if (isBottom) {
while (items.length >= props.displayLimit) {
items.shift();
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
//items.value = items.value.slice(-props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.shift();
}
more.value = true;
}
more = true;
}
}
}
items.push(item);
items.value.push(item);
// TODO
} else {
// Only unshift is required for initial display.
if (!rootEl) {
items.unshift(item);
// unshiftOK
if (!rootEl.value) {
items.value.unshift(item);
return;
}
const isTop = isBackTop || (document.body.contains(rootEl) && isTopVisible(rootEl));
const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
if (isTop) {
// Prepend the item
items.unshift(item);
items.value.unshift(item);
// Discard old items if they overflow.
while (items.length >= props.displayLimit) {
items.pop();
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
//this.items = items.value.slice(0, props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.pop();
}
more.value = true;
}
more = true;
} else {
queue.push(item);
onScrollTop(rootEl, () => {
for (const queueItem of queue) {
queue.value.push(item);
onScrollTop(rootEl.value, () => {
for (const queueItem of queue.value) {
prepend(queueItem);
}
queue = [];
queue.value = [];
});
}
}
};
const append = (item: Item): void => {
items.push(item);
items.value.push(item);
};
const removeItem = (finder: (item: Item) => boolean): void => {
const i = items.findIndex(finder);
items.splice(i, 1);
const i = items.value.findIndex(finder);
items.value.splice(i, 1);
};
const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => {
const i = items.findIndex(item => item.id === id);
items[i] = replacer(items[i]);
const i = items.value.findIndex(item => item.id === id);
items.value[i] = replacer(items.value[i]);
};
if (props.pagination.params && isRef(props.pagination.params)) {
watch(props.pagination.params, init, { deep: true });
}
watch($$(queue), (a, b) => {
if (a.length !== 0 || b.length !== 0) emit('queue', queue.length);
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length);
}, { deep: true });
init();
onActivated(() => {
isBackTop = false;
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop = window.scrollY === 0;
isBackTop.value = window.scrollY === 0;
});
defineExpose({

View file

@ -2,9 +2,7 @@
<transition :name="$store.state.animation ? 'popup' : ''" appear @after-leave="$emit('closed')">
<div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
<div v-if="fetched" class="info">
<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
</div>
<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
<div class="title">
<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
@ -107,16 +105,6 @@ onMounted(() => {
background-color: rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
> .followed {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 8px;
color: #fff;
background: rgba(0, 0, 0, 0.7);
font-size: 0.7em;
border-radius: 6px;
}
}
> .avatar {

View file

@ -32,10 +32,7 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s
body: JSON.stringify(data),
credentials: 'omit',
cache: 'no-cache',
headers: {
'content-type': 'application/json',
...(authorization ? { authorization } : {}),
},
headers: authorization ? { authorization } : {},
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -72,10 +69,7 @@ export const apiGet = ((endpoint: string, data: Record<string, any> = {}, token?
method: 'GET',
credentials: 'omit',
cache: 'default',
headers: {
'content-type': 'application/json',
...(authorization ? { authorization } : {}),
},
headers: authorization ? { authorization } : {},
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -103,7 +97,7 @@ export const apiWithDialog = ((
promiseDialog(promise, null, (err) => {
alert({
type: 'error',
text: (err.message + '\n' + (err?.endpoint ?? '') + (err?.code ?? '')).trim(),
text: err.message + '\n' + (err as any).id,
});
});
@ -141,7 +135,7 @@ export function promiseDialog<T extends Promise<any>>(
}
});
// NOTE: dynamic import results in strange behaviour (showing is not reactive)
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
popup(MkWaitingDialog, {
success,
showing,

View file

@ -26,27 +26,8 @@
<FormSection v-if="iAmModerator">
<template #label>Moderation</template>
<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>
<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>
<MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton>
</FormSection>
@ -171,7 +152,7 @@ const usersPagination = {
offsetMode: true,
};
async function fetch(): Promise<void> {
async function fetch() {
instance = await os.api('federation/show-instance', {
host: props.host,
});
@ -179,21 +160,21 @@ async function fetch(): Promise<void> {
isBlocked = instance.isBlocked;
}
async function toggleBlock(): Promise<void> {
async function toggleBlock(ev) {
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(): Promise<void> {
async function toggleSuspend(v) {
await os.api('admin/federation/update-instance', {
host: instance.host,
isSuspended: suspended,
});
}
function refreshMetadata(): void {
function refreshMetadata() {
os.api('admin/federation/refresh-remote-instance-metadata', {
host: instance.host,
});

View file

@ -7,14 +7,14 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import { defineAsyncComponent, ref } 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 = window.innerWidth >= 1100;
const isDesktop = ref(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="2048" tall manual-save class="_formBlock">
<FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</FormTextarea>

View file

@ -6,6 +6,7 @@
<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>
@ -23,6 +24,7 @@
<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,6 +30,14 @@ 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 { createNotification } from '@/scripts/create-notification';
import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
import { swLang } from '@/scripts/lang';
import { swNotificationRead } from '@/scripts/notification-read';
import { pushNotificationDataMap } from '@/types';
@ -67,6 +67,8 @@ self.addEventListener('push', ev => {
}
break;
}
return createEmptyNotification();
}));
});

View file

@ -406,37 +406,46 @@ __metadata:
languageName: node
linkType: hard
"@bull-board/api@npm:4.3.1, @bull-board/api@npm:^4.3.1":
version: 4.3.1
resolution: "@bull-board/api@npm:4.3.1"
"@bull-board/api@npm:4.0.0":
version: 4.0.0
resolution: "@bull-board/api@npm:4.0.0"
dependencies:
redis-info: ^3.0.8
checksum: 05113b1e888e79f8efecdffdc1043455fa6f8714c55a1e973d8a0a7f60cf574b00487b5b86324523ff91641784a55ff14c469edc8dd985295dcfc27cf55b4c4a
checksum: 9d0da26021265c044d1b9bc0cccb728a76385e0c7ec9d7a9b3cd0f6102d971da08c2909562cb9e2635cf485d1c1d9e0d1b2ddec5f8fe0d11b2f48674fd48664a
languageName: node
linkType: hard
"@bull-board/koa@npm:^4.3.1":
version: 4.3.1
resolution: "@bull-board/koa@npm:4.3.1"
"@bull-board/api@npm:^4.2.2":
version: 4.2.2
resolution: "@bull-board/api@npm:4.2.2"
dependencies:
"@bull-board/api": 4.3.1
"@bull-board/ui": 4.3.1
redis-info: ^3.0.8
checksum: 547174f63d611a568303ad261d3f41a57f632ea2067e8ab900bad90dbc9790a45af8229765350ef47a2eba4300656e8cc792bdcef94003a531f4eabeb4982876
languageName: node
linkType: hard
"@bull-board/koa@npm:4.0.0":
version: 4.0.0
resolution: "@bull-board/koa@npm:4.0.0"
dependencies:
"@bull-board/api": 4.0.0
"@bull-board/ui": 4.0.0
ejs: ^3.1.7
koa: ^2.13.1
koa-mount: ^4.0.0
koa-router: ^10.0.0
koa-static: ^5.0.0
koa-views: ^7.0.1
checksum: 08f198cdaaa28fe8e254288a0d4c13e9cd481a97e40e5e9152fb9094cbac54459e86901da5d90c46fe2dccf310f78a50ef8763bf5980b98d33180299c64fbc3f
checksum: 34b567e46d9d2a1413032f89c8efe4dc30d1844c47588a1ffee2f979698ecefa9a3178c4921746b7a1ac18d999762a96e97e66c5cb031c0074dd39646bb8350f
languageName: node
linkType: hard
"@bull-board/ui@npm:4.3.1":
version: 4.3.1
resolution: "@bull-board/ui@npm:4.3.1"
"@bull-board/ui@npm:4.0.0":
version: 4.0.0
resolution: "@bull-board/ui@npm:4.0.0"
dependencies:
"@bull-board/api": 4.3.1
checksum: 7bc4787ba8f9e3dda5cb580b4374872bc7b0870a08a504cfc2f380a39dda164ae71518e7b1921e53ff8abc4224c0861504b26b63510b6c9c9d23d647bdab54b2
"@bull-board/api": 4.0.0
checksum: 34d90de90137587e0343a2cfe236df4f7c667a6f08e0c1ea5516090c481b5acbce10ebb652b705f9eb0272b3f6a98033d160e2906b0a120bc76b3c89cf303340
languageName: node
linkType: hard
@ -3609,8 +3618,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "backend@workspace:packages/backend"
dependencies:
"@bull-board/api": ^4.3.1
"@bull-board/koa": ^4.3.1
"@bull-board/api": ^4.2.2
"@bull-board/koa": 4.0.0
"@discordapp/twemoji": 14.0.2
"@elastic/elasticsearch": 7.11.0
"@koa/cors": 3.1.0
@ -3743,7 +3752,7 @@ __metadata:
rss-parser: 3.12.0
sanitize-html: 2.7.0
semver: 7.3.7
sharp: 0.31.2
sharp: 0.30.7
speakeasy: 2.0.0
strict-event-emitter-types: 2.0.0
stringz: 2.1.0
@ -15189,17 +15198,6 @@ __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"
@ -15261,20 +15259,20 @@ __metadata:
languageName: node
linkType: hard
"sharp@npm:0.31.2":
version: 0.31.2
resolution: "sharp@npm:0.31.2"
"sharp@npm:0.30.7":
version: 0.30.7
resolution: "sharp@npm:0.30.7"
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.8
semver: ^7.3.7
simple-get: ^4.0.1
tar-fs: ^2.1.1
tunnel-agent: ^0.6.0
checksum: 076717b7a073ea47bb522ff2931b74b6608daeb6f7ae334e4848d47fdf4d23bcb18cd49044fd5fb27ef27a1a4aa87d141894d67d1c4bb15a6e2e63cf4dbe329e
checksum: bbc63ca3c7ea8a5bff32cd77022cfea30e25a03f5bd031e935924bf6cf0e11e3388e8b0e22b3137bf8816aa73407f1e4fbeb190f3a35605c27ffca9f32b91601
languageName: node
linkType: hard