Merge pull request 'Add account migration' (#9186) from account_migration into develop

Reviewed-on: https://codeberg.org/thatonecalculator/calckey/pulls/9186
This commit is contained in:
Kainoa Kanter 2022-12-12 01:53:38 +00:00
commit c03b4f8cfb
47 changed files with 669 additions and 119 deletions

5
.gitignore vendored
View File

@ -59,3 +59,8 @@ packages/backend/assets/instance.css
*.blend3
*.blend4
*.blend5
#intelij stuff
packages/backend/.idea/backend.iml
packages/backend/.idea/modules.xml
packages/backend/.idea/vcs.xml

View File

@ -112,6 +112,7 @@ reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drück
rememberNoteVisibility: "Notizsichtbarkeit merken"
attachCancel: "Anhang entfernen"
markAsSensitive: "Als NSFW markieren"
accountMoved: "Benutzer hat zu einem anderen Account gewechselt."
unmarkAsSensitive: "Als nicht NSFW markieren"
enterFileName: "Dateinamen eingeben"
mute: "Stummschalten"

View File

@ -59,7 +59,7 @@ followRequestAccepted: "Follow request accepted"
mention: "Mention"
mentions: "Mentions"
directNotes: "Direct notes"
importAndExport: "Import / Export"
importAndExport: "Import/Export Data"
import: "Import"
export: "Export"
files: "Files"
@ -149,6 +149,7 @@ addAccount: "Add account"
loginFailed: "Failed to sign in"
showOnRemote: "View on remote instance"
general: "General"
accountMoved: "User has moved to a new account:"
wallpaper: "Wallpaper"
setWallpaper: "Set wallpaper"
removeWallpaper: "Remove wallpaper"
@ -920,6 +921,15 @@ swipeOnDesktop: "Allow mobile-style swiping on desktop"
logoImageUrl: "Logo image URL"
showAdminUpdates: "Indicate a new Calckey version is avaliable (admin only)"
replayTutorial: "Replay tutorial"
migration: "Migration"
moveTo: "Move current account to new account"
moveToLabel: "Account you're moving to:"
moveAccount: "Move account!"
moveAccountDescription: "This process is irriversable. Make sure you've set up an alias for this account on your new account before moving. Please enter the tag of the account formatted like @person@instance.com"
moveFrom: "Move to this account from an older account"
moveFromLabel: "Account you're moving from:"
moveFromDescription: "This will set an alias of your old account so that you can move from that account to this current one. Please enter the tag of the account formatted like @person@instance.com"
migrationConfirm: "Are you absolutely sure you want to migrate your acccount to {account}? Once you do this, you won't be able to reverse it, and you won't be able to use your account normally again."
_sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server."

View File

@ -149,6 +149,7 @@ addAccount: "アカウントを追加"
loginFailed: "ログインに失敗しました"
showOnRemote: "リモートで表示"
general: "全般"
accountMoved: "このユーザーは新しいアカウントに移行しました"
wallpaper: "壁紙"
setWallpaper: "壁紙を設定"
removeWallpaper: "壁紙を削除"

View File

@ -108,7 +108,7 @@ sensitive: "열람주의"
add: "추가"
reaction: "리액션"
reactionSetting: "선택기에 표시할 리액션"
reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, 를 눌러서 추가할 수 있습니다."
reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수 있습니다."
rememberNoteVisibility: "공개 범위를 기억하기"
attachCancel: "첨부 취소"
markAsSensitive: "열람주의로 설정"

View File

@ -19,6 +19,7 @@
"start:test": "yarn workspace backend run start:test",
"init": "yarn migrate",
"migrate": "yarn workspace backend run migrate",
"revertmigration": "yarn workspace backend run revertmigration",
"migrateandstart": "yarn migrate && yarn start",
"gulp": "gulp build",
"watch": "yarn dev",

8
packages/backend/.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

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

View File

@ -7,6 +7,7 @@
"start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "typeorm migration:run -d ormconfig.js",
"revertmigration": "typeorm migration:revert -d ormconfig.js",
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs",
"lint": "eslint --quiet \"src/**/*.ts\"",

View File

@ -7,7 +7,7 @@ export async function fetchMeta(noCache = false): Promise<Meta> {
if (!noCache && cache) return cache;
return await db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
// New IDs are prioritized because multiple records may have been created due to past bugs.
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
@ -20,7 +20,7 @@ export async function fetchMeta(noCache = false): Promise<Meta> {
cache = meta;
return meta;
} else {
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
// If fetchMeta is called at the same time when meta is empty, this part may be called at the same time, so use fail-safe upsert.
const saved = await transactionalEntityManager
.upsert(
Meta,

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

@ -1,22 +1,49 @@
import { EntityRepository, Repository, In, Not } from 'typeorm';
import { URL } from 'url';
import { In, Not } from 'typeorm';
import Ajv from 'ajv';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import type { ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import { User } from '@/models/entities/user.js';
import config from '@/config/index.js';
import { Packed } from '@/misc/schema.js';
import { awaitAll, Promiseable } from '@/prelude/await-all.js';
import type { Packed } from '@/misc/schema.js';
import type { Promiseable } from '@/prelude/await-all.js';
import { awaitAll } from '@/prelude/await-all.js';
import { populateEmojis } from '@/misc/populate-emojis.js';
import { getAntennas } from '@/misc/antenna-cache.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { 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, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js';
import { isActor, getApId } from '@/remote/activitypub/type.js';
import DbResolver from '@/remote/activitypub/db-resolver.js';
import Resolver from '@/remote/activitypub/resolver.js';
import { createPerson } from '@/remote/activitypub/models/person.js';
import {
AnnouncementReads,
Announcements,
AntennaNotes,
Blockings,
ChannelFollowings,
DriveFiles,
Followings,
FollowRequests,
Instances,
MessagingMessages,
Mutings,
Notes,
NoteUnreads,
Notifications,
Pages,
UserGroupJoinings,
UserNotePinings,
UserProfiles,
UserSecurityKeys,
} from '../index.js';
import type { Instance } from '../entities/instance.js';
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> =
Detailed extends true ?
Detailed extends true ?
ExpectsMe extends true ? Packed<'MeDetailed'> :
ExpectsMe extends false ? Packed<'UserDetailedNotMe'> :
Packed<'UserDetailed'> :
@ -33,12 +60,24 @@ const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]
function isLocalUser(user: User): user is ILocalUser;
function isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; };
/**
* Returns true if the user is local.
*
* @param user The user to check.
* @returns True if the user is local.
*/
function isLocalUser(user: User | { host: User['host'] }): boolean {
return user.host == null;
}
function isRemoteUser(user: User): user is IRemoteUser;
function isRemoteUser<T extends { host: User['host'] }>(user: T): user is T & { host: string; };
/**
* Returns true if the user is remote.
*
* @param user The user to check.
* @returns True if the user is remote.
*/
function isRemoteUser(user: User | { host: User['host'] }): boolean {
return !isLocalUser(user);
}
@ -156,6 +195,27 @@ export const UserRepository = db.getRepository(User).extend({
return count > 0;
},
async userFromURI(uri: string): Promise<User | null> {
const dbResolver = new DbResolver();
let local = await dbResolver.getUserFromApId(uri);
if (local) {
return local;
}
// fetching Object once from remote
const resolver = new Resolver();
const object = await resolver.resolve(uri) as any;
// /@user If a URI other than the id is specified,
// the URI is determined here
if (uri !== object.id) {
local = await dbResolver.getUserFromApId(object.id);
if (local != null) return local;
}
return isActor(object) ? await createPerson(getApId(object)) : null;
},
async getHasUnreadAntenna(userId: User['id']): Promise<boolean> {
const myAntennas = (await getAntennas()).filter(a => a.userId === userId);
@ -320,6 +380,8 @@ export const UserRepository = db.getRepository(User).extend({
...(opts.detail ? {
url: profile!.url,
uri: user.uri,
movedToUri: user.movedToUri ? await this.userFromURI(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

@ -96,6 +96,16 @@ 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

@ -20,7 +20,7 @@ import { UserPublickey } from '@/models/entities/user-publickey.js';
const logger = new Logger('inbox');
// ユーザーのinboxにアクティビティが届いた時の処理
// Processing when an activity arrives in the user's inbox
export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
const signature = job.data.signature; // HTTP-signature
const activity = job.data.activity;
@ -30,16 +30,15 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
delete info['@context'];
logger.debug(JSON.stringify(info, null, 2));
//#endregion
const host = toPuny(new URL(signature.keyId).hostname);
// ブロックしてたら中断
// interrupt if blocked
const meta = await fetchMeta();
if (meta.blockedHosts.includes(host)) {
return `Blocked request: ${host}`;
}
// 非公開モードなら許可なインスタンスのみ
// only whitelisted instances in private mode
if (meta.privateMode && !meta.allowedHosts.includes(host)) {
return `Blocked request: ${host}`;
}
@ -51,7 +50,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
const dbResolver = new DbResolver();
// HTTP-Signature keyIdを元にDBから取得
// HTTP-Signature keyId from DB
let authUser: {
user: CacheableRemoteUser;
key: UserPublickey | null;
@ -62,7 +61,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
try {
authUser = await dbResolver.getAuthUserFromApId(getApId(activity.actor));
} catch (e) {
// 対象が4xxならスキップ
// Skip if target is 4xx
if (e instanceof StatusError) {
if (e.isClientError) {
return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`;

View File

@ -5,7 +5,7 @@ import DbResolver from '../../db-resolver.js';
import { Users } from '@/models/index.js';
export default async (actor: CacheableRemoteUser, activity: IBlock): Promise<string> => {
// ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず
// ※ There is a block target in activity.object, which should be a local user that exists.
const dbResolver = new DbResolver();
const blockee = await dbResolver.getUserFromApId(activity.object);
@ -15,7 +15,7 @@ export default async (actor: CacheableRemoteUser, activity: IBlock): Promise<str
}
if (blockee.host != null) {
return `skip: ブロックしようとしているユーザーはローカルユーザーではありません`;
return `skip: The user you are trying to block is not a local user`;
}
await block(await Users.findOneByOrFail({ id: actor.id }), await Users.findOneByOrFail({ id: blockee.id }));

View File

@ -1,5 +1,26 @@
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type.js';
import { CacheableRemoteUser } from '@/models/entities/user.js';
import type { CacheableRemoteUser } from '@/models/entities/user.js';
import { toArray } from '@/prelude/array.js';
import {
isCreate,
isDelete,
isUpdate,
isRead,
isFollow,
isAccept,
isReject,
isAdd,
isRemove,
isAnnounce,
isLike,
isUndo,
isBlock,
isCollectionOrOrderedCollection,
isCollection,
isFlag,
isMove,
} from '../type.js';
import { apLogger } from '../logger.js';
import Resolver from '../resolver.js';
import create from './create/index.js';
import performDeleteActivity from './delete/index.js';
import performUpdateActivity from './update/index.js';
@ -14,10 +35,8 @@ import add from './add/index.js';
import remove from './remove/index.js';
import block from './block/index.js';
import flag from './flag/index.js';
import { apLogger } from '../logger.js';
import Resolver from '../resolver.js';
import { toArray } from '@/prelude/array.js';
import { Users } from '@/models/index.js';
import move from './move/index.js';
import type { IObject } from '../type.js';
export async function performActivity(actor: CacheableRemoteUser, activity: IObject) {
if (isCollectionOrOrderedCollection(activity)) {
@ -68,6 +87,8 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject)
await block(actor, activity);
} else if (isFlag(activity)) {
await flag(actor, activity);
} else if (isMove(activity)) {
await move(actor,activity);
} else {
apLogger.warn(`unrecognized activity type: ${(activity as any).type}`);
}

View File

@ -0,0 +1,85 @@
import type { CacheableRemoteUser } from '@/models/entities/user.js';
import { IRemoteUser, User } from '@/models/entities/user.js';
import DbResolver from '@/remote/activitypub/db-resolver.js';
import { getRemoteUser } from '@/server/api/common/getters.js';
import { updatePerson } from '@/remote/activitypub/models/person.js';
import { Followings, Users } from '@/models/index.js';
import { makePaginationQuery } from '@/server/api/common/make-pagination-query.js';
import deleteFollowing from '@/services/following/delete.js';
import create from '@/services/following/create.js';
import { getUser } from '@/server/api/common/getters.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '@/server/api/error.js';
import { meta } from '@/server/api/endpoints/following/create.js';
import { IObject, IActor } from '../../type.js';
import type { IMove } from '../../type.js';
import Resolver from '@/remote/activitypub/resolver.js';
export default async (actor: CacheableRemoteUser, activity: IMove): Promise<string> => {
// ※ There is a block target in activity.object, which should be a local user that exists.
const dbResolver = new DbResolver();
const resolver = new Resolver();
let new_acc = await dbResolver.getUserFromApId(activity.target);
let actor_new;
if (!new_acc) actor_new = await resolver.resolve(<string>activity.target) as IActor;
if ((!new_acc || new_acc.uri === null) && (!actor_new || actor_new.id === null)) {
return 'move: new acc not found';
}
let newUri: string | null | undefined
newUri = new_acc ? new_acc.uri :
actor_new?.url?.toString();
if(newUri === null || newUri === undefined) return 'move: new acc not found #2';
await updatePerson(newUri);
await updatePerson(actor.uri!);
new_acc = await dbResolver.getUserFromApId(newUri);
let old = await dbResolver.getUserFromApId(actor.uri!);
if (old === null || old.uri === null || !new_acc?.alsoKnownAs?.includes(old.uri)) return 'move: accounts invalid';
old.movedToUri = new_acc.uri;
const followee = await getUser(actor.id).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e;
});
const followeeNew = await getUser(new_acc.id).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e;
});
const followings = await Followings.findBy({
followeeId: followee.id,
});
//TODO remove this
console.log(followings);
followings.forEach(async following => {
//if follower is local
if (!following.followerHost) {
const follower = await getUser(following.followerId).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e;
});
await deleteFollowing(follower!, followee);
try {
await create(follower!, followeeNew);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') return meta.errors.blocking;
if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') return meta.errors.blocked;
}
return e;
}
}
});
return 'ok';
};

View File

@ -172,6 +172,8 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
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(),
@ -277,21 +279,21 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
}
/**
* Personの情報を更新します
* Misskeyに対象のPersonが登録されていなければ無視します
* Update Person data from remote.
* If the target Person is not registered in Calckey, it is ignored.
* @param uri URI of Person
* @param resolver Resolver
* @param hint Hint of Person object (Personの場合Remote resolveをせずに更新に利用します)
* @param hint Hint of Person object (If this value is a valid Person, it is used for updating without Remote resolve)
*/
export async function updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise<void> {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
// Skip if the URI points to this server
if (uri.startsWith(config.url + '/')) {
return;
}
//#region このサーバーに既に登録されているか
//#region Already registered on this server?
const exist = await Users.findOneBy({ uri }) as IRemoteUser;
if (exist == null) {
@ -307,7 +309,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
logger.info(`Updating the Person: ${person.id}`);
// アバターとヘッダー画像をフェッチ
// Fetch avatar and header image
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
@ -317,7 +319,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
: resolveImage(exist, img).catch(() => null),
));
// カスタム絵文字取得
// Custom pictogram acquisition
const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => {
logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
@ -343,6 +345,8 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
isBot: getApType(object) === 'Service',
isCat: (person as any).isCat === true,
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
} as Partial<User>;
@ -374,10 +378,10 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
publishInternalEvent('remoteUserUpdated', { id: exist.id });
// ハッシュタグ更新
// Hashtag Update
updateUsertags(exist, tags);
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
// If the user in question is a follower, followers will also be updated.
await Followings.update({
followerId: exist.id,
}, {
@ -388,15 +392,15 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
}
/**
* Personを解決します
* Resolve Person.
*
* Misskeyに対象のPersonが登録されていればそれを返し
* Misskeyに登録しそれを返します
* If the target person is registered in Calckey, it returns it;
* otherwise, it fetches it from the remote server, registers it in Calckey, and returns it.
*/
export async function resolvePerson(uri: string, resolver?: Resolver): Promise<CacheableUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
//#region If already registered on this server, return it.
const exist = await fetchPerson(uri);
if (exist) {
@ -404,7 +408,7 @@ export async function resolvePerson(uri: string, resolver?: Resolver): Promise<C
}
//#endregion
// リモートサーバーからフェッチしてきて登録
// Fetched from remote server and registered
if (resolver == null) resolver = new Resolver();
return await createPerson(uri, resolver);
}
@ -482,14 +486,14 @@ export async function updateFeatured(userId: User['id'], resolver?: Resolver) {
// Resolve and regist Notes
const limit = promiseLimit<Note | null>(2);
const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
.filter(item => getApType(item) === 'Note') // TODO: Maybe it doesn't have to be a Note.
.slice(0, 5)
.map(item => limit(() => resolveNote(item, resolver))));
await db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.delete(UserNotePining, { userId: user.id });
// とりあえずidを別の時間で生成して順番を維持
// For now, generate the id at a different time and maintain the order.
let td = 0;
for (const note of featuredNotes.filter(note => note != null)) {
td -= 1000;

View File

@ -1,9 +1,9 @@
import config from '@/config/index.js';
import { v4 as uuid } from 'uuid';
import { IActivity } from '../type.js';
import { LdSignature } from '../misc/ld-signature.js';
import config from '@/config/index.js';
import { getUserKeypair } from '@/misc/keypair-store.js';
import { User } from '@/models/entities/user.js';
import type { User } from '@/models/entities/user.js';
import { LdSignature } from '../misc/ld-signature.js';
import type { IActivity } from '../type.js';
export const renderActivity = (x: any): IActivity | null => {
if (x == null) return null;
@ -19,6 +19,7 @@ export const renderActivity = (x: any): IActivity | null => {
{
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
movedToUri: 'as:movedTo',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',

View File

@ -1,16 +1,16 @@
import { URL } from 'node:url';
import * as mfm from 'mfm-js';
import renderImage from './image.js';
import renderKey from './key.js';
import config from '@/config/index.js';
import { ILocalUser } from '@/models/entities/user.js';
import { toHtml } from '../../../mfm/to-html.js';
import { getEmojis } from './note.js';
import renderEmoji from './emoji.js';
import { IIdentifier } from '../models/identifier.js';
import renderHashtag from './hashtag.js';
import type { ILocalUser } from '@/models/entities/user.js';
import { DriveFiles, UserProfiles } from '@/models/index.js';
import { getUserKeypair } from '@/misc/keypair-store.js';
import { toHtml } from '../../../mfm/to-html.js';
import renderImage from './image.js';
import renderKey from './key.js';
import { getEmojis } from './note.js';
import renderEmoji from './emoji.js';
import renderHashtag from './hashtag.js';
import type { IIdentifier } from '../models/identifier.js';
export async function renderPerson(user: ILocalUser) {
const id = `${config.url}/users/${user.id}`;
@ -71,17 +71,18 @@ export async function renderPerson(user: ILocalUser) {
image: banner ? renderImage(banner) : null,
tag,
manuallyApprovesFollowers: user.isLocked,
movedToUri: user.movedToUri,
discoverable: !!user.isExplorable,
publicKey: renderKey(user, keypair, `#main-key`),
publicKey: renderKey(user, keypair, '#main-key'),
isCat: user.isCat,
attachment: attachment.length ? attachment : undefined,
} as any;
if (profile?.birthday) {
if (profile.birthday) {
person['vcard:bday'] = profile.birthday;
}
if (profile?.location) {
if (profile.location) {
person['vcard:Address'] = profile.location;
}

View File

@ -156,9 +156,11 @@ export interface IActor extends IObject {
name?: string;
preferredUsername?: string;
manuallyApprovesFollowers?: boolean;
movedTo?: string;
alsoKnownAs?: string[];
discoverable?: boolean;
inbox: string;
sharedInbox?: string; // 後方互換性のため
sharedInbox?: string; // backward compatibility.. ig
publicKey?: {
id: string;
publicKeyPem: string;
@ -279,6 +281,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';
@ -293,3 +300,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

@ -49,9 +49,9 @@ export async function resolveUser(username: string, host: string | null): Promis
return await createPerson(self.href);
}
// ユーザー情報が古い場合は、WebFilgerからやりなおして返す
// If user information is out of date, return it by starting over from WebFilger
if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
// Prevent multiple attempts to connect to unconnected instances, update before each attempt to prevent subsequent similar attempts
await Users.update(user.id, {
lastFetchedAt: new Date(),
});

View File

@ -38,6 +38,7 @@ function inbox(ctx: Router.RouterContext) {
return;
}
// @ts-ignore
processInbox(ctx.request.body, signature);
ctx.status = 202;
@ -86,7 +87,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;

View File

@ -61,7 +61,7 @@ export default async (ctx: Router.RouterContext) => {
followerId: user.id,
} as FindOptionsWhere<Following>;
// カーソルが指定されている場合
// If a cursor is specified
if (cursor) {
query.id = LessThan(cursor);
}
@ -73,7 +73,7 @@ export default async (ctx: Router.RouterContext) => {
order: { id: -1 },
});
// 「次のページ」があるかどうか
// Whether there is a "next page" or not
const inStock = followings.length === limit + 1;
if (inStock) followings.pop();

View File

@ -325,6 +325,10 @@ import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js';
//Calckey Move
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_known_as from './endpoints/i/known-as.js';
const eps = [
['admin/meta', ep___admin_meta],
['admin/abuse-user-reports', ep___admin_abuseUserReports],
@ -489,6 +493,8 @@ const eps = [
['hashtags/trend', ep___hashtags_trend],
['hashtags/users', ep___hashtags_users],
['i', ep___i],
['i/known-as', ep___i_known_as],
['i/move', ep___i_move],
['i/2fa/done', ep___i_2fa_done],
['i/2fa/key-done', ep___i_2fa_keyDone],
['i/2fa/password-less', ep___i_2fa_passwordLess],

View File

@ -88,10 +88,10 @@ export default define(meta, paramDef, async (ps, me) => {
});
/***
* URIからUserかNoteを解決する
* Resolve User or Note from URI
*/
async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// ブロックしてたら中断
// Wait if blocked.
const fetchedMeta = await fetchMeta();
if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null;
@ -103,12 +103,12 @@ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined):
]));
if (local != null) return local;
// リモートから一旦オブジェクトフェッチ
// fetching Object once from remote
const resolver = new Resolver();
const object = await resolver.resolve(uri) as any;
// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
// これはDBに存在する可能性があるため再度DB検索
// /@user If a URI other than the id is specified,
// the URI is determined here
if (uri !== object.id) {
local = await mergePack(me, ...await Promise.all([
dbResolver.getUserFromApId(object.id),

View File

@ -6,7 +6,7 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
export const meta = {
tags: ['federation'],
requireCredential: true,
requireCredential: false,
requireCredentialPrivateMode: true,
res: {

View File

@ -0,0 +1,85 @@
import { In } from 'typeorm';
import { User } from '@/models/entities/user.js';
import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js';
import { resolveUser } from '@/remote/resolve-user.js';
import { ApiError } from '../../error.js';
import acceptAllFollowRequests from '@/services/following/requests/accept-all.js';
import { publishToFollowers } from '@/services/i/update.js';
import { apiLogger } from '../../logger.js';
import { publishMainStream, publishUserEvent } from '@/services/stream.js';
import define from '../../define.js';
import { DAY } from '@/const.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: DAY,
max: 30,
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
notRemote: {
message: 'User not remote.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
}
} as const;
export const paramDef = {
type: 'object',
properties: {
alsoKnownAs: { type: 'string' },
},
required: ['alsoKnownAs'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
if(!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
let unfiltered: string = ps.alsoKnownAs;
if(unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if(!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
let userAddress: string[] = unfiltered.split("@");
const knownAs: User = await resolveUser(userAddress[0], userAddress[1]).catch(e => {
apiLogger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});
const updates = {} as Partial<User>;
if(!knownAs.uri) knownAs.uri = "";
updates.alsoKnownAs = [knownAs.uri];
await Users.update(user.id, updates);
const iObj = await Users.pack<true, true>(user.id, user, {
detail: true,
includeSecrets: true,
});
// Publish meUpdated event
publishMainStream(user.id, 'meUpdated', iObj);
if (user.isLocked === false) {
acceptAllFollowRequests(user);
}
publishToFollowers(user.id);
return iObj;
});

View File

@ -0,0 +1,97 @@
import type { User } from '@/models/entities/user.js';
import { resolveUser } from '@/remote/resolve-user.js';
import { DAY } from '@/const.js';
import DeliverManager from '@/remote/activitypub/deliver-manager.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import type { IActivity } from '@/remote/activitypub/type.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { apiLogger } from '../../logger.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: DAY,
max: 1,
},
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.',
code: 'REMOTE_ACCOUNT_FORBIDS',
id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4',
},
notRemote: {
message: 'User not remote.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
adminForbidden: {
message: 'Adminds cant migrate.',
code: 'NOT_ADMIN_FORBIDDEN',
id: '4362e8dc-731f-4ad8-a694-be2a88922a24',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
moveToAccount: { type: 'string' },
},
required: ['moveToAccount'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
if(user.isAdmin) throw new ApiError(meta.errors.adminForbidden);
let unfiltered: string = ps.moveToAccount;
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress: string[] = unfiltered.split('@');
const moveTo: User = await resolveUser(userAddress[0], userAddress[1]).catch(e => {
apiLogger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchMoveTarget);
});
let allowed = false;
moveTo.alsoKnownAs?.forEach(element => {
if (user.uri?.includes(element)) allowed = true;
});
if (!allowed || !moveTo.uri || !user.uri) throw new ApiError(meta.errors.remoteAccountForbids);
(async (): Promise<void> => {
const moveAct = await moveActivity(moveTo.uri!, user.uri!);
const dm = new DeliverManager(user, moveAct);
dm.addFollowersRecipe();
dm.execute();
})();
return true;
});
async function moveActivity(to: string, from: string): Promise<IActivity | null> {
const activity = {
id: 'foo',
actor: from,
type: 'Move',
object: from,
target: to,
} as any;
return renderActivity(activity);
}

View File

@ -78,6 +78,12 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
},
accountLocked: {
message: 'You migrated. Your account is now locked.',
code: 'ACCOUNT_LOCKED',
id: 'd390d7e1-8a5e-46ed-b625-06271cafd3d3',
},
},
} as const;
@ -163,6 +169,7 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
if(user.movedToUri) throw new ApiError(meta.errors.accountLocked);
let visibleUsers: User[] = [];
if (ps.visibleUserIds) {
visibleUsers = await Users.findBy({
@ -250,7 +257,7 @@ export default define(meta, paramDef, async (ps, user) => {
}
}
// 投稿を作成
// Create a post
const note = await create(user, {
createdAt: new Date(),
files: files,

View File

@ -19,7 +19,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { createTemp } from '@/misc/create-temp.js';
import { publishMainStream } from '@/services/stream.js';
import * as Acct from '@/misc/acct.js';
import { envOption } from '../env.js';
import { envOption } from '@/env.js';
import activityPub from './activitypub.js';
import nodeinfo from './nodeinfo.js';
import wellKnown from './well-known.js';
@ -164,5 +164,6 @@ export default () => new Promise(resolve => {
}
});
// @ts-ignore
server.listen(config.port, resolve);
});

View File

@ -11,11 +11,11 @@ const router = new Router();
const nodeinfo2_1path = '/nodeinfo/2.1';
const nodeinfo2_0path = '/nodeinfo/2.0';
export const links = [/* (awaiting release) {
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
export const links = [{
rel: 'https://nodeinfo.diaspora.software/ns/schema/2.1',
href: config.url + nodeinfo2_1path
}, */{
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
}, {
rel: 'https://nodeinfo.diaspora.software/ns/schema/2.0',
href: config.url + nodeinfo2_0path,
}];
@ -96,6 +96,7 @@ router.get(nodeinfo2_1path, async ctx => {
router.get(nodeinfo2_0path, async ctx => {
const base = await cache.fetch(null, () => nodeinfo2());
// @ts-ignore
delete base.software.repository;
ctx.body = { version: '2.0', ...base };

View File

@ -127,8 +127,8 @@ type Option = {
};
export default async (user: { id: User['id']; username: User['username']; host: User['host']; isSilenced: User['isSilenced']; createdAt: User['createdAt']; }, data: Option, silent = false) => new Promise<Note>(async (res, rej) => {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
// If you reply outside the channel, match the scope of the target.
// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
if (data.reply.channelId) {
data.channel = await Channels.findOneBy({ id: data.reply.channelId });
@ -137,8 +137,8 @@ export default async (user: { id: User['id']; username: User['username']; host:
}
}
// チャンネル内にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
// When you reply in a channel, match the scope of the target
// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
if (data.reply && (data.channel == null) && data.reply.channelId) {
data.channel = await Channels.findOneBy({ id: data.reply.channelId });
}
@ -150,37 +150,37 @@ export default async (user: { id: User['id']; username: User['username']; host:
if (data.channel != null) data.visibleUsers = [];
if (data.channel != null) data.localOnly = true;
// サイレンス
// enforce silent clients on server
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
data.visibility = 'home';
}
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
// Reject if the target of the renote is a public range other than "Home or Entire".
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
return rej('Renote target is not public or home');
}
// Renote対象がpublicではないならhomeにする
// If the target of the renote is not public, make it home.
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// Renote対象がfollowersならfollowersにする
// If the target of Renote is followers, make it followers.
if (data.renote && data.renote.visibility === 'followers') {
data.visibility = 'followers';
}
// 返信対象がpublicではないならhomeにする
// If the reply target is not public, make it home.
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// ローカルのみをRenoteしたらローカルのみにする
// Renote local only if you Renote local only.
if (data.renote && data.renote.localOnly && data.channel == null) {
data.localOnly = true;
}
// ローカルのみにリプライしたらローカルのみにする
// If you reply to local only, make it local only.
if (data.reply && data.reply.localOnly && data.channel == null) {
data.localOnly = true;
}

View File

@ -9,7 +9,7 @@
<script lang="ts" setup>
import { } from 'vue';
const props = defineProps<{
defineProps<{
warn?: boolean;
}>();
</script>

View File

@ -0,0 +1,30 @@
<template>
<div class="msjugskd _block">
<i class="ph-airplane-takeoff-bold ph-lg" style="margin-right: 8px;"/>
{{ i18n.ts.accountMoved }}
<MkMention class="link" :username="acct" :host="host"/>
</div>
</template>
<script lang="ts" setup>
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n';
defineProps<{
acct: string;
host: string;
}>();
</script>
<style lang="scss" scoped>
.msjugskd {
padding: 16px;
background: var(--infoWarnBg);
color: var(--error);
> .link {
margin-left: 4px;
color: var(--accent);
}
}
</style>

View File

@ -12,7 +12,6 @@ defineProps<{
<style lang="scss" scoped>
.jmgmzlwq {
font-size: 0.8em;
padding: 16px;
background: var(--infoWarnBg);
color: var(--infoWarnFg);

View File

@ -34,8 +34,9 @@
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<div class="_formLinks">
<FormLink to="/@t1c@i.calckey.cloud"><Mfm :text="'$[sparkle @t1c@i.calckey.cloud] (Main fork developer)'"/></FormLink>
<FormLink to="/@syuilo@misskey.io"><Mfm :text="'@syuilo@misskey.io (Misskey developer)'"/></FormLink>
<FormLink to="/@t1c@i.calckey.cloud"><Mfm :text="'$[sparkle @t1c@i.calckey.cloud] (Main developer)'"/></FormLink>
<FormLink to="/@cleo@tech.lgbt"><Mfm :text="'$[sparkle @cleo@tech.lgbt] (Maintainer)'"/></FormLink>
<FormLink to="/@syuilo@misskey.io"><Mfm :text="'@syuilo@misskey.io (Original Misskey developer)'"/></FormLink>
<FormLink to="https://www.youtube.com/c/Henkiwashere" external>Henki (error images artist)</FormLink>
</div>
<template #caption><MkLink url="https://codeberg.org/thatonecalculator/calckey/activity">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>

View File

@ -1,16 +1,18 @@
<template><MkStickyContainer>
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue class="_formBlock">
<template #key>{{ i18n.ts.proxyAccount }}</template>
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
</MkKeyValue>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue class="_formBlock">
<template #key>{{ i18n.ts.proxyAccount }}</template>
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
</MkKeyValue>
<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
</FormSuspense>
</MkSpacer></MkStickyContainer>
<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>

View File

@ -9,26 +9,26 @@
<div v-if="origin === 'local'">
<template v-if="tag == null">
<MkFolder class="_gap" persist-key="explore-pinned-users">
<template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<template #header><i class="ph-bookmark-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<XUserList :pagination="pinnedUsers"/>
</MkFolder>
<MkFolder v-if="$i != null" class="_gap" persist-key="explore-popular-users">
<template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<template #header><i class="ph-chart-line-up-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsers"/>
</MkFolder>
<MkFolder v-if="$i != null" class="_gap" persist-key="explore-recently-updated-users">
<template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<template #header><i class="ph-activity-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<XUserList :pagination="recentlyUpdatedUsers"/>
</MkFolder>
<MkFolder v-if="$i != null" class="_gap" persist-key="explore-recently-registered-users">
<template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<template #header><i class="ph-butterfly-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsers"/>
</MkFolder>
</template>
</div>
<div v-else>
<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<template #header><i class="ph-hash-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div class="vxjfqztj">
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
@ -37,21 +37,21 @@
</MkFolder>
<MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
<template #header><i class="ph-hash-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
<XUserList :pagination="tagUsers"/>
</MkFolder>
<template v-if="tag == null && $i != null">
<MkFolder class="_gap">
<template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<template #header><i class="ph-chart-line-up-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsersF"/>
</MkFolder>
<MkFolder class="_gap">
<template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<template #header><i class="ph-activity-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<XUserList :pagination="recentlyUpdatedUsersF"/>
</MkFolder>
<MkFolder class="_gap">
<template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
<template #header><i class="ph-rocke-launch-bold ph-lg ph-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsersF"/>
</MkFolder>
</template>

View File

@ -130,11 +130,11 @@ function onRead(ids): void {
function startMenu(ev) {
os.popupMenu([{
text: i18n.ts.messagingWithUser,
icon: 'fas fa-user',
icon: 'ph-user-bold ph-lg',
action: () => { startUser(); },
}, {
text: i18n.ts.messagingWithGroup,
icon: 'fas fa-users',
icon: 'ph-users-three-bold ph-lg',
action: () => { startGroup(); },
}], ev.currentTarget ?? ev.target);
}

View File

@ -134,6 +134,11 @@ const menuDef = computed(() => [{
}, {
title: i18n.ts.otherSettings,
items: [{
icon: 'ph-airplane-takeoff-bold ph-lg',
text: i18n.ts.migration,
to: '/settings/migration',
active: currentPage?.route.name === 'migration',
}, {
icon: 'ph-package-bold ph-lg',
text: i18n.ts.importAndExport,
to: '/settings/import-export',

View File

@ -1,21 +1,21 @@
<template>
<div class="_formRoot">
<FormSection v-if="instance.enableTwitterIntegration">
<template #label><i class="fab fa-twitter"></i> Twitter</template>
<template #label><i class="ph-twitter-logo-bold ph-lg"></i> Twitter</template>
<p v-if="integrations.twitter">{{ i18n.ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
<MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton>
<MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton>
</FormSection>
<FormSection v-if="instance.enableDiscordIntegration">
<template #label><i class="fab fa-discord"></i> Discord</template>
<template #label><i class="ph-discord-logo-bold ph-lg"></i> Discord</template>
<p v-if="integrations.discord">{{ i18n.ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
<MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton>
<MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton>
</FormSection>
<FormSection v-if="instance.enableGithubIntegration">
<template #label><i class="fab fa-github"></i> GitHub</template>
<template #label><i class="ph-github-logo-bold ph-lg"></i> GitHub</template>
<p v-if="integrations.github">{{ i18n.ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
<MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton>
<MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton>

View File

@ -0,0 +1,61 @@
<template>
<div class="_formRoot">
<FormSection>
<template #label>{{ i18n.ts.moveTo }}</template>
<FormInput v-model="moveToAccount" class="_formBlock">
<template #prefix><i class="ph-airplane-takeoff-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.moveToLabel }}</template>
</FormInput>
<FormButton primary danger @click="move(moveToAccount)">
{{ i18n.ts.moveAccount }}
</FormButton>
<div class="label">{{ i18n.ts.moveAccountDescription }}</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.moveFrom }}</template>
<FormInput v-model="accountAlias" class="_formBlock">
<template #prefix><i class="ph-airplane-landing-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.moveFromLabel }}</template>
</FormInput>
<FormButton class="button" inline primary @click="save(accountAlias)">
<i class="ph-floppy-disk-back-bold ph-lg"></i> {{ i18n.ts.save }}
</FormButton>
<div class="label">{{ i18n.ts.moveFromDescription }}</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import FormSection from '@/components/form/section.vue';
import FormInput from '@/components/form/input.vue';
import FormButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
let moveToAccount = $ref('');
let accountAlias = $ref('');
async function save(account): Promise<void> {
os.apiWithDialog('i/known-as', {
alsoKnownAs: account,
});
}
async function move(account): Promise<void> {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('migrationConfirm', { account: account.toString() }),
});
if (confirm.canceled) return;
os.api('i/move', {
moveToAccount: account,
});
}
definePageMetadata({
title: i18n.ts.security,
icon: 'ph-lock-bold ph-lg',
});
</script>

View File

@ -9,7 +9,7 @@
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
<X2fa/>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template>
<MkPagination :pagination="pagination" disable-auto-load>
@ -52,7 +52,7 @@ const pagination = {
limit: 5,
};
async function change() {
async function change(): Promise<void> {
const { canceled: canceled1, result: currentPassword } = await os.inputText({
title: i18n.ts.currentPassword,
type: 'password',
@ -78,14 +78,14 @@ async function change() {
});
return;
}
os.apiWithDialog('i/change-password', {
currentPassword,
newPassword,
});
}
function regenerateToken() {
function regenerateToken(): void {
os.inputText({
title: i18n.ts.password,
type: 'password',

View File

@ -7,6 +7,7 @@
<!-- <div class="punished" v-if="user.isSilenced"><i class="ph-warning-bold ph-lg" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile">
<MkMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username" />
<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
<div :key="user.id" class="_block main">
@ -120,6 +121,7 @@ import MkFolder from '@/components/MkFolder.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
import MkTab from '@/components/MkTab.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkMoved from '@/components/MkMoved.vue';
import { getScrollPosition } from '@/scripts/scroll';
import { getUserMenu } from '@/scripts/get-user-menu';
import number from '@/filters/number';
@ -128,6 +130,7 @@ import * as os from '@/os';
import { useRouter } from '@/router';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { host } from '@/config';
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));

View File

@ -112,7 +112,7 @@ definePageMetadata(
computed(() =>
user
? {
icon: 'fas fa-user',
icon: 'ph-user-bold ph-lg',
title: user.name
? `${user.name} (@${user.username})`
: `@${user.username}`,

View File

@ -180,6 +180,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',