add/expose user & instance metadata
This commit is contained in:
parent
b4f2bdba4a
commit
4e46ba2ef9
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue