import * as Sentry from "@sentry/node";
import { Router } from "express";
import { ResponseBodyError } from "openid-client";
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
import { OpenID } from "../lib/oidc";
import { prisma } from "../lib/prisma";
import { RateLimiter } from "../lib/RateLimiter";
import { Instance } from "../models/Instance";
import SentryRouter from "./sentry";
const Logger = getLogger("HTTP/CLIENT");
const ClientParams = {
TYPE: "auth_type",
ERROR: "auth_error",
ERROR_DESC: "auth_error_desc",
CAN_RETRY: "auth_retry",
};
const buildQuery = (obj: { [k in keyof typeof ClientParams]?: string }) => {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) {
const k_: keyof typeof ClientParams = k as any;
params.set(ClientParams[k_], v);
}
return "?" + params.toString();
};
const app = Router();
// register sentry tunnel
// if the sentry environment variables aren't set, the loaded router will be empty
app.use(SentryRouter);
/**
* Redirect to actual authorization page
*/
app.get("/login", (req, res) => {
if (process.env.INHIBIT_LOGINS) {
res.status(400).json({
success: false,
error: "Login is not permitted.",
});
return;
}
res.redirect(OpenID.getAuthorizationURL());
});
app.get("/logout", (req, res) => {
res.send(
`
`
);
});
app.post("/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/");
});
});
/**
* Process token exchange from openid server
*
* This executes multiple database queries and should be ratelimited
*/
app.get("/callback", RateLimiter.HIGH, async (req, res) => {
if (process.env.INHIBIT_LOGINS) {
res.status(400).json({
success: false,
error: "Login is not permitted.",
});
return;
}
let exchange: Awaited>;
try {
exchange = await OpenID.exchangeToken(req.originalUrl);
} catch (e) {
Sentry.captureException(e);
Logger.error(e);
if (e instanceof ResponseBodyError) {
switch (e.error) {
case "invalid_client":
// this happens when we're configured with invalid credentials
Logger.error(
"OpenID is improperly configured. Cannot exchange tokens, do I have valid credentials?"
);
res.redirect(
"/" +
buildQuery({
TYPE: "op",
ERROR: "Internal Server Error",
ERROR_DESC: "I'm misconfigured.",
})
);
return;
case "invalid_grant":
res.redirect(
"/" +
buildQuery({
TYPE: "op",
ERROR: "invalid_grant",
CAN_RETRY: "true",
})
);
return;
}
}
res.redirect(
"/" +
buildQuery({
TYPE: "op",
ERROR: "unknown error",
ERROR_DESC: "report this",
})
);
return;
}
try {
const whoami = await OpenID.userInfo<{
instance: {
software: {
name: string;
version: string;
logo_uri?: string;
repository?: string;
homepage?: string;
};
instance: {
logo_uri?: string;
banner_uri?: string;
name?: string;
};
};
}>(exchange.access_token, exchange.claims()!.sub);
const [username, hostname] = whoami.sub.split("@");
const instance = await Instance.fromAuth(
hostname,
whoami.instance.instance
);
const instanceBan = await instance.getEffectiveBan();
if (instanceBan) {
res.redirect(
"/" +
buildQuery({
TYPE: "banned",
ERROR_DESC: instanceBan.publicNote || undefined,
})
);
return;
}
const sub = [username, hostname].join("@");
await prisma.user.upsert({
where: {
sub,
},
update: {
sub,
display_name: whoami.name,
picture_url: whoami.picture,
profile_url: whoami.profile,
},
create: {
sub,
display_name: whoami.name,
picture_url: whoami.picture,
profile_url: whoami.profile,
},
});
req.session.user = {
service: {
...whoami.instance,
instance: {
...whoami.instance.instance,
hostname,
},
},
user: {
picture_url: whoami.picture,
username,
},
};
req.session.save();
res.redirect("/");
} catch (e) {
Sentry.captureException(e);
Logger.error("callback error", e);
res
.status(500)
.json({ success: false, error: "internal error, try again" });
}
});
app.get("/canvas/pixel/:x/:y", RateLimiter.HIGH, async (req, res) => {
const x = parseInt(req.params.x);
const y = parseInt(req.params.y);
if (isNaN(x) || isNaN(y)) {
res.status(400).json({ success: false, error: "x or y is not a number" });
return;
}
const pixel = await Canvas.getPixel(x, y);
if (!pixel) {
res.json({ success: false, error: "no_pixel" });
return;
}
const otherPixels = await prisma.pixel.count({ where: { x, y } });
const user = await prisma.user.findFirst({ where: { sub: pixel.userId } });
const instance = await prisma.instance.findFirst({
where: { hostname: pixel.userId.split("@")[1] },
});
res.json({
success: true,
pixel,
otherPixels: otherPixels - 1,
user: user && {
sub: user.sub,
display_name: user.display_name,
picture_url: user.picture_url,
profile_url: user.profile_url,
isAdmin: user.isAdmin,
isModerator: user.isModerator,
},
instance: instance && {
hostname: instance.hostname,
name: instance.name,
logo_url: instance.logo_url,
banner_url: instance.banner_url,
},
});
});
/**
* Get the heatmap
*
* This is cached, so no need to ratelimit this
* Even if the heatmap isn't ready, this doesn't cause the heatmap to get generated
*/
app.get("/heatmap", async (req, res) => {
const heatmap = await Canvas.getCachedHeatmap();
if (!heatmap) {
res.json({ success: false, error: "heatmap_not_generated" });
return;
}
res.json({ success: true, heatmap });
});
/**
* Get user information from the sub (grant@toast.ooo)
*
* This causes a database query, so ratelimit it
*/
app.get("/user/:sub", RateLimiter.HIGH, async (req, res) => {
const user = await prisma.user.findFirst({ where: { sub: req.params.sub } });
if (!user) {
res.status(404).json({ success: false, error: "unknown_user" });
return;
}
res.json({
success: true,
user: {
sub: user.sub,
display_name: user.display_name,
picture_url: user.picture_url,
profile_url: user.profile_url,
isAdmin: user.isAdmin,
isModerator: user.isModerator,
},
});
});
export default app;