Commit 4e46ba2e authored by Grant's avatar Grant
Browse files

add/expose user & instance metadata

parent b4f2bdba
Loading
Loading
Loading
Loading
+22 −2
Original line number Diff line number Diff line
@@ -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,
  });
});

+74 −12
Original line number Diff line number Diff line
@@ -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,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 && {
@@ -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,
  };
};
+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,
  };
};
+23 −1
Original line number Diff line number Diff line
@@ -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,
  },
+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("@");
@@ -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>