add/expose user & instance metadata

This commit is contained in:
Grant 2024-05-22 14:16:42 -06:00
parent b4f2bdba4a
commit 4e46ba2ef9
5 changed files with 275 additions and 23 deletions

View File

@ -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();
@ -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,
});
});

View File

@ -3,6 +3,7 @@
*/
import { NodeInfo } from "../../types/nodeinfo.js";
import { safe_fetch } from "../fetch.js";
import { getNodeInfo } from "../nodeinfo.js";
export interface IInstance {
@ -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
@ -46,17 +46,79 @@ export const getInstanceMeta = async (
_nodeinfo = await getNodeInfo(instance_hostname);
}
let software: IInstance["software"] = {
name: _nodeinfo.software.name,
version: _nodeinfo.software.version,
...("repository" in _nodeinfo.software && {
repository: _nodeinfo.software.repository,
}),
...("homepage" in _nodeinfo.software && {
homepage: _nodeinfo.software.homepage,
}),
};
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: {
name: _nodeinfo.software.name,
version: _nodeinfo.software.version,
...("repository" in _nodeinfo.software && {
repository: _nodeinfo.software.repository,
}),
...("homepage" in _nodeinfo.software && {
homepage: _nodeinfo.software.homepage,
}),
},
instance: {},
software,
instance,
};
};

View File

@ -0,0 +1,99 @@
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,
};
};

View File

@ -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
@ -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,
},

View File

@ -1,11 +1,60 @@
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("@");
@ -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>
@ -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>