diff --git a/packages/glitch-server/main.ts b/packages/glitch-server/main.ts index f3e8590a..305187c0 100644 --- a/packages/glitch-server/main.ts +++ b/packages/glitch-server/main.ts @@ -2,6 +2,8 @@ import { join } from "node:path"; import { config } from "config-manager"; import type { LogManager, MultiLogManager } from "~packages/log-manager"; import { languages } from "./glitch-languages"; +import { redirect } from "@response"; +import { retrieveUserFromToken, userToAPI } from "~database/entities/User"; export const handleGlitchRequest = async ( req: Request, @@ -9,6 +11,10 @@ export const handleGlitchRequest = async ( ): Promise => { const url = new URL(req.url); let path = url.pathname; + const accessToken = req.headers + .get("Cookie") + ?.match(/_mastodon_session=(.*?)(;|$)/)?.[1]; + const user = await retrieveUserFromToken(accessToken ?? ""); // Strip leading /web from path 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 if (path === "/" || path === "") path = "/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()) { 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( + `
+ ${decodeURIComponent( + url.searchParams.get("error") ?? "", + )} +
`, + { + html: true, + }, + ); + }, + }) + .transform(new Response(fileContents)); + + fileContents = await rewriter.text(); + } for (const server of config.frontend.glitch.server) { - fileContents = fileContents.replace( + fileContents = fileContents.replaceAll( `${new URL(server).origin}/`, "/", ); + fileContents = fileContents.replaceAll( + new URL(server).host, + new URL(config.http.base_url).host, + ); } fileContents = fileContents.replaceAll( @@ -161,7 +199,7 @@ export const handleGlitchRequest = async ( element.setInnerContent( JSON.stringify({ meta: { - access_token: null, + access_token: accessToken || null, activity_api_enabled: true, admin: null, domain: new URL(config.http.base_url).host, @@ -177,7 +215,9 @@ export const handleGlitchRequest = async ( "https://github.com/lysand-org/lysand", sso_redirect: 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, title: config.instance.name, trends_as_landing_page: false, @@ -187,9 +227,24 @@ export const handleGlitchRequest = async ( display_media: null, reduce_motion: null, use_blurhash: null, + me: user ? user.id : undefined, }, - compose: { text: "" }, - accounts: {}, + compose: user + ? { + text: "", + me: user.id, + default_privacy: "public", + default_sensitive: false, + default_language: "en", + } + : { + text: "", + }, + accounts: user + ? { + [user.id]: userToAPI(user, true), + } + : {}, media_attachments: { accept_content_types: config.validation.allowed_mime_types, diff --git a/routes.ts b/routes.ts index 28f3d448..8d648b9f 100644 --- a/routes.ts +++ b/routes.ts @@ -35,6 +35,7 @@ export const rawRoutes = { "/api/v2/media": "./server/api/api/v2/media/index", "/api/v2/search": "./server/api/api/v2/search/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", "/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index", "/oauth/authorize-external": "./server/api/oauth/authorize-external/index", diff --git a/server/api/api/auth/login/index.ts b/server/api/api/auth/login/index.ts index 48951ddb..9beeffb8 100644 --- a/server/api/api/auth/login/index.ts +++ b/server/api/api/auth/login/index.ts @@ -4,6 +4,7 @@ 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"; export const meta = applyConfig({ 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 */ -export default apiRoute<{ - email: string; - password: string; -}>(async (req, matchedRoute, extraData) => { - const scopes = (matchedRoute.query.scope || "") - .replaceAll("+", " ") - .split(" "); - const redirect_uri = matchedRoute.query.redirect_uri; - const response_type = matchedRoute.query.response_type; - const client_id = matchedRoute.query.client_id; +export default apiRoute( + async (req, matchedRoute, extraData) => { + const scopes = (matchedRoute.query.scope || "") + .replaceAll("+", " ") + .split(" "); + const redirect_uri = matchedRoute.query.redirect_uri; + 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) => - Response.redirect( - `/oauth/authorize?${new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), + const redirectToLogin = (error: string) => + Response.redirect( + `/oauth/authorize?${new URLSearchParams({ + ...matchedRoute.query, + 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()}`, 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, - ); -}); + }, +); diff --git a/server/api/api/auth/mastodon-login/index.ts b/server/api/api/auth/mastodon-login/index.ts new file mode 100644 index 00000000..3a13f55a --- /dev/null +++ b/server/api/api/auth/mastodon-login/index.ts @@ -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( + 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, + }); + }, +);