mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(frontend): ✨ Allow glitch-soc users to login to their account
This commit is contained in:
parent
de60f37393
commit
866cd4345d
|
|
@ -2,6 +2,8 @@ import { join } from "node:path";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import type { LogManager, MultiLogManager } from "~packages/log-manager";
|
import type { LogManager, MultiLogManager } from "~packages/log-manager";
|
||||||
import { languages } from "./glitch-languages";
|
import { languages } from "./glitch-languages";
|
||||||
|
import { redirect } from "@response";
|
||||||
|
import { retrieveUserFromToken, userToAPI } from "~database/entities/User";
|
||||||
|
|
||||||
export const handleGlitchRequest = async (
|
export const handleGlitchRequest = async (
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|
@ -9,6 +11,10 @@ export const handleGlitchRequest = async (
|
||||||
): Promise<Response | null> => {
|
): Promise<Response | null> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
let path = url.pathname;
|
let path = url.pathname;
|
||||||
|
const accessToken = req.headers
|
||||||
|
.get("Cookie")
|
||||||
|
?.match(/_mastodon_session=(.*?)(;|$)/)?.[1];
|
||||||
|
const user = await retrieveUserFromToken(accessToken ?? "");
|
||||||
|
|
||||||
// Strip leading /web from path
|
// Strip leading /web from path
|
||||||
if (path.startsWith("/web")) path = path.slice(4);
|
if (path.startsWith("/web")) path = path.slice(4);
|
||||||
|
|
@ -103,6 +109,13 @@ export const handleGlitchRequest = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/auth/sign_in") {
|
||||||
|
if (req.method === "POST") {
|
||||||
|
return redirect("/api/auth/mastodon-login", 307);
|
||||||
|
}
|
||||||
|
path = "/auth/sign_in.html";
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect / to /index.html
|
// Redirect / to /index.html
|
||||||
if (path === "/" || path === "") path = "/index.html";
|
if (path === "/" || path === "") path = "/index.html";
|
||||||
// If path doesn't have an extension (e.g. /about), serve index.html
|
// If path doesn't have an extension (e.g. /about), serve index.html
|
||||||
|
|
@ -115,11 +128,36 @@ export const handleGlitchRequest = async (
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
let fileContents = await file.text();
|
let fileContents = await file.text();
|
||||||
|
|
||||||
|
if (path === "/auth/sign_in.html" && url.searchParams.get("error")) {
|
||||||
|
// Insert error message as first child of form.form_container
|
||||||
|
const rewriter = new HTMLRewriter()
|
||||||
|
.on("div.form-container", {
|
||||||
|
element(element) {
|
||||||
|
element.prepend(
|
||||||
|
` <div class='flash-message alert'>
|
||||||
|
<strong>${decodeURIComponent(
|
||||||
|
url.searchParams.get("error") ?? "",
|
||||||
|
)}</strong>
|
||||||
|
</div>`,
|
||||||
|
{
|
||||||
|
html: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.transform(new Response(fileContents));
|
||||||
|
|
||||||
|
fileContents = await rewriter.text();
|
||||||
|
}
|
||||||
for (const server of config.frontend.glitch.server) {
|
for (const server of config.frontend.glitch.server) {
|
||||||
fileContents = fileContents.replace(
|
fileContents = fileContents.replaceAll(
|
||||||
`${new URL(server).origin}/`,
|
`${new URL(server).origin}/`,
|
||||||
"/",
|
"/",
|
||||||
);
|
);
|
||||||
|
fileContents = fileContents.replaceAll(
|
||||||
|
new URL(server).host,
|
||||||
|
new URL(config.http.base_url).host,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fileContents = fileContents.replaceAll(
|
fileContents = fileContents.replaceAll(
|
||||||
|
|
@ -161,7 +199,7 @@ export const handleGlitchRequest = async (
|
||||||
element.setInnerContent(
|
element.setInnerContent(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
meta: {
|
meta: {
|
||||||
access_token: null,
|
access_token: accessToken || null,
|
||||||
activity_api_enabled: true,
|
activity_api_enabled: true,
|
||||||
admin: null,
|
admin: null,
|
||||||
domain: new URL(config.http.base_url).host,
|
domain: new URL(config.http.base_url).host,
|
||||||
|
|
@ -177,7 +215,9 @@ export const handleGlitchRequest = async (
|
||||||
"https://github.com/lysand-org/lysand",
|
"https://github.com/lysand-org/lysand",
|
||||||
sso_redirect: null,
|
sso_redirect: null,
|
||||||
status_page_url: null,
|
status_page_url: null,
|
||||||
streaming_api_base_url: null,
|
streaming_api_base_url: `wss://${
|
||||||
|
new URL(config.http.base_url).host
|
||||||
|
}`,
|
||||||
timeline_preview: true,
|
timeline_preview: true,
|
||||||
title: config.instance.name,
|
title: config.instance.name,
|
||||||
trends_as_landing_page: false,
|
trends_as_landing_page: false,
|
||||||
|
|
@ -187,9 +227,24 @@ export const handleGlitchRequest = async (
|
||||||
display_media: null,
|
display_media: null,
|
||||||
reduce_motion: null,
|
reduce_motion: null,
|
||||||
use_blurhash: null,
|
use_blurhash: null,
|
||||||
|
me: user ? user.id : undefined,
|
||||||
},
|
},
|
||||||
compose: { text: "" },
|
compose: user
|
||||||
accounts: {},
|
? {
|
||||||
|
text: "",
|
||||||
|
me: user.id,
|
||||||
|
default_privacy: "public",
|
||||||
|
default_sensitive: false,
|
||||||
|
default_language: "en",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
accounts: user
|
||||||
|
? {
|
||||||
|
[user.id]: userToAPI(user, true),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
media_attachments: {
|
media_attachments: {
|
||||||
accept_content_types:
|
accept_content_types:
|
||||||
config.validation.allowed_mime_types,
|
config.validation.allowed_mime_types,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export const rawRoutes = {
|
||||||
"/api/v2/media": "./server/api/api/v2/media/index",
|
"/api/v2/media": "./server/api/api/v2/media/index",
|
||||||
"/api/v2/search": "./server/api/api/v2/search/index",
|
"/api/v2/search": "./server/api/api/v2/search/index",
|
||||||
"/api/auth/login": "./server/api/api/auth/login/index",
|
"/api/auth/login": "./server/api/api/auth/login/index",
|
||||||
|
"/api/auth/mastodon-login": "./server/api/api/auth/mastodon-login/index",
|
||||||
"/api/auth/redirect": "./server/api/api/auth/redirect/index",
|
"/api/auth/redirect": "./server/api/api/auth/redirect/index",
|
||||||
"/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index",
|
"/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index",
|
||||||
"/oauth/authorize-external": "./server/api/oauth/authorize-external/index",
|
"/oauth/authorize-external": "./server/api/oauth/authorize-external/index",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { TokenType } from "~database/entities/Token";
|
||||||
import { findFirstUser } from "~database/entities/User";
|
import { findFirstUser } from "~database/entities/User";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { token } from "~drizzle/schema";
|
import { token } from "~drizzle/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -17,71 +18,78 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().max(100).min(3),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth Code flow
|
* OAuth Code flow
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
email: string;
|
async (req, matchedRoute, extraData) => {
|
||||||
password: string;
|
const scopes = (matchedRoute.query.scope || "")
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
.replaceAll("+", " ")
|
||||||
const scopes = (matchedRoute.query.scope || "")
|
.split(" ");
|
||||||
.replaceAll("+", " ")
|
const redirect_uri = matchedRoute.query.redirect_uri;
|
||||||
.split(" ");
|
const response_type = matchedRoute.query.response_type;
|
||||||
const redirect_uri = matchedRoute.query.redirect_uri;
|
const client_id = matchedRoute.query.client_id;
|
||||||
const response_type = matchedRoute.query.response_type;
|
|
||||||
const client_id = matchedRoute.query.client_id;
|
|
||||||
|
|
||||||
const { email, password } = extraData.parsedRequest;
|
const { email, password } = extraData.parsedRequest;
|
||||||
|
|
||||||
const redirectToLogin = (error: string) =>
|
const redirectToLogin = (error: string) =>
|
||||||
Response.redirect(
|
Response.redirect(
|
||||||
`/oauth/authorize?${new URLSearchParams({
|
`/oauth/authorize?${new URLSearchParams({
|
||||||
...matchedRoute.query,
|
...matchedRoute.query,
|
||||||
error: encodeURIComponent(error),
|
error: encodeURIComponent(error),
|
||||||
|
}).toString()}`,
|
||||||
|
302,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response_type !== "code")
|
||||||
|
return redirectToLogin("Invalid response_type");
|
||||||
|
|
||||||
|
if (!email || !password)
|
||||||
|
return redirectToLogin("Invalid username or password");
|
||||||
|
|
||||||
|
const user = await findFirstUser({
|
||||||
|
where: (user, { eq }) => eq(user.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await Bun.password.verify(password, user.password || ""))
|
||||||
|
)
|
||||||
|
return redirectToLogin("Invalid username or password");
|
||||||
|
|
||||||
|
const application = await db.query.application.findFirst({
|
||||||
|
where: (app, { eq }) => eq(app.clientId, client_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!application) return redirectToLogin("Invalid client_id");
|
||||||
|
|
||||||
|
const code = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
await db.insert(token).values({
|
||||||
|
accessToken: randomBytes(64).toString("base64url"),
|
||||||
|
code: code,
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
tokenType: TokenType.BEARER,
|
||||||
|
applicationId: application.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to OAuth confirmation screen
|
||||||
|
return Response.redirect(
|
||||||
|
`/oauth/redirect?${new URLSearchParams({
|
||||||
|
redirect_uri,
|
||||||
|
code,
|
||||||
|
client_id,
|
||||||
|
application: application.name,
|
||||||
|
website: application.website ?? "",
|
||||||
|
scope: scopes.join(" "),
|
||||||
}).toString()}`,
|
}).toString()}`,
|
||||||
302,
|
302,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
if (response_type !== "code")
|
);
|
||||||
return redirectToLogin("Invalid response_type");
|
|
||||||
|
|
||||||
if (!email || !password)
|
|
||||||
return redirectToLogin("Invalid username or password");
|
|
||||||
|
|
||||||
const user = await findFirstUser({
|
|
||||||
where: (user, { eq }) => eq(user.email, email),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !(await Bun.password.verify(password, user.password || "")))
|
|
||||||
return redirectToLogin("Invalid username or password");
|
|
||||||
|
|
||||||
const application = await db.query.application.findFirst({
|
|
||||||
where: (app, { eq }) => eq(app.clientId, client_id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!application) return redirectToLogin("Invalid client_id");
|
|
||||||
|
|
||||||
const code = randomBytes(32).toString("hex");
|
|
||||||
|
|
||||||
await db.insert(token).values({
|
|
||||||
accessToken: randomBytes(64).toString("base64url"),
|
|
||||||
code: code,
|
|
||||||
scope: scopes.join(" "),
|
|
||||||
tokenType: TokenType.BEARER,
|
|
||||||
applicationId: application.id,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to OAuth confirmation screen
|
|
||||||
return Response.redirect(
|
|
||||||
`/oauth/redirect?${new URLSearchParams({
|
|
||||||
redirect_uri,
|
|
||||||
code,
|
|
||||||
client_id,
|
|
||||||
application: application.name,
|
|
||||||
website: application.website ?? "",
|
|
||||||
scope: scopes.join(" "),
|
|
||||||
}).toString()}`,
|
|
||||||
302,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
77
server/api/api/auth/mastodon-login/index.ts
Normal file
77
server/api/api/auth/mastodon-login/index.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { apiRoute, applyConfig } from "@api";
|
||||||
|
import { TokenType } from "~database/entities/Token";
|
||||||
|
import { findFirstUser } from "~database/entities/User";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { token } from "~drizzle/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
|
|
||||||
|
export const meta = applyConfig({
|
||||||
|
allowedMethods: ["POST"],
|
||||||
|
ratelimits: {
|
||||||
|
max: 4,
|
||||||
|
duration: 60,
|
||||||
|
},
|
||||||
|
route: "/api/auth/login",
|
||||||
|
auth: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
"user[email]": z.string().email(),
|
||||||
|
"user[password]": z.string().max(100).min(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mastodon-FE login route
|
||||||
|
*/
|
||||||
|
export default apiRoute<typeof meta, typeof schema>(
|
||||||
|
async (req, matchedRoute, extraData) => {
|
||||||
|
const { "user[email]": email, "user[password]": password } =
|
||||||
|
extraData.parsedRequest;
|
||||||
|
|
||||||
|
const redirectToLogin = (error: string) =>
|
||||||
|
Response.redirect(
|
||||||
|
`/auth/sign_in?${new URLSearchParams({
|
||||||
|
...matchedRoute.query,
|
||||||
|
error: encodeURIComponent(error),
|
||||||
|
}).toString()}`,
|
||||||
|
302,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await findFirstUser({
|
||||||
|
where: (user, { eq }) => eq(user.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await Bun.password.verify(password, user.password || ""))
|
||||||
|
)
|
||||||
|
return redirectToLogin("Invalid email or password");
|
||||||
|
|
||||||
|
const code = randomBytes(32).toString("hex");
|
||||||
|
const accessToken = randomBytes(64).toString("base64url");
|
||||||
|
|
||||||
|
await db.insert(token).values({
|
||||||
|
accessToken,
|
||||||
|
code: code,
|
||||||
|
scope: "read write follow push",
|
||||||
|
tokenType: TokenType.BEARER,
|
||||||
|
applicationId: null,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to home
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
Location: "/",
|
||||||
|
"Set-Cookie": `_mastodon_session=${accessToken}; Domain=${
|
||||||
|
new URL(config.http.base_url).hostname
|
||||||
|
}; SameSite=Lax; Path=/; HttpOnly`,
|
||||||
|
},
|
||||||
|
status: 303,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
Loading…
Reference in a new issue