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 { getProviderFor } from "./delivery/index.js";
|
||||||
import { prisma } from "./prisma.js";
|
import { prisma } from "./prisma.js";
|
||||||
import { ReceiveCodeProvider } from "./delivery/receive.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();
|
const app = express.Router();
|
||||||
|
|
||||||
|
@ -24,10 +26,28 @@ app.use(cookieParser());
|
||||||
*/
|
*/
|
||||||
app.get("/whoami", async (req, res) => {
|
app.get("/whoami", async (req, res) => {
|
||||||
const session = await oidc.Session.find(req.cookies._session);
|
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({
|
res.json({
|
||||||
sub: session.accountId,
|
...user,
|
||||||
|
instance,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NodeInfo } from "../../types/nodeinfo.js";
|
import { NodeInfo } from "../../types/nodeinfo.js";
|
||||||
|
import { safe_fetch } from "../fetch.js";
|
||||||
import { getNodeInfo } from "../nodeinfo.js";
|
import { getNodeInfo } from "../nodeinfo.js";
|
||||||
|
|
||||||
export interface IInstance {
|
export interface IInstance {
|
||||||
|
@ -30,7 +31,6 @@ export interface IInstance {
|
||||||
* Get instance metadata from hostname
|
* Get instance metadata from hostname
|
||||||
*
|
*
|
||||||
* TODO: write software#logo_uri
|
* TODO: write software#logo_uri
|
||||||
* TODO: write instance (software specific)
|
|
||||||
*
|
*
|
||||||
* @throws NodeInfo_Invalid|NodeInfo_Unsupported if nodeinfo param is not provided
|
* @throws NodeInfo_Invalid|NodeInfo_Unsupported if nodeinfo param is not provided
|
||||||
* @param instance_hostname
|
* @param instance_hostname
|
||||||
|
@ -46,8 +46,7 @@ export const getInstanceMeta = async (
|
||||||
_nodeinfo = await getNodeInfo(instance_hostname);
|
_nodeinfo = await getNodeInfo(instance_hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
let software: IInstance["software"] = {
|
||||||
software: {
|
|
||||||
name: _nodeinfo.software.name,
|
name: _nodeinfo.software.name,
|
||||||
version: _nodeinfo.software.version,
|
version: _nodeinfo.software.version,
|
||||||
...("repository" in _nodeinfo.software && {
|
...("repository" in _nodeinfo.software && {
|
||||||
|
@ -56,7 +55,70 @@ export const getInstanceMeta = async (
|
||||||
...("homepage" in _nodeinfo.software && {
|
...("homepage" in _nodeinfo.software && {
|
||||||
homepage: _nodeinfo.software.homepage,
|
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 fs from "node:fs";
|
||||||
import { PrismaAdapter } from "./adapter.js";
|
import { PrismaAdapter } from "./adapter.js";
|
||||||
import { Issuer } from "openid-client";
|
import { Issuer } from "openid-client";
|
||||||
|
import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js";
|
||||||
|
import { IProfile, getUserMeta } from "./instance/userMeta.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠ DEVELOPMENT KEYS ⚠
|
* ⚠ DEVELOPMENT KEYS ⚠
|
||||||
|
@ -44,10 +46,30 @@ export const oidc = new Provider(process.env.OIDC_ISSUER!, {
|
||||||
return {
|
return {
|
||||||
accountId: sub,
|
accountId: sub,
|
||||||
async claims(use, scope, claims, rejected) {
|
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: {
|
jwks: {
|
||||||
keys: jwks_keys,
|
keys: jwks_keys,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,11 +1,60 @@
|
||||||
import { Box, IconButton, Stack, SxProps, Typography } from "@mui/material";
|
import { Box, IconButton, Stack, SxProps, Typography } from "@mui/material";
|
||||||
import LogoutIcon from "@mui/icons-material/Logout";
|
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 = ({
|
export const UserInfoCard = ({
|
||||||
user,
|
user,
|
||||||
sx,
|
sx,
|
||||||
}: {
|
}: {
|
||||||
user: { sub: string };
|
user: IProfile & { instance?: IInstance };
|
||||||
sx?: SxProps;
|
sx?: SxProps;
|
||||||
}) => {
|
}) => {
|
||||||
const [username, instance] = user.sub.split("@");
|
const [username, instance] = user.sub.split("@");
|
||||||
|
@ -30,14 +79,14 @@ export const UserInfoCard = ({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
// ...(client.logoUri && {
|
...(user.picture && {
|
||||||
// backgroundImage: `url(${client.logoUri})`,
|
backgroundImage: `url(${user.picture})`,
|
||||||
// backgroundPosition: "center",
|
backgroundPosition: "center",
|
||||||
// backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
// }),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
?
|
{!user.picture && "?"}
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="column" sx={{ flexGrow: 1 }}>
|
<Stack direction="column" sx={{ flexGrow: 1 }}>
|
||||||
<Typography>@{username}</Typography>
|
<Typography>@{username}</Typography>
|
||||||
|
@ -46,7 +95,7 @@ export const UserInfoCard = ({
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" gap={0.5}>
|
<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" />
|
<LogoutIcon fontSize="inherit" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
Loading…
Reference in New Issue