diff --git a/README.md b/README.md index d128f45..8e09fc5 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes **Important Notes**: - **Auto-Start**: When `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set, the service automatically imports all GitHub repositories and mirrors them on startup. No manual "Import" or "Mirror" button clicks required! - The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync. +- **Large repo bootstrap**: For first-time mirroring of large repositories (especially with metadata/LFS), avoid very short intervals (for example `5m`). Start with a longer interval (`1h` to `8h`) or temporarily disable scheduling during the initial import/mirror run, then enable your regular interval after the first pass completes. +- **Why this matters**: If your Gitea instance takes a long time to complete migrations/imports, aggressive schedules can cause repeated retries and duplicate-looking mirror attempts. **🛡️ Backup Protection Features**: - **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors) @@ -307,6 +309,20 @@ If sync logs show authentication failures (for example `terminal prompts disable 1. In Gitea/Forgejo, open repository **Settings → Mirror Settings** and update the mirror authorization password/token. 2. Or delete and re-mirror the repository from Gitea Mirror so it is recreated with current credentials. +### Re-sync Metadata After Changing Mirror Options + +If you enable metadata options (issues/PRs/labels/milestones/releases) after repositories were already mirrored: + +1. Go to **Repositories**, select the repositories, and click **Sync** to run a fresh sync pass. +2. For a full metadata refresh, use **Re-run Metadata** on selected repositories. This clears metadata sync state for those repos and immediately starts Sync. +3. If some repositories still miss metadata, reset metadata sync state in SQLite and sync again: + +```bash +sqlite3 data/gitea-mirror.db "UPDATE repositories SET metadata = NULL;" +``` + +This clears per-repository metadata completion flags so the next sync can re-run metadata import steps. + ## Development ```bash @@ -343,6 +359,20 @@ bun run build - Never stored in plaintext - Secure cookie-based session management +### Admin Password Recovery (CLI) +If email delivery is not configured, an admin with server access can reset a user password from the command line: + +```bash +bun run reset-password -- --email=user@example.com --new-password='new-secure-password' +``` + +What this does: +- Updates the credential password hash for the matching user +- Creates a credential account if one does not already exist +- Invalidates all active sessions for that user (forces re-login) + +Use this only from trusted server/admin environments. + ## Authentication Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.** diff --git a/package.json b/package.json index dc68391..1dae0a1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "check-db": "bun scripts/manage-db.ts check", "fix-db": "bun scripts/manage-db.ts fix", "reset-users": "bun scripts/manage-db.ts reset-users", + "reset-password": "bun scripts/manage-db.ts reset-password", "db:generate": "bun drizzle-kit generate", "db:migrate": "bun drizzle-kit migrate", "db:push": "bun drizzle-kit push", diff --git a/scripts/manage-db.ts b/scripts/manage-db.ts index ae858de..cb09c5c 100644 --- a/scripts/manage-db.ts +++ b/scripts/manage-db.ts @@ -4,9 +4,9 @@ import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { v4 as uuidv4 } from "uuid"; -import { users, configs, repositories, organizations, mirrorJobs, events } from "../src/lib/db/schema"; -import bcrypt from "bcryptjs"; -import { eq } from "drizzle-orm"; +import { users, configs, repositories, organizations, mirrorJobs, events, accounts, sessions } from "../src/lib/db/schema"; +import { and, eq } from "drizzle-orm"; +import { hashPassword } from "better-auth/crypto"; // Command line arguments const args = process.argv.slice(2); @@ -194,6 +194,92 @@ async function fixDatabase() { console.log("✅ Database location fixed"); } +/** + * Reset a single user's password (admin recovery flow) + */ +async function resetPassword() { + const emailArg = args.find((arg) => arg.startsWith("--email=")); + const passwordArg = args.find((arg) => arg.startsWith("--new-password=")); + const email = emailArg?.split("=")[1]?.trim().toLowerCase(); + const newPassword = passwordArg?.split("=")[1]; + + if (!email || !newPassword) { + console.log("❌ Missing required arguments"); + console.log("Usage:"); + console.log(" bun run manage-db reset-password --email=user@example.com --new-password='new-secure-password'"); + process.exit(1); + } + + if (newPassword.length < 8) { + console.log("❌ Password must be at least 8 characters"); + process.exit(1); + } + + if (!fs.existsSync(dbPath)) { + console.log("❌ Database does not exist"); + process.exit(1); + } + + const sqlite = new Database(dbPath); + const db = drizzle({ client: sqlite }); + + try { + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (!user) { + console.log(`❌ No user found for email: ${email}`); + sqlite.close(); + process.exit(1); + } + + const hashedPassword = await hashPassword(newPassword); + const now = new Date(); + + const credentialAccount = await db.query.accounts.findFirst({ + where: and( + eq(accounts.userId, user.id), + eq(accounts.providerId, "credential"), + ), + }); + + if (credentialAccount) { + await db + .update(accounts) + .set({ + password: hashedPassword, + updatedAt: now, + }) + .where(eq(accounts.id, credentialAccount.id)); + } else { + await db.insert(accounts).values({ + id: uuidv4(), + accountId: user.id, + userId: user.id, + providerId: "credential", + password: hashedPassword, + createdAt: now, + updatedAt: now, + }); + } + + const deletedSessions = await db + .delete(sessions) + .where(eq(sessions.userId, user.id)) + .returning({ id: sessions.id }); + + console.log(`✅ Password reset for ${email}`); + console.log(`🔒 Cleared ${deletedSessions.length} active session(s)`); + + sqlite.close(); + } catch (error) { + console.error("❌ Error resetting password:", error); + sqlite.close(); + process.exit(1); + } +} + /** * Auto mode - check and initialize if needed */ @@ -224,6 +310,9 @@ switch (command) { case "cleanup": await cleanupDatabase(); break; + case "reset-password": + await resetPassword(); + break; case "auto": await autoMode(); break; @@ -233,7 +322,8 @@ switch (command) { console.log(" check - Check database status"); console.log(" fix - Fix database location issues"); console.log(" reset-users - Remove all users and related data"); + console.log(" reset-password - Reset one user's password and clear sessions"); console.log(" cleanup - Remove all database files"); console.log(" auto - Auto initialize if needed"); process.exit(1); -} \ No newline at end of file +} diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx index 9c1821f..1d6985a 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -377,14 +377,13 @@ export function GitHubMirrorSettings({ id="release-limit" type="number" min="1" - max="100" value={mirrorOptions.releaseLimit || 10} onChange={(e) => { const value = parseInt(e.target.value) || 10; - const clampedValue = Math.min(100, Math.max(1, value)); + const clampedValue = Math.max(1, value); handleMirrorChange('releaseLimit', clampedValue); }} - className="w-16 px-2 py-1 text-xs border border-input rounded bg-background text-foreground" + className="w-20 px-2 py-1 text-xs border border-input rounded bg-background text-foreground" /> releases diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 549abcc..f10e2c7 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -44,6 +44,7 @@ import { toast } from "sonner"; import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync"; import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes"; import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry"; +import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata"; import AddRepositoryDialog from "./AddRepositoryDialog"; import { useLiveRefresh } from "@/hooks/useLiveRefresh"; @@ -378,6 +379,67 @@ export default function Repository() { } }; + const handleBulkRerunMetadata = async () => { + if (selectedRepoIds.size === 0) return; + + const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); + const eligibleRepos = selectedRepos.filter( + repo => ["mirrored", "synced", "archived"].includes(repo.status) + ); + + if (eligibleRepos.length === 0) { + toast.info("No eligible repositories to re-run metadata in selection"); + return; + } + + const repoIds = eligibleRepos.map(repo => repo.id as string); + + setLoadingRepoIds(prev => { + const newSet = new Set(prev); + repoIds.forEach(id => newSet.add(id)); + return newSet; + }); + + try { + const resetPayload: ResetMetadataRequest = { + userId: user?.id || "", + repositoryIds: repoIds, + }; + + const resetResponse = await apiRequest("/job/reset-metadata", { + method: "POST", + data: resetPayload, + }); + + if (!resetResponse.success) { + showErrorToast(resetResponse.error || "Failed to reset metadata state", toast); + return; + } + + const syncResponse = await apiRequest("/job/sync-repo", { + method: "POST", + data: { userId: user?.id, repositoryIds: repoIds }, + }); + + if (syncResponse.success) { + toast.success(`Re-running metadata for ${repoIds.length} repositories`); + setRepositories(prevRepos => + prevRepos.map(repo => { + const updated = syncResponse.repositories.find(r => r.id === repo.id); + return updated ? updated : repo; + }) + ); + setSelectedRepoIds(new Set()); + } else { + showErrorToast(syncResponse.error || "Error starting metadata re-sync", toast); + } + } catch (error) { + showErrorToast(error, toast); + } finally { + setLoadingRepoIds(new Set()); + } + }; + const handleBulkRetry = async () => { if (selectedRepoIds.size === 0) return; @@ -806,6 +868,10 @@ export default function Repository() { if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) { actions.push('sync'); } + + if (selectedRepos.some(repo => ["mirrored", "synced", "archived"].includes(repo.status))) { + actions.push('rerun-metadata'); + } // Check if any selected repos are failed if (selectedRepos.some(repo => repo.status === "failed")) { @@ -834,6 +900,7 @@ export default function Repository() { return { mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed").length, sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").length, + rerunMetadata: selectedRepos.filter(repo => ["mirrored", "synced", "archived"].includes(repo.status)).length, retry: selectedRepos.filter(repo => repo.status === "failed").length, ignore: selectedRepos.filter(repo => repo.status !== "ignored").length, include: selectedRepos.filter(repo => repo.status === "ignored").length, @@ -1157,6 +1224,18 @@ export default function Repository() { Sync ({actionCounts.sync}) )} + + {availableActions.includes('rerun-metadata') && ( + + )} {availableActions.includes('retry') && ( )} + + {availableActions.includes('rerun-metadata') && ( + + )} {availableActions.includes('retry') && (