diff --git a/database/entities/Status.ts b/database/entities/Status.ts index bf4c5548..48579723 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -23,7 +23,7 @@ import { applicationToAPI } from "./Application"; import { attachmentToAPI, attachmentToLysand } from "./Attachment"; import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; import type { UserWithRelations } from "./User"; -import { fetchRemoteUser, parseMentionsUris, userToAPI } from "./User"; +import { resolveUser, parseMentionsUris, userToAPI } from "./User"; import { statusAndUserRelations, userRelations } from "./relations"; const statusRelations = Prisma.validator()({ @@ -57,62 +57,9 @@ export const isViewableByUser = (status: Status, user: User | null) => { return user && (status.mentions as User[]).includes(user); }; -export const fetchFromRemote = async (uri: string): Promise => { - // Check if already in database - /* const existingStatus: StatusWithRelations | null = - await client.status.findFirst({ - where: { - uri: uri, - }, - include: statusAndUserRelations, - }); - - if (existingStatus) return existingStatus; - - const status = await fetch(uri); - - if (status.status === 404) return null; - - const body = (await status.json()) as LysandPublication; - - const content = getBestContentType(body.contents); - - const emojis = await parseEmojis(content?.content || ""); - - const author = await fetchRemoteUser(body.author); - - let replyStatus: Status | null = null; - let quotingStatus: Status | null = null; - - if (body.replies_to.length > 0) { - replyStatus = await fetchFromRemote(body.replies_to[0]); - } - - if (body.quotes.length > 0) { - quotingStatus = await fetchFromRemote(body.quotes[0]); - } - - return await createNewStatus({ - account: author, - content: content?.content || "", - content_type: content?.content_type, - application: null, - // TODO: Add visibility - visibility: "public", - spoiler_text: body.subject || "", - uri: body.uri, - sensitive: body.is_sensitive, - emojis: emojis, - mentions: await parseMentionsUris(body.mentions), - reply: replyStatus - ? { - status: replyStatus, - user: (replyStatus as StatusWithRelations).author, - } - : undefined, - quote: quotingStatus || undefined, - }); */ -}; +export const fetchFromRemote = async ( + uri: string, +): Promise => {}; /** * Return all the ancestors of this post, diff --git a/database/entities/User.ts b/database/entities/User.ts index 0af0cb5b..c5a79b73 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -128,7 +128,7 @@ export const followRequestUser = async ( return relationship; }; -export const fetchRemoteUser = async (uri: string) => { +export const resolveUser = async (uri: string) => { // Check if user not already in database const foundUser = await client.user.findUnique({ where: { @@ -228,6 +228,68 @@ export const fetchRemoteUser = async (uri: string) => { }); }; +/** + * Resolves a WebFinger identifier to a user. + * @param identifier Either a UUID or a username + */ +export const resolveWebFinger = async (identifier: string, host: string) => { + // Check if user not already in database + const foundUser = await client.user.findUnique({ + where: { + username: identifier, + instance: { + base_url: host, + }, + }, + include: userRelations, + }); + + if (foundUser) return foundUser; + + const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`; + + const response = await fetch( + new URL( + `/.well-known/webfinger?${new URLSearchParams({ + resource: `acct:${identifier}@${host}`, + })}`, + hostWithProtocol, + ), + { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + ); + + const data = (await response.json()) as { + subject: string; + links: { + rel: string; + type: string; + href: string; + }[]; + }; + + if (!data.subject || !data.links) { + throw new Error( + "Invalid WebFinger data (missing subject or links from response)", + ); + } + + const relevantLink = data.links.find((link) => link.rel === "self"); + + if (!relevantLink) { + throw new Error( + "Invalid WebFinger data (missing link with rel: 'self')", + ); + } + + return resolveUser(relevantLink.href); +}; + /** * Fetches the list of followers associated with the actor and updates the user's followers */ diff --git a/package.json b/package.json index c2f1b830..62135513 100644 --- a/package.json +++ b/package.json @@ -1,134 +1,134 @@ { - "name": "lysand", - "module": "index.ts", - "type": "module", - "version": "0.3.1", - "description": "A project to build a federated social network", - "author": { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - }, - "bugs": { - "url": "https://github.com/lysand-org/lysand/issues" - }, - "icon": "https://github.com/lysand-org/lysand", - "license": "AGPL-3.0", - "keywords": ["federated", "activitypub", "bun"], - "workspaces": ["packages/*"], - "maintainers": [ - { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" + "name": "lysand", + "module": "index.ts", + "type": "module", + "version": "0.4.0", + "description": "A project to build a federated social network", + "author": { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + }, + "bugs": { + "url": "https://github.com/lysand-org/lysand/issues" + }, + "icon": "https://github.com/lysand-org/lysand", + "license": "AGPL-3.0", + "keywords": ["federated", "activitypub", "bun"], + "workspaces": ["packages/*"], + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lysand-org/lysand.git" + }, + "private": true, + "scripts": { + "dev": "bun run --watch index.ts", + "vite:dev": "bunx --bun vite pages", + "vite:build": "bunx --bun vite build pages", + "fe:dev": "bun --bun nuxt dev packages/frontend", + "fe:build": "bun --bun nuxt build packages/frontend", + "fe:analyze": "bun --bun nuxt analyze packages/frontend", + "start": "NITRO_PORT=5173 bun run dist/frontend/server/index.mjs & NODE_ENV=production bun run dist/index.js --prod", + "migrate-dev": "bun prisma migrate dev", + "migrate": "bun prisma migrate deploy", + "lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .", + "prod-build": "bun run build.ts", + "prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma", + "generate": "bun prisma generate", + "benchmark:timeline": "bun run benchmarks/timelines.ts", + "cloc": "cloc . --exclude-dir node_modules,dist", + "cli": "bun run cli.ts" + }, + "trustedDependencies": [ + "@biomejs/biome", + "@fortawesome/fontawesome-common-types", + "@fortawesome/free-regular-svg-icons", + "@fortawesome/free-solid-svg-icons", + "@prisma/client", + "@prisma/engines", + "esbuild", + "json-editor-vue", + "msgpackr-extract", + "nuxt-app", + "prisma", + "sharp", + "vue-demi" + ], + "devDependencies": { + "@biomejs/biome": "1.6.4", + "@img/sharp-wasm32": "^0.33.3", + "@julr/unocss-preset-forms": "^0.1.0", + "@nuxtjs/seo": "^2.0.0-rc.10", + "@nuxtjs/tailwindcss": "^6.11.4", + "@types/cli-table": "^0.3.4", + "@types/html-to-text": "^9.0.4", + "@types/ioredis": "^5.0.0", + "@types/jsonld": "^1.5.13", + "@types/mime-types": "^2.1.4", + "@typescript-eslint/eslint-plugin": "latest", + "@unocss/cli": "latest", + "@unocss/transformer-directives": "^0.59.0", + "@vitejs/plugin-vue": "latest", + "@vueuse/head": "^2.0.0", + "activitypub-types": "^1.0.3", + "bun-types": "latest", + "shiki": "^1.2.4", + "typescript": "latest", + "unocss": "latest", + "untyped": "^1.4.2", + "vite": "^5.2.8", + "vite-ssr": "^0.17.1", + "vue": "^3.3.9", + "vue-router": "^4.2.5", + "vue-tsc": "latest" + }, + "peerDependencies": { + "typescript": "^5.3.2" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.461.0", + "@iarna/toml": "^2.2.5", + "@json2csv/plainjs": "^7.0.6", + "@prisma/client": "^5.6.0", + "blurhash": "^2.0.5", + "bullmq": "latest", + "chalk": "^5.3.0", + "cli-parser": "workspace:*", + "cli-table": "^0.3.11", + "config-manager": "workspace:*", + "eventemitter3": "^5.0.1", + "extract-zip": "^2.0.1", + "html-to-text": "^9.0.5", + "ioredis": "^5.3.2", + "ip-matching": "^2.1.2", + "iso-639-1": "^3.1.0", + "isomorphic-dompurify": "latest", + "jsonld": "^8.3.1", + "linkify-html": "^4.1.3", + "linkify-string": "^4.1.3", + "linkifyjs": "^4.1.3", + "log-manager": "workspace:*", + "marked": "latest", + "media-manager": "workspace:*", + "megalodon": "^10.0.0", + "meilisearch": "latest", + "merge-deep-ts": "^1.2.6", + "mime-types": "^2.1.35", + "next-route-matcher": "^1.0.1", + "oauth4webapi": "^2.4.0", + "prisma": "^5.6.0", + "prisma-json-types-generator": "^3.0.4", + "prisma-redis-middleware": "^4.8.0", + "request-parser": "workspace:*", + "semver": "^7.5.4", + "sharp": "^0.33.3", + "strip-ansi": "^7.1.0" } - ], - "repository": { - "type": "git", - "url": "git+https://github.com/lysand-org/lysand.git" - }, - "private": true, - "scripts": { - "dev": "bun run --watch index.ts", - "vite:dev": "bunx --bun vite pages", - "vite:build": "bunx --bun vite build pages", - "fe:dev": "bun --bun nuxt dev packages/frontend", - "fe:build": "bun --bun nuxt build packages/frontend", - "fe:analyze": "bun --bun nuxt analyze packages/frontend", - "start": "NITRO_PORT=5173 bun run dist/frontend/server/index.mjs & NODE_ENV=production bun run dist/index.js --prod", - "migrate-dev": "bun prisma migrate dev", - "migrate": "bun prisma migrate deploy", - "lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .", - "prod-build": "bun run build.ts", - "prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma", - "generate": "bun prisma generate", - "benchmark:timeline": "bun run benchmarks/timelines.ts", - "cloc": "cloc . --exclude-dir node_modules,dist", - "cli": "bun run cli.ts" - }, - "trustedDependencies": [ - "@biomejs/biome", - "@fortawesome/fontawesome-common-types", - "@fortawesome/free-regular-svg-icons", - "@fortawesome/free-solid-svg-icons", - "@prisma/client", - "@prisma/engines", - "esbuild", - "json-editor-vue", - "msgpackr-extract", - "nuxt-app", - "prisma", - "sharp", - "vue-demi" - ], - "devDependencies": { - "@biomejs/biome": "1.6.4", - "@img/sharp-wasm32": "^0.33.3", - "@julr/unocss-preset-forms": "^0.1.0", - "@nuxtjs/seo": "^2.0.0-rc.10", - "@nuxtjs/tailwindcss": "^6.11.4", - "@types/cli-table": "^0.3.4", - "@types/html-to-text": "^9.0.4", - "@types/ioredis": "^5.0.0", - "@types/jsonld": "^1.5.13", - "@types/mime-types": "^2.1.4", - "@typescript-eslint/eslint-plugin": "latest", - "@unocss/cli": "latest", - "@unocss/transformer-directives": "^0.59.0", - "@vitejs/plugin-vue": "latest", - "@vueuse/head": "^2.0.0", - "activitypub-types": "^1.0.3", - "bun-types": "latest", - "shiki": "^1.2.4", - "typescript": "latest", - "unocss": "latest", - "untyped": "^1.4.2", - "vite": "^5.2.8", - "vite-ssr": "^0.17.1", - "vue": "^3.3.9", - "vue-router": "^4.2.5", - "vue-tsc": "latest" - }, - "peerDependencies": { - "typescript": "^5.3.2" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.461.0", - "@iarna/toml": "^2.2.5", - "@json2csv/plainjs": "^7.0.6", - "@prisma/client": "^5.6.0", - "blurhash": "^2.0.5", - "bullmq": "latest", - "chalk": "^5.3.0", - "cli-parser": "workspace:*", - "cli-table": "^0.3.11", - "config-manager": "workspace:*", - "eventemitter3": "^5.0.1", - "extract-zip": "^2.0.1", - "html-to-text": "^9.0.5", - "ioredis": "^5.3.2", - "ip-matching": "^2.1.2", - "iso-639-1": "^3.1.0", - "isomorphic-dompurify": "latest", - "jsonld": "^8.3.1", - "linkify-html": "^4.1.3", - "linkify-string": "^4.1.3", - "linkifyjs": "^4.1.3", - "log-manager": "workspace:*", - "marked": "latest", - "media-manager": "workspace:*", - "megalodon": "^10.0.0", - "meilisearch": "latest", - "merge-deep-ts": "^1.2.6", - "mime-types": "^2.1.35", - "next-route-matcher": "^1.0.1", - "oauth4webapi": "^2.4.0", - "prisma": "^5.6.0", - "prisma-json-types-generator": "^3.0.4", - "prisma-redis-middleware": "^4.8.0", - "request-parser": "workspace:*", - "semver": "^7.5.4", - "sharp": "^0.33.3", - "strip-ansi": "^7.1.0" - } } diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index 58b04905..0da2b68b 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -3,7 +3,11 @@ import { MeiliIndexType, meilisearch } from "@meilisearch"; import { errorResponse, jsonResponse } from "@response"; import { client } from "~database/datasource"; import { statusToAPI } from "~database/entities/Status"; -import { userToAPI } from "~database/entities/User"; +import { + resolveUser, + resolveWebFinger, + userToAPI, +} from "~database/entities/User"; import { statusAndUserRelations, userRelations, @@ -71,6 +75,42 @@ export default apiRoute<{ let statusResults: { id: string }[] = []; if (!type || type === "accounts") { + // Check if q is matching format username@domain.com or @username@domain.com + if (q?.trim().match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g)) { + const [username, domain] = q.trim().split("@"); + const account = await client.user.findFirst({ + where: { + username, + instance: { + base_url: domain, + }, + }, + include: userRelations, + }); + + if (account) { + return jsonResponse({ + accounts: [userToAPI(account)], + statuses: [], + hashtags: [], + }); + } + + if (resolve) { + const newUser = await resolveWebFinger(username, domain).catch( + () => null, + ); + + if (newUser) { + return jsonResponse({ + accounts: [userToAPI(newUser)], + statuses: [], + hashtags: [], + }); + } + } + } + accountResults = ( await meilisearch.index(MeiliIndexType.Accounts).search<{ id: string; diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index df5d7f3e..4927538a 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -142,7 +142,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { note.subject ?? "", [], note.uri, - // TODO: Resolve mention,s + // TODO: Resolve mentions [], // TODO: Add attachments [], diff --git a/server/api/well-known/webfinger/index.ts b/server/api/well-known/webfinger/index.ts index 682eaaa1..3aa005c3 100644 --- a/server/api/well-known/webfinger/index.ts +++ b/server/api/well-known/webfinger/index.ts @@ -21,14 +21,10 @@ export default apiRoute<{ if (!resource) return errorResponse("No resource provided", 400); - // Check if resource is in the correct format (acct:uuid@domain) - if ( - !resource.match( - /^acct:[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}@.+/i, - ) - ) { + // Check if resource is in the correct format (acct:uuid/username@domain) + if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) { return errorResponse( - "Invalid resource (should be acct:uuid@domain)", + "Invalid resource (should be acct:(id or username)@domain)", 400, ); } @@ -43,8 +39,17 @@ export default apiRoute<{ return errorResponse("User is a remote user", 404); } + const isUuid = requestedUser + .split("@")[0] + .match( + /[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/i, + ); + const user = await client.user.findUnique({ - where: { id: requestedUser.split("@")[0] }, + where: { + id: isUuid ? requestedUser.split("@")[0] : undefined, + username: isUuid ? undefined : requestedUser.split("@")[0], + }, }); if (!user) { @@ -52,7 +57,7 @@ export default apiRoute<{ } return jsonResponse({ - subject: `acct:${user.id}@${host}`, + subject: `acct:${isUuid ? user.id : user.username}@${host}`, links: [ {