Compare commits

...

2 Commits

Author SHA1 Message Date
Arunavo Ray
7d6bbe908f fix: respect BASE_URL in SAML callback fallback 2026-04-02 08:15:14 +05:30
Arunavo Ray
96e4653cda feat: support reverse proxy path prefixes 2026-04-02 08:03:54 +05:30
54 changed files with 372 additions and 108 deletions

View File

@@ -9,6 +9,8 @@
NODE_ENV=production NODE_ENV=production
HOST=0.0.0.0 HOST=0.0.0.0
PORT=4321 PORT=4321
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
BASE_URL=/
# Database Configuration # Database Configuration
# For self-hosted, SQLite is used by default # For self-hosted, SQLite is used by default
@@ -31,6 +33,12 @@ BETTER_AUTH_URL=http://localhost:4321
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# #
# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
# BASE_URL=/mirror
# BETTER_AUTH_URL=https://git.example.com
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
#
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects # BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls # PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests # BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests

View File

@@ -8,6 +8,8 @@ RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-reco
# ---------------------------- # ----------------------------
FROM base AS builder FROM base AS builder
ARG BASE_URL=/
ENV BASE_URL=${BASE_URL}
COPY package.json ./ COPY package.json ./
COPY bun.lock* ./ COPY bun.lock* ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -73,6 +75,7 @@ ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db ENV DATABASE_URL=file:data/gitea-mirror.db
ENV BASE_URL=/
# Create directories and setup permissions # Create directories and setup permissions
RUN mkdir -p /app/certs && \ RUN mkdir -p /app/certs && \
@@ -90,6 +93,6 @@ VOLUME /app/data
EXPOSE 4321 EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1 CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
ENTRYPOINT ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]

View File

@@ -300,7 +300,19 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
### Reverse Proxy Configuration ### Reverse Proxy Configuration
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference. If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
```bash
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
```
Notes:
- `BASE_URL` sets the application path prefix.
- `BETTER_AUTH_TRUSTED_ORIGINS` should contain origins only (no path).
- When building Docker images, pass `BASE_URL` at build time as well.
### Mirror Token Rotation (GitHub Token Changed) ### Mirror Token Rotation (GitHub Token Changed)

View File

@@ -4,8 +4,25 @@ import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react'; import react from '@astrojs/react';
import node from '@astrojs/node'; import node from '@astrojs/node';
const normalizeBaseUrl = (value) => {
if (!value || value.trim() === '') {
return '/';
}
let normalized = value.trim();
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, '');
return normalized || '/';
};
const base = normalizeBaseUrl(process.env.BASE_URL);
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
base,
output: 'server', output: 'server',
adapter: node({ adapter: node({
mode: 'standalone', mode: 'standalone',

View File

@@ -22,6 +22,8 @@ services:
# BETTER_AUTH_URL=https://gitea-mirror.example.com # BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# NOTE: Path-prefix deployments (e.g. /mirror) require BASE_URL at build time.
# Use docker-compose.yml (which builds from source) and set BASE_URL there.
# === CORE SETTINGS === # === CORE SETTINGS ===
# These are technically required but have working defaults # These are technically required but have working defaults
@@ -29,6 +31,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BASE_URL=${BASE_URL:-/}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321} - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
# Optional concurrency controls (defaults match in-app defaults) # Optional concurrency controls (defaults match in-app defaults)
# If you want perfect ordering of issues and PRs, set these at 1 # If you want perfect ordering of issues and PRs, set these at 1
@@ -36,7 +39,11 @@ services:
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5} - MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5

View File

@@ -45,6 +45,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
BASE_URL: ${BASE_URL:-/}
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
@@ -66,6 +68,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=dev-secret-key - BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config # GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username} - GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
@@ -89,7 +92,11 @@ services:
# Optional: Skip TLS verification (insecure, use only for testing) # Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} # - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"] test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@@ -7,6 +7,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
BASE_URL: ${BASE_URL:-/}
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
@@ -30,6 +32,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik), # REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
@@ -37,6 +40,11 @@ services:
# BETTER_AUTH_URL=https://gitea-mirror.example.com # BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
# BASE_URL=/mirror
# BETTER_AUTH_URL=https://git.example.com
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321} - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided # Optional: ENCRYPTION_SECRET will be auto-generated if not provided
@@ -81,7 +89,11 @@ services:
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false} - HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-} - HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5

View File

@@ -33,6 +33,7 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No | | `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No | | `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No | | `PORT` | Server port | `4321` | No |
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No | | `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes | | `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No | | `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
@@ -302,6 +303,7 @@ services:
environment: environment:
# Core Configuration # Core Configuration
- NODE_ENV=production - NODE_ENV=production
- BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here - BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL: # Primary access URL:
@@ -370,6 +372,21 @@ This setup allows you to:
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons. **Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
### Path Prefix Deployments
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
```bash
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
```
Notes:
- `BETTER_AUTH_TRUSTED_ORIGINS` must contain origins only (no path).
- `BASE_URL` is applied at build time, so set it for image builds too.
### Trusted Origins ### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes: The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:

View File

@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react"; import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
import { withBase } from "@/lib/base-path";
export function NotFound() { export function NotFound() {
return ( return (
@@ -21,7 +22,7 @@ export function NotFound() {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button asChild className="w-full"> <Button asChild className="w-full">
<a href="/"> <a href={withBase("/")}>
<Home className="mr-2 h-4 w-4" /> <Home className="mr-2 h-4 w-4" />
Go to Dashboard Go to Dashboard
</a> </a>
@@ -45,21 +46,21 @@ export function NotFound() {
{/* Quick Links */} {/* Quick Links */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<a <a
href="/repositories" href={withBase("/repositories")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors" className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
> >
<GitBranch className="h-5 w-5 text-muted-foreground" /> <GitBranch className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Repositories</span> <span className="text-xs">Repositories</span>
</a> </a>
<a <a
href="/config" href={withBase("/config")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors" className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
> >
<Settings className="h-5 w-5 text-muted-foreground" /> <Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Config</span> <span className="text-xs">Config</span>
</a> </a>
<a <a
href="/docs" href={withBase("/docs")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors" className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
> >
<BookOpen className="h-5 w-5 text-muted-foreground" /> <BookOpen className="h-5 w-5 text-muted-foreground" />
@@ -77,4 +78,4 @@ export function NotFound() {
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -36,6 +36,7 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh'; import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus'; import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout'; import { useNavigation } from '@/components/layout/MainLayout';
import { withBase } from '@/lib/base-path';
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -321,7 +322,7 @@ export function ActivityLog() {
setIsInitialLoading(true); setIsInitialLoading(true);
setShowCleanupDialog(false); setShowCleanupDialog(false);
const response = await fetch('/api/activities/cleanup', { const response = await fetch(withBase('/api/activities/cleanup'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }), body: JSON.stringify({ userId: user.id }),

View File

@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
import { toast, Toaster } from 'sonner'; import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils'; import { showErrorToast } from '@/lib/utils';
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react'; import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function LoginForm() { export function LoginForm() {
@@ -47,7 +48,7 @@ export function LoginForm() {
toast.success('Login successful!'); toast.success('Login successful!');
// Small delay before redirecting to see the success message // Small delay before redirecting to see the success message
setTimeout(() => { setTimeout(() => {
window.location.href = '/'; window.location.href = withBase('/');
}, 1000); }, 1000);
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
@@ -64,12 +65,15 @@ export function LoginForm() {
return; return;
} }
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321'; const callbackURL =
typeof window !== 'undefined'
? new URL(withBase('/'), window.location.origin).toString()
: `http://localhost:4321${withBase('/')}`;
await authClient.signIn.sso({ await authClient.signIn.sso({
email: ssoEmail || undefined, email: ssoEmail || undefined,
domain: domain, domain: domain,
providerId: providerId, providerId: providerId,
callbackURL: `${baseURL}/`, callbackURL,
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin. scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
}); });
} catch (error) { } catch (error) {
@@ -85,7 +89,7 @@ export function LoginForm() {
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<img <img
src="/logo.png" src={withBase('/logo.png')}
alt="Gitea Mirror Logo" alt="Gitea Mirror Logo"
className="h-8 w-10" className="h-8 w-10"
/> />

View File

@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils'; import { showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function SignupForm() { export function SignupForm() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -42,7 +43,7 @@ export function SignupForm() {
toast.success('Account created successfully! Redirecting to dashboard...'); toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message // Small delay before redirecting to see the success message
setTimeout(() => { setTimeout(() => {
window.location.href = '/'; window.location.href = withBase('/');
}, 1500); }, 1500);
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
@@ -57,7 +58,7 @@ export function SignupForm() {
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<img <img
src="/logo.png" src={withBase('/logo.png')}
alt="Gitea Mirror Logo" alt="Gitea Mirror Logo"
className="h-8 w-10" className="h-8 w-10"
/> />

View File

@@ -24,6 +24,7 @@ import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus'; import { invalidateConfigCache } from '@/hooks/useConfigStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { withBase } from '@/lib/base-path';
type ConfigState = { type ConfigState = {
githubConfig: GitHubConfig; githubConfig: GitHubConfig;
@@ -35,6 +36,8 @@ type ConfigState = {
notificationConfig: NotificationConfig; notificationConfig: NotificationConfig;
}; };
const CONFIG_API_PATH = withBase('/api/config');
export function ConfigTabs() { export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({ const [config, setConfig] = useState<ConfigState>({
githubConfig: { githubConfig: {
@@ -198,7 +201,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -264,7 +267,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -329,7 +332,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -378,7 +381,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -418,7 +421,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -453,7 +456,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -498,7 +501,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),

View File

@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Bell, Activity, Send } from "lucide-react"; import { Bell, Activity, Send } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config"; import type { NotificationConfig } from "@/types/config";
import { withBase } from "@/lib/base-path";
interface NotificationSettingsProps { interface NotificationSettingsProps {
notificationConfig: NotificationConfig; notificationConfig: NotificationConfig;
@@ -31,7 +32,7 @@ export function NotificationSettings({
const handleTestNotification = async () => { const handleTestNotification = async () => {
setIsTesting(true); setIsTesting(true);
try { try {
const resp = await fetch("/api/notifications/test", { const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }), body: JSON.stringify({ notificationConfig }),

View File

@@ -14,6 +14,7 @@ import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select'; import { MultiSelect } from '@/components/ui/multi-select';
import { withBase } from '@/lib/base-path';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean { function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try { try {
@@ -100,6 +101,9 @@ export function SSOSettings() {
digestAlgorithm: 'sha256', digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
}); });
const appOrigin = typeof window !== 'undefined' ? window.location.origin : '';
const buildAbsoluteAppUrl = (path: string) =>
appOrigin ? new URL(withBase(path), appOrigin).toString() : withBase(path);
@@ -179,8 +183,8 @@ export function SSOSettings() {
} else { } else {
requestData.entryPoint = providerForm.entryPoint; requestData.entryPoint = providerForm.entryPoint;
requestData.cert = providerForm.cert; requestData.cert = providerForm.cert;
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`; requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
requestData.audience = providerForm.audience || window.location.origin; requestData.audience = providerForm.audience || appOrigin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned; requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm; requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm; requestData.digestAlgorithm = providerForm.digestAlgorithm;
@@ -517,7 +521,7 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
<div className="space-y-2"> <div className="space-y-2">
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p> <p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
{isTrustedIssuer(providerForm.issuer, ['google.com']) && ( {isTrustedIssuer(providerForm.issuer, ['google.com']) && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes. Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
@@ -563,8 +567,8 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
<div className="space-y-1"> <div className="space-y-1">
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p> <p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p> <p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
</div> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -724,4 +728,4 @@ export function SSOSettings() {
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { usePageVisibility } from "@/hooks/usePageVisibility"; import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout"; import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
// Helper function to format last sync time // Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string { function formatLastSyncTime(date: Date | null): string {
@@ -110,7 +111,7 @@ export function Dashboard() {
useEffectForToasts(() => { useEffectForToasts(() => {
if (!user?.id) return; if (!user?.id) return;
const eventSource = new EventSource(`/api/events?userId=${user.id}`); const eventSource = new EventSource(`${withBase("/api/events")}?userId=${user.id}`);
eventSource.addEventListener("rate-limit", (event) => { eventSource.addEventListener("rate-limit", (event) => {
try { try {

View File

@@ -3,6 +3,7 @@ import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils"; import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react"; import { Activity, Clock } from "lucide-react";
import { withBase } from "@/lib/base-path";
interface RecentActivityProps { interface RecentActivityProps {
activities: MirrorJob[]; activities: MirrorJob[];
@@ -14,7 +15,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Activity</CardTitle> <CardTitle>Recent Activity</CardTitle>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/activity">View All</a> <a href={withBase("/activity")}>View All</a>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<a href="/activity"> <a href={withBase("/activity")}>
<Activity className="h-3.5 w-3.5 mr-1.5" /> <Activity className="h-3.5 w-3.5 mr-1.5" />
View History View History
</a> </a>

View File

@@ -6,6 +6,7 @@ import type { Repository } from "@/lib/db/schema";
import { getStatusColor } from "@/lib/utils"; import { getStatusColor } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url"; import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
interface RepositoryListProps { interface RepositoryListProps {
repositories: Repository[]; repositories: Repository[];
@@ -42,7 +43,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle> <CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/repositories">View All</a> <a href={withBase("/repositories")}>View All</a>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -54,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
Configure your GitHub connection to start mirroring repositories. Configure your GitHub connection to start mirroring repositories.
</p> </p>
<Button asChild> <Button asChild>
<a href="/config">Configure GitHub</a> <a href={withBase("/config")}>Configure GitHub</a>
</Button> </Button>
</div> </div>
) : ( ) : (

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { withBase } from "@/lib/base-path";
interface HeaderProps { interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
@@ -101,14 +102,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
<button <button
onClick={() => { onClick={() => {
if (currentPage !== 'dashboard') { if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/'); window.history.pushState({}, '', withBase('/'));
onNavigate?.('dashboard'); onNavigate?.('dashboard');
} }
}} }}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity" className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
> >
<img <img
src="/logo.png" src={withBase('/logo.png')}
alt="Gitea Mirror Logo" alt="Gitea Mirror Logo"
className="h-5 w-6" className="h-5 w-6"
/> />
@@ -163,7 +164,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<a href="/login">Login</a> <a href={withBase('/login')}>Login</a>
</Button> </Button>
)} )}
</div> </div>

View File

@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRepoSync } from "@/hooks/useSyncRepo"; import { useRepoSync } from "@/hooks/useSyncRepo";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { stripBasePath, withBase } from "@/lib/base-path";
// Navigation context to signal when navigation happens // Navigation context to signal when navigation happens
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 }); const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
// Handle browser back/forward navigation // Handle browser back/forward navigation
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
const path = window.location.pathname; const path = stripBasePath(window.location.pathname);
const pageMap: Record<string, AppProps['page']> = { const pageMap: Record<string, AppProps['page']> = {
'/': 'dashboard', '/': 'dashboard',
'/repositories': 'repositories', '/repositories': 'repositories',
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
if (!authLoading && !user) { if (!authLoading && !user) {
// Use window.location for client-side redirect // Use window.location for client-side redirect
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = withBase('/login');
} }
return null; return null;
} }

View File

@@ -9,6 +9,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { stripBasePath, withBase } from "@/lib/base-path";
interface SidebarProps { interface SidebarProps {
className?: string; className?: string;
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
useEffect(() => { useEffect(() => {
// Hydration happens here // Hydration happens here
const path = window.location.pathname; const path = stripBasePath(window.location.pathname);
setCurrentPath(path); setCurrentPath(path);
}, []); }, []);
// Listen for URL changes (browser back/forward) // Listen for URL changes (browser back/forward)
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
setCurrentPath(window.location.pathname); setCurrentPath(stripBasePath(window.location.pathname));
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
if (currentPath === href) return; if (currentPath === href) return;
// Update URL without page reload // Update URL without page reload
window.history.pushState({}, '', href); window.history.pushState({}, '', withBase(href));
setCurrentPath(href); setCurrentPath(href);
// Map href to page name for the parent component // Map href to page name for the parent component
@@ -163,7 +164,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
Check out the documentation for help with setup and configuration. Check out the documentation for help with setup and configuration.
</p> </p>
<a <a
href="/docs" href={withBase("/docs")}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0" className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
@@ -177,7 +178,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<a <a
href="/docs" href={withBase("/docs")}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(

View File

@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url"; import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor"; import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -85,7 +86,7 @@ export function OrganizationList({
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => { const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
// Call API to update organization destination // Call API to update organization destination
const response = await fetch(`/api/organizations/${orgId}`, { const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -189,7 +190,7 @@ export function OrganizationList({
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<a <a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`} href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer truncate" className="font-medium hover:underline cursor-pointer truncate"
> >
{org.name} {org.name}
@@ -264,7 +265,7 @@ export function OrganizationList({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<a <a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`} href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="text-xl font-semibold hover:underline cursor-pointer" className="text-xl font-semibold hover:underline cursor-pointer"
> >
{org.name} {org.name}

View File

@@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout"; import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
const REPOSITORY_SORT_OPTIONS = [ const REPOSITORY_SORT_OPTIONS = [
{ value: "imported-desc", label: "Recently Imported" }, { value: "imported-desc", label: "Recently Imported" },
@@ -1518,7 +1519,7 @@ export default function Repository() {
<Button <Button
variant="default" variant="default"
onClick={() => { onClick={() => {
window.history.pushState({}, '', '/config'); window.history.pushState({}, '', withBase('/config'));
// We need to trigger a page change event for the navigation system // We need to trigger a page change event for the navigation system
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
}} }}

View File

@@ -28,6 +28,7 @@ import {
import { InlineDestinationEditor } from "./InlineDestinationEditor"; import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { withBase } from "@/lib/base-path";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -102,7 +103,7 @@ export default function RepositoryTable({
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => { const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination // Call API to update repository destination
const response = await fetch(`/api/repositories/${repoId}`, { const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { authApi } from "@/lib/api"; import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user"; import type { ExtendedUser } from "@/types/user";
import { withBase } from "@/lib/base-path";
interface AuthContextType { interface AuthContextType {
user: ExtendedUser | null; user: ExtendedUser | null;
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Redirect user based on error // Redirect user based on error
if (err?.message === "No users found") { if (err?.message === "No users found") {
window.location.href = "/signup"; window.location.href = withBase("/signup");
} else { } else {
window.location.href = "/login"; window.location.href = withBase("/login");
} }
console.error("Auth check failed", err); console.error("Auth check failed", err);
} finally { } finally {
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
await authApi.logout(); await authApi.logout();
setUser(null); setUser(null);
window.location.href = "/login"; window.location.href = withBase("/login");
} catch (err) { } catch (err) {
console.error("Logout error:", err); console.error("Logout error:", err);
} finally { } finally {

View File

@@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client"; import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client"; import type { Session, AuthUser } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
interface AuthContextType { interface AuthContextType {
user: AuthUser | null; user: AuthUser | null;
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const result = await authClient.signIn.email({ const result = await authClient.signIn.email({
email, email,
password, password,
callbackURL: "/", callbackURL: withBase("/"),
}); });
if (result.error) { if (result.error) {
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email, email,
password, password,
name: username, // Better Auth uses 'name' field for display name name: username, // Better Auth uses 'name' field for display name
callbackURL: "/", callbackURL: withBase("/"),
}); });
if (result.error) { if (result.error) {
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authClient.signOut({ await authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
window.location.href = "/login"; window.location.href = withBase("/login");
}, },
}, },
}); });
@@ -140,4 +141,4 @@ export function useAuth() {
} }
// Export the Better Auth session hook for direct use when needed // Export the Better Auth session hook for direct use when needed
export { useBetterAuthSession }; export { useBetterAuthSession };

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import type { MirrorJob } from "@/lib/db/schema"; import type { MirrorJob } from "@/lib/db/schema";
import { withBase } from "@/lib/base-path";
interface UseSSEOptions { interface UseSSEOptions {
userId?: string; userId?: string;
@@ -41,7 +42,7 @@ export const useSSE = ({
} }
// Create new EventSource connection // Create new EventSource connection
const eventSource = new EventSource(`/api/sse?userId=${userId}`); const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
eventSourceRef.current = eventSource; eventSourceRef.current = eventSource;
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useAuth } from "./useAuth"; import { useAuth } from "./useAuth";
import { withBase } from "@/lib/base-path";
interface UseRepoSyncOptions { interface UseRepoSyncOptions {
userId?: string; userId?: string;
@@ -51,7 +52,7 @@ export function useRepoSync({
const sync = async () => { const sync = async () => {
try { try {
const response = await fetch("/api/job/schedule-sync-repo", { const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -2,6 +2,7 @@
import '../styles/global.css'; import '../styles/global.css';
import '../styles/docs.css'; import '../styles/docs.css';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Accept title as a prop with a default value // Accept title as a prop with a default value
const { title = 'Gitea Mirror' } = Astro.props; const { title = 'Gitea Mirror' } = Astro.props;
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<title>{title}</title> <title>{title}</title>
<ThemeScript /> <ThemeScript />
</head> </head>

View File

@@ -1,5 +1,7 @@
import { withBase } from "@/lib/base-path";
// Base API URL // Base API URL
const API_BASE = "/api"; const API_BASE = withBase("/api");
// Helper function for API requests // Helper function for API requests
async function apiRequest<T>( async function apiRequest<T>(

View File

@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins"; import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client"; import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth"; import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
import { withBase } from "@/lib/base-path";
function normalizeAuthBaseUrl(url: string): string {
const validatedUrl = new URL(url.trim());
return validatedUrl.origin;
}
export const authClient = createAuthClient({ export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin // Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
// Validate and clean the URL if provided // Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') { if (url && typeof url === 'string' && url.trim() !== '') {
try { try {
// Validate URL format and remove trailing slash // Validate URL format and preserve optional base path
const validatedUrl = new URL(url.trim()); return normalizeAuthBaseUrl(url);
return validatedUrl.origin; // Use origin to ensure clean URL without path
} catch (e) { } catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`); console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
} }
@@ -34,7 +39,7 @@ export const authClient = createAuthClient({
// Default for SSR - always return a valid URL // Default for SSR - always return a valid URL
return 'http://localhost:4321'; return 'http://localhost:4321';
})(), })(),
basePath: '/api/auth', // Explicitly set the base path basePath: withBase('/api/auth'), // Explicitly set the base path
plugins: [ plugins: [
oidcClient(), oidcClient(),
ssoClient(), ssoClient(),

View File

@@ -5,6 +5,7 @@ import { sso } from "@better-auth/sso";
import { db, users } from "./db"; import { db, users } from "./db";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
/** /**
* Resolves the list of trusted origins for Better Auth CSRF validation. * Resolves the list of trusted origins for Better Auth CSRF validation.
@@ -97,7 +98,7 @@ export const auth = betterAuth({
try { try {
// Validate URL format and ensure it's a proper origin // Validate URL format and ensure it's a proper origin
const validatedUrl = new URL(url.trim()); const validatedUrl = new URL(url.trim());
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths const cleanUrl = validatedUrl.origin;
console.info('Using BETTER_AUTH_URL:', cleanUrl); console.info('Using BETTER_AUTH_URL:', cleanUrl);
return cleanUrl; return cleanUrl;
} catch (e) { } catch (e) {
@@ -107,7 +108,7 @@ export const auth = betterAuth({
return defaultUrl; return defaultUrl;
} }
})(), })(),
basePath: "/api/auth", // Specify the base path for auth endpoints basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs. // Trusted origins - this is how we support multiple access URLs.
// Uses the function form so that the origin can be auto-detected from // Uses the function form so that the origin can be auto-detected from
@@ -150,8 +151,8 @@ export const auth = betterAuth({
plugins: [ plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider // OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({ oidcProvider({
loginPage: "/login", loginPage: withBase("/login"),
consentPage: "/oauth/consent", consentPage: withBase("/oauth/consent"),
// Allow dynamic client registration for flexibility // Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true, allowDynamicClientRegistration: true,
// Note: trustedClients would be configured here if Better Auth supports it // Note: trustedClients would be configured here if Better Auth supports it

48
src/lib/base-path.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from "bun:test";
const originalBaseUrl = process.env.BASE_URL;
async function loadModule(baseUrl?: string) {
if (baseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = baseUrl;
}
return import(`./base-path.ts?case=${encodeURIComponent(baseUrl ?? "default")}-${Date.now()}-${Math.random()}`);
}
afterEach(() => {
if (originalBaseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = originalBaseUrl;
}
});
describe("base-path helpers", () => {
test("defaults to root paths", async () => {
const mod = await loadModule(undefined);
expect(mod.BASE_PATH).toBe("/");
expect(mod.withBase("/api/health")).toBe("/api/health");
expect(mod.withBase("repositories")).toBe("/repositories");
expect(mod.stripBasePath("/config")).toBe("/config");
});
test("normalizes prefixed base paths", async () => {
const mod = await loadModule("mirror/");
expect(mod.BASE_PATH).toBe("/mirror");
expect(mod.withBase("/api/health")).toBe("/mirror/api/health");
expect(mod.withBase("repositories")).toBe("/mirror/repositories");
expect(mod.stripBasePath("/mirror/config")).toBe("/config");
expect(mod.stripBasePath("/mirror")).toBe("/");
});
test("keeps absolute URLs unchanged", async () => {
const mod = await loadModule("/mirror");
expect(mod.withBase("https://example.com/path")).toBe("https://example.com/path");
});
});

63
src/lib/base-path.ts Normal file
View File

@@ -0,0 +1,63 @@
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
function normalizeBasePath(basePath: string | null | undefined): string {
if (!basePath) {
return "/";
}
let normalized = basePath.trim();
if (!normalized) {
return "/";
}
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, "");
return normalized || "/";
}
const rawBasePath =
(typeof import.meta !== "undefined" && import.meta.env?.BASE_URL) ||
process.env.BASE_URL ||
"/";
export const BASE_PATH = normalizeBasePath(rawBasePath);
export function withBase(path: string): string {
if (!path) {
return BASE_PATH === "/" ? "/" : `${BASE_PATH}/`;
}
if (URL_SCHEME_REGEX.test(path) || path.startsWith("//")) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (BASE_PATH === "/") {
return normalizedPath;
}
return `${BASE_PATH}${normalizedPath}`;
}
export function stripBasePath(pathname: string): string {
if (!pathname) {
return "/";
}
if (BASE_PATH === "/") {
return pathname;
}
if (pathname === BASE_PATH || pathname === `${BASE_PATH}/`) {
return "/";
}
if (pathname.startsWith(`${BASE_PATH}/`)) {
return pathname.slice(BASE_PATH.length) || "/";
}
return pathname;
}

View File

@@ -2,8 +2,9 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client"; import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository"; import type { RepoStatus } from "@/types/Repository";
import { withBase } from "@/lib/base-path";
export const API_BASE = "/api"; export const API_BASE = withBase("/api");
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));

View File

@@ -2,6 +2,7 @@
import '../styles/global.css'; import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { NotFound } from '@/components/NotFound'; import { NotFound } from '@/components/NotFound';
import { withBase } from '@/lib/base-path';
const generator = Astro.generator; const generator = Astro.generator;
--- ---
@@ -10,7 +11,7 @@ const generator = Astro.generator;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={generator} /> <meta name="generator" content={generator} />
<title>Page Not Found - Gitea Mirror</title> <title>Page Not Found - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />
@@ -34,4 +35,4 @@ const generator = Astro.generator;
transform: translateY(-10px); transform: translateY(-10px);
} }
} }
</style> </style>

View File

@@ -3,6 +3,7 @@ import '../styles/global.css';
import App from '@/components/layout/MainLayout'; import App from '@/components/layout/MainLayout';
import { db, mirrorJobs } from '@/lib/db'; import { db, mirrorJobs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Fetch activity data from the database // Fetch activity data from the database
let activityData = []; let activityData = [];
@@ -53,7 +54,7 @@ const handleRefresh = () => {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Activity Log - Gitea Mirror</title> <title>Activity Log - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { withBase } from "@/lib/base-path";
export const ALL: APIRoute = async (ctx) => { export const ALL: APIRoute = async (ctx) => {
// If you want to use rate limiting, make sure to set the 'x-forwarded-for' header // If you want to use rate limiting, make sure to set the 'x-forwarded-for' header
@@ -18,7 +19,7 @@ export const ALL: APIRoute = async (ctx) => {
if (url.pathname.includes('/sso/callback')) { if (url.pathname.includes('/sso/callback')) {
// Redirect to error page for SSO errors // Redirect to error page for SSO errors
return Response.redirect( return Response.redirect(
`${ctx.url.origin}/auth-error?error=sso_callback_failed&error_description=${encodeURIComponent( `${ctx.url.origin}${withBase('/auth-error')}?error=sso_callback_failed&error_description=${encodeURIComponent(
error instanceof Error ? error.message : "SSO authentication failed" error instanceof Error ? error.message : "SSO authentication failed"
)}`, )}`,
302 302
@@ -34,4 +35,4 @@ export const ALL: APIRoute = async (ctx) => {
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
}); });
} }
}; };

View File

@@ -6,6 +6,7 @@ import { db, ssoProviders } from "@/lib/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config"; import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config";
import { withBase } from "@/lib/base-path";
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth // POST /api/auth/sso/register - Register a new SSO provider using Better Auth
export async function POST(context: APIContext) { export async function POST(context: APIContext) {
@@ -87,7 +88,9 @@ export async function POST(context: APIContext) {
registrationBody.samlConfig = { registrationBody.samlConfig = {
entryPoint, entryPoint,
cert, cert,
callbackUrl: callbackUrl || `${context.url.origin}/api/auth/sso/saml2/callback/${providerId}`, callbackUrl:
callbackUrl ||
`${context.url.origin}${withBase(`/api/auth/sso/saml2/callback/${providerId}`)}`,
audience: audience || context.url.origin, audience: audience || context.url.origin,
wantAssertionsSigned, wantAssertionsSigned,
signatureAlgorithm, signatureAlgorithm,

View File

@@ -1,6 +1,7 @@
--- ---
import Layout from '@/layouts/main.astro'; import Layout from '@/layouts/main.astro';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { withBase } from '@/lib/base-path';
const error = Astro.url.searchParams.get('error'); const error = Astro.url.searchParams.get('error');
const errorDescription = Astro.url.searchParams.get('error_description'); const errorDescription = Astro.url.searchParams.get('error_description');
@@ -30,13 +31,13 @@ const errorDescription = Astro.url.searchParams.get('error_description');
<div class="mt-6 flex gap-2"> <div class="mt-6 flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => window.location.href = '/login'} onClick={() => window.location.href = withBase('/login')}
> >
Back to Login Back to Login
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => window.location.href = '/'} onClick={() => window.location.href = withBase('/')}
> >
Go Home Go Home
</Button> </Button>
@@ -44,4 +45,4 @@ const errorDescription = Astro.url.searchParams.get('error_description');
</div> </div>
</div> </div>
</div> </div>
</Layout> </Layout>

View File

@@ -7,13 +7,14 @@ import { db, configs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config'; import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config';
import { withBase } from '@/lib/base-path';
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Configuration - Gitea Mirror</title> <title>Configuration - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Advanced Topics - Gitea Mirror"> <MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <a
href="/docs/" href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
> >
<span aria-hidden="true">&larr;</span> Back to Documentation <span aria-hidden="true">&larr;</span> Back to Documentation
@@ -51,6 +52,7 @@ import MainLayout from '../../layouts/main.astro';
{ var: 'NODE_ENV', desc: 'Application environment', default: 'production' }, { var: 'NODE_ENV', desc: 'Application environment', default: 'production' },
{ var: 'PORT', desc: 'Server port', default: '4321' }, { var: 'PORT', desc: 'Server port', default: '4321' },
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' }, { var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
{ var: 'BASE_URL', desc: 'Application base path ("/" or e.g. "/mirror")', default: '/' },
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' }, { var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' }, { var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' }, { var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
@@ -225,8 +227,16 @@ import MainLayout from '../../layouts/main.astro';
BETTER_AUTH_URL=https://gitea-mirror.example.com BETTER_AUTH_URL=https://gitea-mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre> BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
</div>
<div class="bg-muted/30 rounded p-3 mt-3">
<pre class="text-sm"><code>{`# If deployed under a path prefix (example: /mirror):
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com`}</code></pre>
</div> </div>
<ul class="mt-3 space-y-1 text-sm"> <ul class="mt-3 space-y-1 text-sm">
<li><code class="bg-red-500/10 px-1 rounded">BASE_URL</code> — Application base path for path-prefix deployments (e.g. <code>/mirror</code>)</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li> <li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li>
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li> <li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li> <li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
@@ -243,9 +253,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
image: ghcr.io/raylabshq/gitea-mirror:latest image: ghcr.io/raylabshq/gitea-mirror:latest
environment: environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars - BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- BETTER_AUTH_URL=https://gitea-mirror.example.com - BASE_URL=/mirror
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com - BETTER_AUTH_URL=https://git.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com - PUBLIC_BETTER_AUTH_URL=https://git.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# ... other settings ...`}</code></pre> # ... other settings ...`}</code></pre>
</div> </div>
@@ -509,4 +520,4 @@ ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</section> </section>
</article> </article>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Architecture - Gitea Mirror"> <MainLayout title="Architecture - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <a
href="/docs/" href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
> >
<span aria-hidden="true">&larr;</span> Back to Documentation <span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Authentication & SSO - Gitea Mirror"> <MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <a
href="/docs/" href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
> >
<span aria-hidden="true">&larr;</span> Back to Documentation <span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="CA Certificates - Gitea Mirror"> <MainLayout title="CA Certificates - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <a
href="/docs/" href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
> >
<span aria-hidden="true">&larr;</span> Back to Documentation <span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,8 +1,10 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
const envVars = [ const envVars = [
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' }, { name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
{ name: 'BASE_URL', desc: 'Application base path', default: '/', example: '/mirror' },
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' }, { name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
{ name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' }, { name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' },
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' }, { name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
@@ -35,7 +37,7 @@ const giteaOptions = [
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <a
href="/docs/" href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
> >
<span aria-hidden="true">&larr;</span> Back to Documentation <span aria-hidden="true">&larr;</span> Back to Documentation
@@ -509,4 +511,4 @@ curl http://your-server:port/api/health`}</code></pre>
</section> </section>
</article> </article>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -1,6 +1,7 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu'; import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
import { withBase } from '@/lib/base-path';
// Define our documentation pages directly // Define our documentation pages directly
const docs = [ const docs = [
@@ -69,7 +70,7 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
return ( return (
<a <a
href={doc.href} href={withBase(doc.href)}
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none" class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
tabindex="0" tabindex="0"
> >
@@ -85,4 +86,4 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
})} })}
</div> </div>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Quick Start Guide - Gitea Mirror"> <MainLayout title="Quick Start Guide - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <a
href="/docs/" href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
> >
<span aria-hidden="true">&larr;</span> Back to Documentation <span aria-hidden="true">&larr;</span> Back to Documentation
@@ -418,11 +419,11 @@ bun run start</code></pre>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-primary">📖</span> <span class="text-primary">📖</span>
<span>Check out the <a href="/docs/configuration" class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span> <span>Check out the <a href={withBase('/docs/configuration')} class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-primary">🏗️</span> <span class="text-primary">🏗️</span>
<span>Review the <a href="/docs/architecture" class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span> <span>Review the <a href={withBase('/docs/architecture')} class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-primary">📊</span> <span class="text-primary">📊</span>
@@ -434,4 +435,4 @@ bun run start</code></pre>
</section> </section>
</article> </article>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -4,6 +4,7 @@ import App from '@/components/layout/MainLayout';
import { db, repositories, mirrorJobs, users } from '@/lib/db'; import { db, repositories, mirrorJobs, users } from '@/lib/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database // Check if any users exist in the database
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(users); const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(users);
@@ -11,7 +12,7 @@ const userCount = userCountResult[0]?.count || 0;
// Redirect to signup if no users exist // Redirect to signup if no users exist
if (userCount === 0) { if (userCount === 0) {
return Astro.redirect('/signup'); return Astro.redirect(withBase('/signup'));
} }
// Fetch data from the database // Fetch data from the database
@@ -59,7 +60,7 @@ try {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href={withBase('/favicon.ico')} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Dashboard - Gitea Mirror</title> <title>Dashboard - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -4,6 +4,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
import { LoginPage } from '@/components/auth/LoginPage'; import { LoginPage } from '@/components/auth/LoginPage';
import { db, users } from '@/lib/db'; import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database // Check if any users exist in the database
const userCountResult = await db const userCountResult = await db
@@ -13,7 +14,7 @@ const userCount = userCountResult[0].count;
// Redirect to signup if no users exist // Redirect to signup if no users exist
if (userCount === 0) { if (userCount === 0) {
return Astro.redirect('/signup'); return Astro.redirect(withBase('/signup'));
} }
const generator = Astro.generator; const generator = Astro.generator;
@@ -23,7 +24,7 @@ const generator = Astro.generator;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={generator} /> <meta name="generator" content={generator} />
<title>Login - Gitea Mirror</title> <title>Login - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -3,11 +3,12 @@ import '@/styles/global.css';
import ConsentPage from '@/components/oauth/ConsentPage'; import ConsentPage from '@/components/oauth/ConsentPage';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import Providers from '@/components/layout/Providers'; import Providers from '@/components/layout/Providers';
import { withBase } from '@/lib/base-path';
// Check if user is authenticated // Check if user is authenticated
const sessionCookie = Astro.cookies.get('better-auth-session'); const sessionCookie = Astro.cookies.get('better-auth-session');
if (!sessionCookie) { if (!sessionCookie) {
return Astro.redirect('/login'); return Astro.redirect(withBase('/login'));
} }
--- ---
@@ -15,7 +16,7 @@ if (!sessionCookie) {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Authorize Application - Gitea Mirror</title> <title>Authorize Application - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />
@@ -25,4 +26,4 @@ if (!sessionCookie) {
<ConsentPage client:load /> <ConsentPage client:load />
</Providers> </Providers>
</body> </body>
</html> </html>

View File

@@ -2,6 +2,7 @@
import '../styles/global.css'; import '../styles/global.css';
import App from '@/components/layout/MainLayout'; import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
--- ---
@@ -9,7 +10,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Organizations - Gitea Mirror</title> <title>Organizations - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -2,13 +2,14 @@
import '../styles/global.css'; import '../styles/global.css';
import App from '@/components/layout/MainLayout'; import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Repositories - Gitea Mirror</title> <title>Repositories - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -4,6 +4,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
import { SignupPage } from '@/components/auth/SignupPage'; import { SignupPage } from '@/components/auth/SignupPage';
import { db, users } from '@/lib/db'; import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database // Check if any users exist in the database
const userCountResult = await db const userCountResult = await db
@@ -13,7 +14,7 @@ const userCount = userCountResult[0]?.count;
// Redirect to login if users already exist // Redirect to login if users already exist
if (userCount !== null && Number(userCount) > 0) { if (userCount !== null && Number(userCount) > 0) {
return Astro.redirect('/login'); return Astro.redirect(withBase('/login'));
} }
const generator = Astro.generator; const generator = Astro.generator;
@@ -23,7 +24,7 @@ const generator = Astro.generator;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={generator} /> <meta name="generator" content={generator} />
<title>Setup Admin Account - Gitea Mirror</title> <title>Setup Admin Account - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />