auth: clarify invalid origin error toast guidance (#193)

* nix: fix flake module and runtime scripts

* auth: clarify invalid origin toast
This commit is contained in:
ARUNAVO RAY
2026-02-26 10:39:08 +05:30
committed by GitHub
parent 08da526ddd
commit 855906d990
3 changed files with 235 additions and 174 deletions

334
flake.nix
View File

@@ -7,14 +7,17 @@
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: let
forEachSystem = flake-utils.lib.eachDefaultSystem;
in
(forEachSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
# Build the application # Build the application
gitea-mirror = pkgs.stdenv.mkDerivation { gitea-mirror = pkgs.stdenv.mkDerivation {
pname = "gitea-mirror"; pname = "gitea-mirror";
version = "3.8.11"; version = "3.9.4";
src = ./.; src = ./.;
@@ -53,7 +56,7 @@
# Create entrypoint script that matches Docker behavior # Create entrypoint script that matches Docker behavior
cat > $out/bin/gitea-mirror <<'EOF' cat > $out/bin/gitea-mirror <<'EOF'
#!/usr/bin/env bash #!${pkgs.bash}/bin/bash
set -e set -e
# === DEFAULT CONFIGURATION === # === DEFAULT CONFIGURATION ===
@@ -112,7 +115,7 @@ if [ -z "$ENCRYPTION_SECRET" ]; then
fi fi
# === DATABASE INITIALIZATION === # === DATABASE INITIALIZATION ===
DB_PATH=$(echo "$DATABASE_URL" | sed 's|^file:||') DB_PATH=$(echo "$DATABASE_URL" | ${pkgs.gnused}/bin/sed 's|^file:||')
if [ ! -f "$DB_PATH" ]; then if [ ! -f "$DB_PATH" ]; then
echo "Database not found. It will be created and initialized via Drizzle migrations on first app startup..." echo "Database not found. It will be created and initialized via Drizzle migrations on first app startup..."
touch "$DB_PATH" touch "$DB_PATH"
@@ -123,25 +126,25 @@ fi
# === STARTUP SCRIPTS === # === STARTUP SCRIPTS ===
# Initialize configuration from environment variables # Initialize configuration from environment variables
echo "Checking for environment configuration..." echo "Checking for environment configuration..."
if [ -f "dist/scripts/startup-env-config.js" ]; then if [ -f "scripts/startup-env-config.ts" ]; then
echo "Loading configuration from environment variables..." echo "Loading configuration from environment variables..."
${pkgs.bun}/bin/bun dist/scripts/startup-env-config.js && \ ${pkgs.bun}/bin/bun scripts/startup-env-config.ts && \
echo " Environment configuration loaded successfully" || \ echo " Environment configuration loaded successfully" || \
echo " Environment configuration loading completed with warnings" echo " Environment configuration loading completed with warnings"
fi fi
# Run startup recovery # Run startup recovery
echo "Running startup recovery..." echo "Running startup recovery..."
if [ -f "dist/scripts/startup-recovery.js" ]; then if [ -f "scripts/startup-recovery.ts" ]; then
${pkgs.bun}/bin/bun dist/scripts/startup-recovery.js --timeout=30000 && \ ${pkgs.bun}/bin/bun scripts/startup-recovery.ts --timeout=30000 && \
echo " Startup recovery completed successfully" || \ echo " Startup recovery completed successfully" || \
echo " Startup recovery completed with warnings" echo " Startup recovery completed with warnings"
fi fi
# Run repository status repair # Run repository status repair
echo "Running repository status repair..." echo "Running repository status repair..."
if [ -f "dist/scripts/repair-mirrored-repos.js" ]; then if [ -f "scripts/repair-mirrored-repos.ts" ]; then
${pkgs.bun}/bin/bun dist/scripts/repair-mirrored-repos.js --startup && \ ${pkgs.bun}/bin/bun scripts/repair-mirrored-repos.ts --startup && \
echo " Repository status repair completed successfully" || \ echo " Repository status repair completed successfully" || \
echo " Repository status repair completed with warnings" echo " Repository status repair completed with warnings"
fi fi
@@ -170,7 +173,7 @@ EOF
# Create database management helper # Create database management helper
cat > $out/bin/gitea-mirror-db <<'EOF' cat > $out/bin/gitea-mirror-db <<'EOF'
#!/usr/bin/env bash #!${pkgs.bash}/bin/bash
export DATA_DIR=''${DATA_DIR:-"$HOME/.local/share/gitea-mirror"} export DATA_DIR=''${DATA_DIR:-"$HOME/.local/share/gitea-mirror"}
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
cd $out/lib/gitea-mirror cd $out/lib/gitea-mirror
@@ -217,176 +220,175 @@ EOF
''; '';
}; };
# NixOS module }
nixosModules.default = { config, lib, pkgs, ... }: )) // {
with lib; nixosModules.default = { config, lib, pkgs, ... }:
let with lib;
cfg = config.services.gitea-mirror; let
in { cfg = config.services.gitea-mirror;
options.services.gitea-mirror = { in {
enable = mkEnableOption "Gitea Mirror service"; options.services.gitea-mirror = {
enable = mkEnableOption "Gitea Mirror service";
package = mkOption { package = mkOption {
type = types.package; type = types.package;
default = self.packages.${system}.default; default = self.packages.${pkgs.system}.default;
description = "The Gitea Mirror package to use"; description = "The Gitea Mirror package to use";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/gitea-mirror";
description = "Directory to store data and database";
};
user = mkOption {
type = types.str;
default = "gitea-mirror";
description = "User account under which Gitea Mirror runs";
};
group = mkOption {
type = types.str;
default = "gitea-mirror";
description = "Group under which Gitea Mirror runs";
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host to bind to";
};
port = mkOption {
type = types.port;
default = 4321;
description = "Port to listen on";
};
betterAuthUrl = mkOption {
type = types.str;
default = "http://localhost:4321";
description = "Better Auth URL (external URL of the service)";
};
betterAuthTrustedOrigins = mkOption {
type = types.str;
default = "http://localhost:4321";
description = "Comma-separated list of trusted origins for Better Auth";
};
mirrorIssueConcurrency = mkOption {
type = types.int;
default = 3;
description = "Number of concurrent issue mirror operations (set to 1 for perfect ordering)";
};
mirrorPullRequestConcurrency = mkOption {
type = types.int;
default = 5;
description = "Number of concurrent PR mirror operations (set to 1 for perfect ordering)";
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to file containing environment variables.
Only needed if you want to set BETTER_AUTH_SECRET or ENCRYPTION_SECRET manually.
Otherwise, secrets will be auto-generated and stored in the data directory.
Example:
BETTER_AUTH_SECRET=your-32-character-secret-here
ENCRYPTION_SECRET=your-encryption-secret-here
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open the firewall for the specified port";
};
}; };
config = mkIf cfg.enable { dataDir = mkOption {
users.users.${cfg.user} = { type = types.path;
isSystemUser = true; default = "/var/lib/gitea-mirror";
group = cfg.group; description = "Directory to store data and database";
home = cfg.dataDir; };
createHome = true;
};
users.groups.${cfg.group} = {}; user = mkOption {
type = types.str;
default = "gitea-mirror";
description = "User account under which Gitea Mirror runs";
};
systemd.services.gitea-mirror = { group = mkOption {
description = "Gitea Mirror - GitHub to Gitea mirroring service"; type = types.str;
after = [ "network.target" ]; default = "gitea-mirror";
wantedBy = [ "multi-user.target" ]; description = "Group under which Gitea Mirror runs";
};
environment = { host = mkOption {
DATA_DIR = cfg.dataDir; type = types.str;
DATABASE_URL = "file:${cfg.dataDir}/gitea-mirror.db"; default = "0.0.0.0";
HOST = cfg.host; description = "Host to bind to";
PORT = toString cfg.port; };
NODE_ENV = "production";
BETTER_AUTH_URL = cfg.betterAuthUrl;
BETTER_AUTH_TRUSTED_ORIGINS = cfg.betterAuthTrustedOrigins;
PUBLIC_BETTER_AUTH_URL = cfg.betterAuthUrl;
MIRROR_ISSUE_CONCURRENCY = toString cfg.mirrorIssueConcurrency;
MIRROR_PULL_REQUEST_CONCURRENCY = toString cfg.mirrorPullRequestConcurrency;
};
serviceConfig = { port = mkOption {
Type = "simple"; type = types.port;
User = cfg.user; default = 4321;
Group = cfg.group; description = "Port to listen on";
ExecStart = "${cfg.package}/bin/gitea-mirror"; };
Restart = "always";
RestartSec = "10s";
# Security hardening betterAuthUrl = mkOption {
NoNewPrivileges = true; type = types.str;
PrivateTmp = true; default = "http://localhost:4321";
ProtectSystem = "strict"; description = "Better Auth URL (external URL of the service)";
ProtectHome = true; };
ReadWritePaths = [ cfg.dataDir ];
# Load environment file if specified (optional) betterAuthTrustedOrigins = mkOption {
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; type = types.str;
default = "http://localhost:4321";
description = "Comma-separated list of trusted origins for Better Auth";
};
# Graceful shutdown mirrorIssueConcurrency = mkOption {
TimeoutStopSec = "30s"; type = types.int;
KillMode = "mixed"; default = 3;
KillSignal = "SIGTERM"; description = "Number of concurrent issue mirror operations (set to 1 for perfect ordering)";
}; };
};
# Health check timer (optional monitoring) mirrorPullRequestConcurrency = mkOption {
systemd.timers.gitea-mirror-healthcheck = mkIf cfg.enable { type = types.int;
description = "Gitea Mirror health check timer"; default = 5;
wantedBy = [ "timers.target" ]; description = "Number of concurrent PR mirror operations (set to 1 for perfect ordering)";
timerConfig = { };
OnBootSec = "5min";
OnUnitActiveSec = "5min";
};
};
systemd.services.gitea-mirror-healthcheck = mkIf cfg.enable { environmentFile = mkOption {
description = "Gitea Mirror health check"; type = types.nullOr types.path;
after = [ "gitea-mirror.service" ]; default = null;
serviceConfig = { description = ''
Type = "oneshot"; Path to file containing environment variables.
ExecStart = "${pkgs.curl}/bin/curl -f http://${cfg.host}:${toString cfg.port}/api/health || true"; Only needed if you want to set BETTER_AUTH_SECRET or ENCRYPTION_SECRET manually.
User = "nobody"; Otherwise, secrets will be auto-generated and stored in the data directory.
};
};
networking.firewall = mkIf cfg.openFirewall { Example:
allowedTCPPorts = [ cfg.port ]; BETTER_AUTH_SECRET=your-32-character-secret-here
}; ENCRYPTION_SECRET=your-encryption-secret-here
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open the firewall for the specified port";
}; };
}; };
}
) // { config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = {};
systemd.services.gitea-mirror = {
description = "Gitea Mirror - GitHub to Gitea mirroring service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
DATA_DIR = cfg.dataDir;
DATABASE_URL = "file:${cfg.dataDir}/gitea-mirror.db";
HOST = cfg.host;
PORT = toString cfg.port;
NODE_ENV = "production";
BETTER_AUTH_URL = cfg.betterAuthUrl;
BETTER_AUTH_TRUSTED_ORIGINS = cfg.betterAuthTrustedOrigins;
PUBLIC_BETTER_AUTH_URL = cfg.betterAuthUrl;
MIRROR_ISSUE_CONCURRENCY = toString cfg.mirrorIssueConcurrency;
MIRROR_PULL_REQUEST_CONCURRENCY = toString cfg.mirrorPullRequestConcurrency;
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = "${cfg.package}/bin/gitea-mirror";
Restart = "always";
RestartSec = "10s";
# Security hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
# Graceful shutdown
TimeoutStopSec = "30s";
KillMode = "mixed";
KillSignal = "SIGTERM";
} // optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
# Health check timer (optional monitoring)
systemd.timers.gitea-mirror-healthcheck = {
description = "Gitea Mirror health check timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5min";
OnUnitActiveSec = "5min";
};
};
systemd.services.gitea-mirror-healthcheck = {
description = "Gitea Mirror health check";
after = [ "gitea-mirror.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash -c '${pkgs.curl}/bin/curl -f http://127.0.0.1:${toString cfg.port}/api/health || true'";
User = "nobody";
};
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
};
};
# Overlay for adding to nixpkgs # Overlay for adding to nixpkgs
overlays.default = final: prev: { overlays.default = final: prev: {
gitea-mirror = self.packages.${final.system}.default; gitea-mirror = self.packages.${final.system}.default;

View File

@@ -169,4 +169,31 @@ describe("parseErrorMessage", () => {
expect(result.description).toBeUndefined(); expect(result.description).toBeUndefined();
expect(result.isStructured).toBe(false); expect(result.isStructured).toBe(false);
}); });
test("adds trusted origins guidance for invalid origin errors", () => {
const errorMessage = "Invalid Origin: https://mirror.example.com";
const result = parseErrorMessage(errorMessage);
expect(result.title).toBe("Invalid Origin");
expect(result.description).toContain("BETTER_AUTH_TRUSTED_ORIGINS");
expect(result.description).toContain("https://mirror.example.com");
expect(result.isStructured).toBe(true);
});
});
describe("showErrorToast", () => {
test("shows invalid origin guidance in toast description", () => {
const calls: any[] = [];
const toast = {
error: (...args: any[]) => calls.push(args),
};
showErrorToast("Invalid Origin: http://10.10.20.45:4321", toast);
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe("Invalid Origin");
expect(calls[0][1].description).toContain("BETTER_AUTH_TRUSTED_ORIGINS");
expect(calls[0][1].description).toContain("http://10.10.20.45:4321");
});
}); });

View File

@@ -86,6 +86,30 @@ export interface ParsedErrorMessage {
isStructured: boolean; isStructured: boolean;
} }
function getInvalidOriginGuidance(title: string, description?: string): ParsedErrorMessage | null {
const fullMessage = `${title} ${description ?? ""}`.trim();
if (!/invalid origin/i.test(fullMessage)) {
return null;
}
const urlMatch = fullMessage.match(/https?:\/\/[^\s'")]+/i);
let originHint = "this URL";
if (urlMatch) {
try {
originHint = new URL(urlMatch[0]).origin;
} catch {
originHint = urlMatch[0];
}
}
return {
title: "Invalid Origin",
description: `Add ${originHint} to BETTER_AUTH_TRUSTED_ORIGINS and restart the app.`,
isStructured: true,
};
}
export function parseErrorMessage(error: unknown): ParsedErrorMessage { export function parseErrorMessage(error: unknown): ParsedErrorMessage {
// Handle Error objects // Handle Error objects
if (error instanceof Error) { if (error instanceof Error) {
@@ -102,29 +126,32 @@ export function parseErrorMessage(error: unknown): ParsedErrorMessage {
if (typeof parsed === "object" && parsed !== null) { if (typeof parsed === "object" && parsed !== null) {
// Format 1: { error: "message", errorType: "type", troubleshooting: "info" } // Format 1: { error: "message", errorType: "type", troubleshooting: "info" }
if (parsed.error) { if (parsed.error) {
return { const formatted = {
title: parsed.error, title: parsed.error,
description: parsed.troubleshooting || parsed.errorType || undefined, description: parsed.troubleshooting || parsed.errorType || undefined,
isStructured: true, isStructured: true,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
// Format 2: { title: "title", description: "desc" } // Format 2: { title: "title", description: "desc" }
if (parsed.title) { if (parsed.title) {
return { const formatted = {
title: parsed.title, title: parsed.title,
description: parsed.description || undefined, description: parsed.description || undefined,
isStructured: true, isStructured: true,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
// Format 3: { message: "msg", details: "details" } // Format 3: { message: "msg", details: "details" }
if (parsed.message) { if (parsed.message) {
return { const formatted = {
title: parsed.message, title: parsed.message,
description: parsed.details || undefined, description: parsed.details || undefined,
isStructured: true, isStructured: true,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
} }
} catch { } catch {
@@ -132,11 +159,12 @@ export function parseErrorMessage(error: unknown): ParsedErrorMessage {
} }
// Plain string message // Plain string message
return { const formatted = {
title: error, title: error,
description: undefined, description: undefined,
isStructured: false, isStructured: false,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
// Handle objects directly // Handle objects directly
@@ -144,36 +172,40 @@ export function parseErrorMessage(error: unknown): ParsedErrorMessage {
const errorObj = error as any; const errorObj = error as any;
if (errorObj.error) { if (errorObj.error) {
return { const formatted = {
title: errorObj.error, title: errorObj.error,
description: errorObj.troubleshooting || errorObj.errorType || undefined, description: errorObj.troubleshooting || errorObj.errorType || undefined,
isStructured: true, isStructured: true,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
if (errorObj.title) { if (errorObj.title) {
return { const formatted = {
title: errorObj.title, title: errorObj.title,
description: errorObj.description || undefined, description: errorObj.description || undefined,
isStructured: true, isStructured: true,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
if (errorObj.message) { if (errorObj.message) {
return { const formatted = {
title: errorObj.message, title: errorObj.message,
description: errorObj.details || undefined, description: errorObj.details || undefined,
isStructured: true, isStructured: true,
}; };
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
} }
} }
// Fallback for unknown types // Fallback for unknown types
return { const fallback = {
title: String(error), title: String(error),
description: undefined, description: undefined,
isStructured: false, isStructured: false,
}; };
return getInvalidOriginGuidance(fallback.title, fallback.description) || fallback;
} }
// Enhanced toast helper that parses structured error messages // Enhanced toast helper that parses structured error messages