diff --git a/README.md b/README.md index b50bd548..3d62b044 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## Features -- [x] Federation (partial) +- [x] Fully compliant Lysand 3.0 federation (partial) - [x] Hyper fast (thousands of HTTP requests per second) - [x] S3 or local media storage - [x] Deduplication of uploaded files @@ -32,11 +32,21 @@ - [x] Configurable defaults - [x] Full regex-based filters for posts, users and media - [x] Custom emoji support +- [x] Users can upload their own emojis for themselves - [x] Automatic image conversion to WebP or other formats - [x] Scripting-compatible CLI with JSON and CSV outputs - [x] Markdown support just about everywhere: posts, profiles, profile fields, etc. Code blocks, tables, and more are supported. - [ ] Moderation tools -- [x] Mastodon API support (partial) +- [x] Fully compliant Mastodon API support (partial) +- [x] Glitch-SOC extensions +- [x] Full compatibility with many clients such as Megalodon +- [x] Ability to use your own frontends +- [x] Non-monolithic architecture, microservices can be hosted in infinite amounts on infinite servers +- [x] Ability to use all your threads +- [x] Support for SSO providers (and disabling password logins) +- [x] Fully written in TypeScript and thoroughly unit tested +- [x] Automatic signed container builds for easy deployment +- [x] Docker and Podman supported ## Screenshots @@ -174,7 +184,7 @@ Working endpoints are: - [ ] `/oauth/revoke` - Admin API -### Main work to do +### Main work to do for API - [ ] Announcements - [ ] Polls @@ -190,6 +200,10 @@ Working endpoints are: - [ ] Reports - [ ] Admin API +## Lysand API + +For Lysand's own custom API, please see the [API documentation](docs/api/index.md). + ## License This project is licensed under the [AGPL-3.0-or-later](LICENSE). diff --git a/config/config.example.toml b/config/config.example.toml index 11e5033d..3229bee3 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -47,6 +47,12 @@ rules = [ # Run Lysand with this value missing to generate a new key jwt_key = "" +# If enabled, Lysand will require users to log in with an OAuth provider +# Note that registering with an OAuth provider is not supported yet, so +# this will lock out users who are not already registered or who do not have +# an OAuth account linked +forced = false + # Delete this section if you don't want to use custom OAuth providers # This is an example configuration # The provider MUST support OpenID Connect with .well-known discovery @@ -99,6 +105,15 @@ enabled = true # The URL to reach the frontend at (should be on a local network) url = "http://localhost:3000" +[frontend.routes] +# Special routes for your frontend, below are the defaults for Lysand-FE +# Can be set to a route already used by Lysand, as long as it is on a different HTTP method +# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize +# home = "/" +# login = "/oauth/authorize" +# consent = "/oauth/consent" +# register = "/register" + [frontend.settings] # Arbitrary key/value pairs to be passed to the frontend # This can be used to set up custom themes, etc on supported frontends. diff --git a/docs/api/frontend.md b/docs/api/frontend.md index 4d1822b2..aef3c0ea 100644 --- a/docs/api/frontend.md +++ b/docs/api/frontend.md @@ -4,7 +4,9 @@ The frontend API contains endpoints that are useful for frontend developers. The ## Routes that the Frontend must implement -- `GET /oauth/authorize` (NOT `POST`): Identifier/password login form, submits to [`POST /api/auth/login`](#sign-in) or OpenID Connect flow. +These routes can be set to a different URL in the Lysand configuration, at `frontend.routes`. The frontend must implement these routes for the instance to function correctly. + +- `GET /oauth/authorize`: (NOT `POST`): Identifier/password login form, submits to [`POST /api/auth/login`](#sign-in) or OpenID Connect flow. - `GET /oauth/consent`: Consent form, submits to [`POST /api/auth/redirect`](#consent) ## Get Frontend Configuration diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 76904aa7..708d1675 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -6,6 +6,20 @@ export enum MediaBackendType { S3 = "s3", } +const zUrlPath = z + .string() + .trim() + .min(1) + // Remove trailing slashes, but keep the root slash + .transform((arg) => (arg === "/" ? arg : arg.replace(/\/$/, ""))); + +const zUrl = z + .string() + .trim() + .min(1) + .refine((arg) => URL.canParse(arg), "Invalid url") + .transform((arg) => arg.replace(/\/$/, "")); + export const configValidator = z.object({ database: z.object({ host: z.string().min(1).default("localhost"), @@ -78,6 +92,7 @@ export const configValidator = z.object({ rules: z.array(z.string()).default([]), }), oidc: z.object({ + forced: z.boolean().default(false), providers: z .array( z.object({ @@ -136,18 +151,31 @@ export const configValidator = z.object({ frontend: z .object({ enabled: z.boolean().default(true), - url: z.string().min(1).url().default("http://localhost:3000"), + url: zUrl.default("http://localhost:3000"), glitch: z .object({ enabled: z.boolean().default(false), assets: z.string().min(1).default("glitch"), - server: z.array(z.string().url().min(1)).default([]), + server: z.array(zUrl).default([]), }) .default({ enabled: false, assets: "glitch", server: [], }), + routes: z + .object({ + home: zUrlPath.default("/"), + login: zUrlPath.default("/oauth/authorize"), + consent: zUrlPath.default("/oauth/consent"), + register: zUrlPath.default("/register"), + }) + .default({ + home: "/", + login: "/oauth/authorize", + consent: "/oauth/consent", + register: "/register", + }), settings: z.record(z.string(), z.any()).default({}), }) .default({ @@ -215,7 +243,7 @@ export const configValidator = z.object({ secret_access_key: z.string(), region: z.string().optional(), bucket_name: z.string().default("lysand"), - public_url: z.string().url(), + public_url: zUrl, }) .default({ endpoint: "", @@ -374,8 +402,8 @@ export const configValidator = z.object({ .object({ visibility: z.string().default("public"), language: z.string().default("en"), - avatar: z.string().url().optional(), - header: z.string().url().optional(), + avatar: zUrl.optional(), + header: zUrl.optional(), placeholder_style: z.string().default("thumbs"), }) .default({ @@ -387,18 +415,18 @@ export const configValidator = z.object({ }), federation: z .object({ - blocked: z.array(z.string().url()).default([]), - followers_only: z.array(z.string().url()).default([]), + blocked: z.array(zUrl).default([]), + followers_only: z.array(zUrl).default([]), discard: z.object({ - reports: z.array(z.string().url()).default([]), - deletes: z.array(z.string().url()).default([]), - updates: z.array(z.string().url()).default([]), - media: z.array(z.string().url()).default([]), - follows: z.array(z.string().url()).default([]), - likes: z.array(z.string().url()).default([]), - reactions: z.array(z.string().url()).default([]), - banners: z.array(z.string().url()).default([]), - avatars: z.array(z.string().url()).default([]), + reports: z.array(zUrl).default([]), + deletes: z.array(zUrl).default([]), + updates: z.array(zUrl).default([]), + media: z.array(zUrl).default([]), + follows: z.array(zUrl).default([]), + likes: z.array(zUrl).default([]), + reactions: z.array(zUrl).default([]), + banners: z.array(zUrl).default([]), + avatars: z.array(zUrl).default([]), }), }) .default({ @@ -421,8 +449,8 @@ export const configValidator = z.object({ name: z.string().min(1).default("Lysand"), description: z.string().min(1).default("A Lysand instance"), extended_description_path: z.string().optional(), - logo: z.string().url().optional(), - banner: z.string().url().optional(), + logo: zUrl.optional(), + banner: zUrl.optional(), }) .default({ name: "Lysand", diff --git a/packages/database-interface/oauth.ts b/packages/database-interface/oauth.ts index 6f5e4764..5a681c55 100644 --- a/packages/database-interface/oauth.ts +++ b/packages/database-interface/oauth.ts @@ -152,7 +152,9 @@ export class OAuthManager { // Check if userId is equal to application.clientId if ((flow.application?.clientId ?? "") !== userId) { return response(null, 302, { - Location: `${config.http.base_url}?${new URLSearchParams({ + Location: `${config.http.base_url}${ + config.frontend.routes.home + }?${new URLSearchParams({ oidc_account_linking_error: "Account linking error", oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`, })}`, @@ -170,7 +172,9 @@ export class OAuthManager { if (account) { return response(null, 302, { - Location: `${config.http.base_url}?${new URLSearchParams({ + Location: `${config.http.base_url}${ + config.frontend.routes.home + }?${new URLSearchParams({ oidc_account_linking_error: "Account already linked", oidc_account_linking_error_message: "This account has already been linked to this OpenID Connect provider.", @@ -186,7 +190,9 @@ export class OAuthManager { }); return response(null, 302, { - Location: `${config.http.base_url}?${new URLSearchParams({ + Location: `${config.http.base_url}${ + config.frontend.routes.home + }?${new URLSearchParams({ oidc_account_linked: "true", })}`, }); diff --git a/server/api/api/auth/login/index.ts b/server/api/api/auth/login/index.ts index 122dc69d..ed387d51 100644 --- a/server/api/api/auth/login/index.ts +++ b/server/api/api/auth/login/index.ts @@ -74,7 +74,7 @@ const returnError = (query: object, error: string, description: string) => { return response(null, 302, { Location: new URL( - `/oauth/authorize?${searchParams.toString()}`, + `${config.frontend.routes.login}?${searchParams.toString()}`, config.http.base_url, ).toString(), }); @@ -87,6 +87,14 @@ export default (app: Hono) => zValidator("form", schemas.form, handleZodError), zValidator("query", schemas.query, handleZodError), async (context) => { + if (config.oidc.forced) { + return returnError( + context.req.query(), + "invalid_request", + "Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.", + ); + } + const { identifier, password } = context.req.valid("form"); const { client_id } = context.req.valid("query"); @@ -160,7 +168,9 @@ export default (app: Hono) => // Redirect to OAuth authorize with JWT return response(null, 302, { Location: new URL( - `/oauth/consent?${searchParams.toString()}`, + `${ + config.frontend.routes.consent + }?${searchParams.toString()}`, config.http.base_url, ).toString(), // Set cookie with JWT diff --git a/server/api/api/auth/redirect/index.ts b/server/api/api/auth/redirect/index.ts index f9093757..61a1f5c9 100644 --- a/server/api/api/auth/redirect/index.ts +++ b/server/api/api/auth/redirect/index.ts @@ -5,6 +5,7 @@ import type { Hono } from "hono"; import { z } from "zod"; import { db } from "~drizzle/db"; import { Applications, Tokens } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -40,7 +41,7 @@ export default (app: Hono) => const redirectToLogin = (error: string) => Response.redirect( - `/oauth/authorize?${new URLSearchParams({ + `${config.frontend.routes.login}?${new URLSearchParams({ ...context.req.query, error: encodeURIComponent(error), }).toString()}`, diff --git a/server/api/oauth/authorize/index.ts b/server/api/oauth/authorize/index.ts index 46bffb8d..5883208b 100644 --- a/server/api/oauth/authorize/index.ts +++ b/server/api/oauth/authorize/index.ts @@ -72,7 +72,7 @@ const returnError = (query: object, error: string, description: string) => { searchParams.append("error_description", description); return response(null, 302, { - Location: `/oauth/authorize?${searchParams.toString()}`, + Location: `${config.frontend.routes.login}?${searchParams.toString()}`, }); }; diff --git a/server/api/oauth/providers/index.ts b/server/api/oauth/providers/index.ts deleted file mode 100644 index 29d03ca8..00000000 --- a/server/api/oauth/providers/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { applyConfig } from "@api"; -import { jsonResponse } from "@response"; -import type { Hono } from "hono"; -import { config } from "~packages/config-manager"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 10, - }, - route: "/oauth/providers", -}); - -export default (app: Hono) => - app.on(meta.allowedMethods, meta.route, async () => { - return jsonResponse( - config.oidc.providers.map((p) => ({ - name: p.name, - icon: p.icon, - id: p.id, - })), - ); - }); diff --git a/server/api/oauth/sso/:issuer/callback/index.ts b/server/api/oauth/sso/:issuer/callback/index.ts index e28a6490..e23fd0fc 100644 --- a/server/api/oauth/sso/:issuer/callback/index.ts +++ b/server/api/oauth/sso/:issuer/callback/index.ts @@ -52,7 +52,7 @@ const returnError = (query: object, error: string, description: string) => { searchParams.append("error_description", description); return response(null, 302, { - Location: `/oauth/authorize?${searchParams.toString()}`, + Location: `${config.frontend.routes.login}?${searchParams.toString()}`, }); }; @@ -177,7 +177,7 @@ export default (app: Hono) => // Redirect back to application return response(null, 302, { Location: new URL( - `/oauth/consent?${new URLSearchParams({ + `${config.frontend.routes.consent}?${new URLSearchParams({ redirect_uri: flow.application.redirectUri, code, client_id: flow.application.clientId, diff --git a/server/api/oauth/sso/index.ts b/server/api/oauth/sso/index.ts index e4840fb0..d79d4256 100644 --- a/server/api/oauth/sso/index.ts +++ b/server/api/oauth/sso/index.ts @@ -46,7 +46,7 @@ const returnError = (query: object, error: string, description: string) => { searchParams.append("error_description", description); return response(null, 302, { - Location: `/oauth/authorize?${searchParams.toString()}`, + Location: `${config.frontend.routes.login}?${searchParams.toString()}`, }); };