diff --git a/cli/commands/user/refetch.ts b/cli/commands/user/refetch.ts new file mode 100644 index 00000000..969e6565 --- /dev/null +++ b/cli/commands/user/refetch.ts @@ -0,0 +1,98 @@ +import confirm from "@inquirer/confirm"; +import { Flags } from "@oclif/core"; +import chalk from "chalk"; +import { UserFinderCommand } from "~/cli/classes"; +import { formatArray } from "~/cli/utils/format"; + +export default class UserRefetch extends UserFinderCommand { + static override description = "Refetch remote users"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> johngastron --type username", + "<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912", + ]; + + static override flags = { + confirm: Flags.boolean({ + description: + "Ask for confirmation before refetching the user (default yes)", + allowNo: true, + default: true, + }), + limit: Flags.integer({ + char: "n", + description: "Limit the number of users", + default: 1, + }), + }; + + static override args = { + identifier: UserFinderCommand.baseArgs.identifier, + }; + + public async run(): Promise { + const { flags } = await this.parse(UserRefetch); + + const users = await this.findUsers(); + + if (!users || users.length === 0) { + this.log(chalk.bold(`${chalk.red("✗")} No users found`)); + this.exit(1); + } + + // Display user + flags.print && + this.log( + chalk.bold( + `${chalk.green("✓")} Found ${chalk.green( + users.length, + )} user(s)`, + ), + ); + + flags.print && + this.log( + formatArray( + users.map((u) => u.getUser()), + [ + "id", + "username", + "displayName", + "createdAt", + "updatedAt", + "isAdmin", + ], + ), + ); + + if (flags.confirm && !flags.print) { + const choice = await confirm({ + message: `Refetch these users? ${chalk.red( + "This is irreversible.", + )}`, + }); + + if (!choice) { + this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`)); + return this.exit(1); + } + } + + for (const user of users) { + try { + await user.updateFromRemote(); + } catch (error) { + this.log( + chalk.bold( + `${chalk.red("✗")} Failed to refetch user ${ + user.getUser().username + }`, + ), + ); + this.log(chalk.red((error as Error).message)); + } + } + + this.exit(0); + } +} diff --git a/cli/index.ts b/cli/index.ts index c4612205..9abb96fb 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -7,6 +7,7 @@ import Start from "./commands/start"; import UserCreate from "./commands/user/create"; import UserDelete from "./commands/user/delete"; import UserList from "./commands/user/list"; +import UserRefetch from "./commands/user/refetch"; import UserReset from "./commands/user/reset"; // Use "explicit" oclif strategy to avoid issues with oclif's module resolver and bundling @@ -15,6 +16,7 @@ export const commands = { "user:delete": UserDelete, "user:create": UserCreate, "user:reset": UserReset, + "user:refetch": UserRefetch, "emoji:add": EmojiAdd, "emoji:delete": EmojiDelete, "emoji:list": EmojiList, diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 40e90a77..1d18fc03 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -3,7 +3,7 @@ import { idValidator } from "@/api"; import { getBestContentType, urlToContentFormat } from "@/content_types"; import { addUserToMeilisearch } from "@/meilisearch"; import { proxyUrl } from "@/response"; -import type { EntityValidator } from "@lysand-org/federation"; +import { EntityValidator } from "@lysand-org/federation"; import { type SQL, and, @@ -187,25 +187,36 @@ export class User { )[0]; } - static async resolve(uri: string): Promise { - // Check if user not already in database - const foundUser = await User.fromSql(eq(Users.uri, uri)); + async save() { + return ( + await db + .update(Users) + .set({ + ...this.user, + updatedAt: new Date().toISOString(), + }) + .where(eq(Users.id, this.id)) + .returning() + )[0]; + } - if (foundUser) return foundUser; - - // Check if URI is of a local user - if (uri.startsWith(config.http.base_url)) { - const uuid = uri.match(idValidator); - - if (!uuid || !uuid[0]) { - throw new Error( - `URI ${uri} is of a local user, but it could not be parsed`, - ); - } - - return await User.fromId(uuid[0]); + async updateFromRemote() { + if (!this.isRemote()) { + throw new Error("Cannot update local user from remote"); } + const updated = await User.saveFromRemote(this.getUri()); + + if (!updated) { + throw new Error("User not found after update"); + } + + this.user = updated.getUser(); + + return this; + } + + static async saveFromRemote(uri: string): Promise { if (!URL.canParse(uri)) { throw new Error(`Invalid URI to parse ${uri}`); } @@ -217,28 +228,13 @@ export class User { }, }); - const data = (await response.json()) as Partial< + const json = (await response.json()) as Partial< typeof EntityValidator.$User >; - if ( - !( - data.id && - data.username && - data.uri && - data.created_at && - data.dislikes && - data.featured && - data.likes && - data.followers && - data.following && - data.inbox && - data.outbox && - data.public_key - ) - ) { - throw new Error("Invalid user data"); - } + const validator = new EntityValidator(); + + const data = await validator.User(json); // Parse emojis and add them to database const userEmojis = @@ -252,6 +248,50 @@ export class User { emojis.push(await fetchEmoji(emoji)); } + // Check if new user already exists + const foundUser = await User.fromSql(eq(Users.uri, data.uri)); + + // If it exists, simply update it + if (foundUser) { + await foundUser.update({ + updatedAt: new Date().toISOString(), + endpoints: { + dislikes: data.dislikes, + featured: data.featured, + likes: data.likes, + followers: data.followers, + following: data.following, + inbox: data.inbox, + outbox: data.outbox, + }, + avatar: data.avatar + ? Object.entries(data.avatar)[0][1].content + : "", + header: data.header + ? Object.entries(data.header)[0][1].content + : "", + displayName: data.display_name ?? "", + note: getBestContentType(data.bio).content, + publicKey: data.public_key.public_key, + }); + + // Add emojis + if (emojis.length > 0) { + await db + .delete(EmojiToUser) + .where(eq(EmojiToUser.userId, foundUser.id)); + + await db.insert(EmojiToUser).values( + emojis.map((emoji) => ({ + emojiId: emoji.id, + userId: foundUser.id, + })), + ); + } + + return foundUser; + } + const newUser = ( await db .insert(Users) @@ -311,6 +351,28 @@ export class User { return finalUser; } + static async resolve(uri: string): Promise { + // Check if user not already in database + const foundUser = await User.fromSql(eq(Users.uri, uri)); + + if (foundUser) return foundUser; + + // Check if URI is of a local user + if (uri.startsWith(config.http.base_url)) { + const uuid = uri.match(idValidator); + + if (!uuid || !uuid[0]) { + throw new Error( + `URI ${uri} is of a local user, but it could not be parsed`, + ); + } + + return await User.fromId(uuid[0]); + } + + return await User.saveFromRemote(uri); + } + /** * Get the user's avatar in raw URL format * @param config The config to use diff --git a/server/api/users/:uuid/inbox/index.ts b/server/api/users/:uuid/inbox/index.ts index 0ed6866f..11ee4e1e 100644 --- a/server/api/users/:uuid/inbox/index.ts +++ b/server/api/users/:uuid/inbox/index.ts @@ -14,8 +14,9 @@ import { sendFollowAccept, } from "~/database/entities/User"; import { db } from "~/drizzle/db"; -import { Notifications, Relationships } from "~/drizzle/schema"; +import { Notes, Notifications, Relationships } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; +import { Note } from "~/packages/database-interface/note"; import { User } from "~/packages/database-interface/user"; import { LogLevel, LogManager } from "~/packages/log-manager"; import { @@ -314,6 +315,57 @@ export default (app: Hono) => return response("Follow request rejected", 200); }, + undo: async (undo) => { + // Delete the specified object from database, if it exists and belongs to the user + const toDelete = undo.object; + + // Try and find a follow, note, or user with the given URI + // Note + const note = await Note.fromSql( + eq(Notes.uri, toDelete), + eq(Notes.authorId, user.id), + ); + + if (note) { + await note.delete(); + return response("Note deleted", 200); + } + + // Follow (unfollow/cancel follow request) + // TODO: Remember to store URIs of follow requests/objects in the future + + // User + const otherUser = await User.resolve(toDelete); + + if (otherUser) { + if (otherUser.id === user.id) { + // Delete own account + await user.delete(); + return response("Account deleted", 200); + } + return errorResponse( + "Cannot delete other users than self", + 400, + ); + } + + return errorResponse( + `Deletion of object ${toDelete} not implemented`, + 400, + ); + }, + user: async (user) => { + // Refetch user to ensure we have the latest data + const updatedAccount = await User.saveFromRemote( + user.uri, + ); + + if (!updatedAccount) { + return errorResponse("Failed to update user", 500); + } + + return response("User refreshed", 200); + }, }); if (result) {