diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..4a287b1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "husky": { + "version": "0.7.1", + "commands": ["husky"], + "rollForward": false + } + } +} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..fd85d23 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,22 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +## husky task runner examples ------------------- +## Note : for local installation use 'dotnet' prefix. e.g. 'dotnet husky' + +## run all tasks +#husky run + +### run all tasks with group: 'group-name' +#husky run --group group-name + +## run task with name: 'task-name' +#husky run --name task-name + +## pass hook arguments to task +#husky run --args "$1" "$2" + +## or put your custom commands ------------------- +#echo 'Husky.Net is awesome!' + +dotnet husky run diff --git a/.husky/task-runner.json b/.husky/task-runner.json new file mode 100644 index 0000000..8ebba21 --- /dev/null +++ b/.husky/task-runner.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://alirezanet.github.io/Husky.Net/schema.json", + "tasks": [ + { + "name": "run-prettier", + "command": "yarn", + "args": ["format"], + "pathMode": "absolute" + }, + { + "name": "dotnet-format", + "command": "dotnet", + "args": ["format"] + } + ] +} diff --git a/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml b/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml index 204acf7..5f8621e 100644 --- a/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml +++ b/.idea/.idea.Foxnouns.NET/.idea/jsLinters/eslint.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/.idea/.idea.Foxnouns.NET/.idea/prettier.xml b/.idea/.idea.Foxnouns.NET/.idea/prettier.xml index 653a9e0..ffcf89b 100644 --- a/.idea/.idea.Foxnouns.NET/.idea/prettier.xml +++ b/.idea/.idea.Foxnouns.NET/.idea/prettier.xml @@ -1,7 +1,7 @@ - + diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index b9570c0..e8b3e76 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -1,14 +1,18 @@ using System.Web; +using Foxnouns.Backend.Database; using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; -[Route("/api/v2/auth")] -public class AuthController(Config config, KeyCacheService keyCache, ILogger logger) : ApiControllerBase +[Route("/api/internal/auth")] +public class AuthController(Config config, DatabaseContext db, KeyCacheService keyCache, ILogger logger) + : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -61,4 +65,15 @@ public class AuthController(Config config, KeyCacheService keyCache, ILogger log public record OauthRegisterRequest(string Ticket, string Username); public record CallbackRequest(string Code, string State); + + [HttpPost("force-log-out")] + [Authorize("identify")] + public async Task ForceLogoutAsync() + { + _logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id); + await db.Tokens.Where(t => t.UserId == CurrentUser.Id) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true)); + + return NoContent(); + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index a1c3eed..f08569a 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -10,7 +10,7 @@ using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; -[Route("/api/v2/auth/discord")] +[Route("/api/internal/auth/discord")] public class DiscordAuthController( [UsedImplicitly] Config config, ILogger logger, diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 1649948..251fb5f 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -1,6 +1,7 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using JetBrains.Annotations; @@ -10,7 +11,7 @@ using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; -[Route("/api/v2/auth/email")] +[Route("/api/internal/auth/email")] public class EmailAuthController( [UsedImplicitly] Config config, DatabaseContext db, @@ -123,9 +124,20 @@ public class EmailAuthController( )); } + [HttpPost("add")] + [Authorize("*")] + public async Task AddEmailAddressAsync() + { + _logger.Information("beep"); + + return NoContent(); + } + + public record AddEmailAddressRequest(string Email, string Password); + private void CheckRequirements() { - if (!config.DiscordAuth.Enabled) + if (!config.EmailAuth.Enabled) throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); } diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 9a182f4..fb0e301 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -186,7 +186,8 @@ public class UsersController( if (preferences.Count > MaxCustomPreferences) errors.Add(("custom_preferences", - ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences, preferences.Count))); + ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences, + preferences.Count))); if (preferences.Count > 50) return errors; // TODO: validate individual preferences diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index c767198..c7bd717 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -27,7 +27,7 @@ public static class AuthUtils public static string[] ExpandScopes(this string[] scopes) { - if (scopes.Contains("*")) return Scopes; + if (scopes.Contains("*")) return ["*", ..Scopes]; List expandedScopes = ["identify"]; if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes); if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes); diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs index 392e5ed..2dd52b9 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -289,6 +289,7 @@ public static partial class ValidationUtils [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")] private static partial Regex UsernameRegex(); + [GeneratedRegex("""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", RegexOptions.IgnoreCase, "en-NL")] private static partial Regex MemberRegex(); } \ No newline at end of file diff --git a/Foxnouns.Frontend/app/env.server.ts b/Foxnouns.Frontend/app/env.server.ts index 2add747..5e5e84b 100644 --- a/Foxnouns.Frontend/app/env.server.ts +++ b/Foxnouns.Frontend/app/env.server.ts @@ -2,4 +2,5 @@ import "dotenv/config"; import { env } from "node:process"; export const API_BASE = env.API_BASE || "https://pronouns.localhost/api"; +export const INTERNAL_API_BASE = env.INTERNAL_API_BASE || "https://localhost:5000/api"; export const LANGUAGE = env.LANGUAGE || "en"; diff --git a/Foxnouns.Frontend/app/lib/request.server.ts b/Foxnouns.Frontend/app/lib/request.server.ts index 4648d5f..c92f67d 100644 --- a/Foxnouns.Frontend/app/lib/request.server.ts +++ b/Foxnouns.Frontend/app/lib/request.server.ts @@ -1,5 +1,5 @@ import { parse as parseCookie, serialize as serializeCookie } from "cookie"; -import { API_BASE } from "~/env.server"; +import { API_BASE, INTERNAL_API_BASE } from "~/env.server"; import { ApiError, ErrorCode } from "./api/error"; import { tokenCookieName } from "~/lib/utils"; @@ -8,14 +8,17 @@ export type RequestParams = { // eslint-disable-next-line @typescript-eslint/no-explicit-any body?: any; headers?: Record; + isInternal?: boolean; }; -export default async function serverRequest( +async function requestInternal( method: string, path: string, params: RequestParams = {}, -) { - const url = `${API_BASE}/v2${path}`; +): Promise { + const base = params.isInternal ? INTERNAL_API_BASE + "/internal" : API_BASE + "/v2"; + + const url = `${base}${path}`; const resp = await fetch(url, { method, body: params.body ? JSON.stringify(params.body) : undefined, @@ -37,6 +40,19 @@ export default async function serverRequest( } if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as ApiError; + return resp; +} + +export async function fastRequest(method: string, path: string, params: RequestParams = {}) { + await requestInternal(method, path, params); +} + +export default async function serverRequest( + method: string, + path: string, + params: RequestParams = {}, +) { + const resp = await requestInternal(method, path, params); return (await resp.json()) as T; } diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx index c5200fd..5fb246c 100644 --- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -43,6 +43,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const resp = await serverRequest("POST", "/auth/discord/callback", { body: { code, state }, + isInternal: true, }); if (resp.has_account) { @@ -89,6 +90,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { try { const resp = await serverRequest("POST", "/auth/discord/register", { body: { username, ticket }, + isInternal: true, }); return redirect("/auth/welcome", { diff --git a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx index fc25d75..eadbaa9 100644 --- a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx @@ -41,7 +41,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } } - const urls = await serverRequest("POST", "/auth/urls"); + const urls = await serverRequest("POST", "/auth/urls", { isInternal: true }); return json({ meta: { title: t("log-in.title") }, @@ -57,6 +57,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { try { const resp = await serverRequest("POST", "/auth/email/login", { body: { email, password }, + isInternal: true, }); return redirect("/", { diff --git a/Foxnouns.Frontend/app/routes/settings._index/route.tsx b/Foxnouns.Frontend/app/routes/settings._index/route.tsx index d575108..5b90851 100644 --- a/Foxnouns.Frontend/app/routes/settings._index/route.tsx +++ b/Foxnouns.Frontend/app/routes/settings._index/route.tsx @@ -1,6 +1,6 @@ import { Button, Form, InputGroup, Table } from "react-bootstrap"; import { useTranslation } from "react-i18next"; -import { Form as RemixForm, useActionData, useRouteLoaderData } from "@remix-run/react"; +import { Form as RemixForm, useActionData, useFetcher, useRouteLoaderData } from "@remix-run/react"; import { loader as settingsLoader } from "../settings/route"; import { loader as rootLoader } from "../../root"; import { DateTime } from "luxon"; @@ -43,6 +43,7 @@ export default function SettingsIndex() { const actionData = useActionData(); const { meta } = useRouteLoaderData("root")!; const { t } = useTranslation(); + const fetcher = useFetcher(); const createdAt = idTimestamp(user.id); @@ -55,13 +56,7 @@ export default function SettingsIndex() { {t("settings.general.username")} - + {t("settings.general.change-username")} @@ -85,9 +80,14 @@ export default function SettingsIndex() { {t("settings.general.log-out-everywhere")} - + {t("settings.general.log-out-everywhere-hint")} + + + {t("settings.general.force-log-out-button")} + + - {t("settings.general.table-header")} + {t("settings.general.table-header")} diff --git a/Foxnouns.Frontend/app/routes/settings.force-log-out/route.tsx b/Foxnouns.Frontend/app/routes/settings.force-log-out/route.tsx new file mode 100644 index 0000000..608761b --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.force-log-out/route.tsx @@ -0,0 +1,19 @@ +import { ActionFunction, redirect } from "@remix-run/node"; +import { fastRequest, getToken, writeCookie } from "~/lib/request.server"; +import { tokenCookieName } from "~/lib/utils"; + +export const action: ActionFunction = async ({ request }) => { + const token = getToken(request); + if (!token) + return redirect("/", { + status: 303, + headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) }, + }); + + await fastRequest("POST", "/auth/force-log-out", { token, isInternal: true }); + + return redirect("/", { + status: 303, + headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) }, + }); +}; diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 279173b..c35a1d7 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -96,6 +96,8 @@ "change-username": "Change username", "username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.", "log-out-everywhere": "Log out everywhere", + "log-out-everywhere-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.", + "force-log-out-button": "Force log out", "table-header": "General account information", "id": "Your user ID", "created": "Account created at", diff --git a/docker-compose.yml b/docker-compose.yml index 7176fc2..6fafd18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: build: ./Foxnouns.Frontend environment: - "API_BASE=http://rate:5003/api" + - "INTERNAL_API_BASE=http://backend:5000/api" restart: unless-stopped volumes: - ./docker/frontend.env:/app/.env diff --git a/migrators/NetImporter/ImportUser.cs b/migrators/NetImporter/ImportUser.cs index 3524fad..9547980 100644 --- a/migrators/NetImporter/ImportUser.cs +++ b/migrators/NetImporter/ImportUser.cs @@ -112,12 +112,12 @@ public static class Users foreach (var field in oldUser.Fields ?? []) { var entries = field.Entries.Select(entry => new FieldEntry - { - Value = entry.Value, - Status = prefMapping.TryGetValue(entry.Status, out var newStatus) + { + Value = entry.Value, + Status = prefMapping.TryGetValue(entry.Status, out var newStatus) ? newStatus.ToString() : entry.Status, - }) + }) .ToList(); user.Fields.Add(new Field diff --git a/package.json b/package.json index da7f5dc..4c05b19 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "concurrently": "^9.0.1" }, "scripts": { - "dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'cd Foxnouns.Backend && dotnet watch --no-hot-reload' 'cd Foxnouns.Frontend && yarn dev' 'cd rate && go run -v .'" + "dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'cd Foxnouns.Backend && dotnet watch --no-hot-reload' 'cd Foxnouns.Frontend && yarn dev' 'cd rate && go run -v .'", + "format": "cd Foxnouns.Frontend && yarn format" } }
{t("settings.general.log-out-everywhere-hint")}