Merge branch 'develop' into stories/components-ab

This commit is contained in:
Acid Chicken (硫酸鶏) 2023-04-09 12:58:10 +09:00 committed by GitHub
commit b415ddbc05
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 1330 additions and 235 deletions

View file

@ -0,0 +1,17 @@
export class AccountMove1680931179228 {
name = 'AccountMove1680931179228'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`);
await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`);
await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`);
}
async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`);
await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`);
}
}

View file

@ -25,6 +25,14 @@ export type Source = {
disableCache?: boolean;
extra?: { [x: string]: string };
};
dbReplications?: boolean;
dbSlaves?: {
host: string;
port: number;
db: string;
user: string;
pass: string;
}[];
redis: {
host: string;
port: number;

View file

@ -0,0 +1,114 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { LocalUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { RelayService } from '@/core/RelayService.js';
@Injectable()
export class AccountMoveService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
private relayService: RelayService,
) {
}
/**
* Move a local account to a remote account.
*
* After delivering Move activity, its local followers unfollow the old account and then follow the new one.
*/
@bindThis
public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> {
// Make sure that the destination is a remote account.
if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
if (!dst.uri) throw new Error('destination uri is empty');
// add movedToUri to indicate that the user has moved
const update = {} as Partial<User>;
update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
update.movedToUri = dst.uri;
await this.usersRepository.update(src.id, update);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
await this.apDeliverManagerService.deliverToFollowers(src, updateAct);
this.relayService.deliverToRelays(src, updateAct);
// Deliver Move activity to the followers of the old account
const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst));
await this.apDeliverManagerService.deliverToFollowers(src, moveAct);
// Publish meUpdated event
const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true });
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
where: {
followeeId: src.id,
followerHost: IsNull(), // follower is local
},
});
for (const following of followings) {
if (!following.follower) continue;
try {
await this.userFollowingService.follow(following.follower, dst);
await this.userFollowingService.unfollow(following.follower, src);
} catch {
/* empty */
}
}
return iObj;
}
/**
* Create an alias of an old remote account.
*
* The user's new profile will be published to the followers.
*/
@bindThis
public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
await this.usersRepository.update(me.id, updates);
// Publish meUpdated event
const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
detail: true,
includeSecrets: true,
});
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
if (me.isLocked === false) {
await this.userFollowingService.acceptAllFollowRequests(me);
}
this.accountUpdateService.publishToFollowers(me.id);
return iObj;
}
}

View file

@ -29,7 +29,7 @@ export class AccountUpdateService {
public async publishToFollowers(userId: User['id']) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));

View file

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
import { AntennaService } from './AntennaService.js';
@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
],
providers: [
LoggerService,
AccountMoveService,
AccountUpdateService,
AiService,
AntennaService,
@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AntennaService,
@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
exports: [
QueueModule,
LoggerService,
AccountMoveService,
AccountUpdateService,
AiService,
AntennaService,
@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AntennaService,

View file

@ -44,7 +44,11 @@ export class CustomEmojiService {
memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
toRedisConverter: (value) => JSON.stringify(value.values()),
fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
fromRedisConverter: (value) => {
// 原因不明だが配列以外が入ってくることがあるため
if (!Array.isArray(JSON.parse(value))) return undefined;
return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x]));
}, // TODO: Date型の変換
});
}

View file

@ -29,6 +29,7 @@ export class FederatedInstanceService {
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => {
const parsed = JSON.parse(value);
if (parsed == null) return null;
return {
...parsed,
firstRetrievedAt: new Date(parsed.firstRetrievedAt),

View file

@ -3,10 +3,11 @@ import { ulid } from 'ulid';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid } from '@/misc/id/meid.js';
import { genMeidg } from '@/misc/id/meidg.js';
import { genMeid, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId } from '@/misc/id/object-id.js';
import { bindThis } from '@/decorators.js';
import { parseUlid } from '@/misc/id/ulid.js';
@Injectable()
export class IdService {
@ -37,11 +38,10 @@ export class IdService {
public parse(id: string): { date: Date; } {
switch (this.method) {
case 'aid': return parseAid(id);
// TODO
//case 'meid':
//case 'meidg':
//case 'ulid':
//case 'objectid':
case 'objectid':
case 'meid': return parseMeid(id);
case 'meidg': return parseMeidg(id);
case 'ulid': return parseUlid(id);
default: throw new Error('unrecognized id generation method');
}
}

View file

@ -186,7 +186,7 @@ class DeliverManager {
for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox;
inboxes.set(inbox, following.followerSharedInbox === null);
inboxes.set(inbox, following.followerSharedInbox != null);
}
}

View file

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { In, IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import type { RemoteUser } from '@/models/entities/User.js';
import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js';
@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js';
import type { Resolver } from './ApResolverService.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js';
@Injectable()
export class ApInboxService {
@ -80,7 +80,7 @@ export class ApInboxService {
) {
this.logger = this.apLoggerService.logger;
}
@bindThis
public async performActivity(actor: RemoteUser, activity: IObject) {
if (isCollectionOrOrderedCollection(activity)) {
@ -139,6 +139,8 @@ export class ApInboxService {
await this.block(actor, activity);
} else if (isFlag(activity)) {
await this.flag(actor, activity);
} else if (isMove(activity)) {
//await this.move(actor, activity);
} else {
this.logger.warn(`unrecognized activity type: ${activity.type}`);
}
@ -147,15 +149,15 @@ export class ApInboxService {
@bindThis
private async follow(actor: RemoteUser, activity: IFollow): Promise<string> {
const followee = await this.apDbResolverService.getUserFromApId(activity.object);
if (followee == null) {
return 'skip: followee not found';
}
if (followee.host != null) {
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
}
await this.userFollowingService.follow(actor, followee, activity.id);
return 'ok';
}
@ -183,16 +185,16 @@ export class ApInboxService {
const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`);
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
throw err;
});
if (isFollow(object)) return await this.acceptFollow(actor, object);
return `skip: Unknown Accept type: ${getApType(object)}`;
}
@ -225,18 +227,18 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
if (activity.target == null) {
throw new Error('target is null');
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) throw new Error('note not found');
await this.notePiningService.addPinned(actor, note.id);
return;
}
throw new Error(`unknown target: ${activity.target}`);
}
@ -405,10 +407,10 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
// 削除対象objectのtype
let formerType: string | undefined;
if (typeof activity.object === 'string') {
// typeが不明だけど、どうせ消えてるのでremote resolveしない
formerType = undefined;
@ -420,19 +422,19 @@ export class ApInboxService {
formerType = toSingle(object.type);
}
}
const uri = getApId(activity.object);
// type不明でもactorとobjectが同じならばそれはPersonに違いない
if (!formerType && actor.uri === uri) {
formerType = 'Person';
}
// それでもなかったらおそらくNote
if (!formerType) {
formerType = 'Note';
}
if (validPost.includes(formerType)) {
return await this.deleteNote(actor, uri);
} else if (validActor.includes(formerType)) {
@ -445,44 +447,44 @@ export class ApInboxService {
@bindThis
private async deleteActor(actor: RemoteUser, uri: string): Promise<string> {
this.logger.info(`Deleting the Actor: ${uri}`);
if (actor.uri !== uri) {
return `skip: delete actor ${actor.uri} !== ${uri}`;
}
const user = await this.usersRepository.findOneBy({ id: actor.id });
if (user == null) {
return 'skip: actor not found';
} else if (user.isDeleted) {
return 'skip: already deleted';
}
const job = await this.queueService.createDeleteAccountJob(actor);
await this.usersRepository.update(actor.id, {
isDeleted: true,
});
return `ok: queued ${job.name} ${job.id}`;
}
@bindThis
private async deleteNote(actor: RemoteUser, uri: string): Promise<string> {
this.logger.info(`Deleting the Note: ${uri}`);
const unlock = await this.appLockService.getApLock(uri);
try {
const note = await this.apDbResolverService.getNoteFromApId(uri);
if (note == null) {
return 'message not found';
}
if (note.userId !== actor.id) {
return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
}
await this.noteDeleteService.delete(actor, note);
return 'ok: note deleted';
} finally {
@ -536,23 +538,23 @@ export class ApInboxService {
@bindThis
private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise<string> {
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
const follower = await this.apDbResolverService.getUserFromApId(activity.actor);
if (follower == null) {
return 'skip: follower not found';
}
if (!this.userEntityService.isLocalUser(follower)) {
return 'skip: follower is not a local user';
}
// relay
const match = activity.id?.match(/follow-relay\/(\w+)/);
if (match) {
return await this.relayService.relayRejected(match[1]);
}
await this.userFollowingService.remoteReject(actor, follower);
return 'ok';
}
@ -562,18 +564,18 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
if (activity.target == null) {
throw new Error('target is null');
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) throw new Error('note not found');
await this.notePiningService.removePinned(actor, note.id);
return;
}
throw new Error(`unknown target: ${activity.target}`);
}
@ -582,24 +584,24 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
const uri = activity.id ?? activity;
this.logger.info(`Undo: ${uri}`);
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
});
if (isFollow(object)) return await this.undoFollow(actor, object);
if (isBlock(object)) return await this.undoBlock(actor, object);
if (isLike(object)) return await this.undoLike(actor, object);
if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
if (isAccept(object)) return await this.undoAccept(actor, object);
return `skip: unknown object type ${getApType(object)}`;
}
@ -609,17 +611,17 @@ export class ApInboxService {
if (follower == null) {
return 'skip: follower not found';
}
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: actor.id,
});
if (following) {
await this.userFollowingService.unfollow(follower, actor);
return 'ok: unfollowed';
}
return 'skip: フォローされていない';
}
@ -708,16 +710,16 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
return 'skip: invalid actor';
}
this.logger.debug('Update');
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
});
if (isActor(object)) {
await this.apPersonService.updatePerson(actor.uri!, resolver, object);
return 'ok: Person updated';
@ -728,4 +730,59 @@ export class ApInboxService {
return `skip: Unknown type: ${getApType(object)}`;
}
}
@bindThis
private async move(actor: RemoteUser, activity: IMove): Promise<string> {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
let new_acc = await this.apPersonService.resolvePerson(targetUri);
let old_acc = await this.apPersonService.resolvePerson(actor.uri);
// update them if they're remote
if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri);
if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri);
// retrieve updated users
new_acc = await this.apPersonService.resolvePerson(targetUri);
old_acc = await this.apPersonService.resolvePerson(actor.uri);
// check if alsoKnownAs of the new account is valid
let isValidMove = true;
if (old_acc.uri) {
if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) {
isValidMove = false;
}
} else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) {
isValidMove = false;
}
if (!isValidMove) {
return 'skip: accounts invalid';
}
// add target uri to movedToUri in order to indicate that the user has moved
await this.usersRepository.update(old_acc.id, { movedToUri: targetUri });
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
where: {
followeeId: old_acc.id,
followerHost: IsNull(), // follower is local
},
});
for (const following of followings) {
if (!following.follower) continue;
try {
await this.userFollowingService.follow(following.follower, new_acc);
await this.userFollowingService.unfollow(following.follower, old_acc);
} catch {
/* empty */
}
}
return 'ok';
}
}

View file

@ -25,7 +25,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { LdSignatureService } from './LdSignatureService.js';
import { ApMfmService } from './ApMfmService.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
import type { IIdentifier } from './models/identifier.js';
@Injectable()
@ -292,6 +292,22 @@ export class ApRendererService {
};
}
@bindThis
public renderMove(
src: { id: User['id']; host: User['host']; uri: User['host'] },
dst: { id: User['id']; host: User['host']; uri: User['host'] },
): IMove {
const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!;
const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!;
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
actor,
type: 'Move',
object: actor,
target,
};
}
@bindThis
public async renderNote(note: Note, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]) => {
@ -498,6 +514,14 @@ export class ApRendererService {
attachment: attachment.length ? attachment : undefined,
} as any;
if (user.movedToUri) {
person.movedTo = user.movedToUri;
}
if (user.alsoKnownAs) {
person.alsoKnownAs = user.alsoKnownAs;
}
if (profile.birthday) {
person['vcard:bday'] = profile.birthday;
}

View file

@ -281,6 +281,8 @@ export class ApPersonService implements OnModuleInit {
lastFetchedAt: new Date(),
name: truncate(person.name, nameLength),
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
username: person.preferredUsername,
usernameLower: person.preferredUsername!.toLowerCase(),
@ -473,6 +475,8 @@ export class ApPersonService implements OnModuleInit {
isBot: getApType(object) === 'Service',
isCat: (person as any).isCat === true,
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: !!person.discoverable,
} as Partial<User>;

View file

@ -157,6 +157,8 @@ export interface IActor extends IObject {
name?: string;
preferredUsername?: string;
manuallyApprovesFollowers?: boolean;
movedTo?: string;
alsoKnownAs?: string[];
discoverable?: boolean;
inbox: string;
sharedInbox?: string; // 後方互換性のため
@ -300,6 +302,11 @@ export interface IFlag extends IActivity {
type: 'Flag';
}
export interface IMove extends IActivity {
type: 'Move';
target: IObject | string;
}
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) ==
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';

View file

@ -15,6 +15,7 @@ import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema,
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { AntennaService } from '../AntennaService.js';
@ -25,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js';
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
Detailed extends true ?
Detailed extends true ?
ExpectsMe extends true ? Packed<'MeDetailed'> :
ExpectsMe extends false ? Packed<'UserDetailedNotMe'> :
Packed<'UserDetailed'> :
@ -47,6 +48,7 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean {
@Injectable()
export class UserEntityService implements OnModuleInit {
private apPersonService: ApPersonService;
private noteEntityService: NoteEntityService;
private driveFileEntityService: DriveFileEntityService;
private pageEntityService: PageEntityService;
@ -122,6 +124,7 @@ export class UserEntityService implements OnModuleInit {
}
onModuleInit() {
this.apPersonService = this.moduleRef.get('ApPersonService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.pageEntityService = this.moduleRef.get('PageEntityService');
@ -237,7 +240,7 @@ export class UserEntityService implements OnModuleInit {
@bindThis
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
const latestNotificationIdsRes = await this.redisClient.xrevrange(
`notificationTimeline:${userId}`,
'+',
@ -363,6 +366,8 @@ export class UserEntityService implements OnModuleInit {
...(opts.detail ? {
url: profile!.url,
uri: user.uri,
movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null,
alsoKnownAs: user.alsoKnownAs,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,

View file

@ -8,7 +8,7 @@ export class RedisKVCache<T> {
private memoryCache: MemoryKVCache<T>;
private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
lifetime: RedisKVCache<T>['lifetime'];
@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime'];

View file

@ -3,6 +3,8 @@
import * as crypto from 'node:crypto';
export const aidRegExp = /^[0-9a-z]{10}$/;
const TIME2000 = 946684800000;
let counter = crypto.randomBytes(2).readUInt16LE(0);

View file

@ -1,5 +1,8 @@
const CHARS = '0123456789abcdef';
// same as object-id
export const meidRegExp = /^[0-9a-f]{24}$/;
function getTime(time: number) {
if (time < 0) time = 0;
if (time === 0) {
@ -24,3 +27,9 @@ function getRandom() {
export function genMeid(date: Date): string {
return getTime(date.getTime()) + getRandom();
}
export function parseMeid(id: string): { date: Date; } {
return {
date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000),
};
}

View file

@ -3,6 +3,7 @@ const CHARS = '0123456789abcdef';
// 4bit Fixed hex value 'g'
// 44bit UNIX Time ms in Hex
// 48bit Random value in Hex
export const meidgRegExp = /^g[0-9a-f]{23}$/;
function getTime(time: number) {
if (time < 0) time = 0;
@ -26,3 +27,9 @@ function getRandom() {
export function genMeidg(date: Date): string {
return 'g' + getTime(date.getTime()) + getRandom();
}
export function parseMeidg(id: string): { date: Date; } {
return {
date: new Date(parseInt(id.slice(1, 12), 16)),
};
}

View file

@ -1,5 +1,8 @@
const CHARS = '0123456789abcdef';
// same as meid
export const objectIdRegExp = /^[0-9a-f]{24}$/;
function getTime(time: number) {
if (time < 0) time = 0;
if (time === 0) {
@ -24,3 +27,9 @@ function getRandom() {
export function genObjectId(date: Date): string {
return getTime(date.getTime()) + getRandom();
}
export function parseObjectId(id: string): { date: Date; } {
return {
date: new Date(parseInt(id.slice(0, 8), 16) * 1000),
};
}

View file

@ -0,0 +1,14 @@
// Crockford's Base32
// https://github.com/ulid/spec#encoding
const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/;
export function parseUlid(id: string): { date: Date; } {
const timestamp = id.slice(0, 10);
let time = 0;
for (let i = 0; i < 10; i++) {
time = time * 32 + CHARS.indexOf(timestamp[i]);
}
return { date: new Date(time) };
}

View file

@ -68,6 +68,19 @@ export class User {
})
public followingCount: number;
@Column('varchar', {
length: 512,
nullable: true,
comment: 'The URI of the new account of the User',
})
public movedToUri: string | null;
@Column('simple-array', {
nullable: true,
comment: 'URIs the user is known as too',
})
public alsoKnownAs: string[] | null;
@Column('integer', {
default: 0,
comment: 'The count of notes.',

View file

@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = {
format: 'uri',
nullable: true, optional: false,
},
movedToUri: {
type: 'string',
format: 'uri',
nullable: true,
optional: false,
},
alsoKnownAs: {
type: 'array',
format: 'uri',
nullable: true,
optional: false,
},
createdAt: {
type: 'string',
nullable: false, optional: false,

View file

@ -200,6 +200,22 @@ export function createPostgresDataSource(config: Config) {
statement_timeout: 1000 * 10,
...config.db.extra,
},
replication: config.dbReplications ? {
master: {
host: config.db.host,
port: config.db.port,
username: config.db.user,
password: config.db.pass,
database: config.db.db,
},
slaves: config.dbSlaves!.map(rep => ({
host: rep.host,
port: rep.port,
username: rep.user,
password: rep.pass,
database: rep.db,
})),
} : undefined,
synchronize: process.env.NODE_ENV === 'test',
dropSchema: process.env.NODE_ENV === 'test',
cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...)

View file

@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js';
import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@ -551,6 +553,8 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e
const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default };
const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default };
const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default };
const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default };
const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default };
const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default };
const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default };
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
@ -886,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_unpin,
$i_updateEmail,
$i_update,
$i_move,
$i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,
@ -1215,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_unpin,
$i_updateEmail,
$i_update,
$i_move,
$i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,

View file

@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js';
import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@ -549,6 +551,8 @@ const eps = [
['i/unpin', ep___i_unpin],
['i/update-email', ep___i_updateEmail],
['i/update', ep___i_update],
//['i/move', ep___i_move],
//['i/known-as', ep___i_knownAs],
['i/webhooks/create', ep___i_webhooks_create],
['i/webhooks/list', ep___i_webhooks_list],
['i/webhooks/show', ep___i_webhooks_show],

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, NotesRepository } from '@/models/index.js';
import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@ -73,42 +73,67 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchChannel);
}
const noteIdsRes = await this.redisClient.xrevrange(
`channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
'-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
let timeline: Note[] = [];
if (noteIdsRes.length === 0) {
return [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisClient.xrevrange(
`channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
// redis から取得していないとき・取得数が足りないとき
if (noteIdsRes.length < limit) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.channelId = :channelId', { channelId: channel.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (noteIds.length === 0) {
return [];
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
timeline = await query.take(ps.limit).getMany();
} else {
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
//#region Construct query
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
timeline = await query.getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
//#region Construct query
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
const timeline = await query.getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (me) this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(timeline, me);

View file

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: ms('1day'),
max: 30,
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
notRemote: {
message: 'User is not remote. You can only migrate from other instances.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
uriNull: {
message: 'User ActivityPup URI is null.',
code: 'URI_NULL',
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
alsoKnownAs: { type: 'string' },
},
required: ['alsoKnownAs'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
// Check parameter
if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
let unfiltered = ps.alsoKnownAs;
const updates = {} as Partial<User>;
if (!unfiltered) {
updates.alsoKnownAs = null;
} else {
// Parse user's input into the old account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});
const toUrl: string | null = knownAs.uri;
if (!toUrl) throw new ApiError(meta.errors.uriNull);
// Only allow moving from a remote account
if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
}
return await this.accountMoveService.createAlias(me, updates);
});
}
}

View file

@ -0,0 +1,140 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: ms('1day'),
max: 5,
},
errors: {
noSuchMoveTarget: {
message: 'No such move target.',
code: 'NO_SUCH_MOVE_TARGET',
id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4',
},
remoteAccountForbids: {
message:
'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?',
code: 'REMOTE_ACCOUNT_FORBIDS',
id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4',
},
notRemote: {
message: 'User is not remote. You can only migrate to other instances.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
rootForbidden: {
message: 'The root can\'t migrate.',
code: 'NOT_ROOT_FORBIDDEN',
id: '4362e8dc-731f-4ad8-a694-be2a88922a24',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
uriNull: {
message: 'User ActivityPup URI is null.',
code: 'URI_NULL',
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
},
localUriNull: {
message: 'Local User ActivityPup URI is null.',
code: 'URI_NULL',
id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71',
},
alreadyMoved: {
message: 'Account was already moved to another account.',
code: 'ALREADY_MOVED',
id: 'b234a14e-9ebe-4581-8000-074b3c215962',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
moveToAccount: { type: 'string' },
},
required: ['moveToAccount'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.config)
private config: Config,
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
private getterService: GetterService,
private apPersonService: ApPersonService,
) {
super(meta, paramDef, async (ps, me) => {
// check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
// abort if user is the root
if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
// abort if user has already moved
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
let unfiltered = ps.moveToAccount;
if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget);
// parse user's input into the destination account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
// retrieve the destination account
let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchMoveTarget);
});
const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id);
if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull);
// update local db
await this.apPersonService.updatePerson(remoteMoveTo.uri);
// retrieve updated user
moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri);
// only allow moving to a remote account
if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote);
let allowed = false;
const fromUrl = `${this.config.url}/users/${me.id}`;
// make sure that the user has indicated the old account as an alias
moveTo.alsoKnownAs?.forEach((elem) => {
if (fromUrl.includes(elem)) allowed = true;
});
// abort if unintended
if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids);
return await this.accountMoveService.moveToRemote(me, moveTo);
});
}
}

View file

@ -2,7 +2,7 @@ version: "3"
services:
redistest:
image: redis:6
image: redis:7
ports:
- "127.0.0.1:56312:6379"

View file

@ -391,6 +391,8 @@ describe('Streaming', () => {
});
});
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
/*
describe('Hashtag Timeline', () => {
test('指定したハッシュタグの投稿が流れる', () => new Promise<void>(async done => {
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
@ -410,45 +412,43 @@ describe('Streaming', () => {
});
}));
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
// test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
// let fooCount = 0;
// let barCount = 0;
// let fooBarCount = 0;
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type === 'note') {
if (body.text === '#foo') fooCount++;
if (body.text === '#bar') barCount++;
if (body.text === '#foo #bar') fooBarCount++;
}
}, {
q: [
['foo', 'bar'],
],
});
// const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
// if (type === 'note') {
// if (body.text === '#foo') fooCount++;
// if (body.text === '#bar') barCount++;
// if (body.text === '#foo #bar') fooBarCount++;
// }
// }, {
// q: [
// ['foo', 'bar'],
// ],
// });
post(chitose, {
text: '#foo',
});
// post(chitose, {
// text: '#foo',
// });
post(chitose, {
text: '#bar',
});
// post(chitose, {
// text: '#bar',
// });
post(chitose, {
text: '#foo #bar',
});
// post(chitose, {
// text: '#foo #bar',
// });
// setTimeout(() => {
// assert.strictEqual(fooCount, 0);
// assert.strictEqual(barCount, 0);
// assert.strictEqual(fooBarCount, 1);
// ws.close();
// done();
// }, 3000);
// }));
setTimeout(() => {
assert.strictEqual(fooCount, 0);
assert.strictEqual(barCount, 0);
assert.strictEqual(fooBarCount, 1);
ws.close();
done();
}, 3000);
}));
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
let fooCount = 0;
@ -549,5 +549,6 @@ describe('Streaming', () => {
}, 3000);
}));
});
*/
});
});

View file

@ -0,0 +1,44 @@
import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js';
import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js';
import { ulid } from 'ulid';
import { describe, test, expect } from '@jest/globals';
describe('misc:id', () => {
test('aid', () => {
const date = new Date();
const gotAid = genAid(date);
expect(gotAid).toMatch(aidRegExp);
expect(parseAid(gotAid).date.getTime()).toBe(date.getTime());
});
test('meid', () => {
const date = new Date();
const gotMeid = genMeid(date);
expect(gotMeid).toMatch(meidRegExp);
expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime());
});
test('meidg', () => {
const date = new Date();
const gotMeidg = genMeidg(date);
expect(gotMeidg).toMatch(meidgRegExp);
expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime());
});
test('objectid', () => {
const date = new Date();
const gotObjectId = genObjectId(date);
expect(gotObjectId).toMatch(objectIdRegExp);
expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000));
});
test('ulid', () => {
const date = new Date();
const gotUlid = ulid(date.getTime());
expect(gotUlid).toMatch(ulidRegExp);
expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime());
});
});

View file

@ -1,6 +1,12 @@
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true" as="image" type="image/png" crossorigin="anonymous">
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true" as="image" type="image/jpeg" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
html {
font-family: 'Hiragino Maru Gothic Pro', 'BIZ UDGothic', Roboto, HelveticaNeue, Arial, 'M PLUS Rounded 1c', sans-serif;
}
</style>
<script>
window.global = window;
</script>

View file

@ -0,0 +1,32 @@
<template>
<div :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
<MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/>
</div>
</template>
<script lang="ts" setup>
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n';
import { host as localHost } from '@/config';
defineProps<{
acct: string;
host: string;
}>();
</script>
<style lang="scss" module>
.root {
padding: 16px;
font-size: 90%;
background: var(--infoWarnBg);
color: var(--error);
border-radius: var(--radius);
}
.link {
margin-left: 4px;
}
</style>

View file

@ -17,8 +17,8 @@
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" />
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" />
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
</template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>
@ -32,11 +32,11 @@
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
<MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
</div>
</div>
</MkModal>

View file

@ -439,6 +439,7 @@ defineExpose({
&.asDrawer {
width: 100% !important;
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
> .emojis {
::v-deep(section) {

View file

@ -11,15 +11,21 @@
<div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown">
<div :class="[$style.header, { [$style.mini]: mini }]" @contextmenu.prevent.stop="onContextmenu">
<span :class="$style.headerLeft">
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
<template v-if="!minimized">
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
</template>
</span>
<span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
<slot name="header"></slot>
</span>
<span :class="$style.headerRight">
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
<template v-if="!minimized">
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
</template>
<button v-if="canResize && minimized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMinimize()"><i class="ti ti-maximize"></i></button>
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMinimize" class="_button" :class="$style.headerButton" @click="minimize()"><i class="ti ti-minimize"></i></button>
<button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button>
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
<button v-else-if="canResize && !maximized && !minimized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
</span>
</div>
@ -27,7 +33,7 @@
<slot></slot>
</div>
</div>
<template v-if="canResize">
<template v-if="canResize && !minimized">
<div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div>
<div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div>
<div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div>
@ -100,10 +106,11 @@ let rootEl = $shallowRef<HTMLElement | null>();
let showing = $ref(true);
let beforeClickedAt = 0;
let maximized = $ref(false);
let unMaximizedTop = '';
let unMaximizedLeft = '';
let unMaximizedWidth = '';
let unMaximizedHeight = '';
let minimized = $ref(false);
let unResizedTop = '';
let unResizedLeft = '';
let unResizedWidth = '';
let unResizedHeight = '';
function close() {
showing = false;
@ -132,10 +139,10 @@ function top() {
function maximize() {
maximized = true;
unMaximizedTop = rootEl.style.top;
unMaximizedLeft = rootEl.style.left;
unMaximizedWidth = rootEl.style.width;
unMaximizedHeight = rootEl.style.height;
unResizedTop = rootEl.style.top;
unResizedLeft = rootEl.style.left;
unResizedWidth = rootEl.style.width;
unResizedHeight = rootEl.style.height;
rootEl.style.top = '0';
rootEl.style.left = '0';
rootEl.style.width = '100%';
@ -144,10 +151,35 @@ function maximize() {
function unMaximize() {
maximized = false;
rootEl.style.top = unMaximizedTop;
rootEl.style.left = unMaximizedLeft;
rootEl.style.width = unMaximizedWidth;
rootEl.style.height = unMaximizedHeight;
rootEl.style.top = unResizedTop;
rootEl.style.left = unResizedLeft;
rootEl.style.width = unResizedWidth;
rootEl.style.height = unResizedHeight;
}
function minimize() {
minimized = true;
unResizedWidth = rootEl.style.width;
unResizedHeight = rootEl.style.height;
rootEl.style.width = minWidth + 'px';
rootEl.style.height = props.mini ? '32px' : '39px';
}
function unMinimize() {
const main = rootEl;
if (main == null) return;
minimized = false;
rootEl.style.width = unResizedWidth;
rootEl.style.height = unResizedHeight;
const browserWidth = window.innerWidth;
const browserHeight = window.innerHeight;
const windowWidth = main.offsetWidth;
const windowHeight = main.offsetHeight;
const position = main.getBoundingClientRect();
if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';
if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';
}
function onBodyMousedown() {
@ -155,7 +187,11 @@ function onBodyMousedown() {
}
function onDblClick() {
maximize();
if (minimized) {
unMinimize();
} else {
maximize();
}
}
function onHeaderMousedown(evt: MouseEvent) {
@ -187,7 +223,7 @@ function onHeaderMousedown(evt: MouseEvent) {
const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX;
const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY;
const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseInt
const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseInt
const moveBaseY = beforeMaximized ? 20 : clickY - position.top;
const browserWidth = window.innerWidth;
const browserHeight = window.innerHeight;

View file

@ -4,12 +4,16 @@
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
<div :class="$style.earLeft">
<div v-if="useBlurEffect" :class="$style.layer">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
</div>
</div>
<div :class="$style.earRight">
<div v-if="useBlurEffect" :class="$style.layer">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
</div>
</div>
@ -195,11 +199,21 @@ watch(() => props.user.avatarBlurhash, () => {
> .plot {
contain: strict;
position: absolute;
width: 100%;
height: 100%;
clip-path: path('M0 0H1V1H0z');
transform: scale(32767);
transform-origin: 0 0;
opacity: 0.5;
&:first-child {
opacity: 1;
}
&:last-child {
opacity: calc(1 / 3);
}
}
}
}
@ -221,6 +235,14 @@ watch(() => props.user.avatarBlurhash, () => {
> .plot {
background-position: 20% 10%; /* ~= 37.5deg */
&:first-child {
background-position-x: 21%;
}
&:last-child {
background-position-y: 11%;
}
}
}
}
@ -241,7 +263,16 @@ watch(() => props.user.avatarBlurhash, () => {
-38.5857864376%); /* 40 - 2 * sqrt(2) */
> .plot {
position: absolute;
background-position: 80% 10%; /* ~= 37.5deg */
&:first-child {
background-position-x: 79%;
}
&:last-child {
background-position-y: 11%;
}
}
}
}

View file

@ -13,8 +13,6 @@ export class UserPreview {
this.el = el;
this.user = user;
this.attach();
this.show = this.show.bind(this);
this.close = this.close.bind(this);
this.onMouseover = this.onMouseover.bind(this);
@ -22,6 +20,8 @@ export class UserPreview {
this.onClick = this.onClick.bind(this);
this.attach = this.attach.bind(this);
this.detach = this.detach.bind(this);
this.attach();
}
private show() {

View file

@ -187,7 +187,7 @@ try {
} catch (err) {}
const app = createApp(
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :

View file

@ -113,16 +113,37 @@ function remove(ad) {
function save(ad) {
if (ad.id == null) {
os.apiWithDialog('admin/ad/create', {
os.api('admin/ad/create', {
...ad,
expiresAt: new Date(ad.expiresAt).getTime(),
startsAt: new Date(ad.startsAt).getTime(),
}).then(() => {
os.alert({
type: 'success',
text: i18n.ts.saved,
});
refresh();
}).catch(err => {
os.alert({
type: 'error',
text: err,
});
});
} else {
os.apiWithDialog('admin/ad/update', {
os.api('admin/ad/update', {
...ad,
expiresAt: new Date(ad.expiresAt).getTime(),
startsAt: new Date(ad.startsAt).getTime(),
}).then(() => {
os.alert({
type: 'success',
text: i18n.ts.saved,
});
}).catch(err => {
os.alert({
type: 'error',
text: err,
});
});
}
}
@ -141,6 +162,25 @@ function more() {
}));
});
}
function refresh() {
os.api('admin/ad/list').then(adsResponse => {
ads = adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff);
stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff);
return {
...r,
expiresAt: exdate.toISOString().slice(0, 16),
startsAt: stdate.toISOString().slice(0, 16),
};
});
});
}
refresh();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',

View file

@ -69,6 +69,7 @@ function save(announcement) {
type: 'success',
text: i18n.ts.saved,
});
refresh();
}).catch(err => {
os.alert({
type: 'error',
@ -90,6 +91,14 @@ function save(announcement) {
}
}
function refresh() {
os.api('admin/announcements/list').then(announcementResponse => {
announcements = announcementResponse;
});
}
refresh();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',

View file

@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<div v-if="channel" class="_gaps_m">
<div v-if="channelId == null || channel != null" class="_gaps_m">
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>

View file

@ -47,6 +47,7 @@ const featuredPagination = {
const favoritesPagination = {
endpoint: 'channels/my-favorites' as const,
limit: 100,
noPaging: true,
};
const followingPagination = {
endpoint: 'channels/followed' as const,

View file

@ -88,7 +88,7 @@ const tagUsers = $computed(() => ({
},
}));
const pinnedUsers = { endpoint: 'pinned-users' };
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'local',

View file

@ -24,6 +24,7 @@ import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'antennas/list' as const,
noPaging: true,
limit: 10,
};

View file

@ -32,6 +32,7 @@ import { clipsCache } from '@/cache';
const pagination = {
endpoint: 'clips/list' as const,
noPaging: true,
limit: 10,
};

View file

@ -30,6 +30,7 @@ const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>();
const pagination = {
endpoint: 'users/lists/list' as const,
noPaging: true,
limit: 10,
};

View file

@ -130,11 +130,6 @@ const menuDef = computed(() => [{
}, {
title: i18n.ts.otherSettings,
items: [{
icon: 'ti ti-package',
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: currentPage?.route.name === 'import-export',
}, {
icon: 'ti ti-badges',
text: i18n.ts.roles,
to: '/settings/roles',
@ -165,6 +160,16 @@ const menuDef = computed(() => [{
to: '/settings/webhook',
active: currentPage?.route.name === 'webhook',
}, {
icon: 'ti ti-package',
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: currentPage?.route.name === 'import-export',
}, /*{
icon: 'ti ti-plane',
text: i18n.ts.accountMigration,
to: '/settings/migration',
active: currentPage?.route.name === 'migration',
},*/ {
icon: 'ti ti-dots',
text: i18n.ts.other,
to: '/settings/other',
@ -231,7 +236,7 @@ onUnmounted(() => {
});
watch(router.currentRef, (to) => {
if (to.route.name === "settings" && to.child?.route.name == null && !narrow) {
if (to.route.name === 'settings' && to.child?.route.name == null && !narrow) {
router.replace('/settings/profile');
}
});

View file

@ -0,0 +1,73 @@
<template>
<div class="_gaps_m">
<FormSection first>
<template #label>{{ i18n.ts._accountMigration.moveTo }}</template>
<MkInput v-model="moveToAccount" manual-save>
<template #prefix><i class="ti ti-plane-departure"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
</MkInput>
</FormSection>
<FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
<FormSection>
<template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
<MkInput v-model="accountAlias" manual-save>
<template #prefix><i class="ti ti-plane-arrival"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template>
</MkInput>
</FormSection>
<FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
import FormInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const moveToAccount = ref('');
const accountAlias = ref('');
async function move(): Promise<void> {
const account = moveToAccount.value;
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('migrationConfirm', { account: account.toString() }),
});
if (confirm.canceled) return;
os.apiWithDialog('i/move', {
moveToAccount: account,
});
}
async function save(): Promise<void> {
const account = accountAlias.value;
os.apiWithDialog('i/known-as', {
alsoKnownAs: account,
});
}
watch(accountAlias, async () => {
await save();
});
watch(moveToAccount, async () => {
await move();
});
definePageMetadata({
title: i18n.ts.accountMigration,
icon: 'ti ti-plane',
});
</script>
<style lang="scss">
.description {
font-size: .85em;
padding: 1rem;
}
</style>

View file

@ -7,6 +7,7 @@
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@ -117,6 +118,7 @@ import calcAge from 's-age';
import * as misskey from 'misskey-js';
import MkNote from '@/components/MkNote.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkAccountMoved from '@/components/MkAccountMoved.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
import MkOmit from '@/components/MkOmit.vue';
import MkInfo from '@/components/MkInfo.vue';

View file

@ -161,6 +161,10 @@ export const routes = [{
path: '/preferences-backups',
name: 'preferences-backups',
component: page(() => import('./pages/settings/preferences-backups.vue')),
}, {
path: '/migration',
name: 'migration',
component: page(() => import('./pages/settings/migration.vue'))
}, {
path: '/custom-css',
name: 'general',

View file

@ -1353,6 +1353,14 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
'i/move': {
req: TODO;
res: TODO;
};
'i/known-as': {
req: TODO;
res: TODO;
};
'i/notifications': {
req: {
limit?: number;
@ -2688,6 +2696,8 @@ type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
alsoKnownAs: string[];
movedToUri: any;
emojis: {
name: string;
url: string;
@ -2709,7 +2719,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:594:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/api.types.ts:596:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -362,6 +362,8 @@ export type Endpoints = {
'i/get-word-muted-notes-count': { req: TODO; res: TODO; };
'i/import-following': { req: TODO; res: TODO; };
'i/import-user-lists': { req: TODO; res: TODO; };
'i/move': { req: TODO; res: TODO; };
'i/known-as': { req: TODO; res: TODO; };
'i/notifications': { req: {
limit?: number;
sinceId?: Notification['id'];

View file

@ -14,6 +14,8 @@ export type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
alsoKnownAs: string[];
movedToUri: any;
emojis: {
name: string;
url: string;