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
HOST=0.0.0.0
PORT=4321
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
BASE_URL=/
# Database Configuration
# 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
# 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
# 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

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
ARG BASE_URL=/
ENV BASE_URL=${BASE_URL}
COPY package.json ./
COPY bun.lock* ./
RUN bun install --frozen-lockfile
@@ -73,6 +75,7 @@ ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db
ENV BASE_URL=/
# Create directories and setup permissions
RUN mkdir -p /app/certs && \
@@ -90,6 +93,6 @@ VOLUME /app/data
EXPOSE 4321
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"]

View File

@@ -300,7 +300,19 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
### 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)

View File

@@ -4,8 +4,25 @@ import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
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
export default defineConfig({
base,
output: 'server',
adapter: node({
mode: 'standalone',

View File

@@ -22,6 +22,8 @@ services:
# 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
# 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 ===
# These are technically required but have working defaults
@@ -29,6 +31,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
# Optional concurrency controls (defaults match in-app defaults)
# 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}
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
timeout: 10s
retries: 5

View File

@@ -45,6 +45,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
BASE_URL: ${BASE_URL:-/}
platforms:
- linux/amd64
- linux/arm64
@@ -66,6 +68,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
@@ -89,7 +92,11 @@ services:
# Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
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
timeout: 5s
retries: 3

View File

@@ -7,6 +7,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
BASE_URL: ${BASE_URL:-/}
platforms:
- linux/amd64
- linux/arm64
@@ -30,6 +32,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- 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),
@@ -37,6 +40,11 @@ services:
# 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
# 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}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
# 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_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
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
timeout: 10s
retries: 5

View File

@@ -33,6 +33,7 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | 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 |
| `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 |
@@ -302,6 +303,7 @@ services:
environment:
# Core Configuration
- NODE_ENV=production
- BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here
# 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.
### 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
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Bell, Activity, Send } from "lucide-react";
import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config";
import { withBase } from "@/lib/base-path";
interface NotificationSettingsProps {
notificationConfig: NotificationConfig;
@@ -31,7 +32,7 @@ export function NotificationSettings({
const handleTestNotification = async () => {
setIsTesting(true);
try {
const resp = await fetch("/api/notifications/test", {
const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST",
headers: { "Content-Type": "application/json" },
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 { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select';
import { withBase } from '@/lib/base-path';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try {
@@ -100,6 +101,9 @@ export function SSOSettings() {
digestAlgorithm: 'sha256',
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 {
requestData.entryPoint = providerForm.entryPoint;
requestData.cert = providerForm.cert;
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
requestData.audience = providerForm.audience || window.location.origin;
requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
requestData.audience = providerForm.audience || appOrigin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm;
@@ -517,7 +521,7 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<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']) && (
<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.
@@ -563,8 +567,8 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
<p>Callback URL: {window.location.origin}/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>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
</div>
</AlertDescription>
</Alert>
@@ -724,4 +728,4 @@ export function SSOSettings() {
</Card>
</div>
);
}
}

View File

@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
@@ -110,7 +111,7 @@ export function Dashboard() {
useEffectForToasts(() => {
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) => {
try {

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { stripBasePath, withBase } from "@/lib/base-path";
interface SidebarProps {
className?: string;
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
useEffect(() => {
// Hydration happens here
const path = window.location.pathname;
const path = stripBasePath(window.location.pathname);
setCurrentPath(path);
}, []);
// Listen for URL changes (browser back/forward)
useEffect(() => {
const handlePopState = () => {
setCurrentPath(window.location.pathname);
setCurrentPath(stripBasePath(window.location.pathname));
};
window.addEventListener('popstate', handlePopState);
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
if (currentPath === href) return;
// Update URL without page reload
window.history.pushState({}, '', href);
window.history.pushState({}, '', withBase(href));
setCurrentPath(href);
// 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.
</p>
<a
href="/docs"
href={withBase("/docs")}
target="_blank"
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"
@@ -177,7 +178,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
href="/docs"
href={withBase("/docs")}
target="_blank"
rel="noopener noreferrer"
className={cn(

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
} from "react";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
interface AuthContextType {
user: AuthUser | null;
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const result = await authClient.signIn.email({
email,
password,
callbackURL: "/",
callbackURL: withBase("/"),
});
if (result.error) {
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email,
password,
name: username, // Better Auth uses 'name' field for display name
callbackURL: "/",
callbackURL: withBase("/"),
});
if (result.error) {
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authClient.signOut({
fetchOptions: {
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 { useBetterAuthSession };
export { useBetterAuthSession };

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import '../styles/global.css';
import '../styles/docs.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Accept title as a prop with a default value
const { title = 'Gitea Mirror' } = Astro.props;
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
<head>
<meta charset="utf-8" />
<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>
<ThemeScript />
</head>

View File

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

View File

@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
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({
// 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
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
// Validate URL format and preserve optional base path
return normalizeAuthBaseUrl(url);
} catch (e) {
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
return 'http://localhost:4321';
})(),
basePath: '/api/auth', // Explicitly set the base path
basePath: withBase('/api/auth'), // Explicitly set the base path
plugins: [
oidcClient(),
ssoClient(),

View File

@@ -5,6 +5,7 @@ import { sso } from "@better-auth/sso";
import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
/**
* Resolves the list of trusted origins for Better Auth CSRF validation.
@@ -97,7 +98,7 @@ export const auth = betterAuth({
try {
// Validate URL format and ensure it's a proper origin
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);
return cleanUrl;
} catch (e) {
@@ -107,7 +108,7 @@ export const auth = betterAuth({
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.
// Uses the function form so that the origin can be auto-detected from
@@ -150,8 +151,8 @@ export const auth = betterAuth({
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
loginPage: withBase("/login"),
consentPage: withBase("/oauth/consent"),
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// 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 { httpRequest, HttpError } from "@/lib/http-client";
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[]) {
return twMerge(clsx(inputs));

View File

@@ -2,6 +2,7 @@
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { NotFound } from '@/components/NotFound';
import { withBase } from '@/lib/base-path';
const generator = Astro.generator;
---
@@ -10,7 +11,7 @@ const generator = Astro.generator;
<head>
<meta charset="utf-8" />
<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} />
<title>Page Not Found - Gitea Mirror</title>
<ThemeScript />
@@ -34,4 +35,4 @@ const generator = Astro.generator;
transform: translateY(-10px);
}
}
</style>
</style>

View File

@@ -3,6 +3,7 @@ import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import { db, mirrorJobs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Fetch activity data from the database
let activityData = [];
@@ -53,7 +54,7 @@ const handleRefresh = () => {
<head>
<meta charset="utf-8" />
<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} />
<title>Activity Log - Gitea Mirror</title>
<ThemeScript />

View File

@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth";
import type { APIRoute } from "astro";
import { withBase } from "@/lib/base-path";
export const ALL: APIRoute = async (ctx) => {
// 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')) {
// Redirect to error page for SSO errors
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"
)}`,
302
@@ -34,4 +35,4 @@ export const ALL: APIRoute = async (ctx) => {
headers: { "Content-Type": "application/json" }
});
}
};
};

View File

@@ -6,6 +6,7 @@ import { db, ssoProviders } from "@/lib/db";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
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
export async function POST(context: APIContext) {
@@ -87,7 +88,9 @@ export async function POST(context: APIContext) {
registrationBody.samlConfig = {
entryPoint,
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,
wantAssertionsSigned,
signatureAlgorithm,

View File

@@ -1,6 +1,7 @@
---
import Layout from '@/layouts/main.astro';
import { Button } from '@/components/ui/button';
import { withBase } from '@/lib/base-path';
const error = Astro.url.searchParams.get('error');
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">
<Button
variant="outline"
onClick={() => window.location.href = '/login'}
onClick={() => window.location.href = withBase('/login')}
>
Back to Login
</Button>
<Button
variant="outline"
onClick={() => window.location.href = '/'}
onClick={() => window.location.href = withBase('/')}
>
Go Home
</Button>
@@ -44,4 +45,4 @@ const errorDescription = Astro.url.searchParams.get('error_description');
</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 { Button } from '@/components/ui/button';
import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config';
import { withBase } from '@/lib/base-path';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<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} />
<title>Configuration - Gitea Mirror</title>
<ThemeScript />

View File

@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<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"
>
<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: 'PORT', desc: 'Server port', default: '4321' },
{ 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_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
{ 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
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
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>
<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">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>
@@ -243,9 +253,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- 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
- 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
# ... other settings ...`}</code></pre>
</div>
@@ -509,4 +520,4 @@ ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</section>
</article>
</main>
</MainLayout>
</MainLayout>

View File

@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Architecture - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<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"
>
<span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<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"
>
<span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="CA Certificates - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<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"
>
<span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,8 +1,10 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
const envVars = [
{ 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: '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' },
@@ -35,7 +37,7 @@ const giteaOptions = [
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<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"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
@@ -509,4 +511,4 @@ curl http://your-server:port/api/health`}</code></pre>
</section>
</article>
</main>
</MainLayout>
</MainLayout>

View File

@@ -1,6 +1,7 @@
---
import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
import { withBase } from '@/lib/base-path';
// Define our documentation pages directly
const docs = [
@@ -69,7 +70,7 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
return (
<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"
tabindex="0"
>
@@ -85,4 +86,4 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
})}
</div>
</main>
</MainLayout>
</MainLayout>

View File

@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Quick Start Guide - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<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"
>
<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="flex gap-3">
<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 class="flex gap-3">
<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 class="flex gap-3">
<span class="text-primary">📊</span>
@@ -434,4 +435,4 @@ bun run start</code></pre>
</section>
</article>
</main>
</MainLayout>
</MainLayout>

View File

@@ -4,6 +4,7 @@ import App from '@/components/layout/MainLayout';
import { db, repositories, mirrorJobs, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database
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
if (userCount === 0) {
return Astro.redirect('/signup');
return Astro.redirect(withBase('/signup'));
}
// Fetch data from the database
@@ -59,7 +60,7 @@ try {
<head>
<meta charset="utf-8" />
<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} />
<title>Dashboard - Gitea Mirror</title>
<ThemeScript />

View File

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

View File

@@ -3,11 +3,12 @@ import '@/styles/global.css';
import ConsentPage from '@/components/oauth/ConsentPage';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import Providers from '@/components/layout/Providers';
import { withBase } from '@/lib/base-path';
// Check if user is authenticated
const sessionCookie = Astro.cookies.get('better-auth-session');
if (!sessionCookie) {
return Astro.redirect('/login');
return Astro.redirect(withBase('/login'));
}
---
@@ -15,7 +16,7 @@ if (!sessionCookie) {
<head>
<meta charset="utf-8" />
<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} />
<title>Authorize Application - Gitea Mirror</title>
<ThemeScript />
@@ -25,4 +26,4 @@ if (!sessionCookie) {
<ConsentPage client:load />
</Providers>
</body>
</html>
</html>

View File

@@ -2,6 +2,7 @@
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
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>
<meta charset="utf-8" />
<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} />
<title>Organizations - Gitea Mirror</title>
<ThemeScript />

View File

@@ -2,13 +2,14 @@
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<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} />
<title>Repositories - Gitea Mirror</title>
<ThemeScript />

View File

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