Compare commits

...

7 Commits

Author SHA1 Message Date
ARUNAVO RAY 89a6372565 nix: fix runtime wrapper paths and startup script packaging (#194)
* nix: fix flake module and runtime scripts

* docs: refresh readme and docs links/examples
2026-02-26 10:59:56 +05:30
ARUNAVO RAY f40cad4713 nix: fix flake module and runtime scripts (#192) 2026-02-26 10:39:50 +05:30
ARUNAVO RAY 855906d990 auth: clarify invalid origin error toast guidance (#193)
* nix: fix flake module and runtime scripts

* auth: clarify invalid origin toast
2026-02-26 10:39:08 +05:30
ARUNAVO RAY 08da526ddd fix(github): keep disabled repos from cleanup while skipping new imports (#191)
* fix: preserve disabled repos while skipping new imports

* ci: upgrade bun to 1.3.6 for test workflow
2026-02-26 10:19:28 +05:30
ARUNAVO RAY 2395e14382 Add pre-sync snapshot protection for mirror rewrites (#190)
* add pre-sync snapshot protection

* stabilize test module mocks

* fix cross-test gitea mock exports

* fix gitea mock strategy behavior
2026-02-26 10:13:13 +05:30
Arunavo Ray 91c1703bb5 chore: release v3.9.4 2026-02-24 11:47:47 +05:30
ARUNAVO RAY 6a548e3dac security: enforce session-derived user identity on API routes (#186)
* security: enforce session user on api routes

* test: harden auth guard failure path
2026-02-24 11:47:29 +05:30
51 changed files with 1206 additions and 419 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: '1.2.16'
bun-version: '1.3.6'
- name: Check lockfile and install dependencies
run: |
+1 -1
View File
@@ -24,7 +24,7 @@ Secrets auto-generate, database auto-initializes, and the web UI starts at http:
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
# Pin to specific version
nix run github:RayLabsHQ/gitea-mirror/v3.8.11
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
```
### 2. Install to Profile
+3 -3
View File
@@ -112,7 +112,7 @@ docker compose up -d
#### Using Pre-built Image Directly
```bash
docker pull ghcr.io/raylabshq/gitea-mirror:v3.1.1
docker pull ghcr.io/raylabshq/gitea-mirror:latest
```
### Configuration Options
@@ -483,7 +483,7 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
## License
GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
GNU Affero General Public License v3.0 (AGPL-3.0) - see [LICENSE](LICENSE) file for details.
## Star History
@@ -498,7 +498,7 @@ GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
## Support
- 📖 [Documentation](https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs)
- 🔐 [Custom CA Certificates](docs/CA_CERTIFICATES.md)
- 🔐 [Environment Variables](docs/ENVIRONMENT_VARIABLES.md)
- 🐛 [Report Issues](https://github.com/RayLabsHQ/gitea-mirror/issues)
- 💬 [Discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions)
- 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)
+5 -5
View File
@@ -325,8 +325,8 @@ bun test
4. **Create release**:
```bash
git tag v2.23.0
git push origin v2.23.0
git tag vX.Y.Z
git push origin vX.Y.Z
```
5. **Create GitHub release**
@@ -349,6 +349,6 @@ git push origin v2.23.0
## Getting Help
- Check existing [issues](https://github.com/yourusername/gitea-mirror/issues)
- Join [discussions](https://github.com/yourusername/gitea-mirror/discussions)
- Read the [FAQ](./FAQ.md)
- Check existing [issues](https://github.com/RayLabsHQ/gitea-mirror/issues)
- Join [discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions)
- Review project docs in [docs/README.md](./README.md)
+8 -8
View File
@@ -16,7 +16,7 @@ nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gite
nix run github:RayLabsHQ/gitea-mirror/abc123def
# Pin to git tag
nix run github:RayLabsHQ/gitea-mirror/v3.8.11
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
```
**How it works:**
@@ -110,11 +110,11 @@ GitHub Actions workflow validates builds on every push/PR:
Tag releases for version pinning:
```bash
git tag v3.8.11
git push origin v3.8.11
git tag vX.Y.Z
git push origin vX.Y.Z
# Users can then pin:
nix run github:RayLabsHQ/gitea-mirror/v3.8.11
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
```
### Phase 4: nixpkgs Submission (Long Term)
@@ -143,13 +143,13 @@ nix profile install --extra-experimental-features 'nix-command flakes' github:Ra
```bash
# Pin to git tag
nix run github:RayLabsHQ/gitea-mirror/v3.8.11
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
# Pin to commit
nix run github:RayLabsHQ/gitea-mirror/abc123def
# Lock in flake.nix
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/v3.8.11";
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/vX.Y.Z";
```
#### Option 3: NixOS Configuration
@@ -160,7 +160,7 @@ inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/v3.8.11";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
# Or pin to version:
# gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/v3.8.11";
# gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/vX.Y.Z";
};
outputs = { nixpkgs, gitea-mirror, ... }: {
@@ -257,7 +257,7 @@ git tag -l
git ls-remote --tags origin
# Test specific tag
nix run github:RayLabsHQ/gitea-mirror/v3.8.11
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
```
---
+3 -3
View File
@@ -7,6 +7,8 @@ This folder contains engineering and operations references for the open-source G
### Core workflow
- **[DEVELOPMENT_WORKFLOW.md](./DEVELOPMENT_WORKFLOW.md)** Set up a local environment, run scripts, and understand the repo layout (app + marketing site).
- **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)** Complete reference for every configuration flag supported by the app and Docker images.
- **[NIX_DEPLOYMENT.md](./NIX_DEPLOYMENT.md)** User-facing deployment guide for Nix and NixOS.
- **[NIX_DISTRIBUTION.md](./NIX_DISTRIBUTION.md)** Maintainer notes for packaging, releases, and distribution strategy.
### Reliability & recovery
- **[GRACEFUL_SHUTDOWN.md](./GRACEFUL_SHUTDOWN.md)** How signal handling, shutdown coordination, and job persistence work in v3.
@@ -32,8 +34,6 @@ The first user you create locally becomes the administrator. All other configura
## Contributing & support
- 🎯 Contribution guide: [../CONTRIBUTING.md](../CONTRIBUTING.md)
- 📘 Code of conduct: [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)
- 🐞 Issues & feature requests: <https://github.com/RayLabsHQ/gitea-mirror/issues>
- 💬 Discussions: <https://github.com/RayLabsHQ/gitea-mirror/discussions>
Security disclosures should follow the process in [../SECURITY.md](../SECURITY.md).
- 🔐 Security policy & advisories: <https://github.com/RayLabsHQ/gitea-mirror/security>
+175 -168
View File
@@ -7,14 +7,17 @@
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
forEachSystem = flake-utils.lib.eachDefaultSystem;
in
(forEachSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# Build the application
gitea-mirror = pkgs.stdenv.mkDerivation {
pname = "gitea-mirror";
version = "3.8.11";
version = "3.9.4";
src = ./.;
@@ -49,11 +52,14 @@
cp -r dist $out/lib/gitea-mirror/
cp -r node_modules $out/lib/gitea-mirror/
cp -r scripts $out/lib/gitea-mirror/
cp -r src $out/lib/gitea-mirror/
cp -r drizzle $out/lib/gitea-mirror/
cp package.json $out/lib/gitea-mirror/
cp tsconfig.json $out/lib/gitea-mirror/
# Create entrypoint script that matches Docker behavior
cat > $out/bin/gitea-mirror <<'EOF'
#!/usr/bin/env bash
#!${pkgs.bash}/bin/bash
set -e
# === DEFAULT CONFIGURATION ===
@@ -75,7 +81,8 @@ export MIRROR_PULL_REQUEST_CONCURRENCY=''${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
# Create data directory
mkdir -p "$DATA_DIR"
cd $out/lib/gitea-mirror
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/../lib/gitea-mirror"
# === AUTO-GENERATE SECRETS ===
BETTER_AUTH_SECRET_FILE="$DATA_DIR/.better_auth_secret"
@@ -112,7 +119,7 @@ if [ -z "$ENCRYPTION_SECRET" ]; then
fi
# === DATABASE INITIALIZATION ===
DB_PATH=$(echo "$DATABASE_URL" | sed 's|^file:||')
DB_PATH=$(echo "$DATABASE_URL" | ${pkgs.gnused}/bin/sed 's|^file:||')
if [ ! -f "$DB_PATH" ]; then
echo "Database not found. It will be created and initialized via Drizzle migrations on first app startup..."
touch "$DB_PATH"
@@ -123,25 +130,25 @@ fi
# === STARTUP SCRIPTS ===
# Initialize configuration from environment variables
echo "Checking for environment configuration..."
if [ -f "dist/scripts/startup-env-config.js" ]; then
if [ -f "scripts/startup-env-config.ts" ]; then
echo "Loading configuration from environment variables..."
${pkgs.bun}/bin/bun dist/scripts/startup-env-config.js && \
${pkgs.bun}/bin/bun scripts/startup-env-config.ts && \
echo " Environment configuration loaded successfully" || \
echo " Environment configuration loading completed with warnings"
fi
# Run startup recovery
echo "Running startup recovery..."
if [ -f "dist/scripts/startup-recovery.js" ]; then
${pkgs.bun}/bin/bun dist/scripts/startup-recovery.js --timeout=30000 && \
if [ -f "scripts/startup-recovery.ts" ]; then
${pkgs.bun}/bin/bun scripts/startup-recovery.ts --timeout=30000 && \
echo " Startup recovery completed successfully" || \
echo " Startup recovery completed with warnings"
fi
# Run repository status repair
echo "Running repository status repair..."
if [ -f "dist/scripts/repair-mirrored-repos.js" ]; then
${pkgs.bun}/bin/bun dist/scripts/repair-mirrored-repos.js --startup && \
if [ -f "scripts/repair-mirrored-repos.ts" ]; then
${pkgs.bun}/bin/bun scripts/repair-mirrored-repos.ts --startup && \
echo " Repository status repair completed successfully" || \
echo " Repository status repair completed with warnings"
fi
@@ -170,10 +177,11 @@ EOF
# Create database management helper
cat > $out/bin/gitea-mirror-db <<'EOF'
#!/usr/bin/env bash
#!${pkgs.bash}/bin/bash
export DATA_DIR=''${DATA_DIR:-"$HOME/.local/share/gitea-mirror"}
mkdir -p "$DATA_DIR"
cd $out/lib/gitea-mirror
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/../lib/gitea-mirror"
exec ${pkgs.bun}/bin/bun scripts/manage-db.ts "$@"
EOF
chmod +x $out/bin/gitea-mirror-db
@@ -217,176 +225,175 @@ EOF
'';
};
# NixOS module
nixosModules.default = { config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.gitea-mirror;
in {
options.services.gitea-mirror = {
enable = mkEnableOption "Gitea Mirror service";
}
)) // {
nixosModules.default = { config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.gitea-mirror;
in {
options.services.gitea-mirror = {
enable = mkEnableOption "Gitea Mirror service";
package = mkOption {
type = types.package;
default = self.packages.${system}.default;
description = "The Gitea Mirror package to use";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/gitea-mirror";
description = "Directory to store data and database";
};
user = mkOption {
type = types.str;
default = "gitea-mirror";
description = "User account under which Gitea Mirror runs";
};
group = mkOption {
type = types.str;
default = "gitea-mirror";
description = "Group under which Gitea Mirror runs";
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host to bind to";
};
port = mkOption {
type = types.port;
default = 4321;
description = "Port to listen on";
};
betterAuthUrl = mkOption {
type = types.str;
default = "http://localhost:4321";
description = "Better Auth URL (external URL of the service)";
};
betterAuthTrustedOrigins = mkOption {
type = types.str;
default = "http://localhost:4321";
description = "Comma-separated list of trusted origins for Better Auth";
};
mirrorIssueConcurrency = mkOption {
type = types.int;
default = 3;
description = "Number of concurrent issue mirror operations (set to 1 for perfect ordering)";
};
mirrorPullRequestConcurrency = mkOption {
type = types.int;
default = 5;
description = "Number of concurrent PR mirror operations (set to 1 for perfect ordering)";
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to file containing environment variables.
Only needed if you want to set BETTER_AUTH_SECRET or ENCRYPTION_SECRET manually.
Otherwise, secrets will be auto-generated and stored in the data directory.
Example:
BETTER_AUTH_SECRET=your-32-character-secret-here
ENCRYPTION_SECRET=your-encryption-secret-here
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open the firewall for the specified port";
};
package = mkOption {
type = types.package;
default = self.packages.${pkgs.system}.default;
description = "The Gitea Mirror package to use";
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/gitea-mirror";
description = "Directory to store data and database";
};
users.groups.${cfg.group} = {};
user = mkOption {
type = types.str;
default = "gitea-mirror";
description = "User account under which Gitea Mirror runs";
};
systemd.services.gitea-mirror = {
description = "Gitea Mirror - GitHub to Gitea mirroring service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
group = mkOption {
type = types.str;
default = "gitea-mirror";
description = "Group under which Gitea Mirror runs";
};
environment = {
DATA_DIR = cfg.dataDir;
DATABASE_URL = "file:${cfg.dataDir}/gitea-mirror.db";
HOST = cfg.host;
PORT = toString cfg.port;
NODE_ENV = "production";
BETTER_AUTH_URL = cfg.betterAuthUrl;
BETTER_AUTH_TRUSTED_ORIGINS = cfg.betterAuthTrustedOrigins;
PUBLIC_BETTER_AUTH_URL = cfg.betterAuthUrl;
MIRROR_ISSUE_CONCURRENCY = toString cfg.mirrorIssueConcurrency;
MIRROR_PULL_REQUEST_CONCURRENCY = toString cfg.mirrorPullRequestConcurrency;
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host to bind to";
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = "${cfg.package}/bin/gitea-mirror";
Restart = "always";
RestartSec = "10s";
port = mkOption {
type = types.port;
default = 4321;
description = "Port to listen on";
};
# Security hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
betterAuthUrl = mkOption {
type = types.str;
default = "http://localhost:4321";
description = "Better Auth URL (external URL of the service)";
};
# Load environment file if specified (optional)
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
betterAuthTrustedOrigins = mkOption {
type = types.str;
default = "http://localhost:4321";
description = "Comma-separated list of trusted origins for Better Auth";
};
# Graceful shutdown
TimeoutStopSec = "30s";
KillMode = "mixed";
KillSignal = "SIGTERM";
};
};
mirrorIssueConcurrency = mkOption {
type = types.int;
default = 3;
description = "Number of concurrent issue mirror operations (set to 1 for perfect ordering)";
};
# Health check timer (optional monitoring)
systemd.timers.gitea-mirror-healthcheck = mkIf cfg.enable {
description = "Gitea Mirror health check timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5min";
OnUnitActiveSec = "5min";
};
};
mirrorPullRequestConcurrency = mkOption {
type = types.int;
default = 5;
description = "Number of concurrent PR mirror operations (set to 1 for perfect ordering)";
};
systemd.services.gitea-mirror-healthcheck = mkIf cfg.enable {
description = "Gitea Mirror health check";
after = [ "gitea-mirror.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.curl}/bin/curl -f http://${cfg.host}:${toString cfg.port}/api/health || true";
User = "nobody";
};
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to file containing environment variables.
Only needed if you want to set BETTER_AUTH_SECRET or ENCRYPTION_SECRET manually.
Otherwise, secrets will be auto-generated and stored in the data directory.
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
Example:
BETTER_AUTH_SECRET=your-32-character-secret-here
ENCRYPTION_SECRET=your-encryption-secret-here
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open the firewall for the specified port";
};
};
}
) // {
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = {};
systemd.services.gitea-mirror = {
description = "Gitea Mirror - GitHub to Gitea mirroring service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
DATA_DIR = cfg.dataDir;
DATABASE_URL = "file:${cfg.dataDir}/gitea-mirror.db";
HOST = cfg.host;
PORT = toString cfg.port;
NODE_ENV = "production";
BETTER_AUTH_URL = cfg.betterAuthUrl;
BETTER_AUTH_TRUSTED_ORIGINS = cfg.betterAuthTrustedOrigins;
PUBLIC_BETTER_AUTH_URL = cfg.betterAuthUrl;
MIRROR_ISSUE_CONCURRENCY = toString cfg.mirrorIssueConcurrency;
MIRROR_PULL_REQUEST_CONCURRENCY = toString cfg.mirrorPullRequestConcurrency;
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = "${cfg.package}/bin/gitea-mirror";
Restart = "always";
RestartSec = "10s";
# Security hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
# Graceful shutdown
TimeoutStopSec = "30s";
KillMode = "mixed";
KillSignal = "SIGTERM";
} // optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
# Health check timer (optional monitoring)
systemd.timers.gitea-mirror-healthcheck = {
description = "Gitea Mirror health check timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5min";
OnUnitActiveSec = "5min";
};
};
systemd.services.gitea-mirror-healthcheck = {
description = "Gitea Mirror health check";
after = [ "gitea-mirror.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash -c '${pkgs.curl}/bin/curl -f http://127.0.0.1:${toString cfg.port}/api/health || true'";
User = "nobody";
};
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
};
};
# Overlay for adding to nixpkgs
overlays.default = final: prev: {
gitea-mirror = self.packages.${final.system}.default;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.9.3",
"version": "3.9.4",
"engines": {
"bun": ">=1.2.9"
},
+4
View File
@@ -50,6 +50,10 @@ export function ConfigTabs() {
starredReposOrg: 'starred',
starredReposMode: 'dedicated-org',
preserveOrgStructure: false,
backupBeforeSync: true,
backupRetentionCount: 20,
backupDirectory: 'data/repo-backups',
blockSyncOnBackupFailure: true,
},
scheduleConfig: {
enabled: false, // Don't set defaults here - will be loaded from API
+79 -2
View File
@@ -100,9 +100,16 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
);
}
const normalizedValue =
type === "checkbox"
? checked
: name === "backupRetentionCount"
? Math.max(1, Number.parseInt(value, 10) || 20)
: value;
const newConfig = {
...config,
[name]: type === "checkbox" ? checked : value,
[name]: normalizedValue,
};
setConfig(newConfig);
@@ -286,7 +293,77 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
if (onAutoSave) onAutoSave(newConfig);
}}
/>
<Separator />
<div className="space-y-4">
<h3 className="text-sm font-semibold">Destructive Update Protection</h3>
<label className="flex items-start gap-3 text-sm">
<input
name="backupBeforeSync"
type="checkbox"
checked={Boolean(config.backupBeforeSync)}
onChange={handleChange}
className="mt-0.5 rounded border-input"
/>
<span>
Create snapshot before each sync
<p className="text-xs text-muted-foreground">
Saves a restore point so force-pushes or rewritten upstream history can be recovered.
</p>
</span>
</label>
{config.backupBeforeSync && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="gitea-backup-retention" className="block text-sm font-medium mb-1.5">
Snapshot retention count
</label>
<input
id="gitea-backup-retention"
name="backupRetentionCount"
type="number"
min={1}
value={config.backupRetentionCount ?? 20}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div>
<label htmlFor="gitea-backup-directory" className="block text-sm font-medium mb-1.5">
Snapshot directory
</label>
<input
id="gitea-backup-directory"
name="backupDirectory"
type="text"
value={config.backupDirectory || "data/repo-backups"}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="data/repo-backups"
/>
</div>
</div>
)}
<label className="flex items-start gap-3 text-sm">
<input
name="blockSyncOnBackupFailure"
type="checkbox"
checked={Boolean(config.blockSyncOnBackupFailure)}
onChange={handleChange}
className="mt-0.5 rounded border-input"
/>
<span>
Block sync when snapshot fails
<p className="text-xs text-muted-foreground">
Recommended for backup-first behavior. If disabled, sync continues even when snapshot creation fails.
</p>
</span>
</label>
</div>
{/* Mobile: Show button at bottom */}
<Button
type="button"
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, mock, test } from "bun:test";
const getSessionMock = mock(async () => null);
mock.module("@/lib/auth", () => ({
auth: {
api: {
getSession: getSessionMock,
},
},
}));
import { requireAuthenticatedUserId } from "./auth-guards";
describe("requireAuthenticatedUserId", () => {
test("returns user id from locals session without calling auth api", async () => {
getSessionMock.mockImplementation(async () => {
throw new Error("should not be called");
});
const result = await requireAuthenticatedUserId({
request: new Request("http://localhost/test"),
locals: {
session: { userId: "local-user-id" },
} as any,
});
expect("userId" in result).toBe(true);
if ("userId" in result) {
expect(result.userId).toBe("local-user-id");
}
});
test("returns user id from auth session when locals are empty", async () => {
getSessionMock.mockImplementation(async () => ({
user: { id: "session-user-id" },
session: { id: "session-id" },
}));
const result = await requireAuthenticatedUserId({
request: new Request("http://localhost/test"),
locals: {} as any,
});
expect("userId" in result).toBe(true);
if ("userId" in result) {
expect(result.userId).toBe("session-user-id");
}
});
test("returns unauthorized response when auth lookup throws", async () => {
getSessionMock.mockImplementation(async () => {
throw new Error("session provider unavailable");
});
const result = await requireAuthenticatedUserId({
request: new Request("http://localhost/test"),
locals: {} as any,
});
expect("response" in result).toBe(true);
if ("response" in result) {
expect(result.response.status).toBe(401);
}
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { APIContext } from "astro";
import { auth } from "@/lib/auth";
function unauthorizedResponse() {
return new Response(
JSON.stringify({
success: false,
error: "Unauthorized",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
/**
* Ensures request is authenticated and returns the authenticated user ID.
* Never trust client-provided userId for authorization decisions.
*/
export async function requireAuthenticatedUserId(
context: Pick<APIContext, "request" | "locals">
): Promise<{ userId: string } | { response: Response }> {
const localUserId =
context.locals?.session?.userId || context.locals?.user?.id;
if (localUserId) {
return { userId: localUserId };
}
let session: Awaited<ReturnType<typeof auth.api.getSession>> | null = null;
try {
session = await auth.api.getSession({
headers: context.request.headers,
});
} catch {
return { response: unauthorizedResponse() };
}
if (!session?.user?.id) {
return { response: unauthorizedResponse() };
}
return { userId: session.user.id };
}
+4
View File
@@ -65,6 +65,10 @@ export const giteaConfigSchema = z.object({
mirrorPullRequests: z.boolean().default(false),
mirrorLabels: z.boolean().default(false),
mirrorMilestones: z.boolean().default(false),
backupBeforeSync: z.boolean().default(true),
backupRetentionCount: z.number().int().min(1).default(20),
backupDirectory: z.string().optional(),
blockSyncOnBackupFailure: z.boolean().default(true),
});
export const scheduleConfigSchema = z.object({
+146 -1
View File
@@ -13,6 +13,11 @@ const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve());
const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred"));
const mockCreatePreSyncBundleBackup = mock(() =>
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
);
let mockShouldCreatePreSyncBackup = false;
let mockShouldBlockSyncOnBackupFailure = true;
// Mock the database module
const mockDb = {
@@ -28,8 +33,14 @@ const mockDb = {
mock.module("@/lib/db", () => ({
db: mockDb,
users: {},
configs: {},
organizations: {},
mirrorJobs: {},
repositories: {}
repositories: {},
events: {},
accounts: {},
sessions: {},
}));
// Mock config encryption
@@ -235,6 +246,12 @@ mock.module("@/lib/http-client", () => ({
HttpError: MockHttpError
}));
mock.module("@/lib/repo-backup", () => ({
createPreSyncBundleBackup: mockCreatePreSyncBundleBackup,
shouldCreatePreSyncBackup: () => mockShouldCreatePreSyncBackup,
shouldBlockSyncOnBackupFailure: () => mockShouldBlockSyncOnBackupFailure,
}));
// Now import the modules we're testing
import {
getGiteaRepoInfo,
@@ -264,6 +281,15 @@ describe("Enhanced Gitea Operations", () => {
mockMirrorGitRepoMilestonesToGitea.mockClear();
mockGetGiteaRepoOwnerAsync.mockClear();
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("starred"));
mockHttpGet.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
mockCreatePreSyncBundleBackup.mockClear();
mockCreatePreSyncBundleBackup.mockImplementation(() =>
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
);
mockShouldCreatePreSyncBackup = false;
mockShouldBlockSyncOnBackupFailure = true;
// Reset tracking variables
orgCheckCount = 0;
orgTestContext = "";
@@ -529,6 +555,125 @@ describe("Enhanced Gitea Operations", () => {
expect(releaseCall.octokit).toBeDefined();
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true;
mockCreatePreSyncBundleBackup.mockImplementation(() =>
Promise.reject(new Error("simulated backup failure"))
);
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
backupBeforeSync: true,
blockSyncOnBackupFailure: true,
},
};
const repository: Repository = {
id: "repo456",
name: "mirror-repo",
fullName: "user/mirror-repo",
owner: "user",
cloneUrl: "https://github.com/user/mirror-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
createdAt: new Date(),
updatedAt: new Date(),
};
await expect(
syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
)
).rejects.toThrow("Snapshot failed; sync blocked to protect history.");
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls.length).toBe(0);
});
test("continues sync when pre-sync snapshot fails and blocking is disabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = false;
mockCreatePreSyncBundleBackup.mockImplementation(() =>
Promise.reject(new Error("simulated backup failure"))
);
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
backupBeforeSync: true,
blockSyncOnBackupFailure: false,
},
};
const repository: Repository = {
id: "repo457",
name: "mirror-repo",
fullName: "user/mirror-repo",
owner: "user",
cloneUrl: "https://github.com/user/mirror-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls.length).toBe(1);
});
test("mirrors metadata components when enabled and not previously synced", async () => {
const config: Partial<Config> = {
userId: "user123",
+60
View File
@@ -15,6 +15,11 @@ import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
import { db, repositories } from "./db";
import { eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository";
import {
createPreSyncBundleBackup,
shouldCreatePreSyncBackup,
shouldBlockSyncOnBackupFailure,
} from "./repo-backup";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
@@ -313,6 +318,61 @@ export async function syncGiteaRepoEnhanced({
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
}
if (shouldCreatePreSyncBackup(config)) {
const cloneUrl =
repoInfo.clone_url ||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
try {
const backupResult = await createPreSyncBundleBackup({
config,
owner: repoOwner,
repoName: repository.name,
cloneUrl,
});
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Snapshot created for ${repository.name}`,
details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`,
status: "syncing",
});
} catch (backupError) {
const errorMessage =
backupError instanceof Error ? backupError.message : String(backupError);
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Snapshot failed for ${repository.name}`,
details: `Pre-sync snapshot failed: ${errorMessage}`,
status: "failed",
});
if (shouldBlockSyncOnBackupFailure(config)) {
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: `Snapshot failed; sync blocked to protect history. ${errorMessage}`,
})
.where(eq(repositories.id, repository.id!));
throw new Error(
`Snapshot failed; sync blocked to protect history. ${errorMessage}`
);
}
console.warn(
`[Sync] Snapshot failed for ${repository.name}, continuing because blockSyncOnBackupFailure=false: ${errorMessage}`
);
}
}
// Update mirror interval if needed
if (config.giteaConfig?.mirrorInterval) {
try {
+13 -2
View File
@@ -24,9 +24,14 @@ mock.module("@/lib/db", () => {
values: mock(() => Promise.resolve())
}))
},
users: {},
configs: {},
repositories: {},
organizations: {},
events: {}
events: {},
mirrorJobs: {},
accounts: {},
sessions: {},
};
});
@@ -59,10 +64,16 @@ const mockGetOrCreateGiteaOrg = mock(async ({ orgName, config }: any) => {
const mockMirrorGitHubOrgRepoToGiteaOrg = mock(async () => {});
const mockIsRepoPresentInGitea = mock(async () => false);
const mockMirrorGithubRepoToGitea = mock(async () => {});
const mockGetGiteaRepoOwnerAsync = mock(async () => "starred");
const mockGetGiteaRepoOwner = mock(() => "starred");
mock.module("./gitea", () => ({
getOrCreateGiteaOrg: mockGetOrCreateGiteaOrg,
mirrorGitHubOrgRepoToGiteaOrg: mockMirrorGitHubOrgRepoToGiteaOrg,
mirrorGithubRepoToGitea: mockMirrorGithubRepoToGitea,
getGiteaRepoOwner: mockGetGiteaRepoOwner,
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
isRepoPresentInGitea: mockIsRepoPresentInGitea
}));
@@ -226,4 +237,4 @@ describe("Starred Repository Error Handling", () => {
});
});
});
});
+49 -1
View File
@@ -27,8 +27,14 @@ mock.module("@/lib/db", () => {
})
})
},
users: {},
configs: {},
repositories: {},
organizations: {}
organizations: {},
mirrorJobs: {},
events: {},
accounts: {},
sessions: {},
};
});
@@ -55,8 +61,50 @@ mock.module("@/lib/http-client", () => {
// Mock the gitea module itself
mock.module("./gitea", () => {
const mockGetGiteaRepoOwner = mock(({ config, repository }: any) => {
if (repository?.isStarred && config?.githubConfig?.starredReposMode === "preserve-owner") {
return repository.organization || repository.owner;
}
if (repository?.isStarred) {
return config?.githubConfig?.starredReposOrg || "starred";
}
const mirrorStrategy =
config?.githubConfig?.mirrorStrategy ||
(config?.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
switch (mirrorStrategy) {
case "preserve":
return repository?.organization || config?.giteaConfig?.defaultOwner || "giteauser";
case "single-org":
return config?.giteaConfig?.organization || config?.giteaConfig?.defaultOwner || "giteauser";
case "mixed":
if (repository?.organization) return repository.organization;
return config?.giteaConfig?.organization || config?.giteaConfig?.defaultOwner || "giteauser";
case "flat-user":
default:
return config?.giteaConfig?.defaultOwner || "giteauser";
}
});
const mockGetGiteaRepoOwnerAsync = mock(async ({ config, repository }: any) => {
if (repository?.isStarred && config?.githubConfig?.starredReposMode === "preserve-owner") {
return repository.organization || repository.owner;
}
if (repository?.destinationOrg) {
return repository.destinationOrg;
}
if (repository?.organization && mockDbSelectResult[0]?.destinationOrg) {
return mockDbSelectResult[0].destinationOrg;
}
return config?.giteaConfig?.defaultOwner || "giteauser";
});
return {
isRepoPresentInGitea: mockIsRepoPresentInGitea,
getGiteaRepoOwner: mockGetGiteaRepoOwner,
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGithubRepoToGitea: mock(async () => {}),
mirrorGitHubOrgRepoToGiteaOrg: mock(async () => {})
};
+4 -1
View File
@@ -254,6 +254,7 @@ export async function getGithubRepositories({
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
isDisabled: repo.disabled ?? false,
lastMirrored: undefined,
errorMessage: undefined,
@@ -275,7 +276,7 @@ export async function getGithubStarredRepositories({
}: {
octokit: Octokit;
config: Partial<Config>;
}) {
}): Promise<GitRepo[]> {
try {
const starredRepos = await octokit.paginate(
octokit.activity.listReposStarredByAuthenticatedUser,
@@ -314,6 +315,7 @@ export async function getGithubStarredRepositories({
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
isDisabled: repo.disabled ?? false,
lastMirrored: undefined,
errorMessage: undefined,
@@ -438,6 +440,7 @@ export async function getGithubOrganizationRepositories({
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
isDisabled: repo.disabled ?? false,
lastMirrored: undefined,
errorMessage: undefined,
+164
View File
@@ -0,0 +1,164 @@
import { mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Config } from "@/types/config";
import { decryptConfigTokens } from "./utils/config-encryption";
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return TRUE_VALUES.has(value.trim().toLowerCase());
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
function sanitizePathSegment(input: string): string {
return input.replace(/[^a-zA-Z0-9._-]/g, "_");
}
function buildTimestamp(): string {
// Example: 2026-02-25T18-34-22-123Z
return new Date().toISOString().replace(/[:.]/g, "-");
}
function buildAuthenticatedCloneUrl(cloneUrl: string, token: string): string {
const parsed = new URL(cloneUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return cloneUrl;
}
parsed.username = process.env.PRE_SYNC_BACKUP_GIT_USERNAME || "oauth2";
parsed.password = token;
return parsed.toString();
}
function maskToken(text: string, token: string): string {
if (!token) return text;
return text.split(token).join("***");
}
async function runGit(args: string[], tokenToMask: string): Promise<void> {
const proc = Bun.spawn({
cmd: ["git", ...args],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
const details = [stdout, stderr].filter(Boolean).join("\n").trim();
const safeDetails = maskToken(details, tokenToMask);
throw new Error(`git command failed: ${safeDetails || "unknown git error"}`);
}
}
async function enforceRetention(repoBackupDir: string, keepCount: number): Promise<void> {
const entries = await readdir(repoBackupDir);
const bundleFiles = entries
.filter((name) => name.endsWith(".bundle"))
.map((name) => path.join(repoBackupDir, name));
if (bundleFiles.length <= keepCount) return;
const filesWithMtime = await Promise.all(
bundleFiles.map(async (filePath) => ({
filePath,
mtimeMs: (await stat(filePath)).mtimeMs,
}))
);
filesWithMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
const toDelete = filesWithMtime.slice(keepCount);
await Promise.all(toDelete.map((entry) => rm(entry.filePath, { force: true })));
}
export function isPreSyncBackupEnabled(): boolean {
return parseBoolean(process.env.PRE_SYNC_BACKUP_ENABLED, true);
}
export function shouldCreatePreSyncBackup(config: Partial<Config>): boolean {
const configSetting = config.giteaConfig?.backupBeforeSync;
const fallback = isPreSyncBackupEnabled();
return configSetting === undefined ? fallback : Boolean(configSetting);
}
export function shouldBlockSyncOnBackupFailure(config: Partial<Config>): boolean {
const configSetting = config.giteaConfig?.blockSyncOnBackupFailure;
return configSetting === undefined ? true : Boolean(configSetting);
}
export async function createPreSyncBundleBackup({
config,
owner,
repoName,
cloneUrl,
}: {
config: Partial<Config>;
owner: string;
repoName: string;
cloneUrl: string;
}): Promise<{ bundlePath: string }> {
if (!shouldCreatePreSyncBackup(config)) {
throw new Error("Pre-sync backup is disabled.");
}
if (!config.giteaConfig?.token) {
throw new Error("Gitea token is required for pre-sync backup.");
}
const decryptedConfig = decryptConfigTokens(config as Config);
const giteaToken = decryptedConfig.giteaConfig?.token;
if (!giteaToken) {
throw new Error("Decrypted Gitea token is required for pre-sync backup.");
}
const backupRoot =
config.giteaConfig?.backupDirectory?.trim() ||
process.env.PRE_SYNC_BACKUP_DIR?.trim() ||
path.join(process.cwd(), "data", "repo-backups");
const retention = Math.max(
1,
Number.isFinite(config.giteaConfig?.backupRetentionCount)
? Number(config.giteaConfig?.backupRetentionCount)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 20)
);
const repoBackupDir = path.join(
backupRoot,
sanitizePathSegment(config.userId || "unknown-user"),
sanitizePathSegment(owner),
sanitizePathSegment(repoName)
);
await mkdir(repoBackupDir, { recursive: true });
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "gitea-mirror-backup-"));
const mirrorClonePath = path.join(tmpDir, "repo.git");
const bundlePath = path.join(repoBackupDir, `${buildTimestamp()}.bundle`);
try {
const authCloneUrl = buildAuthenticatedCloneUrl(cloneUrl, giteaToken);
await runGit(["clone", "--mirror", authCloneUrl, mirrorClonePath], giteaToken);
await runGit(["-C", mirrorClonePath, "bundle", "create", bundlePath, "--all"], giteaToken);
await enforceRetention(repoBackupDir, retention);
return { bundlePath };
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test";
import { isMirrorableGitHubRepo } from "@/lib/repo-eligibility";
describe("isMirrorableGitHubRepo", () => {
it("returns false for disabled repos", () => {
expect(isMirrorableGitHubRepo({ isDisabled: true })).toBe(false);
});
it("returns true for enabled repos", () => {
expect(isMirrorableGitHubRepo({ isDisabled: false })).toBe(true);
});
it("returns true when disabled flag is absent", () => {
expect(isMirrorableGitHubRepo({})).toBe(true);
});
});
+6
View File
@@ -0,0 +1,6 @@
import type { GitRepo } from "@/types/Repository";
export function isMirrorableGitHubRepo(repo: Pick<GitRepo, "isDisabled">): boolean {
return repo.isDisabled !== true;
}
+15 -7
View File
@@ -10,6 +10,7 @@ import { createGitHubClient, getGithubRepositories, getGithubStarredRepositories
import { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo, getGiteaRepoOwnerAsync, checkRepoLocation } from '@/lib/gitea';
import { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption';
import { publishEvent } from '@/lib/events';
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
let cleanupInterval: NodeJS.Timeout | null = null;
let isCleanupRunning = false;
@@ -59,7 +60,9 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
return [];
}
const githubRepoFullNames = new Set(allGithubRepos.map(repo => repo.fullName));
const githubReposByFullName = new Map(
allGithubRepos.map((repo) => [repo.fullName, repo] as const)
);
// Get all repositories from our database
const dbRepos = await db
@@ -70,18 +73,23 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
// Only identify repositories as orphaned if we successfully accessed GitHub
// This prevents false positives when GitHub is down or account is inaccessible
const orphanedRepos = dbRepos.filter(repo => {
const isOrphaned = !githubRepoFullNames.has(repo.fullName);
if (!isOrphaned) {
return false;
}
// Skip repositories we've already archived/preserved
if (repo.status === 'archived' || repo.isArchived) {
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
return false;
}
return true;
const githubRepo = githubReposByFullName.get(repo.fullName);
if (!githubRepo) {
return true;
}
if (!isMirrorableGitHubRepo(githubRepo)) {
console.log(`[Repository Cleanup] Preserving ${repo.fullName} - repository is disabled on GitHub`);
return false;
}
return false;
});
if (orphanedRepos.length > 0) {
+13 -2
View File
@@ -12,6 +12,7 @@ import { parseInterval, formatDuration } from '@/lib/utils/duration-parser';
import type { Repository } from '@/lib/db/schema';
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false;
@@ -96,6 +97,7 @@ async function runScheduledSync(config: any): Promise<void> {
: Promise.resolve([]),
]);
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
// Check for new repositories
const existingRepos = await db
@@ -104,7 +106,7 @@ async function runScheduledSync(config: any): Promise<void> {
.where(eq(repositories.userId, userId));
const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName));
const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
const newRepos = mirrorableGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
if (newRepos.length > 0) {
console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`);
@@ -129,6 +131,10 @@ async function runScheduledSync(config: any): Promise<void> {
} else {
console.log(`[Scheduler] No new repositories found for user ${userId}`);
}
const skippedDisabledCount = allGithubRepos.length - mirrorableGithubRepos.length;
if (skippedDisabledCount > 0) {
console.log(`[Scheduler] Skipped ${skippedDisabledCount} disabled GitHub repositories for user ${userId}`);
}
} catch (error) {
console.error(`[Scheduler] Failed to auto-import repositories for user ${userId}:`, error);
}
@@ -429,6 +435,7 @@ async function performInitialAutoStart(): Promise<void> {
: Promise.resolve([]),
]);
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
// Check for new repositories
const existingRepos = await db
@@ -437,7 +444,7 @@ async function performInitialAutoStart(): Promise<void> {
.where(eq(repositories.userId, config.userId));
const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName));
const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
const reposToImport = mirrorableGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
if (reposToImport.length > 0) {
console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`);
@@ -462,6 +469,10 @@ async function performInitialAutoStart(): Promise<void> {
} else {
console.log(`[Scheduler] No new repositories to import for user ${config.userId}`);
}
const skippedDisabledCount = allGithubRepos.length - mirrorableGithubRepos.length;
if (skippedDisabledCount > 0) {
console.log(`[Scheduler] Skipped ${skippedDisabledCount} disabled GitHub repositories for user ${config.userId}`);
}
// Check if we already have mirrored repositories (indicating this isn't first run)
const mirroredRepos = await db
+27
View File
@@ -169,4 +169,31 @@ describe("parseErrorMessage", () => {
expect(result.description).toBeUndefined();
expect(result.isStructured).toBe(false);
});
test("adds trusted origins guidance for invalid origin errors", () => {
const errorMessage = "Invalid Origin: https://mirror.example.com";
const result = parseErrorMessage(errorMessage);
expect(result.title).toBe("Invalid Origin");
expect(result.description).toContain("BETTER_AUTH_TRUSTED_ORIGINS");
expect(result.description).toContain("https://mirror.example.com");
expect(result.isStructured).toBe(true);
});
});
describe("showErrorToast", () => {
test("shows invalid origin guidance in toast description", () => {
const calls: any[] = [];
const toast = {
error: (...args: any[]) => calls.push(args),
};
showErrorToast("Invalid Origin: http://10.10.20.45:4321", toast);
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe("Invalid Origin");
expect(calls[0][1].description).toContain("BETTER_AUTH_TRUSTED_ORIGINS");
expect(calls[0][1].description).toContain("http://10.10.20.45:4321");
});
});
+40 -8
View File
@@ -86,6 +86,30 @@ export interface ParsedErrorMessage {
isStructured: boolean;
}
function getInvalidOriginGuidance(title: string, description?: string): ParsedErrorMessage | null {
const fullMessage = `${title} ${description ?? ""}`.trim();
if (!/invalid origin/i.test(fullMessage)) {
return null;
}
const urlMatch = fullMessage.match(/https?:\/\/[^\s'")]+/i);
let originHint = "this URL";
if (urlMatch) {
try {
originHint = new URL(urlMatch[0]).origin;
} catch {
originHint = urlMatch[0];
}
}
return {
title: "Invalid Origin",
description: `Add ${originHint} to BETTER_AUTH_TRUSTED_ORIGINS and restart the app.`,
isStructured: true,
};
}
export function parseErrorMessage(error: unknown): ParsedErrorMessage {
// Handle Error objects
if (error instanceof Error) {
@@ -102,29 +126,32 @@ export function parseErrorMessage(error: unknown): ParsedErrorMessage {
if (typeof parsed === "object" && parsed !== null) {
// Format 1: { error: "message", errorType: "type", troubleshooting: "info" }
if (parsed.error) {
return {
const formatted = {
title: parsed.error,
description: parsed.troubleshooting || parsed.errorType || undefined,
isStructured: true,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
// Format 2: { title: "title", description: "desc" }
if (parsed.title) {
return {
const formatted = {
title: parsed.title,
description: parsed.description || undefined,
isStructured: true,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
// Format 3: { message: "msg", details: "details" }
if (parsed.message) {
return {
const formatted = {
title: parsed.message,
description: parsed.details || undefined,
isStructured: true,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
}
} catch {
@@ -132,11 +159,12 @@ export function parseErrorMessage(error: unknown): ParsedErrorMessage {
}
// Plain string message
return {
const formatted = {
title: error,
description: undefined,
isStructured: false,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
// Handle objects directly
@@ -144,36 +172,40 @@ export function parseErrorMessage(error: unknown): ParsedErrorMessage {
const errorObj = error as any;
if (errorObj.error) {
return {
const formatted = {
title: errorObj.error,
description: errorObj.troubleshooting || errorObj.errorType || undefined,
isStructured: true,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
if (errorObj.title) {
return {
const formatted = {
title: errorObj.title,
description: errorObj.description || undefined,
isStructured: true,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
if (errorObj.message) {
return {
const formatted = {
title: errorObj.message,
description: errorObj.details || undefined,
isStructured: true,
};
return getInvalidOriginGuidance(formatted.title, formatted.description) || formatted;
}
}
// Fallback for unknown types
return {
const fallback = {
title: String(error),
description: undefined,
isStructured: false,
};
return getInvalidOriginGuidance(fallback.title, fallback.description) || fallback;
}
// Enhanced toast helper that parses structured error messages
+4
View File
@@ -93,6 +93,10 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
forkStrategy: "reference",
issueConcurrency: 3,
pullRequestConcurrency: 5,
backupBeforeSync: true,
backupRetentionCount: 20,
backupDirectory: "data/repo-backups",
blockSyncOnBackupFailure: true,
},
include: [],
exclude: [],
+8
View File
@@ -100,6 +100,10 @@ export function mapUiToDbConfig(
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels,
mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones,
backupBeforeSync: giteaConfig.backupBeforeSync ?? true,
backupRetentionCount: giteaConfig.backupRetentionCount ?? 20,
backupDirectory: giteaConfig.backupDirectory?.trim() || undefined,
blockSyncOnBackupFailure: giteaConfig.blockSyncOnBackupFailure ?? true,
};
return {
@@ -140,6 +144,10 @@ export function mapDbToUiConfig(dbConfig: any): {
personalReposOrg: undefined, // Not stored in current schema
issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3,
pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5,
backupBeforeSync: dbConfig.giteaConfig?.backupBeforeSync ?? true,
backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 20,
backupDirectory: dbConfig.giteaConfig?.backupDirectory || "data/repo-backups",
blockSyncOnBackupFailure: dbConfig.giteaConfig?.blockSyncOnBackupFailure ?? true,
};
// Map mirror options from various database fields
+5 -20
View File
@@ -2,28 +2,13 @@ import type { APIRoute } from "astro";
import { db, mirrorJobs, events } from "@/lib/db";
import { eq, count } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
let body;
try {
body = await request.json();
} catch (jsonError) {
console.error("Invalid JSON in request body:", jsonError);
return new Response(
JSON.stringify({ error: "Invalid JSON in request body." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const { userId } = body || {};
if (!userId) {
return new Response(
JSON.stringify({ error: "Missing 'userId' in request body." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Start a transaction to ensure all operations succeed or fail together
const result = await db.transaction(async (tx) => {
+6 -11
View File
@@ -1,21 +1,16 @@
import type { APIRoute } from "astro";
import { db, mirrorJobs, configs } from "@/lib/db";
import { db, mirrorJobs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import type { MirrorJob } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ url }) => {
export const GET: APIRoute = async ({ request, locals }) => {
try {
const searchParams = new URL(url).searchParams;
const userId = searchParams.get("userId");
if (!userId) {
return new Response(
JSON.stringify({ error: "Missing 'userId' in query parameters." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch mirror jobs associated with the user
const jobs = await db
+15 -17
View File
@@ -2,7 +2,6 @@ import type { APIRoute } from "astro";
import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
import { calculateCleanupInterval } from "@/lib/cleanup-service";
import { createSecureErrorResponse } from "@/lib/utils";
import {
mapUiToDbConfig,
@@ -12,20 +11,25 @@ import {
mapDbScheduleToUi,
mapDbCleanupToUi
} from "@/lib/utils/config-mapper";
import { encrypt, decrypt, migrateToken } from "@/lib/utils/encryption";
import { encrypt, decrypt } from "@/lib/utils/encryption";
import { createDefaultConfig } from "@/lib/utils/config-defaults";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body = await request.json();
const { userId, githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) {
const body = await request.json();
const { githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body;
if (!githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) {
return new Response(
JSON.stringify({
success: false,
message:
"userId, githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, and advancedOptions are required.",
"githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, and advancedOptions are required.",
}),
{
status: 400,
@@ -172,17 +176,11 @@ export const POST: APIRoute = async ({ request }) => {
}
};
export const GET: APIRoute = async ({ request }) => {
export const GET: APIRoute = async ({ request, locals }) => {
try {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response(JSON.stringify({ error: "User ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch the configuration for the user
const config = await db
+6 -16
View File
@@ -3,24 +3,14 @@ import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
import { eq, count, and, sql, or } from "drizzle-orm";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: {
success: false,
error: "Missing userId",
},
status: 400,
});
}
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
const [
userRepos,
userOrgs,
+6 -8
View File
@@ -1,13 +1,11 @@
import type { APIRoute } from "astro";
import { getNewEvents } from "@/lib/events";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response("Missing userId", { status: 400 });
}
export const GET: APIRoute = async ({ request, locals }) => {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Create a new ReadableStream for SSE
const stream = new ReadableStream({
@@ -66,4 +64,4 @@ export const GET: APIRoute = async ({ request }) => {
"X-Accel-Buffering": "no", // Disable nginx buffering
},
});
};
};
+6 -14
View File
@@ -9,22 +9,14 @@ import {
import type { Organization } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: {
success: false,
error: "Missing userId",
},
status: 400,
});
}
export const GET: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch the user's active configuration to respect filtering settings
const [config] = await db
.select()
+6 -11
View File
@@ -7,19 +7,14 @@ import {
type RepositoryApiResponse,
} from "@/types/Repository";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: { success: false, error: "Missing userId" },
status: 400,
});
}
export const GET: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch the user's active configuration
const [config] = await db
.select()
+17 -7
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
import { db, configs, organizations } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { createGitHubClient } from "@/lib/github";
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
import { repoStatusEnum } from "@/types/Repository";
@@ -10,17 +10,22 @@ import { createSecureErrorResponse } from "@/lib/utils";
import { processWithResilience } from "@/lib/utils/concurrency";
import { v4 as uuidv4 } from "uuid";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: MirrorOrgRequest = await request.json();
const { userId, organizationIds } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !organizationIds || !Array.isArray(organizationIds)) {
const body: MirrorOrgRequest = await request.json();
const { organizationIds } = body;
if (!organizationIds || !Array.isArray(organizationIds)) {
return new Response(
JSON.stringify({
success: false,
message: "userId and organizationIds are required.",
message: "organizationIds are required.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
@@ -56,7 +61,12 @@ export const POST: APIRoute = async ({ request }) => {
const orgs = await db
.select()
.from(organizations)
.where(inArray(organizations.id, organizationIds));
.where(
and(
eq(organizations.userId, userId),
inArray(organizations.id, organizationIds)
)
);
if (!orgs.length) {
return new Response(
+28 -8
View File
@@ -62,7 +62,13 @@ const mockRepositories = {};
mock.module("@/lib/db", () => ({
db: mockDb,
configs: mockConfigs,
repositories: mockRepositories
repositories: mockRepositories,
users: {},
organizations: {},
mirrorJobs: {},
events: {},
accounts: {},
sessions: {}
}));
// Mock the gitea module
@@ -71,7 +77,10 @@ const mockMirrorGitHubOrgRepoToGiteaOrg = mock(() => Promise.resolve());
mock.module("@/lib/gitea", () => ({
mirrorGithubRepoToGitea: mockMirrorGithubRepoToGitea,
mirrorGitHubOrgRepoToGiteaOrg: mockMirrorGitHubOrgRepoToGiteaOrg
mirrorGitHubOrgRepoToGiteaOrg: mockMirrorGitHubOrgRepoToGiteaOrg,
getGiteaRepoOwnerAsync: mock(() => Promise.resolve("test-owner")),
isRepoPresentInGitea: mock(() => Promise.resolve(true)),
syncGiteaRepo: mock(() => Promise.resolve({ success: true })),
}));
// Mock the github module
@@ -90,6 +99,7 @@ mock.module("@/lib/utils/concurrency", () => ({
// Mock drizzle-orm
mock.module("drizzle-orm", () => ({
and: mock(() => ({})),
eq: mock(() => ({})),
inArray: mock(() => ({}))
}));
@@ -121,7 +131,7 @@ describe("Repository Mirroring API", () => {
console.error = originalConsoleError;
});
test("returns 400 if userId is missing", async () => {
test("returns 401 when request is unauthenticated", async () => {
const request = new Request("http://localhost/api/job/mirror-repo", {
method: "POST",
headers: {
@@ -134,11 +144,11 @@ describe("Repository Mirroring API", () => {
const response = await POST({ request } as any);
expect(response.status).toBe(400);
expect(response.status).toBe(401);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe("userId and repositoryIds are required.");
expect(data.error).toBe("Unauthorized");
});
test("returns 400 if repositoryIds is missing", async () => {
@@ -152,13 +162,18 @@ describe("Repository Mirroring API", () => {
})
});
const response = await POST({ request } as any);
const response = await POST({
request,
locals: {
session: { userId: "user-id" },
},
} as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe("userId and repositoryIds are required.");
expect(data.message).toBe("repositoryIds are required.");
});
test("returns 200 and starts mirroring repositories", async () => {
@@ -173,7 +188,12 @@ describe("Repository Mirroring API", () => {
})
});
const response = await POST({ request } as any);
const response = await POST({
request,
locals: {
session: { userId: "user-id" },
},
} as any);
expect(response.status).toBe(200);
+17 -7
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import {
mirrorGithubRepoToGitea,
@@ -12,17 +12,22 @@ import { createGitHubClient } from "@/lib/github";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { processWithResilience } from "@/lib/utils/concurrency";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: MirrorRepoRequest = await request.json();
const { userId, repositoryIds } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !repositoryIds || !Array.isArray(repositoryIds)) {
const body: MirrorRepoRequest = await request.json();
const { repositoryIds } = body;
if (!repositoryIds || !Array.isArray(repositoryIds)) {
return new Response(
JSON.stringify({
success: false,
message: "userId and repositoryIds are required.",
message: "repositoryIds are required.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
@@ -58,7 +63,12 @@ export const POST: APIRoute = async ({ request }) => {
const repos = await db
.select()
.from(repositories)
.where(inArray(repositories.id, repositoryIds));
.where(
and(
eq(repositories.userId, userId),
inArray(repositories.id, repositoryIds)
)
);
if (!repos.length) {
return new Response(
+10 -5
View File
@@ -4,17 +4,22 @@ import { db, configs, repositories } from "@/lib/db";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: ResetMetadataRequest = await request.json();
const { userId, repositoryIds } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !repositoryIds || !Array.isArray(repositoryIds)) {
const body: ResetMetadataRequest = await request.json();
const { repositoryIds } = body;
if (!repositoryIds || !Array.isArray(repositoryIds)) {
return new Response(
JSON.stringify({
success: false,
message: "userId and repositoryIds are required.",
message: "repositoryIds are required.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
+17 -7
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
import {
mirrorGithubRepoToGitea,
@@ -14,17 +14,22 @@ import { processWithRetry } from "@/lib/utils/concurrency";
import { createMirrorJob } from "@/lib/helpers";
import { createSecureErrorResponse } from "@/lib/utils";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: RetryRepoRequest = await request.json();
const { userId, repositoryIds } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !repositoryIds || !Array.isArray(repositoryIds)) {
const body: RetryRepoRequest = await request.json();
const { repositoryIds } = body;
if (!repositoryIds || !Array.isArray(repositoryIds)) {
return new Response(
JSON.stringify({
success: false,
message: "userId and repositoryIds are required.",
message: "repositoryIds are required.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
@@ -60,7 +65,12 @@ export const POST: APIRoute = async ({ request }) => {
const repos = await db
.select()
.from(repositories)
.where(inArray(repositories.id, repositoryIds));
.where(
and(
eq(repositories.userId, userId),
inArray(repositories.id, repositoryIds)
)
);
if (!repos.length) {
return new Response(
+10 -15
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { eq, or } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
import { isRepoPresentInGitea, syncGiteaRepo } from "@/lib/gitea";
import type {
@@ -9,22 +9,15 @@ import type {
} from "@/types/sync";
import { createSecureErrorResponse } from "@/lib/utils";
import { parseInterval } from "@/lib/utils/duration-parser";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: ScheduleSyncRepoRequest = await request.json();
const { userId } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId) {
return new Response(
JSON.stringify({
success: false,
error: "Missing userId in request body.",
repositories: [],
} satisfies ScheduleSyncRepoResponse),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
await request.json().catch(() => ({} as ScheduleSyncRepoRequest));
// Fetch config for the user
const configResult = await db
@@ -51,12 +44,14 @@ export const POST: APIRoute = async ({ request }) => {
.select()
.from(repositories)
.where(
eq(repositories.userId, userId) &&
and(
eq(repositories.userId, userId),
or(
eq(repositories.status, "mirrored"),
eq(repositories.status, "synced"),
eq(repositories.status, "failed")
)
)
);
if (!repos.length) {
+17 -7
View File
@@ -1,23 +1,28 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepo } from "@/lib/gitea";
import type { SyncRepoResponse } from "@/types/sync";
import { processWithResilience } from "@/lib/utils/concurrency";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: MirrorRepoRequest = await request.json();
const { userId, repositoryIds } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !repositoryIds || !Array.isArray(repositoryIds)) {
const body: MirrorRepoRequest = await request.json();
const { repositoryIds } = body;
if (!repositoryIds || !Array.isArray(repositoryIds)) {
return new Response(
JSON.stringify({
success: false,
message: "userId and repositoryIds are required.",
message: "repositoryIds are required.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
@@ -53,7 +58,12 @@ export const POST: APIRoute = async ({ request }) => {
const repos = await db
.select()
.from(repositories)
.where(inArray(repositories.id, repositoryIds));
.where(
and(
eq(repositories.userId, userId),
inArray(repositories.id, repositoryIds)
)
);
if (!repos.length) {
return new Response(
+10 -5
View File
@@ -2,18 +2,23 @@ import type { APIContext } from "astro";
import { db, organizations } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export async function PATCH({ params, request }: APIContext) {
export async function PATCH({ params, request, locals }: APIContext) {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
const { id } = params;
const body = await request.json();
const { status, userId } = body;
const { status } = body;
if (!id || !userId) {
if (!id) {
return new Response(
JSON.stringify({
success: false,
error: "Organization ID and User ID are required",
error: "Organization ID is required",
}),
{
status: 400,
@@ -78,4 +83,4 @@ export async function PATCH({ params, request }: APIContext) {
} catch (error) {
return createSecureErrorResponse(error);
}
}
}
+7 -10
View File
@@ -6,19 +6,16 @@ import { RateLimitManager } from "@/lib/rate-limit-manager";
import { createGitHubClient } from "@/lib/github";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { configs } from "@/lib/db";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
const refresh = url.searchParams.get("refresh") === "true";
if (!userId) {
return jsonResponse({
data: { error: "Missing userId" },
status: 400,
});
}
try {
// If refresh is requested, fetch current rate limit from GitHub
if (refresh) {
@@ -101,4 +98,4 @@ export const GET: APIRoute = async ({ request }) => {
} catch (error) {
return createSecureErrorResponse(error, "rate limit check", 500);
}
};
};
+10 -5
View File
@@ -3,18 +3,23 @@ import { db, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import { repoStatusEnum } from "@/types/Repository";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export async function PATCH({ params, request }: APIContext) {
export async function PATCH({ params, request, locals }: APIContext) {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
const { id } = params;
const body = await request.json();
const { status, userId } = body;
const { status } = body;
if (!id || !userId) {
if (!id) {
return new Response(
JSON.stringify({
success: false,
error: "Repository ID and User ID are required",
error: "Repository ID is required",
}),
{
status: 400,
@@ -79,4 +84,4 @@ export async function PATCH({ params, request }: APIContext) {
} catch (error) {
return createSecureErrorResponse(error);
}
}
}
+5 -7
View File
@@ -1,13 +1,11 @@
import type { APIRoute } from "astro";
import { getNewEvents } from "@/lib/events";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response("Missing userId", { status: 400 });
}
export const GET: APIRoute = async ({ request, locals }) => {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
const channel = `mirror-status:${userId}`;
let isClosed = false;
+9 -8
View File
@@ -12,14 +12,13 @@ import {
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { mergeGitReposPreferStarred, calcBatchSizeForInsert } from "@/lib/repo-utils";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { isMirrorableGitHubRepo } from "@/lib/repo-eligibility";
export const POST: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({ data: { error: "Missing userId" }, status: 400 });
}
export const POST: APIRoute = async ({ request, locals }) => {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
try {
const [config] = await db
@@ -58,9 +57,10 @@ export const POST: APIRoute = async ({ request }) => {
// Merge and de-duplicate by fullName, preferring starred variant when duplicated
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
// Prepare full list of repos and orgs
const newRepos = allGithubRepos.map((repo) => ({
const newRepos = mirrorableGithubRepos.map((repo) => ({
id: uuidv4(),
userId,
configId: config.id,
@@ -188,6 +188,7 @@ export const POST: APIRoute = async ({ request }) => {
message: "Repositories and organizations synced successfully",
newRepositories: insertedRepos.length,
newOrganizations: insertedOrgs.length,
skippedDisabledRepositories: allGithubRepos.length - mirrorableGithubRepos.length,
},
});
} catch (error) {
+12 -6
View File
@@ -10,15 +10,20 @@ import type { RepositoryVisibility, RepoStatus } from "@/types/Repository";
import { v4 as uuidv4 } from "uuid";
import { decryptConfigTokens } from "@/lib/utils/config-encryption";
import { createGitHubClient } from "@/lib/github";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: AddOrganizationApiRequest = await request.json();
const { role, org, userId, force = false } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!org || !userId || !role) {
const body: AddOrganizationApiRequest = await request.json();
const { role, org, force = false } = body;
if (!org || !role) {
return jsonResponse({
data: { success: false, error: "Missing org, role or userId" },
data: { success: false, error: "Missing org or role" },
status: 400,
});
}
@@ -145,9 +150,10 @@ export const POST: APIRoute = async ({ request }) => {
const existingIds = new Set(allRepos.map(r => r.id));
const uniqueMemberRepos = memberRepos.filter(r => !existingIds.has(r.id));
allRepos.push(...uniqueMemberRepos);
const mirrorableRepos = allRepos.filter((repo) => !repo.disabled);
// Insert repositories
const repoRecords = allRepos.map((repo) => {
const repoRecords = mirrorableRepos.map((repo) => {
const normalizedOwner = repo.owner.login.trim().toLowerCase();
const normalizedRepoName = repo.name.trim().toLowerCase();
+11 -6
View File
@@ -11,17 +11,22 @@ import type {
RepositoryVisibility,
} from "@/types/Repository";
import { createMirrorJob } from "@/lib/helpers";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body: AddRepositoriesApiRequest = await request.json();
const { owner, repo, userId, force = false } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!owner || !repo || !userId) {
const body: AddRepositoriesApiRequest = await request.json();
const { owner, repo, force = false } = body;
if (!owner || !repo) {
return new Response(
JSON.stringify({
success: false,
error: "Missing owner, repo, or userId",
error: "Missing owner or repo",
}),
{ status: 400 }
);
@@ -34,7 +39,7 @@ export const POST: APIRoute = async ({ request }) => {
return jsonResponse({
data: {
success: false,
error: "Missing owner, repo, or userId",
error: "Missing owner or repo",
},
status: 400,
});
+10 -5
View File
@@ -2,16 +2,21 @@ import type { APIRoute } from "astro";
import { publishEvent } from "@/lib/events";
import { v4 as uuidv4 } from "uuid";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const body = await request.json();
const { userId, message, status } = body;
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
if (!userId || !message || !status) {
const body = await request.json();
const { message, status } = body;
if (!message || !status) {
return new Response(
JSON.stringify({
error: "Missing required fields: userId, message, status",
error: "Missing required fields: message, status",
}),
{ status: 400 }
);
+1
View File
@@ -70,6 +70,7 @@ export interface GitRepo {
visibility: RepositoryVisibility;
status: RepoStatus;
isDisabled?: boolean;
lastMirrored?: Date;
errorMessage?: string;
+4
View File
@@ -18,6 +18,10 @@ export interface GiteaConfig {
personalReposOrg?: string; // Override destination for personal repos
issueConcurrency?: number;
pullRequestConcurrency?: number;
backupBeforeSync?: boolean;
backupRetentionCount?: number;
backupDirectory?: string;
blockSyncOnBackupFailure?: boolean;
}
export interface ScheduleConfig {