Loading backend/src/lib/api.ts +22 −2 Original line number Diff line number Diff line Loading @@ -10,6 +10,8 @@ import { getNodeInfo } from "./nodeinfo.js"; import { getProviderFor } from "./delivery/index.js"; import { prisma } from "./prisma.js"; import { ReceiveCodeProvider } from "./delivery/receive.js"; import { IProfile, getUserMeta } from "./instance/userMeta.js"; import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js"; const app = express.Router(); Loading @@ -24,10 +26,28 @@ app.use(cookieParser()); */ app.get("/whoami", async (req, res) => { const session = await oidc.Session.find(req.cookies._session); if (!session) return res.json({ success: false, error: "no-session" }); if (!session || !session.accountId) return res.json({ success: false, error: "no-session" }); const [username, hostname] = session.accountId.split("@"); let user: IProfile = { sub: session.accountId }; let instance: IInstance | undefined; try { user = await getUserMeta([username, hostname]); } catch (e) { // it's aight, not required } try { instance = await getInstanceMeta(hostname); } catch (e) { // it's aight, not required } res.json({ sub: session.accountId, ...user, instance, }); }); Loading backend/src/lib/instance/instanceMeta.ts +74 −12 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ */ import { NodeInfo } from "../../types/nodeinfo.js"; import { safe_fetch } from "../fetch.js"; import { getNodeInfo } from "../nodeinfo.js"; export interface IInstance { Loading Loading @@ -30,7 +31,6 @@ export interface IInstance { * Get instance metadata from hostname * * TODO: write software#logo_uri * TODO: write instance (software specific) * * @throws NodeInfo_Invalid|NodeInfo_Unsupported if nodeinfo param is not provided * @param instance_hostname Loading @@ -46,8 +46,7 @@ export const getInstanceMeta = async ( _nodeinfo = await getNodeInfo(instance_hostname); } return { software: { let software: IInstance["software"] = { name: _nodeinfo.software.name, version: _nodeinfo.software.version, ...("repository" in _nodeinfo.software && { Loading @@ -56,7 +55,70 @@ export const getInstanceMeta = async ( ...("homepage" in _nodeinfo.software && { homepage: _nodeinfo.software.homepage, }), }, instance: {}, }; try { const fedidbReq = await safe_fetch( `https://api.fedidb.org/v1/software/${software.name}` ); if (fedidbReq.status !== 200) throw new Error(); const fedidbRes: any = await fedidbReq.json(); if (!fedidbRes) throw new Error(); software.logo_uri = typeof fedidbRes.logo_url === "string" && fedidbRes.logo_url; } catch (e) { // ignore failed } let instance: IInstance["instance"] = {}; try { switch (_nodeinfo.software.name) { case "mastodon": { const metaReq = await safe_fetch( `https://${instance_hostname}/api/v2/instance` ); if (metaReq.status !== 200) throw new Error(); const metaRes: any = await metaReq.json(); if (!metaRes) throw new Error(); instance.name = typeof metaRes.title === "string" && metaRes.title; instance.banner_uri = typeof metaRes?.thumbnail?.url === "string" && metaRes.thumbnail.url; break; } case "lemmy": { const metaReq = await safe_fetch( `https://${instance_hostname}/api/v3/site` ); if (metaReq.status !== 200) throw new Error(); const metaRes: any = await metaReq.json(); if (!metaRes) throw new Error(); instance.name = typeof metaRes.site_view?.site?.name === "string" && metaRes.site_view.site.name; instance.logo_uri = typeof metaRes.site_view?.site?.icon === "string" && metaRes.site_view.site.icon; instance.banner_uri = typeof metaRes.site_view?.site?.banner === "string" && metaRes.site_view.site.banner; break; } } } catch (e) { // ignore meta if failed } return { software, instance, }; }; backend/src/lib/instance/userMeta.ts 0 → 100644 +99 −0 Original line number Diff line number Diff line import { safe_fetch } from "../fetch.js"; /** * Matches as close as possible to standard OpenID claims */ export interface IProfile { /** * username@hostname.tld * @example grant@grants.cafe */ sub: string; /** * Display name from AP actor */ name?: string; /** * How the instance formats the name (capitalization) */ preferred_username?: string; /** * HTML URL to profile page */ profile?: string; /** * URL to profile picture */ picture?: string; } export const getUserMeta = async ( user: [username: string, hostname: string] ): Promise<IProfile> => { const req = await safe_fetch( `https://${user[1]}/.well-known/webfinger?resource=acct:${user.join("@")}` ); if (req.status !== 200) { // will throw if not found throw new Error(); } let data: any; try { data = await req.json(); } catch (e) { throw new Error(); } const getLinkFor = (rel: string, type: string): string | undefined => { const link = data?.links?.find( (l: any) => typeof l.rel === "string" && typeof l.type === "string" && l.rel === rel && l.type === type ); if (!link || typeof link.href !== "string") return undefined; return link.href; }; const apURL = getLinkFor("self", "application/activity+json"); const profilePage = getLinkFor("http://webfinger.net/rel/profile-page", "text/html") || apURL; if (!apURL) { // url is not found, shouldn't be a valid user throw new Error(); } const apReq = await safe_fetch(apURL, { headers: { Accept: "application/activity+json", }, }); if (apReq.status !== 200) { throw new Error(); } let apData: any; try { apData = await apReq.json(); } catch (e) { throw new Error(); } return { sub: user.join("@"), name: apData.name, picture: apData.icon?.url, preferred_username: apData.preferredUsername, profile: profilePage, }; }; backend/src/lib/oidc.ts +23 −1 Original line number Diff line number Diff line Loading @@ -2,6 +2,8 @@ import Provider from "oidc-provider"; import fs from "node:fs"; import { PrismaAdapter } from "./adapter.js"; import { Issuer } from "openid-client"; import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js"; import { IProfile, getUserMeta } from "./instance/userMeta.js"; /** * ⚠ DEVELOPMENT KEYS ⚠ Loading Loading @@ -44,10 +46,30 @@ export const oidc = new Provider(process.env.OIDC_ISSUER!, { return { accountId: sub, async claims(use, scope, claims, rejected) { return { sub }; const [username, hostname] = sub.split("@"); let user: IProfile = { sub }; let instance: IInstance | undefined; try { user = await getUserMeta([username, hostname]); } catch (e) { // it's aight, not required } try { instance = await getInstanceMeta(hostname); } catch (e) { // it's aight, not required } return { ...user, instance }; }, }; }, claims: { openid: ["sub", "name", "preferred_username", "profile", "picture"], instance: ["instance"], }, jwks: { keys: jwks_keys, }, Loading frontend/src/Interaction/UserInfoCard.tsx +57 −8 Original line number Diff line number Diff line import { Box, IconButton, Stack, SxProps, Typography } from "@mui/material"; import LogoutIcon from "@mui/icons-material/Logout"; interface IProfile { /** * username@hostname.tld * @example grant@grants.cafe */ sub: string; /** * Display name from AP actor */ name?: string; /** * How the instance formats the name (capitalization) */ preferred_username?: string; /** * HTML URL to profile page */ profile?: string; /** * URL to profile picture */ picture?: string; } interface IInstance { software: { name: string; version: string; logo_uri?: string; repository?: string; homepage?: string; }; instance: { /** * Untrusted URL */ logo_uri?: string; /** * Untrusted URL */ banner_uri?: string; name?: string; }; } export const UserInfoCard = ({ user, sx, }: { user: { sub: string }; user: IProfile & { instance?: IInstance }; sx?: SxProps; }) => { const [username, instance] = user.sub.split("@"); Loading @@ -30,14 +79,14 @@ export const UserInfoCard = ({ display: "flex", justifyContent: "center", alignItems: "center", // ...(client.logoUri && { // backgroundImage: `url(${client.logoUri})`, // backgroundPosition: "center", // backgroundSize: "cover", // }), ...(user.picture && { backgroundImage: `url(${user.picture})`, backgroundPosition: "center", backgroundSize: "cover", }), }} > ? {!user.picture && "?"} </Box> <Stack direction="column" sx={{ flexGrow: 1 }}> <Typography>@{username}</Typography> Loading @@ -46,7 +95,7 @@ export const UserInfoCard = ({ </Typography> </Stack> <Stack direction="row" gap={0.5}> <IconButton href={`${import.meta.env.VITE_APP_ROOT}/logout`}> <IconButton href={`${import.meta.env.VITE_APP_ROOT || ""}/logout`}> <LogoutIcon fontSize="inherit" /> </IconButton> </Stack> Loading Loading
backend/src/lib/api.ts +22 −2 Original line number Diff line number Diff line Loading @@ -10,6 +10,8 @@ import { getNodeInfo } from "./nodeinfo.js"; import { getProviderFor } from "./delivery/index.js"; import { prisma } from "./prisma.js"; import { ReceiveCodeProvider } from "./delivery/receive.js"; import { IProfile, getUserMeta } from "./instance/userMeta.js"; import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js"; const app = express.Router(); Loading @@ -24,10 +26,28 @@ app.use(cookieParser()); */ app.get("/whoami", async (req, res) => { const session = await oidc.Session.find(req.cookies._session); if (!session) return res.json({ success: false, error: "no-session" }); if (!session || !session.accountId) return res.json({ success: false, error: "no-session" }); const [username, hostname] = session.accountId.split("@"); let user: IProfile = { sub: session.accountId }; let instance: IInstance | undefined; try { user = await getUserMeta([username, hostname]); } catch (e) { // it's aight, not required } try { instance = await getInstanceMeta(hostname); } catch (e) { // it's aight, not required } res.json({ sub: session.accountId, ...user, instance, }); }); Loading
backend/src/lib/instance/instanceMeta.ts +74 −12 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ */ import { NodeInfo } from "../../types/nodeinfo.js"; import { safe_fetch } from "../fetch.js"; import { getNodeInfo } from "../nodeinfo.js"; export interface IInstance { Loading Loading @@ -30,7 +31,6 @@ export interface IInstance { * Get instance metadata from hostname * * TODO: write software#logo_uri * TODO: write instance (software specific) * * @throws NodeInfo_Invalid|NodeInfo_Unsupported if nodeinfo param is not provided * @param instance_hostname Loading @@ -46,8 +46,7 @@ export const getInstanceMeta = async ( _nodeinfo = await getNodeInfo(instance_hostname); } return { software: { let software: IInstance["software"] = { name: _nodeinfo.software.name, version: _nodeinfo.software.version, ...("repository" in _nodeinfo.software && { Loading @@ -56,7 +55,70 @@ export const getInstanceMeta = async ( ...("homepage" in _nodeinfo.software && { homepage: _nodeinfo.software.homepage, }), }, instance: {}, }; try { const fedidbReq = await safe_fetch( `https://api.fedidb.org/v1/software/${software.name}` ); if (fedidbReq.status !== 200) throw new Error(); const fedidbRes: any = await fedidbReq.json(); if (!fedidbRes) throw new Error(); software.logo_uri = typeof fedidbRes.logo_url === "string" && fedidbRes.logo_url; } catch (e) { // ignore failed } let instance: IInstance["instance"] = {}; try { switch (_nodeinfo.software.name) { case "mastodon": { const metaReq = await safe_fetch( `https://${instance_hostname}/api/v2/instance` ); if (metaReq.status !== 200) throw new Error(); const metaRes: any = await metaReq.json(); if (!metaRes) throw new Error(); instance.name = typeof metaRes.title === "string" && metaRes.title; instance.banner_uri = typeof metaRes?.thumbnail?.url === "string" && metaRes.thumbnail.url; break; } case "lemmy": { const metaReq = await safe_fetch( `https://${instance_hostname}/api/v3/site` ); if (metaReq.status !== 200) throw new Error(); const metaRes: any = await metaReq.json(); if (!metaRes) throw new Error(); instance.name = typeof metaRes.site_view?.site?.name === "string" && metaRes.site_view.site.name; instance.logo_uri = typeof metaRes.site_view?.site?.icon === "string" && metaRes.site_view.site.icon; instance.banner_uri = typeof metaRes.site_view?.site?.banner === "string" && metaRes.site_view.site.banner; break; } } } catch (e) { // ignore meta if failed } return { software, instance, }; };
backend/src/lib/instance/userMeta.ts 0 → 100644 +99 −0 Original line number Diff line number Diff line import { safe_fetch } from "../fetch.js"; /** * Matches as close as possible to standard OpenID claims */ export interface IProfile { /** * username@hostname.tld * @example grant@grants.cafe */ sub: string; /** * Display name from AP actor */ name?: string; /** * How the instance formats the name (capitalization) */ preferred_username?: string; /** * HTML URL to profile page */ profile?: string; /** * URL to profile picture */ picture?: string; } export const getUserMeta = async ( user: [username: string, hostname: string] ): Promise<IProfile> => { const req = await safe_fetch( `https://${user[1]}/.well-known/webfinger?resource=acct:${user.join("@")}` ); if (req.status !== 200) { // will throw if not found throw new Error(); } let data: any; try { data = await req.json(); } catch (e) { throw new Error(); } const getLinkFor = (rel: string, type: string): string | undefined => { const link = data?.links?.find( (l: any) => typeof l.rel === "string" && typeof l.type === "string" && l.rel === rel && l.type === type ); if (!link || typeof link.href !== "string") return undefined; return link.href; }; const apURL = getLinkFor("self", "application/activity+json"); const profilePage = getLinkFor("http://webfinger.net/rel/profile-page", "text/html") || apURL; if (!apURL) { // url is not found, shouldn't be a valid user throw new Error(); } const apReq = await safe_fetch(apURL, { headers: { Accept: "application/activity+json", }, }); if (apReq.status !== 200) { throw new Error(); } let apData: any; try { apData = await apReq.json(); } catch (e) { throw new Error(); } return { sub: user.join("@"), name: apData.name, picture: apData.icon?.url, preferred_username: apData.preferredUsername, profile: profilePage, }; };
backend/src/lib/oidc.ts +23 −1 Original line number Diff line number Diff line Loading @@ -2,6 +2,8 @@ import Provider from "oidc-provider"; import fs from "node:fs"; import { PrismaAdapter } from "./adapter.js"; import { Issuer } from "openid-client"; import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js"; import { IProfile, getUserMeta } from "./instance/userMeta.js"; /** * ⚠ DEVELOPMENT KEYS ⚠ Loading Loading @@ -44,10 +46,30 @@ export const oidc = new Provider(process.env.OIDC_ISSUER!, { return { accountId: sub, async claims(use, scope, claims, rejected) { return { sub }; const [username, hostname] = sub.split("@"); let user: IProfile = { sub }; let instance: IInstance | undefined; try { user = await getUserMeta([username, hostname]); } catch (e) { // it's aight, not required } try { instance = await getInstanceMeta(hostname); } catch (e) { // it's aight, not required } return { ...user, instance }; }, }; }, claims: { openid: ["sub", "name", "preferred_username", "profile", "picture"], instance: ["instance"], }, jwks: { keys: jwks_keys, }, Loading
frontend/src/Interaction/UserInfoCard.tsx +57 −8 Original line number Diff line number Diff line import { Box, IconButton, Stack, SxProps, Typography } from "@mui/material"; import LogoutIcon from "@mui/icons-material/Logout"; interface IProfile { /** * username@hostname.tld * @example grant@grants.cafe */ sub: string; /** * Display name from AP actor */ name?: string; /** * How the instance formats the name (capitalization) */ preferred_username?: string; /** * HTML URL to profile page */ profile?: string; /** * URL to profile picture */ picture?: string; } interface IInstance { software: { name: string; version: string; logo_uri?: string; repository?: string; homepage?: string; }; instance: { /** * Untrusted URL */ logo_uri?: string; /** * Untrusted URL */ banner_uri?: string; name?: string; }; } export const UserInfoCard = ({ user, sx, }: { user: { sub: string }; user: IProfile & { instance?: IInstance }; sx?: SxProps; }) => { const [username, instance] = user.sub.split("@"); Loading @@ -30,14 +79,14 @@ export const UserInfoCard = ({ display: "flex", justifyContent: "center", alignItems: "center", // ...(client.logoUri && { // backgroundImage: `url(${client.logoUri})`, // backgroundPosition: "center", // backgroundSize: "cover", // }), ...(user.picture && { backgroundImage: `url(${user.picture})`, backgroundPosition: "center", backgroundSize: "cover", }), }} > ? {!user.picture && "?"} </Box> <Stack direction="column" sx={{ flexGrow: 1 }}> <Typography>@{username}</Typography> Loading @@ -46,7 +95,7 @@ export const UserInfoCard = ({ </Typography> </Stack> <Stack direction="row" gap={0.5}> <IconButton href={`${import.meta.env.VITE_APP_ROOT}/logout`}> <IconButton href={`${import.meta.env.VITE_APP_ROOT || ""}/logout`}> <LogoutIcon fontSize="inherit" /> </IconButton> </Stack> Loading