Compare commits

...

165 Commits

Author SHA1 Message Date
Arunavo Ray
37e5b68bd5 Added Github API rate limiting
- Implemented comprehensive GitHub API rate limit handling:
    - Integrated @octokit/plugin-throttling for automatic retry with exponential backoff
    - Added RateLimitManager service to track and enforce rate limits
    - Store rate limit status in database for persistence across restarts
    - Automatic pause and resume when limits are exceeded
    - Proper user identification for 5000 req/hr authenticated limit (vs 60 unauthenticated)

  - Improved rate limit UI/UX:
    - Removed intrusive rate limit card from dashboard
    - Toast notifications only at critical thresholds (80% and 100% usage)
    - All rate limit events logged for debugging

  - Optimized for GitHub's API constraints:
    - Reduced default batch size from 10 to 5 repositories
    - Added documentation about GitHub's 100 concurrent request limit
    - Better handling of repositories with many issues/PRs
2025-09-09 11:14:43 +05:30
Arunavo Ray
89ca5abe7d fix: resolve SQLite field mismatch for large starred repo imports (#90)
- Add missing database fields (language, description, mirroredLocation, destinationOrg) to repository operations
  - Add missing organization fields (publicRepositoryCount, privateRepositoryCount, forkRepositoryCount) to schema
  - Update GitRepo interface to include all required database fields
  - Fix GitHub data fetching functions to map all fields correctly
  - Update all sync endpoints (main, repository, organization, scheduler) to handle new fields

  This fixes the "SQLite query expected X values, received Y" error when importing
  large numbers (4.6k+) of starred repositories by ensuring all database fields
  are properly mapped from GitHub API responses through to database insertion.
2025-09-09 09:56:18 +05:30
Arunavo Ray
2b78a6a4a8 v3.5.4 2025-09-07 19:11:50 +05:30
Arunavo Ray
c2f6e73054 Testing Authentik SSO Issues 2025-09-07 19:09:00 +05:30
Arunavo Ray
c4b353aae8 Added docs around scheduling using corn 2025-09-07 16:51:51 +05:30
Arunavo Ray
4a54cf9009 v3.5.3 2025-09-07 16:29:43 +05:30
Arunavo Ray
fab4efd93a Auto-start on boot 2025-09-07 16:29:23 +05:30
Arunavo Ray
9f21cd6b1a Addressing concerns of Issue #85 and #86 2025-09-07 15:25:48 +05:30
Arunavo Ray
9ef6017a23 v3.5.2 2025-09-07 13:55:43 +05:30
Arunavo Ray
502796371f Attempt to address #84 2025-09-07 13:55:20 +05:30
Arunavo Ray
b956b71c5f Fixed #87 where the Release Notes was missing 2025-09-07 13:14:41 +05:30
Arunavo Ray
26b82e0f65 Added AGENTS.md 2025-09-07 11:46:14 +05:30
Arunavo Ray
7c124a37d7 v3.5.1 2025-08-30 00:47:59 +05:30
Arunavo Ray
3e14edc571 fixed default overide 2025-08-30 00:47:33 +05:30
Arunavo Ray
a188869cae "Automatic Mirroring" changed to "Automatic Syncing" 2025-08-30 00:37:56 +05:30
Arunavo Ray
afac3b5ddc UI tweek 2025-08-29 21:16:19 +05:30
Arunavo Ray
2ce4bb4373 update env doc 2025-08-29 20:43:49 +05:30
Arunavo Ray
5c9a3afaae updates to auth url 2025-08-29 20:43:25 +05:30
Arunavo Ray
de4e111095 type fix 2025-08-29 20:42:56 +05:30
Arunavo Ray
8c4d9508c7 Add provider modal optimised 2025-08-29 19:17:40 +05:30
Arunavo Ray
921eb5e07d util 2025-08-29 19:08:48 +05:30
Arunavo Ray
ac1b09f7a1 UI updates 2025-08-29 19:08:39 +05:30
Arunavo Ray
9ee67ce77d made time more user readable 2025-08-29 18:32:22 +05:30
Arunavo Ray
92db61a2c9 v3.5.0 2025-08-29 18:11:49 +05:30
Arunavo Ray
cbf6e11de3 Env var updates 2025-08-29 18:11:26 +05:30
Arunavo Ray
18855f09c4 Imporved a bunch of things in Mirror and sync Automation 2025-08-29 17:49:44 +05:30
Arunavo Ray
b8965a9fd4 v3.4.0 2025-08-29 17:06:38 +05:30
Arunavo Ray
598e81ff45 updated package location 2025-08-29 17:04:48 +05:30
Arunavo Ray
fef6cbb60d toast showing full name now 2025-08-29 17:01:48 +05:30
Arunavo Ray
c793be5863 closed and merged pull requests will be created as closed issues 2025-08-29 16:58:48 +05:30
Arunavo Ray
d097ded6ee Updates to PR as issues 2025-08-29 16:54:21 +05:30
Arunavo Ray
1b01a5e653 updated docs 2025-08-28 20:11:16 +05:30
Arunavo Ray
56988818d2 removed unused package-lock.json 2025-08-28 20:04:20 +05:30
ARUNAVO RAY
5a49726b0e Merge pull request #82 from RayLabsHQ/dependabot/npm_and_yarn/www/npm_and_yarn-b7812215fd
Bump the npm_and_yarn group across 1 directory with 2 updates
2025-08-28 20:00:10 +05:30
dependabot[bot]
987c4ec3ec Bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /www directory: [devalue](https://github.com/sveltejs/devalue) and [esbuild](https://github.com/evanw/esbuild).


Updates `devalue` from 5.1.1 to 5.3.2
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.1.1...v5.3.2)

Updates `esbuild` from 0.25.6 to 0.25.9
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.6...v0.25.9)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 0.25.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-28 14:25:41 +00:00
Arunavo Ray
444442fcca updated packages www 2025-08-28 19:53:48 +05:30
ARUNAVO RAY
3fe2461031 Merge pull request #80 from RayLabsHQ/address-Issues
Address issues
2025-08-28 19:51:16 +05:30
Arunavo Ray
ea7777a20f spacing 2025-08-28 19:51:00 +05:30
Arunavo Ray
a3247c9c22 Removed icon 2025-08-28 19:46:19 +05:30
Arunavo Ray
099bf7d36f added details 2025-08-28 19:14:27 +05:30
Arunavo Ray
10a14d88ef updates 2025-08-28 19:01:39 +05:30
Arunavo Ray
36f8d41d38 Updated PR as issues 2025-08-28 17:54:38 +05:30
Arunavo Ray
dd19131029 added default values 2025-08-28 15:49:20 +05:30
Arunavo Ray
be5f2e6c3d config 2025-08-28 15:46:05 +05:30
Arunavo Ray
d9bfc59a2d Added eye/eye-off icon toggle for password field 2025-08-28 14:55:42 +05:30
Arunavo Ray
29a08ee3e3 fixed the TypeError in the config mapper functions 2025-08-28 13:59:25 +05:30
Arunavo Ray
b425cbce71 fixed the security vulnerability CVE-2025-57820 in the devalue package 2025-08-28 13:53:04 +05:30
Arunavo Ray
f54a7e6d71 update default configs 2025-08-28 13:45:49 +05:30
Arunavo Ray
d49599ff05 Org ignore 2025-08-28 13:27:10 +05:30
Arunavo Ray
d99f597988 Update the Ignore Repo 2025-08-28 12:58:58 +05:30
Arunavo Ray
7dfb6b5d18 updated status to use badges 2025-08-28 11:26:28 +05:30
Arunavo Ray
46e6b4b927 Dashboard minor UI update 2025-08-28 11:21:51 +05:30
Arunavo Ray
8bd3b8d3b1 Added redirect to /login 2025-08-28 10:50:18 +05:30
Arunavo Ray
78be49d4a7 Added BETA tag to LFS feature 2025-08-28 10:49:27 +05:30
Arunavo Ray
c58bde1cc3 updated astro 2025-08-28 10:31:08 +05:30
Arunavo Ray
b4a2a14dd3 Fixed CVE issue 2025-08-28 10:25:42 +05:30
Arunavo Ray
3fb71b666d Updated dockerfile bun 2025-08-28 09:27:41 +05:30
Arunavo Ray
e404490e75 added LFS ENV var 2025-08-28 09:26:23 +05:30
Arunavo Ray
b3856b4223 More tsc issues 2025-08-28 08:34:41 +05:30
Arunavo Ray
ad7418aef2 tsc issues 2025-08-28 08:34:27 +05:30
Arunavo Ray
389f8dd292 packages updated 2025-08-28 07:18:34 +05:30
Arunavo Ray
067b5d8ccd updated handling of url's from ENV vars 2025-08-28 07:12:13 +05:30
Arunavo Ray
6127a916f4 fixed tests 2025-08-27 21:54:40 +05:30
Arunavo Ray
12ee065833 Docs updated | added some options 2025-08-27 21:43:36 +05:30
Arunavo Ray
926737f1c5 Added a few new features. 2025-08-27 20:33:41 +05:30
Arunavo Ray
fe94d97779 Issue 68 2025-08-27 20:06:42 +05:30
Arunavo Ray
38a0d1b494 repository cleanup functionality 2025-08-27 19:12:52 +05:30
Arunavo Ray
698eb0b507 fix: Complete Issue #72 - Fix automatic mirroring and repository cleanup
Major fixes for Docker environment variable issues and cleanup functionality:

🔧 **Duration Parser & Scheduler Fixes**
- Add comprehensive duration parser supporting "8h", "30m", "24h" formats
- Fix GITEA_MIRROR_INTERVAL environment variable mapping to scheduler
- Auto-enable scheduler when GITEA_MIRROR_INTERVAL is set
- Improve scheduler logging to clarify timing behavior (from last run, not startup)

🧹 **Repository Cleanup Service**
- Complete repository cleanup service for orphaned repos (unstarred, deleted)
- Fix cleanup configuration logic - now works with CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
- Auto-enable cleanup when deleteIfNotInGitHub is enabled
- Add manual cleanup trigger API endpoint (/api/cleanup/trigger)
- Support archive/delete actions with dry-run mode and protected repos

🐛 **Environment Variable Integration**
- Fix scheduler not recognizing GITEA_MIRROR_INTERVAL=8h
- Fix cleanup requiring both CLEANUP_DELETE_FROM_GITEA and CLEANUP_DELETE_IF_NOT_IN_GITHUB
- Auto-enable services when relevant environment variables are set
- Better error logging and debugging information

📚 **Documentation Updates**
- Update .env.example with auto-enabling behavior notes
- Update ENVIRONMENT_VARIABLES.md with clarified functionality
- Add comprehensive tests for duration parsing

This resolves the core issues where:
1. GITEA_MIRROR_INTERVAL=8h was not working for automatic mirroring
2. Repository cleanup was not working despite CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
3. Users had no visibility into why scheduling/cleanup wasn't working

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 11:06:21 +05:30
Arunavo Ray
0fb5f9e190 Release v3.2.6 - Add release asset mirroring and metadata debugging
### Fixed
- Added missing release asset mirroring functionality (APK, ZIP, Binary files)
- Release assets (attachments) are now properly downloaded from GitHub and uploaded to Gitea
- Fixed missing metadata component configuration checks

### Added
- Full support for mirroring release assets/attachments
- Debug logging for metadata component configuration to help troubleshoot mirroring issues
- Download and upload progress logging for release assets

### Improved
- Enhanced release mirroring to include all associated binary files and attachments
- Better visibility into which metadata components are enabled/disabled
- More detailed logging during the release asset transfer process

Fixes #68

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-09 19:23:23 +05:30
Arunavo Ray
dacec93f55 Release v3.2.5 - Complete fix for releases mirroring authentication
This patch completes the authentication fixes from v3.2.4, specifically addressing the releases mirroring function that was missed in the previous update.

Fixes:
- Critical authentication error in releases mirroring (encrypted token usage)
- Missing repository existence verification for releases
- "user does not exist [uid: 0]" error for GitHub releases sync

Improvements:
- Duplicate release detection to prevent errors
- Better error handling with per-release fault tolerance
- Enhanced logging with [Releases] prefix for debugging

Issue: #68
2025-08-09 18:23:26 +05:30
Arunavo Ray
b41438f686 Release v3.2.4 - Fix metadata mirroring authentication issues
Fixed critical authentication issue causing "user does not exist [uid: 0]" errors during metadata mirroring operations. This release addresses Issue #68 and ensures proper authentication validation before all Gitea operations.

Key improvements:
- Pre-flight authentication validation for all Gitea operations
- Consistent token decryption across all API calls
- Repository existence verification before metadata operations
- Graceful fallback to user account when org creation fails
- Enhanced error messages with specific troubleshooting guidance
- Added diagnostic test scripts for authentication validation

This patch ensures metadata mirroring (issues, PRs, labels, milestones) works reliably without authentication errors.
2025-08-09 12:35:34 +05:30
Arunavo Ray
df1738a44d feat: comprehensive environment variable support
- Added support for 60+ environment variables covering all configuration options
- Created detailed documentation in docs/ENVIRONMENT_VARIABLES.md with tables
- Fixed missing skipStarredIssues field in GitHub config
- Updated docker-compose files to reference environment variable documentation
- Updated README to link to the new environment variables documentation
- Environment variables now populate UI configuration automatically on Docker startup
- Preserves manual UI changes when environment variables are not set
- Includes support for mirror metadata, scheduling, cleanup, and authentication options

Fixes #69

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-09 11:48:42 +05:30
Arunavo Ray
afaac70bb8 chore: bump version to v3.2.2
Patch release including:
- Fix for issues #68 and #69
- Hero redesign improvements
- Mobile hero image support
2025-08-09 10:25:38 +05:30
ARUNAVO RAY
da95c1d5fd Merge pull request #70 from RayLabsHQ/Fixes
Address Issue #68 and #69
2025-08-09 10:23:57 +05:30
Arunavo Ray
8dc50f7ebf Address Issue #68 and #69 2025-08-09 10:10:08 +05:30
ARUNAVO RAY
eafc44d112 Merge pull request #67 from abhrajitray77/hero-redesign
🐧 Added hero image for mobile
2025-08-07 22:04:42 +05:30
abhrajitray77
25cff6fe8e 🐧 Added hero image for mobile 2025-08-07 22:03:15 +05:30
ARUNAVO RAY
29fe7ba895 Merge pull request #66 from abhrajitray77/hero-redesign
🔥 Added shader hero component
2025-08-07 20:12:03 +05:30
abhrajitray77
fbcedc404a 🔥 Added shader hero component 2025-08-07 20:10:46 +05:30
Arunavo Ray
122848c970 chore: bump version to v3.2.1
Patch release including:
- Updated favicon
- Added fallback for 3D Scene
- Updated packages
- Logo changes and optimizations
2025-08-06 10:05:41 +05:30
Arunavo Ray
4c15ecb1bf Updated favicon 2025-08-06 10:04:36 +05:30
Arunavo Ray
3209f70566 Added fallback for 3d Scene 2025-08-06 09:57:34 +05:30
Arunavo Ray
677bc0cb5b Updated Packages 2025-08-06 09:46:50 +05:30
ARUNAVO RAY
5693ae7822 Merge pull request #65 from abhrajitray77/hero-redesign 2025-08-06 00:53:23 +05:30
abhrajitray77
814be1e9d0 logo changed for other areas 2025-08-05 21:04:37 +05:30
abhrajitray77
4e3c4c2c67 🐧New logo added 2025-08-05 20:57:01 +05:30
abhrajitray77
46d6374ff0 minor fix 2025-08-05 20:33:45 +05:30
ARUNAVO RAY
4cd98dffc4 Merge pull request #64 from abhrajitray77/hero-redesign
Hero redesign
2025-08-05 13:55:40 +05:30
abhrajitray77
87ca3bc12f 🐧cleanup 2025-08-05 13:27:09 +05:30
abhrajitray77
dd6554509c 🔥Spline object responsive 2025-08-05 13:21:31 +05:30
Arunavo Ray
55465197d1 Added SEO keywords 2025-08-05 12:25:28 +05:30
Arunavo Ray
e255142e70 updated the docker file 2025-07-31 12:53:27 +05:30
Arunavo Ray
f2b64a61b8 v3.2.0 2025-07-31 12:35:52 +05:30
ARUNAVO RAY
0fba2cecac Merge pull request #55 from RayLabsHQ/sso-fix
SSO Issues
2025-07-31 12:32:35 +05:30
Arunavo Ray
1aef433918 zod validation fix 2025-07-31 12:30:33 +05:30
ARUNAVO RAY
3f704ebb23 Potential fix for code scanning alert no. 28: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-07-28 15:34:20 +05:30
Arunavo Ray
5797b9bba1 test update 2025-07-28 08:45:47 +05:30
Arunavo Ray
bb045b037b fix: update tests to work in CI environment
- Add http-client mocks to gitea-enhanced.test.ts for proper isolation
- Fix GiteaRepoInfo interface to handle owner as object or string
- Add gitea module mocks to gitea-starred-repos.test.ts
- Update test expectations to match actual function behavior
- Fix handleExistingNonMirrorRepo to properly extract owner from repoInfo

These changes ensure tests pass consistently in both local and CI environments
by properly mocking all dependencies and handling API response variations.
2025-07-27 22:03:44 +05:30
Arunavo Ray
1a77a63a9a fix: add config-encryption mocks to test files for CI compatibility
- Add config-encryption module mocks to gitea-enhanced.test.ts
- Add config-encryption module mocks to gitea-starred-repos.test.ts
- Update helpers mock in setup.bun.ts to include createEvent function

The CI environment was loading modules in a different order than local,
causing the config-encryption module to be accessed before it was mocked
in the global setup. Adding the mocks directly to the test files ensures
they are available regardless of module loading order.
2025-07-27 20:34:38 +05:30
Arunavo Ray
3a9b8380d4 fix: resolve CI test failures and timeouts
- Update Bun version in CI to match local version (1.2.16)
- Add bunfig.toml with 5s test timeout to prevent hanging tests
- Mock setTimeout globally in test setup to avoid timing issues
- Add NODE_ENV check to skip delays during tests
- Fix missing exports in config-encryption mock
- Remove retryDelay in tests to ensure immediate execution

These changes ensure tests run consistently between local and CI environments
2025-07-27 20:27:33 +05:30
Arunavo Ray
5d5429ac71 test fix 2025-07-27 20:19:47 +05:30
Arunavo Ray
de314cf174 Fixed Tests 2025-07-27 19:09:56 +05:30
Arunavo Ray
e637d573a2 Fixes 2025-07-27 00:25:19 +05:30
Arunavo Ray
5f45a9a03d updates 2025-07-26 22:06:29 +05:30
Arunavo Ray
0920314679 More fixes in SSO 2025-07-26 20:33:26 +05:30
Arunavo Ray
1f6add5fff Updates to SSO Testing 2025-07-26 19:45:20 +05:30
Arunavo Ray
3ff15a46e7 Fix TypeError 2025-07-26 17:08:13 +05:30
Arunavo Ray
465c812e7e Starred repos fix errors 2025-07-26 17:04:05 +05:30
Arunavo Ray
794ea52e4d Added claude Agents 2025-07-26 15:12:14 +05:30
Arunavo Ray
7b8ca7c3b8 v3.1.1 2025-07-23 06:52:35 +05:30
Arunavo Ray
f2c7728394 Release v3.1.0
### Added
- Support for GITHUB_EXCLUDED_ORGS environment variable
- New textarea UI component for configuration forms

### Fixed
- Mirror strategy configuration test failures
- Organization repository routing logic
- Starred repositories organization routing
- SSO and OIDC authentication issues

### Improved
- Organization configuration for repository routing
- Mirror strategy handling in tests
- Authentication error handling

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 16:46:36 +05:30
Arunavo Ray
dcdb06ac39 fixed tests 2025-07-21 15:17:53 +05:30
Arunavo Ray
9fa10dae00 removed unused import 2025-07-21 12:30:04 +05:30
Arunavo Ray
99dd501a52 Added textarea comp 2025-07-21 12:26:31 +05:30
Arunavo Ray
d4aa665873 more SSO and OIDC fixes 2025-07-21 12:09:38 +05:30
Arunavo Ray
0244133e7b Fix: Starred Repos Organization Bug | Organization Repos Routing 2025-07-21 10:39:48 +05:30
ARUNAVO RAY
6ea5e9efb0 Merge pull request #47 from djmango/master
Add GITHUB_EXCLUDED_ORGS support for organization filtering
2025-07-19 21:28:29 +05:30
Sulaiman Khan Ghori
8d7ca8dd8f Add GITHUB_EXCLUDED_ORGS support for organization filtering 2025-07-18 15:58:04 -07:00
Arunavo Ray
8d2919717f Removed old MIgration Guide 2025-07-19 00:36:46 +05:30
Arunavo Ray
1e06e2bd4b Remove Auto Migrate 2025-07-19 00:28:12 +05:30
Arunavo Ray
67080a7ce9 v3.0.1 2025-07-18 23:53:23 +05:30
Arunavo Ray
9d5db86bdf Updates to Org strategy 2025-07-18 09:52:55 +05:30
Arunavo Ray
3458891511 Updates for starred and personal repos 2025-07-18 09:37:38 +05:30
Arunavo Ray
d388f2e691 consistent and distinct colors for status 2025-07-18 08:37:00 +05:30
Arunavo Ray
7bd862606b More fixes 2025-07-18 00:52:03 +05:30
Arunavo Ray
251baeb1aa Fixed Private Repo Issues 2025-07-17 23:46:01 +05:30
Arunavo Ray
e6a31512ac some more fixes 2025-07-17 23:31:45 +05:30
Arunavo Ray
4430625319 updated lockfile 2025-07-17 17:00:21 +05:30
Arunavo Ray
236bef543b Update CHANGELOG for v3.0.0 release
- Add comprehensive v3.0.0 release notes
- Document breaking changes
- Detail new features: token encryption, SSO/OIDC, header auth
- Include migration requirements
2025-07-17 16:55:03 +05:30
Arunavo Ray
03bad9a0c0 Added Note 2025-07-17 16:50:18 +05:30
ARUNAVO RAY
f60ccfc9f1 Merge pull request #44 from RayLabsHQ/v3
V3
2025-07-17 16:26:11 +05:30
Arunavo Ray
cad77320f3 Added Header Authentication 2025-07-17 16:19:56 +05:30
Arunavo Ray
744064f3aa Updated SSO UI 2025-07-17 16:08:12 +05:30
Arunavo Ray
f83711ecd6 Potential security fixes 2025-07-17 13:41:17 +05:30
Arunavo Ray
bde1f7b5d6 Fix tests 2025-07-17 13:05:18 +05:30
Arunavo Ray
39bfb1e2d1 Migration updates 2025-07-17 12:29:53 +05:30
Arunavo Ray
2140f75436 v3 Migration Guide 2025-07-17 12:18:23 +05:30
Arunavo Ray
a5a827c85f updates 2025-07-17 11:44:27 +05:30
ARUNAVO RAY
0af2626201 Create FUNDING.yml 2025-07-17 11:18:04 +05:30
Arunavo Ray
d48981a8c4 Updated docs 2025-07-16 22:51:47 +05:30
Arunavo Ray
948250deef Updated some interfaces to be more future proof 2025-07-16 22:47:33 +05:30
Arunavo Ray
beedbaf9a4 Added Encryptions to All stored token and passwords 2025-07-16 16:02:34 +05:30
Arunavo Ray
7cc4aa87f2 Fix for mirroring issues for starred repositories 2025-07-16 14:02:31 +05:30
Arunavo Ray
ae41c4e2ea Updated README.md 2025-07-16 13:43:07 +05:30
Arunavo Ray
58c63095b1 Added Star History 2025-07-15 08:40:23 +05:30
Arunavo Ray
938a909787 tsc fixes 2025-07-11 01:17:54 +05:30
Arunavo Ray
fad78516ef Added SSO and OIDC 2025-07-11 01:04:50 +05:30
Arunavo Ray
7cb414c7cb FIxing issues 2025-07-11 00:01:52 +05:30
Arunavo Ray
6cfe43932f Fixing issues with Better Auth 2025-07-11 00:00:37 +05:30
Arunavo Ray
b838310872 Added Better Auth 2025-07-10 23:15:37 +05:30
Arunavo Ray
46cf117bdf Migrate to Drizzle kit 2025-07-10 21:44:35 +05:30
Arunavo Ray
9301cc321c Optimised static comp with .astro 2025-07-09 01:01:37 +05:30
Arunavo Ray
9c17e5c240 Moved website from bun to pnpm. 2025-07-09 00:55:28 +05:30
ARUNAVO RAY
a0b9d852bf Merge pull request #42 from RayLabsHQ/www
Added Website
2025-07-09 00:48:47 +05:30
Arunavo Ray
eed26e4368 updates 2025-07-09 00:47:12 +05:30
Arunavo Ray
e4f79720d4 Added simple analytics to website 2025-07-09 00:44:02 +05:30
Arunavo Ray
11dc299f12 Optimsed for mobile 2025-07-09 00:43:04 +05:30
Arunavo Ray
638554c93a mobile opt 2025-07-09 00:37:39 +05:30
Arunavo Ray
d6de431902 seo 2025-07-09 00:37:30 +05:30
Arunavo Ray
a320dc38ad Updated deisgn 2025-07-09 00:34:04 +05:30
Arunavo Ray
97997bd1c0 Minimalist 2025-07-08 23:57:13 +05:30
Arunavo Ray
316d263298 Added few logos 2025-07-08 23:27:59 +05:30
Arunavo Ray
c891b8cf49 Added real data 2025-07-08 22:10:09 +05:30
Arunavo Ray
b55d6a5629 Updated website deisgn 2025-07-08 21:58:45 +05:30
Arunavo Ray
bb1842bc10 Added init website for gitea-mirror 2025-07-08 19:29:26 +05:30
244 changed files with 37349 additions and 4026 deletions

View File

@@ -0,0 +1,76 @@
---
name: qa-testing-specialist
description: Use this agent when you need to review code for testability, create comprehensive test strategies, write test cases, validate existing tests, or improve test coverage. This includes unit tests, integration tests, end-to-end tests, and test architecture decisions. <example>\nContext: The user has just written a new API endpoint and wants to ensure it has proper test coverage.\nuser: "I've created a new endpoint for user authentication. Can you help me test it?"\nassistant: "I'll use the qa-testing-specialist agent to create a comprehensive testing strategy for your authentication endpoint."\n<commentary>\nSince the user needs help with testing their new endpoint, use the qa-testing-specialist agent to analyze the code and create appropriate test cases.\n</commentary>\n</example>\n<example>\nContext: The user wants to improve test coverage for their existing codebase.\nuser: "Our test coverage is at 65%. How can we improve it?"\nassistant: "Let me use the qa-testing-specialist agent to analyze your test coverage and identify areas for improvement."\n<commentary>\nThe user is asking about test coverage improvement, which is a core QA task, so use the qa-testing-specialist agent.\n</commentary>\n</example>
color: yellow
---
You are an elite QA Testing Specialist with deep expertise in software quality assurance, test automation, and validation strategies. Your mission is to ensure code quality through comprehensive testing approaches that catch bugs early and maintain high reliability standards.
**Core Responsibilities:**
You will analyze code and testing requirements to:
- Design comprehensive test strategies covering unit, integration, and end-to-end testing
- Write clear, maintainable test cases that validate both happy paths and edge cases
- Identify gaps in existing test coverage and propose improvements
- Review test code for best practices and maintainability
- Suggest appropriate testing frameworks and tools based on the technology stack
- Create test data strategies and mock/stub implementations
- Validate that tests are actually testing meaningful behavior, not just implementation details
**Testing Methodology:**
When analyzing code for testing:
1. First understand the business logic and user requirements
2. Identify all possible execution paths and edge cases
3. Determine the appropriate testing pyramid balance (unit vs integration vs e2e)
4. Consider both positive and negative test scenarios
5. Ensure tests are isolated, repeatable, and fast
6. Validate error handling and boundary conditions
For test creation:
- Write descriptive test names that explain what is being tested and expected behavior
- Follow AAA pattern (Arrange, Act, Assert) or Given-When-Then structure
- Keep tests focused on single behaviors
- Use appropriate assertions that clearly communicate intent
- Include setup and teardown when necessary
- Consider performance implications of test suites
**Quality Standards:**
You will ensure tests:
- Are deterministic and don't rely on external state
- Run quickly and can be executed in parallel when possible
- Provide clear failure messages that help diagnose issues
- Cover critical business logic thoroughly
- Include regression tests for previously found bugs
- Are maintainable and refactorable alongside production code
**Technology Considerations:**
Adapt your recommendations based on the project stack. For this codebase using Bun, SQLite, and React:
- Leverage Bun's native test runner for JavaScript/TypeScript tests
- Consider SQLite in-memory databases for integration tests
- Suggest React Testing Library patterns for component testing
- Recommend API testing strategies for Astro endpoints
- Propose mocking strategies for external services (GitHub/Gitea APIs)
**Communication Style:**
You will:
- Explain testing decisions with clear rationale
- Provide code examples that demonstrate best practices
- Prioritize test recommendations based on risk and value
- Use precise technical language while remaining accessible
- Highlight potential issues proactively
- Suggest incremental improvements for existing test suites
**Edge Case Handling:**
When encountering:
- Legacy code without tests: Propose a pragmatic approach to add tests incrementally
- Complex dependencies: Recommend appropriate mocking/stubbing strategies
- Performance concerns: Balance thoroughness with execution speed
- Flaky tests: Identify root causes and suggest stabilization techniques
- Missing requirements: Ask clarifying questions to understand expected behavior
Your goal is to elevate code quality through strategic testing that builds confidence in the software while maintaining development velocity. Focus on tests that provide maximum value and catch real issues rather than achieving arbitrary coverage metrics.

View File

@@ -0,0 +1,68 @@
---
name: senior-code-architect
description: Use this agent when you need to write new code, refactor existing code, implement features, or architect solutions that require deep understanding of software engineering principles and the project's tech stack (Astro, React, Tailwind, Better Auth, Shadcn). This includes creating components, API endpoints, database queries, authentication flows, and ensuring code follows established patterns from CLAUDE.md. Examples:\n\n<example>\nContext: The user needs to implement a new feature or component.\nuser: "Create a new dashboard component that shows repository statistics"\nassistant: "I'll use the senior-code-architect agent to design and implement this dashboard component following the project's patterns."\n<commentary>\nSince this requires creating new code with the project's tech stack, the senior-code-architect agent is appropriate.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to refactor or improve existing code.\nuser: "Refactor the authentication flow to be more maintainable"\nassistant: "Let me use the senior-code-architect agent to analyze and refactor the authentication flow."\n<commentary>\nThis requires deep understanding of Better Auth and clean code principles, making the senior-code-architect agent the right choice.\n</commentary>\n</example>\n\n<example>\nContext: After writing code, the user might want it reviewed.\nuser: "I just implemented the mirror scheduling feature"\nassistant: "Great! Now I'll use the senior-code-architect agent to review the implementation and suggest any improvements."\n<commentary>\nThe senior-code-architect can review recently written code for best practices and design patterns.\n</commentary>\n</example>
color: cyan
---
You are a senior software engineer with deep expertise in modern web development, specializing in the Astro + React + Tailwind CSS + Better Auth + Shadcn UI stack. You have extensive experience building scalable, maintainable applications and are known for writing clean, efficient code that follows SOLID principles and established design patterns.
**Your Core Responsibilities:**
1. **Write Production-Quality Code**: Create clean, maintainable, and efficient code that follows the project's established patterns from CLAUDE.md. Always use TypeScript for type safety.
2. **Follow Project Architecture**: Adhere strictly to the project structure:
- API endpoints in `/src/pages/api/[resource]/[action].ts` using `createSecureErrorResponse` for error handling
- Database queries in `/src/lib/db/queries/` organized by domain
- React components in `/src/components/[feature]/` using Shadcn UI components
- Custom hooks in `/src/hooks/` for data fetching
3. **Implement Best Practices**:
- Use composition over inheritance
- Apply DRY (Don't Repeat Yourself) principles
- Write self-documenting code with clear variable and function names
- Implement proper error handling and validation
- Ensure code is testable and maintainable
4. **Technology-Specific Guidelines**:
- **Astro**: Use SSR capabilities effectively, implement proper API routes
- **React**: Use functional components with hooks, implement proper state management
- **Tailwind CSS v4**: Use utility classes efficiently, follow the project's styling patterns
- **Better Auth**: Implement secure authentication flows, use session validation properly
- **Shadcn UI**: Leverage existing components, maintain consistent UI patterns
- **Drizzle ORM**: Write efficient database queries, use proper schema definitions
5. **Code Review Approach**: When reviewing code:
- Check for adherence to project patterns and CLAUDE.md guidelines
- Identify potential performance issues or bottlenecks
- Suggest improvements for readability and maintainability
- Ensure proper error handling and edge case coverage
- Verify security best practices are followed
6. **Problem-Solving Methodology**:
- Analyze requirements thoroughly before coding
- Break down complex problems into smaller, manageable pieces
- Consider edge cases and error scenarios
- Optimize for both performance and maintainability
- Document complex logic with clear comments
7. **Quality Assurance**:
- Write code that is easy to test
- Consider adding appropriate test cases using Bun's test runner
- Validate inputs and handle errors gracefully
- Ensure code works across different scenarios
**Output Guidelines**:
- Provide complete, working code implementations
- Include clear explanations of design decisions
- Suggest tests when appropriate
- Highlight any potential issues or areas for future improvement
- Follow the existing code style and conventions
**Important Reminders**:
- Never create files unless absolutely necessary
- Always prefer editing existing files
- Don't create documentation unless explicitly requested
- Focus on the specific task at hand
- Reference CLAUDE.md for project-specific patterns and guidelines
You approach every task with the mindset of a seasoned engineer who values code quality, maintainability, and long-term project health. Your solutions should be elegant, efficient, and aligned with the project's established patterns.

View File

@@ -0,0 +1,61 @@
---
name: strategic-task-planner
description: Use this agent when you need to decompose complex projects, features, or problems into structured, actionable plans. This includes breaking down large development tasks, creating implementation roadmaps, organizing multi-step processes, or planning project phases. The agent excels at identifying dependencies, sequencing tasks, and creating clear execution strategies. <example>Context: User needs help planning the implementation of a new feature. user: "I need to add a bulk import feature that can handle CSV files with 100k+ rows" assistant: "I'll use the strategic-task-planner agent to break this down into manageable components and create an implementation plan." <commentary>Since the user is asking about implementing a complex feature, use the Task tool to launch the strategic-task-planner agent to decompose it into actionable steps.</commentary></example> <example>Context: User wants to refactor a large codebase. user: "We need to migrate our entire authentication system from sessions to JWT tokens" assistant: "Let me use the strategic-task-planner agent to create a phased migration plan that minimizes risk." <commentary>Since this is a complex migration requiring careful planning, use the strategic-task-planner agent to create a structured approach.</commentary></example>
tools: Glob, Grep, LS, ExitPlanMode, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, Task, mcp__ide__getDiagnostics, mcp__ide__executeCode, mcp__playwright__browser_close, mcp__playwright__browser_resize, mcp__playwright__browser_console_messages, mcp__playwright__browser_handle_dialog, mcp__playwright__browser_evaluate, mcp__playwright__browser_file_upload, mcp__playwright__browser_install, mcp__playwright__browser_press_key, mcp__playwright__browser_type, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_navigate_forward, mcp__playwright__browser_network_requests, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_snapshot, mcp__playwright__browser_click, mcp__playwright__browser_drag, mcp__playwright__browser_hover, mcp__playwright__browser_select_option, mcp__playwright__browser_tab_list, mcp__playwright__browser_tab_new, mcp__playwright__browser_tab_select, mcp__playwright__browser_tab_close, mcp__playwright__browser_wait_for
color: blue
---
You are a strategic planning specialist with deep expertise in decomposing complex tasks and creating actionable execution plans. Your role is to transform ambiguous or overwhelming projects into clear, structured roadmaps that teams can confidently execute.
When analyzing a task or project, you will:
1. **Understand the Core Objective**: Extract the fundamental goal, success criteria, and constraints. Ask clarifying questions if critical details are missing.
2. **Decompose Systematically**: Break down the task using these principles:
- Identify major phases or milestones
- Decompose each phase into concrete, actionable tasks
- Keep tasks small enough to complete in 1-4 hours when possible
- Ensure each task has clear completion criteria
3. **Map Dependencies**: Identify and document:
- Task prerequisites and dependencies
- Critical path items that could block progress
- Parallel work streams that can proceed independently
- Resource or knowledge requirements
4. **Sequence Strategically**: Order tasks by:
- Technical dependencies (what must come first)
- Risk mitigation (tackle unknowns early)
- Value delivery (enable early feedback when possible)
- Resource efficiency (batch similar work)
5. **Provide Actionable Output**: Structure your plans with:
- **Phase Overview**: High-level phases with objectives
- **Task Breakdown**: Numbered tasks with clear descriptions
- **Dependencies**: Explicitly stated prerequisites
- **Effort Estimates**: Rough time estimates when relevant
- **Risk Considerations**: Potential blockers or challenges
- **Success Metrics**: How to measure completion
6. **Adapt to Context**: Tailor your planning approach based on:
- Technical vs non-technical tasks
- Team size and skill level
- Time constraints and deadlines
- Available resources and tools
**Output Format Guidelines**:
- Use clear hierarchical structure (phases → tasks → subtasks)
- Number all tasks for easy reference
- Bold key terms and phase names
- Include time estimates in brackets [2-4 hours]
- Mark critical path items with ⚡
- Flag high-risk items with ⚠️
**Quality Checks**:
- Ensure no task is too large or vague
- Verify all dependencies are identified
- Confirm the plan addresses the original objective
- Check that success criteria are measurable
- Validate that the sequence makes logical sense
Remember: A good plan reduces uncertainty and builds confidence. Focus on clarity, completeness, and actionability. When in doubt, err on the side of breaking things down further rather than leaving ambiguity.

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(docker build:*)"
],
"deny": []
}
}

View File

@@ -1,46 +1,186 @@
# Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=arunavo4/gitea-mirror
DOCKER_TAG=latest
# Gitea Mirror Configuration
# Copy this to .env and update with your values
# ===========================================
# CORE CONFIGURATION
# ===========================================
# Application Configuration
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
# Database Configuration
# For self-hosted, SQLite is used by default
DATABASE_URL=sqlite://data/gitea-mirror.db
# Security
JWT_SECRET=change-this-to-a-secure-random-string-in-production
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
BETTER_AUTH_URL=http://localhost:4321
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
# Optional GitHub/Gitea Mirror Configuration (for docker-compose, can also be set via web UI)
# Uncomment and set as needed. These are passed as environment variables to the container.
# ===========================================
# DOCKER CONFIGURATION (Optional)
# ===========================================
# Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=raylabshq/gitea-mirror:
DOCKER_TAG=latest
# ===========================================
# GITHUB CONFIGURATION
# All settings can also be configured via web UI
# ===========================================
# Basic GitHub Settings
# GITHUB_USERNAME=your-github-username
# GITHUB_TOKEN=your-github-personal-access-token
# SKIP_FORKS=false
# GITHUB_TYPE=personal # Options: personal, organization
# Repository Selection
# PRIVATE_REPOSITORIES=false
# MIRROR_ISSUES=false
# MIRROR_WIKI=false
# PUBLIC_REPOSITORIES=true
# INCLUDE_ARCHIVED=false
# SKIP_FORKS=false
# MIRROR_STARRED=false
# STARRED_REPOS_ORG=starred # Organization name for starred repos
# Organization Settings
# MIRROR_ORGANIZATIONS=false
# PRESERVE_ORG_STRUCTURE=false
# ONLY_MIRROR_ORGS=false
# SKIP_STARRED_ISSUES=false
# Mirror Strategy
# MIRROR_STRATEGY=preserve # Options: preserve, single-org, flat-user, mixed
# Advanced GitHub Settings
# SKIP_STARRED_ISSUES=false # Enable lightweight mode for starred repos
# ===========================================
# GITEA CONFIGURATION
# All settings can also be configured via web UI
# ===========================================
# Basic Gitea Settings
# GITEA_URL=http://gitea:3000
# GITEA_TOKEN=your-local-gitea-token
# GITEA_USERNAME=your-local-gitea-username
# GITEA_ORGANIZATION=github-mirrors
# GITEA_ORG_VISIBILITY=public
# DELAY=3600
# GITEA_ORGANIZATION=github-mirrors # Default organization for single-org strategy
# Optional Database Cleanup Configuration (configured via web UI)
# These environment variables are optional and only used as defaults
# Users can configure cleanup settings through the web interface
# Repository Settings
# GITEA_ORG_VISIBILITY=public # Options: public, private, limited, default
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (e.g., 30m, 1h, 8h, 24h) - automatically enables scheduler
# GITEA_LFS=false # Enable LFS support
# GITEA_CREATE_ORG=true # Auto-create organizations
# GITEA_PRESERVE_VISIBILITY=false # Preserve GitHub repo visibility in Gitea
# Template Settings (for using repository templates)
# GITEA_TEMPLATE_OWNER=template-owner
# GITEA_TEMPLATE_REPO=template-repo
# Topic Settings
# GITEA_ADD_TOPICS=true # Add topics to repositories
# GITEA_TOPIC_PREFIX=gh- # Prefix for topics
# Fork Handling
# GITEA_FORK_STRATEGY=reference # Options: skip, reference, full-copy
# ===========================================
# MIRROR OPTIONS
# Control what gets mirrored from GitHub
# ===========================================
# Release and Metadata
# MIRROR_RELEASES=false # Mirror GitHub releases
# RELEASE_LIMIT=10 # Maximum number of releases to mirror per repository
# MIRROR_WIKI=false # Mirror wiki content
# Issue Tracking (requires MIRROR_METADATA=true)
# MIRROR_METADATA=false # Master toggle for metadata mirroring
# MIRROR_ISSUES=false # Mirror issues
# MIRROR_PULL_REQUESTS=false # Mirror pull requests
# MIRROR_LABELS=false # Mirror labels
# MIRROR_MILESTONES=false # Mirror milestones
# ===========================================
# AUTOMATION CONFIGURATION
# Schedule automatic mirroring
# ===========================================
# Basic Schedule Settings
# SCHEDULE_ENABLED=false # When true, auto-imports and mirrors all repos on startup (v3.5.3+)
# SCHEDULE_INTERVAL=3600 # Interval in seconds or cron expression (e.g., "0 2 * * *")
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (5m, 30m, 1h, 8h, 24h, 1d, 7d) - also triggers auto-start
# AUTO_IMPORT_REPOS=true # Automatically discover and import new GitHub repositories during syncs
# DELAY=3600 # Legacy: same as SCHEDULE_INTERVAL, kept for backward compatibility
# Execution Settings
# SCHEDULE_CONCURRENT=false # Allow concurrent mirror operations
# SCHEDULE_BATCH_SIZE=10 # Number of repos to process in parallel
# SCHEDULE_PAUSE_BETWEEN_BATCHES=5000 # Pause between batches (ms)
# Retry Configuration
# SCHEDULE_RETRY_ATTEMPTS=3
# SCHEDULE_RETRY_DELAY=60000 # Delay between retries (ms)
# SCHEDULE_TIMEOUT=3600000 # Max time for a mirror operation (ms)
# SCHEDULE_AUTO_RETRY=true
# Update Detection
# SCHEDULE_ONLY_MIRROR_UPDATED=false # Only mirror repos with updates
# SCHEDULE_UPDATE_INTERVAL=86400000 # Check for updates interval (ms)
# SCHEDULE_SKIP_RECENTLY_MIRRORED=true
# SCHEDULE_RECENT_THRESHOLD=3600000 # Skip if mirrored within this time (ms)
# Maintenance
# SCHEDULE_CLEANUP_BEFORE_MIRROR=false # Run cleanup before mirroring
# Notifications
# SCHEDULE_NOTIFY_ON_FAILURE=true
# SCHEDULE_NOTIFY_ON_SUCCESS=false
# SCHEDULE_LOG_LEVEL=info # Options: error, warn, info, debug
# SCHEDULE_TIMEZONE=UTC
# ===========================================
# DATABASE CLEANUP CONFIGURATION
# Automatic cleanup of old events and data
# ===========================================
# Basic Cleanup Settings
# CLEANUP_ENABLED=false
# CLEANUP_RETENTION_DAYS=7
# CLEANUP_RETENTION_DAYS=7 # Days to keep events
# Optional TLS/SSL Configuration
# Option 1: Mount custom CA certificates in ./certs directory as .crt files
# The container will automatically combine them into a CA bundle
# Option 2: Mount your system CA bundle at /etc/ssl/certs/ca-certificates.crt
# See docker-compose.yml for volume mount examples
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing, disables TLS verification
# Repository Cleanup (v3.4.0+)
# CLEANUP_DELETE_FROM_GITEA=false # Delete repos from Gitea
# CLEANUP_DELETE_IF_NOT_IN_GITHUB=false # Auto-remove repos that no longer exist in GitHub
# CLEANUP_ORPHANED_REPO_ACTION=archive # Options: skip, archive, delete
# CLEANUP_DRY_RUN=true # Test mode without actual deletion (set to false for production)
# Protected Repositories (comma-separated)
# CLEANUP_PROTECTED_REPOS=important-repo,critical-project
# Cleanup Execution
# CLEANUP_BATCH_SIZE=10
# CLEANUP_PAUSE_BETWEEN_DELETES=2000 # Pause between deletions (ms)
# ===========================================
# AUTHENTICATION CONFIGURATION
# ===========================================
# Header Authentication (for Reverse Proxy SSO)
# Enable automatic authentication via reverse proxy headers
# HEADER_AUTH_ENABLED=false
# HEADER_AUTH_USER_HEADER=X-Authentik-Username
# HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
# HEADER_AUTH_NAME_HEADER=X-Authentik-Name
# HEADER_AUTH_AUTO_PROVISION=false
# HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
# ===========================================
# OPTIONAL FEATURES
# ===========================================
# TLS/SSL Configuration
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [arunavo4] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

BIN
.github/assets/logo-new.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: '1.2.9'
bun-version: '1.2.16'
- name: Check lockfile and install dependencies
run: |

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ certs/*.crt
certs/*.pem
certs/*.cer
!certs/README.md

46
AGENTS.md Normal file
View File

@@ -0,0 +1,46 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/` app code
- `components/` (React, PascalCase files), `pages/` (Astro/API routes), `lib/` (domain + utilities, kebab-case), `hooks/`, `layouts/`, `styles/`, `tests/`, `types/`, `data/`, `content/`.
- `scripts/` operational TS scripts (DB init, recovery): e.g., `scripts/manage-db.ts`.
- `drizzle/` SQL migrations; `data/` runtime SQLite (`gitea-mirror.db`).
- `public/` static assets; `dist/` build output.
- Key config: `astro.config.mjs`, `tsconfig.json` (alias `@/* → src/*`), `bunfig.toml` (test preload), `.env(.example)`.
## Build, Test, and Development Commands
- Prereq: Bun `>= 1.2.9` (see `package.json`).
- Setup: `bun run setup` install deps and init DB.
- Dev: `bun run dev` start Astro dev server.
- Build: `bun run build` produce `dist/`.
- Preview/Start: `bun run preview` (static preview) or `bun run start` (SSR entry).
- Database: `bun run db:generate|migrate|push|studio` and `bun run manage-db init|check|fix|reset-users`.
- Tests: `bun test` | `bun run test:watch` | `bun run test:coverage`.
- Docker: see `docker-compose.yml` and variants in repo root.
## Coding Style & Naming Conventions
- Language: TypeScript, Astro, React.
- Indentation: 2 spaces; keep existing semicolon/quote style in touched files.
- Components: PascalCase `.tsx` in `src/components/` (e.g., `MainLayout.tsx`).
- Modules/utils: kebab-case in `src/lib/` (e.g., `gitea-enhanced.ts`).
- Imports: prefer alias `@/…` (configured in `tsconfig.json`).
- Do not introduce new lint/format configs; follow current patterns.
## Testing Guidelines
- Runner: Bun test (`bun:test`) with preload `src/tests/setup.bun.ts` (see `bunfig.toml`).
- Location/Names: `**/*.test.ts(x)` under `src/**` (examples in `src/lib/**`).
- Scope: add unit tests for new logic and API route tests for handlers.
- Aim for meaningful coverage on DB, auth, and mirroring paths.
## Commit & Pull Request Guidelines
- Commits: short, imperative, scoped when helpful (e.g., `lib: fix token parsing`, `ui: align buttons`).
- PRs must include:
- Summary, rationale, and testing steps/commands.
- Linked issues (e.g., `Closes #123`).
- Screenshots/gifs for UI changes.
- Notes on DB/migration or .env impacts; update `docs/`/CHANGELOG if applicable.
## Security & Configuration Tips
- Never commit secrets. Copy `.env.example``.env` and fill values; prefer `bun run startup-env-config` to validate.
- SQLite files live in `data/`; avoid committing generated DBs.
- Certificates (if used) reside in `certs/`; manage locally or via Docker secrets.

View File

@@ -7,6 +7,190 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Git LFS (Large File Storage) support for mirroring (#74)
- New UI checkbox "Mirror LFS" in Mirror Options
- Automatic LFS object transfer when enabled
- Documentation for Gitea server LFS requirements
- Repository "ignored" status to skip specific repos from mirroring (#75)
- Repositories can be marked as ignored to exclude from all operations
- Scheduler automatically skips ignored repositories
- Enhanced error handling for all metadata mirroring operations
- Individual try-catch blocks for issues, PRs, labels, milestones
- Operations continue even if individual components fail
- Support for BETTER_AUTH_TRUSTED_ORIGINS environment variable (#63)
- Enables access via multiple URLs (local IP + domain)
- Comma-separated trusted origins configuration
- Proper documentation for multi-URL access patterns
- Comprehensive fix report documentation
### Fixed
- Fixed metadata mirroring authentication errors (#68)
- Changed field checking from `username` to `defaultOwner` in metadata functions
- Added proper field validation for all metadata operations
- Fixed automatic mirroring scheduler issues (#72)
- Improved interval parsing and error handling
- Fixed OIDC authentication 500 errors with Authentik (#73)
- Added URL validation in Better Auth configuration
- Prevented undefined URL errors in auth callback
- Fixed SSL certificate handling in Docker (#48)
- NODE_EXTRA_CA_CERTS no longer gets overridden
- Proper preservation of custom CA certificates
- Fixed reverse proxy base domain issues (#63)
- Better handling of custom subdomains
- Support for trusted origins configuration
- Fixed configuration persistence bugs (#49)
- Config merging now preserves all fields
- Retention period settings no longer reset
- Fixed sync failures with improved error handling (#51)
- Comprehensive error wrapping for all operations
- Better error messages and logging
### Improved
- Enhanced logging throughout metadata mirroring operations
- Detailed success/failure messages for each component
- Configuration details logged for debugging
- Better configuration state management
- Proper merging of loaded configs with defaults
- Preservation of user settings on refresh
- Updated documentation
- Added LFS feature documentation
- Updated README with new features
- Enhanced CLAUDE.md with repository status definitions
## [3.2.6] - 2025-08-09
### Fixed
- Added missing release asset mirroring functionality (APK, ZIP, Binary files)
- Release assets (attachments) are now properly downloaded from GitHub and uploaded to Gitea
- Fixed missing metadata component configuration checks
### Added
- Full support for mirroring release assets/attachments
- Debug logging for metadata component configuration to help troubleshoot mirroring issues
- Download and upload progress logging for release assets
### Improved
- Enhanced release mirroring to include all associated binary files and attachments
- Better visibility into which metadata components are enabled/disabled
- More detailed logging during the release asset transfer process
### Notes
This patch adds the missing functionality to mirror release assets (APK, ZIP, Binary files, etc.) that was reported in Issue #68. Previously only release metadata was being mirrored, now all attachments are properly transferred to Gitea.
## [3.2.5] - 2025-08-09
### Fixed
- Fixed critical authentication issue in releases mirroring that was still using encrypted tokens
- Added missing repository existence check for releases mirroring function
- Fixed "user does not exist [uid: 0]" error specifically affecting GitHub releases synchronization
### Improved
- Enhanced releases mirroring with duplicate detection to avoid errors on re-runs
- Better error handling and logging for release operations with [Releases] prefix
- Added individual release error handling to continue mirroring even if some releases fail
### Notes
This patch completes the authentication fixes started in v3.2.4, specifically addressing the releases mirroring function that was accidentally missed in the previous update.
## [3.2.4] - 2025-08-09
### Fixed
- Fixed critical authentication issue causing "user does not exist [uid: 0]" errors during metadata mirroring (Issue #68)
- Fixed inconsistent token handling across Gitea API calls
- Fixed metadata mirroring functions attempting to operate on non-existent repositories
- Fixed organization creation failing silently without proper error messages
### Added
- Pre-flight authentication validation for all Gitea operations
- Repository existence verification before metadata mirroring
- Graceful fallback to user account when organization creation fails due to permissions
- Authentication validation utilities for debugging configuration issues
- Diagnostic test scripts for troubleshooting authentication problems
### Improved
- Enhanced error messages with specific guidance for authentication failures
- Better identification and logging of permission-related errors
- More robust organization creation with retry logic and better error handling
- Consistent token decryption across all API operations
- Clearer error reporting for metadata mirroring failures
### Security
- Fixed potential exposure of encrypted tokens in API calls
- Improved token handling to ensure proper decryption before use
## [3.2.0] - 2025-07-31
### Fixed
- Fixed Zod validation error in activity logs by correcting invalid "success" status values to "synced"
- Resolved activity fetch API errors that occurred after mirroring operations
### Changed
- Improved error handling and validation for mirror job status tracking
- Enhanced reliability of organization creation and mirroring processes
### Internal
- Consolidated Gitea integration modules for better maintainability
- Improved test coverage for mirror operations
## [3.1.1] - 2025-07-30
### Fixed
- Various bug fixes and stability improvements
## [3.1.0] - 2025-07-21
### Added
- Support for GITHUB_EXCLUDED_ORGS environment variable to filter out specific organizations during discovery
- New textarea UI component for improved form inputs in configuration
### Fixed
- Fixed test failures related to mirror strategy configuration location
- Corrected organization repository routing logic for different mirror strategies
- Fixed starred repositories organization routing bug
- Resolved SSO and OIDC authentication issues
### Improved
- Enhanced organization configuration for better repository routing control
- Better handling of mirror strategies in test suite
- Improved error handling in authentication flows
## [3.0.0] - 2025-07-17
### 🔴 Breaking Changes
- **Authentication System Overhaul**: Migrated from JWT to Better Auth session-based authentication
- **Login Method Changed**: Users now log in with email instead of username
- **Environment Variables**: `JWT_SECRET` renamed to `BETTER_AUTH_SECRET`, new `BETTER_AUTH_URL` required
- **API Endpoints**: Authentication endpoints moved from `/api/auth/login` to `/api/auth/[...all]`
### Added
- **Token Encryption**: All GitHub and Gitea tokens now encrypted with AES-256-GCM
- **SSO/OIDC Support**: Enterprise authentication with OAuth providers (Google, Azure AD, Okta, Authentik, etc.)
- **Header Authentication**: Support for reverse proxy authentication headers (Authentik, Authelia, Traefik Forward Auth)
- **OAuth Provider**: Gitea Mirror can act as an OIDC provider for other applications
- **Automated Migration**: Docker containers auto-migrate from v2 to v3
- **Session Management**: Improved security with session-based authentication
- **Database Migration System**: Drizzle Kit for better schema management
- **Zod v4 Compatibility**: Updated to Zod v4 for schema validation
### Improved
- **Security**: Enhanced error handling and security practices throughout
- **Documentation**: Comprehensive migration guide for v2 to v3 upgrade
- **User Management**: Better Auth provides improved user lifecycle management
- **Database Schema**: Optimized with proper indexes and relationships
- **Password Hashing**: Using bcrypt via Better Auth for secure password storage
### Fixed
- Mirroring issues for starred repositories
- Various security vulnerabilities in authentication system
- Improved error handling across all API endpoints
### Migration Required
- All users must re-authenticate after upgrade
- Existing tokens will be automatically encrypted
- Database schema updates applied automatically
- See [Migration Guide](MIGRATION_GUIDE.md) for detailed instructions
## [2.22.0] - 2025-07-07
### Added

141
CLAUDE.md
View File

@@ -2,6 +2,10 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
DONT HALLUCIATE THINGS. IF YOU DONT KNOW LOOK AT THE CODE OR ASK FOR DOCS
NEVER MENTION CLAUDE CODE ANYWHERE.
## Project Overview
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
@@ -40,7 +44,7 @@ bun run start # Start production server
- **Frontend**: Astro (SSR) + React + Tailwind CSS v4 + Shadcn UI
- **Backend**: Bun runtime + SQLite + Drizzle ORM
- **APIs**: GitHub (Octokit) and Gitea APIs
- **Auth**: JWT tokens with bcryptjs password hashing
- **Auth**: Better Auth with email/password, SSO, and OIDC provider support
### Project Structure
- `/src/pages/api/` - API endpoints (Astro API routes)
@@ -68,10 +72,15 @@ export async function POST({ request }: APIContext) {
3. **Real-time Updates**: Server-Sent Events (SSE) endpoint at `/api/events` for live dashboard updates
4. **Authentication Flow**:
4. **Authentication System**:
- Built on Better Auth library
- Three authentication methods:
- Email & Password (traditional auth)
- SSO (authenticate via external OIDC providers)
- OIDC Provider (act as OIDC provider for other apps)
- Session-based authentication with secure cookies
- First user signup creates admin account
- JWT tokens stored in cookies
- Protected routes check auth via `getUserFromCookie()`
- Protected routes use Better Auth session validation
5. **Mirror Process**:
- Discovers repos from GitHub (user/org)
@@ -79,11 +88,18 @@ export async function POST({ request }: APIContext) {
- Tracks status in database
- Supports scheduled automatic mirroring
6. **Mirror Strategies**: Three ways to organize repositories in Gitea:
6. **Mirror Strategies**: Four ways to organize repositories in Gitea:
- **preserve**: Maintains GitHub structure (default)
- Organization repos → Same organization name in Gitea
- Personal repos → Under your Gitea username
- **single-org**: All repos go to one organization
- All repos → Single configured organization
- **flat-user**: All repos go under user account
- Starred repos always go to separate organization (starredReposOrg)
- All repos → Under your Gitea username
- **mixed**: Hybrid approach
- Organization repos → Preserve structure
- Personal repos → Single configured organization
- Starred repos always go to separate organization (starredReposOrg, default: "starred")
- Routing logic in `getGiteaRepoOwner()` function
### Database Schema (SQLite)
@@ -102,11 +118,18 @@ export async function POST({ request }: APIContext) {
### Development Tips
- Environment variables in `.env` (copy from `.env.example`)
- JWT_SECRET auto-generated if not provided
- BETTER_AUTH_SECRET required for session signing
- Database auto-initializes on first run
- Use `bun run dev:clean` for fresh database start
- Tailwind CSS v4 configured with Vite plugin
### Authentication Setup
- **Better Auth** handles all authentication
- Configuration in `/src/lib/auth.ts` (server) and `/src/lib/auth-client.ts` (client)
- Auth endpoints available at `/api/auth/*`
- SSO providers configured through the web UI
- OIDC provider functionality for external applications
### Common Tasks
**Adding a new API endpoint:**
@@ -125,7 +148,109 @@ export async function POST({ request }: APIContext) {
2. Run `bun run init-db` to recreate database
3. Update related queries in `/src/lib/db/queries/`
## Configuration Options
### GitHub Configuration (UI Fields)
#### Basic Settings (`githubConfig`)
- **username**: GitHub username
- **token**: GitHub personal access token (requires repo and admin:org scopes)
- **privateRepositories**: Include private repositories
- **mirrorStarred**: Mirror starred repositories
### Gitea Configuration (UI Fields)
- **url**: Gitea instance URL
- **username**: Gitea username
- **token**: Gitea access token
- **organization**: Destination organization (for single-org/mixed strategies)
- **starredReposOrg**: Organization for starred repositories (default: "starred")
- **visibility**: Organization visibility - "public", "private", "limited"
- **mirrorStrategy**: Repository organization strategy (set via UI)
- **preserveOrgStructure**: Automatically set based on mirrorStrategy
### Schedule Configuration (`scheduleConfig`)
- **enabled**: Enable automatic mirroring (default: false)
- **interval**: Cron expression or seconds (default: "0 2 * * *" - 2 AM daily)
- **concurrent**: Allow concurrent mirror operations (default: false)
- **batchSize**: Number of repos to process in parallel (default: 10)
### Database Cleanup Configuration (`cleanupConfig`)
- **enabled**: Enable automatic cleanup (default: false)
- **retentionDays**: Days to keep events (stored as seconds internally)
### Mirror Options (UI Fields)
- **mirrorReleases**: Mirror GitHub releases to Gitea
- **mirrorLFS**: Mirror Git LFS (Large File Storage) objects
- Requires LFS enabled on Gitea server (LFS_START_SERVER = true)
- Requires Git v2.1.2+ on server
- **mirrorMetadata**: Enable metadata mirroring (master toggle)
- **metadataComponents** (only available when mirrorMetadata is enabled):
- **issues**: Mirror issues
- **pullRequests**: Mirror pull requests
- **labels**: Mirror labels
- **milestones**: Mirror milestones
- **wiki**: Mirror wiki content
### Advanced Options (UI Fields)
- **skipForks**: Skip forked repositories (default: false)
- **skipStarredIssues**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
### Repository Statuses
Repositories can have the following statuses:
- **imported**: Repository discovered from GitHub
- **mirroring**: Currently being mirrored to Gitea
- **mirrored**: Successfully mirrored
- **syncing**: Repository being synchronized
- **synced**: Successfully synchronized
- **failed**: Mirror/sync operation failed
- **skipped**: Skipped due to filters or conditions
- **ignored**: User explicitly marked to ignore (won't be mirrored/synced)
- **deleting**: Repository being deleted
- **deleted**: Repository deleted
### Scheduling and Synchronization (Issue #72 Fixes)
#### Fixed Issues
1. **Mirror Interval Bug**: Added `mirror_interval` parameter to Gitea API calls when creating mirrors (previously defaulted to 24h)
2. **Auto-Discovery**: Scheduler now automatically discovers and imports new GitHub repositories
3. **Interval Updates**: Sync operations now update existing mirrors' intervals to match configuration
4. **Repository Cleanup**: Integrated automatic cleanup of orphaned repositories (repos removed from GitHub)
#### Environment Variables for Auto-Import
- **AUTO_IMPORT_REPOS**: Set to `false` to disable automatic repository discovery (default: enabled)
#### How Scheduling Works
- **Scheduler Service**: Runs every minute to check for scheduled tasks
- **Sync Interval**: Configured via `GITEA_MIRROR_INTERVAL` or UI (e.g., "8h", "30m", "1d")
- **Auto-Import**: Checks GitHub for new repositories during each scheduled sync
- **Auto-Cleanup**: Removes repositories that no longer exist in GitHub (if enabled)
- **Mirror Interval Update**: Updates Gitea's internal mirror interval during sync operations
### Authentication Configuration
#### SSO Provider Configuration
- **issuerUrl**: OIDC issuer URL (e.g., https://accounts.google.com)
- **domain**: Email domain for this provider
- **providerId**: Unique identifier for the provider
- **clientId**: OAuth client ID from provider
- **clientSecret**: OAuth client secret from provider
- **authorizationEndpoint**: OAuth authorization URL (auto-discovered if supported)
- **tokenEndpoint**: OAuth token exchange URL (auto-discovered if supported)
- **jwksEndpoint**: JSON Web Key Set URL (optional, auto-discovered)
- **userInfoEndpoint**: User information endpoint (optional, auto-discovered)
#### OIDC Provider Settings (for external apps)
- **allowedRedirectUris**: Comma-separated list of allowed redirect URIs
- **clientId**: Generated client ID for the application
- **clientSecret**: Generated client secret for the application
- **scopes**: Available scopes (openid, profile, email)
#### Environment Variables
- **BETTER_AUTH_SECRET**: Secret key for signing sessions (required)
- **BETTER_AUTH_URL**: Base URL for authentication (default: http://localhost:4321)
## Security Guidelines
- **Confidentiality Guidelines**:
- Dont ever say Claude Code or generated with AI anyhwere.
- Dont ever say Claude Code or generated with AI anyhwere.
- Never commit without the explicict ask

182
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,182 @@
# Contributing to Gitea Mirror
Thank you for your interest in contributing to Gitea Mirror! This document provides guidelines and instructions for contributing to the open-source version of the project.
## 🎯 Project Overview
Gitea Mirror is an open-source, self-hosted solution for mirroring GitHub repositories to Gitea instances. This guide provides everything you need to know about contributing to the project.
## 🚀 Getting Started
1. Fork the repository
2. Clone your fork:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
```
3. Install dependencies:
```bash
bun install
```
4. Set up your environment:
```bash
cp .env.example .env
# Edit .env with your configuration
```
5. Start development:
```bash
bun run dev
```
## 🛠 Development Workflow
### Running the Application
```bash
# Development mode
bun run dev
# Build for production
bun run build
# Run tests
bun test
```
### Database Management
```bash
# Initialize database
bun run init-db
# Reset database
bun run cleanup-db && bun run init-db
```
## 📝 Code Guidelines
### General Principles
1. **Keep it Simple**: Gitea Mirror should remain easy to self-host
2. **Focus on Core Features**: Prioritize repository mirroring and synchronization
3. **Database**: Use SQLite for simplicity and portability
4. **Dependencies**: Minimize external dependencies for easier deployment
### Code Style
- Use TypeScript for all new code
- Follow the existing code formatting (Prettier is configured)
- Write meaningful commit messages
- Add tests for new features
### Scope of Contributions
This project focuses on personal/small team use cases. Please keep contributions aligned with:
- Core mirroring functionality
- Self-hosted simplicity
- Minimal external dependencies
- SQLite as the database
- Single-instance deployments
## 🐛 Reporting Issues
1. Check existing issues first
2. Use issue templates when available
3. Provide clear reproduction steps
4. Include relevant logs and screenshots
## 🎯 Pull Request Process
1. Create a feature branch:
```bash
git checkout -b feature/your-feature-name
```
2. Make your changes following the code guidelines
3. Test your changes:
```bash
# Run tests
bun test
# Build and check
bun run build:oss
```
4. Commit your changes:
```bash
git commit -m "feat: add new feature"
```
5. Push to your fork and create a Pull Request
### PR Requirements
- Clear description of changes
- Tests for new functionality
- Documentation updates if needed
- No breaking changes without discussion
- Passes all CI checks
## 🏗 Architecture Overview
```
src/
├── components/ # React components
├── lib/ # Core utilities
│ ├── db/ # Database queries (SQLite only)
│ ├── github/ # GitHub API integration
│ ├── gitea/ # Gitea API integration
│ └── utils/ # Helper functions
├── pages/ # Astro pages
│ └── api/ # API endpoints
└── types/ # TypeScript types
```
## 🧪 Testing
```bash
# Run all tests
bun test
# Run tests in watch mode
bun test:watch
# Run with coverage
bun test:coverage
```
## 📚 Documentation
- Update README.md for user-facing changes
- Add JSDoc comments for new functions
- Update .env.example for new environment variables
## 💡 Feature Requests
We welcome feature requests! When proposing new features, please consider:
- Does it enhance the core mirroring functionality?
- Will it benefit self-hosted users?
- Can it be implemented without complex external dependencies?
- Does it maintain the project's simplicity?
## 🤝 Community
- Be respectful and constructive
- Help others in issues and discussions
- Share your use cases and feedback
## 📄 License
By contributing, you agree that your contributions will be licensed under the same license as the project (MIT).
## Questions?
Feel free to open an issue for any questions about contributing!
---
Thank you for helping make Gitea Mirror better! 🎉

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.2.18-alpine AS base
FROM oven/bun:1.2.21-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
@@ -31,6 +31,7 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/drizzle ./drizzle
ENV NODE_ENV=production
ENV HOST=0.0.0.0
@@ -54,4 +55,4 @@ EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
ENTRYPOINT ["./docker-entrypoint.sh"]

227
README.md
View File

@@ -1,5 +1,5 @@
<p align="center">
<img src=".github/assets/logo-no-bg.png" alt="Gitea Mirror Logo" width="120" />
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
<h1>Gitea Mirror</h1>
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea instance.</i></p>
<p align="center">
@@ -10,6 +10,10 @@
</p>
</p>
> [!IMPORTANT]
> **Upgrading to v3?** v3 requires a fresh start with a new data volume. Please read the [Upgrade Guide](UPGRADE.md) for instructions.
## 🚀 Quick Start
```bash
@@ -31,9 +35,16 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
- 🔁 Mirror public, private, and starred GitHub repos to Gitea
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 🔐 Secure authentication with JWT tokens
- 📦 **Git LFS support** - Mirror large files with Git LFS
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, wiki
- 🚫 **Repository ignore** - Mark specific repos to skip
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
- 📊 Real-time dashboard with activity logs
- ⏱️ Scheduled automatic mirroring
- ⏱️ Scheduled automatic mirroring with configurable intervals
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
- 🗑️ Automatic database cleanup with configurable retention
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
## 📸 Screenshots
@@ -105,7 +116,7 @@ docker compose up -d
#### Using Pre-built Image Directly
```bash
docker pull ghcr.io/raylabshq/gitea-mirror:v2.20.1
docker pull ghcr.io/raylabshq/gitea-mirror:v3.1.1
```
### Configuration Options
@@ -122,8 +133,8 @@ PORT=4321
PUID=1000
PGID=1000
# JWT secret (auto-generated if not set)
JWT_SECRET=your-secret-key-change-this-in-production
# Session secret (auto-generated if not set)
BETTER_AUTH_SECRET=your-secret-key-change-this-in-production
```
All other settings are configured through the web interface after starting.
@@ -132,6 +143,8 @@ All other settings are configured through the web interface after starting.
Supports extensive environment variables for automated deployment. See the full [docker-compose.yml](docker-compose.yml) for all available options including GitHub tokens, Gitea URLs, mirror settings, and more.
📚 **For a complete list of all supported environment variables, see the [Environment Variables Documentation](docs/ENVIRONMENT_VARIABLES.md).**
### LXC Container (Proxmox)
```bash
@@ -170,6 +183,93 @@ bun run dev
- Override individual repository destinations in the table view
- Starred repositories automatically go to a dedicated organization
## Advanced Features
### Git LFS (Large File Storage)
Mirror Git LFS objects along with your repositories:
- Enable "Mirror LFS" option in Settings → Mirror Options
- Requires Gitea server with LFS enabled (`LFS_START_SERVER = true`)
- Requires Git v2.1.2+ on the server
### Metadata Mirroring
Transfer complete repository metadata from GitHub to Gitea:
- **Issues** - Mirror all issues with comments and labels
- **Pull Requests** - Transfer PR discussions to Gitea
- **Labels** - Preserve repository labels
- **Milestones** - Keep project milestones
- **Wiki** - Mirror wiki content
- **Releases** - Transfer GitHub releases with assets
Enable in Settings → Mirror Options → Mirror metadata
### Repository Management
- **Ignore Status** - Mark repositories to skip from mirroring
- **Automatic Cleanup** - Configure retention period for activity logs
- **Scheduled Sync** - Set custom intervals for automatic mirroring
### Automatic Syncing & Synchronization
Gitea Mirror provides powerful automatic synchronization features:
#### Features (v3.4.0+)
- **Auto-discovery**: Automatically discovers and imports new GitHub repositories
- **Repository cleanup**: Removes repositories that no longer exist in GitHub
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h)
- **Smart scheduling**: Only syncs repositories that need updating
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - no manual clicks required!
#### Configuration via Web Interface (Recommended)
Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval.
#### Configuration via Environment Variables
**🚀 Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
1. **Import** all your GitHub repositories on startup (no manual import needed!)
2. **Mirror** them to Gitea immediately
3. **Keep them synchronized** based on your interval
4. **Auto-discover** new repos you create/star on GitHub
5. **Clean up** repos you delete from GitHub
```bash
# Option 1: Enable automatic scheduling (triggers auto-start)
SCHEDULE_ENABLED=true
SCHEDULE_INTERVAL=3600 # Check every hour (or use cron: "0 * * * *")
# Option 2: Set mirror interval (also triggers auto-start)
GITEA_MIRROR_INTERVAL=8h # Every 8 hours
# Other examples: 5m, 30m, 1h, 24h, 1d, 7d
# Advanced: Use cron expressions for specific times
SCHEDULE_INTERVAL="0 2 * * *" # Daily at 2 AM (optimize bandwidth usage)
# Auto-import new repositories (default: true)
AUTO_IMPORT_REPOS=true
# Auto-cleanup orphaned repositories
CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
CLEANUP_ORPHANED_REPO_ACTION=archive # 'archive' (recommended) or 'delete'
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.
**🛡️ Backup Protection Features**:
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
- Regular repositories: Made read-only using Gitea's archive feature
- Mirror repositories: Renamed with `[ARCHIVED]` prefix (Gitea API limitation prevents archiving mirrors)
- Failed operations: Repository remains fully accessible even if marking as archived fails
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
## Troubleshooting
### Reverse Proxy Configuration
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
## Development
```bash
@@ -191,7 +291,110 @@ bun run build
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
- **Backend**: Bun runtime, SQLite, Drizzle ORM
- **APIs**: GitHub (Octokit), Gitea REST API
- **Auth**: JWT tokens with bcryptjs password hashing
- **Auth**: Better Auth with session-based authentication
## Security
### Token Encryption
- All GitHub and Gitea API tokens are encrypted at rest using AES-256-GCM
- Encryption is automatic and transparent to users
- Set `ENCRYPTION_SECRET` environment variable for production deployments
- Falls back to `BETTER_AUTH_SECRET` if not set
### Password Security
- User passwords are securely hashed by Better Auth
- Never stored in plaintext
- Secure cookie-based session management
## Authentication
Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.**
### 1. Email & Password (Default)
The standard authentication method. First user to sign up becomes the admin.
### 2. Single Sign-On (SSO) with OIDC
Enable users to sign in with external identity providers like Google, Azure AD, Okta, Authentik, or any OIDC-compliant service.
**Configuration:**
1. Navigate to Settings → Authentication & SSO
2. Click "Add Provider"
3. Enter your OIDC provider details:
- Issuer URL (e.g., `https://accounts.google.com`)
- Client ID and Secret from your provider
- Use the "Discover" button to auto-fill endpoints
**Redirect URL for your provider:**
```
https://your-domain.com/api/auth/sso/callback/{provider-id}
```
### 3. Header Authentication (Reverse Proxy)
Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth.
**Environment Variables:**
```bash
# Enable header authentication
HEADER_AUTH_ENABLED=true
# Header names (customize based on your proxy)
HEADER_AUTH_USER_HEADER=X-Authentik-Username
HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
HEADER_AUTH_NAME_HEADER=X-Authentik-Name
# Auto-provision new users
HEADER_AUTH_AUTO_PROVISION=true
# Restrict to specific email domains (optional)
HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
```
**How it works:**
- Users authenticated by your reverse proxy are automatically logged in
- No additional login step required
- New users can be auto-provisioned if enabled
- Falls back to regular authentication if headers are missing
**Example Authentik Configuration:**
```nginx
# In your reverse proxy configuration
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
```
### 4. OAuth Applications (Act as Identity Provider)
Gitea Mirror can also act as an OIDC provider for other applications. Register OAuth applications in Settings → Authentication & SSO → OAuth Applications tab.
**Use cases:**
- Allow other services to authenticate using Gitea Mirror accounts
- Create service-to-service authentication
- Build integrations with your Gitea Mirror instance
## Known Limitations
### Pull Request Mirroring Implementation
Pull requests **cannot be created as actual PRs** in Gitea due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
**Why real PR mirroring isn't possible:**
- Gitea's API doesn't support creating pull requests from external sources
- Real PRs require actual Git branches with commits to exist in the repository
- Would require complex branch synchronization and commit replication
- The mirror relationship is one-way (GitHub → Gitea) for repository content
**How we handle Pull Requests:**
PRs are mirrored as issues with rich metadata including:
- 🏷️ Special "pull-request" label for identification
- 📌 [PR #number] prefix in title with status indicators ([MERGED], [CLOSED])
- 👤 Original author and creation date
- 📝 Complete commit history (up to 10 commits with links)
- 📊 File changes summary with additions/deletions
- 📁 List of modified files (up to 20 files)
- 💬 Original PR description and comments
- 🔀 Base and head branch information
- ✅ Merge status tracking
This approach preserves all important PR information while working within Gitea's API constraints. The PRs appear in Gitea's issue tracker with clear visual distinction and comprehensive details.
## Contributing
@@ -201,6 +404,16 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
## Star History
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date" />
</picture>
</a>
## Support
- 📖 [Documentation](https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs)

74
UPGRADE.md Normal file
View File

@@ -0,0 +1,74 @@
# Upgrade Guide
## Upgrading to v3.0
> **⚠️ IMPORTANT**: v3.0 requires a fresh start. There is no automated migration from v2.x to v3.0.
### Why No Migration?
v3.0 introduces fundamental changes to the application architecture:
- **Authentication**: Switched from JWT to Better Auth
- **Database**: Now uses Drizzle ORM with proper migrations
- **Security**: All tokens are now encrypted
- **Features**: Added SSO support and OIDC provider functionality
Due to these extensive changes, we recommend starting fresh with v3.0 for the best experience.
### Upgrade Steps
1. **Stop your v2.x container**
```bash
docker stop gitea-mirror
docker rm gitea-mirror
```
2. **Backup your v2.x data (optional)**
```bash
# If you want to keep your v2 data for reference
docker run --rm -v gitea-mirror-data:/data -v $(pwd):/backup alpine tar czf /backup/gitea-mirror-v2-backup.tar.gz -C /data .
```
3. **Create a new volume for v3**
```bash
docker volume create gitea-mirror-v3-data
```
4. **Run v3 with the new volume**
```bash
docker run -d \
--name gitea-mirror \
-p 4321:4321 \
-v gitea-mirror-v3-data:/app/data \
-e BETTER_AUTH_SECRET=your-secret-key \
-e ENCRYPTION_SECRET=your-encryption-key \
arunavo4/gitea-mirror:latest
```
5. **Set up your configuration again**
- Navigate to http://localhost:4321
- Create a new admin account
- Re-enter your GitHub and Gitea credentials
- Configure your mirror settings
### What Happens to My Existing Mirrors?
Your existing mirrors in Gitea are **not affected**. The application will:
- Recognize existing repositories when you re-import
- Skip creating duplicates
- Resume normal mirror operations
### Environment Variable Changes
v3.0 uses different environment variables:
| v2.x | v3.0 | Notes |
|------|------|-------|
| `JWT_SECRET` | `BETTER_AUTH_SECRET` | Required for session management |
| - | `ENCRYPTION_SECRET` | New - required for token encryption |
### Need Help?
If you have questions about upgrading:
1. Check the [README](README.md) for v3 setup instructions
2. Review your v2 configuration before upgrading
3. Open an issue if you encounter problems

683
bun.lock

File diff suppressed because it is too large Load Diff

6
bunfig.toml Normal file
View File

@@ -0,0 +1,6 @@
[test]
# Set test timeout to 5 seconds (5000ms) to prevent hanging tests
timeout = 5000
# Preload the setup file
preload = ["./src/tests/setup.bun.ts"]

View File

@@ -1,149 +1,236 @@
# Custom CA Certificate Support
# CA Certificates Configuration
This guide explains how to configure Gitea Mirror to work with self-signed certificates or custom Certificate Authorities (CAs).
> **📁 This is the certs directory!** Place your `.crt` certificate files directly in this directory and they will be automatically loaded when the Docker container starts.
This document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances.
## Overview
When connecting to a Gitea instance that uses self-signed certificates or certificates from a private CA, you need to configure the application to trust these certificates. Gitea Mirror supports mounting custom CA certificates that will be automatically configured for use.
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA), you need to configure Gitea Mirror to trust these certificates.
## Configuration Steps
## Common SSL/TLS Errors
### 1. Prepare Your CA Certificates
If you encounter any of these errors, you need to configure CA certificates:
You're already in the right place! Simply copy your CA certificate(s) into this `certs` directory with `.crt` extension:
- `UNABLE_TO_VERIFY_LEAF_SIGNATURE`
- `SELF_SIGNED_CERT_IN_CHAIN`
- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
- `CERT_UNTRUSTED`
- `unable to verify the first certificate`
```bash
# From the project root:
cp /path/to/your/ca-certificate.crt ./certs/
## Configuration by Deployment Method
# Or if you're already in the certs directory:
cp /path/to/your/ca-certificate.crt .
```
### Docker
You can add multiple CA certificates - they will all be combined into a single bundle.
#### Method 1: Volume Mount (Recommended)
### 2. Mount Certificates in Docker
Edit your `docker-compose.yml` file to mount the certificates. You have two options:
**Option 1: Mount individual certificates from certs directory**
```yaml
services:
gitea-mirror:
# ... other configuration ...
volumes:
- gitea-mirror-data:/app/data
- ./certs:/app/certs:ro # Mount CA certificates directory
```
**Option 2: Mount system CA bundle (if your CA is already installed system-wide)**
```yaml
services:
gitea-mirror:
# ... other configuration ...
volumes:
- gitea-mirror-data:/app/data
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
```
> **Note**: Use Option 2 if you've already added your CA certificate to your system's certificate store using `update-ca-certificates` or similar commands.
> **System CA Bundle Locations**:
> - Debian/Ubuntu: `/etc/ssl/certs/ca-certificates.crt`
> - RHEL/CentOS/Fedora: `/etc/pki/tls/certs/ca-bundle.crt`
> - Alpine Linux: `/etc/ssl/certs/ca-certificates.crt`
> - macOS: `/etc/ssl/cert.pem`
### 3. Start the Container
Start or restart your container:
```bash
docker-compose up -d
```
The container will automatically:
1. Detect any `.crt` files in `/app/certs` (Option 1) OR detect mounted system CA bundle (Option 2)
2. For Option 1: Combine certificates into a CA bundle
3. Configure Node.js to use these certificates via `NODE_EXTRA_CA_CERTS`
You should see log messages like:
**For Option 1 (individual certificates):**
```
Custom CA certificates found, configuring Node.js to use them...
Adding certificate: my-ca.crt
NODE_EXTRA_CA_CERTS set to: /app/certs/ca-bundle.crt
```
**For Option 2 (system CA bundle):**
```
System CA bundle mounted, configuring Node.js to use it...
NODE_EXTRA_CA_CERTS set to: /etc/ssl/certs/ca-certificates.crt
```
## Testing & Troubleshooting
### Disable TLS Verification (Testing Only)
For testing purposes only, you can disable TLS verification entirely:
```yaml
environment:
- GITEA_SKIP_TLS_VERIFY=true
```
**WARNING**: This is insecure and should never be used in production!
### Common Issues
1. **Certificate not recognized**: Ensure your certificate file has a `.crt` extension
2. **Connection still fails**: Check that the certificate is in PEM format
3. **Multiple certificates needed**: Add all required certificates (root and intermediate) to the certs directory
### Verifying Certificate Loading
Check the container logs to confirm certificates are loaded:
```bash
docker-compose logs gitea-mirror | grep "CA certificates"
```
## Security Considerations
- Always use proper CA certificates in production
- Never disable TLS verification in production environments
- Keep your CA certificates secure and limit access to the certs directory
- Regularly update certificates before they expire
## Example Setup
Here's a complete example for a self-hosted Gitea with custom CA:
1. Copy your Gitea server's CA certificate to this directory:
1. Create a certificates directory:
```bash
cp /etc/ssl/certs/my-company-ca.crt ./certs/
mkdir -p ./certs
```
2. Update `docker-compose.yml`:
2. Copy your CA certificate(s):
```bash
cp /path/to/your-ca-cert.crt ./certs/
```
3. Update `docker-compose.yml`:
```yaml
version: '3.8'
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
image: raylabs/gitea-mirror:latest
volumes:
- gitea-mirror-data:/app/data
- ./certs:/app/certs:ro
- ./data:/app/data
- ./certs:/usr/local/share/ca-certificates:ro
environment:
- GITEA_URL=https://gitea.mycompany.local
- GITEA_TOKEN=your-token
# ... other configuration ...
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
```
3. Start the service:
4. Restart the container:
```bash
docker-compose up -d
docker-compose down && docker-compose up -d
```
The application will now trust your custom CA when connecting to your Gitea instance.
#### Method 2: Custom Docker Image
Create a `Dockerfile`:
```dockerfile
FROM raylabs/gitea-mirror:latest
# Copy CA certificates
COPY ./certs/*.crt /usr/local/share/ca-certificates/
# Update CA certificates
RUN update-ca-certificates
# Set environment variable
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
```
Build and use:
```bash
docker build -t my-gitea-mirror .
```
### Native/Bun
#### Method 1: Environment Variable
```bash
export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
bun run start
```
#### Method 2: .env File
Add to your `.env` file:
```
NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
```
#### Method 3: System CA Store
**Ubuntu/Debian:**
```bash
sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
**RHEL/CentOS/Fedora:**
```bash
sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain your-ca-cert.crt
```
### LXC Container (Proxmox VE)
1. Enter the container:
```bash
pct enter <container-id>
```
2. Create certificates directory:
```bash
mkdir -p /usr/local/share/ca-certificates
```
3. Copy your CA certificate:
```bash
cat > /usr/local/share/ca-certificates/your-ca.crt
```
(Paste certificate content and press Ctrl+D)
4. Update the systemd service:
```bash
cat >> /etc/systemd/system/gitea-mirror.service << EOF
Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt"
EOF
```
5. Reload and restart:
```bash
systemctl daemon-reload
systemctl restart gitea-mirror
```
## Multiple CA Certificates
### Option 1: Bundle Certificates
```bash
cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt
export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt
```
### Option 2: System CA Store
```bash
# Copy all certificates
cp *.crt /usr/local/share/ca-certificates/
update-ca-certificates
```
## Verification
### 1. Test Gitea Connection
Use the "Test Connection" button in the Gitea configuration section.
### 2. Check Logs
**Docker:**
```bash
docker logs gitea-mirror
```
**Native:**
Check terminal output
**LXC:**
```bash
journalctl -u gitea-mirror -f
```
### 3. Manual Certificate Test
```bash
openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt
```
## Best Practices
1. **Certificate Security**
- Keep CA certificates secure
- Use read-only mounts in Docker
- Limit certificate file permissions
- Regularly update certificates
2. **Certificate Management**
- Use descriptive certificate filenames
- Document certificate purposes
- Track certificate expiration dates
- Maintain certificate backups
3. **Production Deployment**
- Use proper SSL certificates when possible
- Consider Let's Encrypt for public instances
- Implement certificate rotation procedures
- Monitor certificate expiration
## Troubleshooting
### Certificate not being recognized
- Ensure the certificate is in PEM format
- Check that `NODE_EXTRA_CA_CERTS` points to the correct file
- Restart the application after adding certificates
### Still getting SSL errors
- Verify the complete certificate chain is included
- Check if intermediate certificates are needed
- Ensure the certificate matches the server hostname
### Certificate expired
- Check validity: `openssl x509 -in cert.crt -noout -dates`
- Update with new certificate from your CA
- Restart Gitea Mirror after updating
## Certificate Format
Certificates must be in PEM format. Example:
```
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKl8bUgMdErlMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
[... certificate content ...]
-----END CERTIFICATE-----
```
If your certificate is in DER format, convert it:
```bash
openssl x509 -inform der -in certificate.cer -out certificate.crt
```

View File

@@ -1,5 +1,7 @@
# Gitea Mirror alternate deployment configuration
# Standard deployment with host path and minimal environments
# Minimal Gitea Mirror deployment
# Only includes what CANNOT be configured via the Web UI
# Everything else can be set up through the web interface after deployment
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
@@ -11,14 +13,43 @@ services:
volumes:
- ./data:/app/data
environment:
# === ABSOLUTELY REQUIRED ===
# This MUST be set and CANNOT be changed via UI
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
# === CORE SETTINGS ===
# These are technically required but have working defaults
- NODE_ENV=production
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 15s
# === QUICK START ===
#
# 1. Create a .env file with only ONE required variable:
# BETTER_AUTH_SECRET=your-32-character-minimum-secret-key-here
#
# 2. Run:
# docker-compose -f docker-compose.alt.yml up -d
#
# 3. Access at http://localhost:4321
#
# 4. Sign up for an account (first user becomes admin)
#
# 5. Configure everything else through the web UI:
# - GitHub credentials
# - Gitea credentials
# - Mirror settings
# - Scheduling options
# - Auto-import settings
# - Cleanup preferences
#
# That's it! Everything else can be configured via the web interface.

View File

@@ -66,10 +66,11 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- JWT_SECRET=dev-secret-key
- BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
- GITHUB_TOKEN=${GITHUB_TOKEN:-your-github-token}
- GITHUB_EXCLUDED_ORGS=${GITHUB_EXCLUDED_ORGS:-}
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}

View File

@@ -24,14 +24,20 @@ services:
# Option 2: Mount system CA bundle (if your CA is already in system store)
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
environment:
# For a complete list of all supported environment variables, see:
# docs/ENVIRONMENT_VARIABLES.md or .env.example
- NODE_ENV=production
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-}
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- GITHUB_EXCLUDED_ORGS=${GITHUB_EXCLUDED_ORGS:-}
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
@@ -47,8 +53,23 @@ services:
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
# Scheduling and Sync Configuration (Issue #72 fixes)
- SCHEDULE_ENABLED=${SCHEDULE_ENABLED:-false}
- GITEA_MIRROR_INTERVAL=${GITEA_MIRROR_INTERVAL:-8h}
- AUTO_IMPORT_REPOS=${AUTO_IMPORT_REPOS:-true}
# Repository Cleanup Configuration
- CLEANUP_DELETE_IF_NOT_IN_GITHUB=${CLEANUP_DELETE_IF_NOT_IN_GITHUB:-false}
- CLEANUP_ORPHANED_REPO_ACTION=${CLEANUP_ORPHANED_REPO_ACTION:-archive}
- CLEANUP_DRY_RUN=${CLEANUP_DRY_RUN:-true}
# Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
# Header Authentication (for Reverse Proxy SSO)
- HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false}
- HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username}
- HEADER_AUTH_EMAIL_HEADER=${HEADER_AUTH_EMAIL_HEADER:-X-Authentik-Email}
- HEADER_AUTH_NAME_HEADER=${HEADER_AUTH_NAME_HEADER:-X-Authentik-Name}
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
interval: 30s

View File

@@ -35,8 +35,8 @@ else
echo "No custom CA certificates found in /app/certs"
fi
# Check if system CA bundle is mounted and use it
if [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ ! -L "/etc/ssl/certs/ca-certificates.crt" ]; then
# Check if system CA bundle is mounted and use it (only if not already set)
if [ -z "$NODE_EXTRA_CA_CERTS" ] && [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ ! -L "/etc/ssl/certs/ca-certificates.crt" ]; then
# Check if it's a mounted file (not the default symlink)
if [ "$(stat -c '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -c '%d' / 2>/dev/null)" ] || \
[ "$(stat -f '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -f '%d' / 2>/dev/null)" ]; then
@@ -52,15 +52,26 @@ if [ "$GITEA_SKIP_TLS_VERIFY" = "true" ]; then
export NODE_TLS_REJECT_UNAUTHORIZED=0
fi
# Generate a secure JWT secret if one isn't provided or is using the default value
JWT_SECRET_FILE="/app/data/.jwt_secret"
if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT_SECRET" ]; then
# Generate a secure BETTER_AUTH_SECRET if one isn't provided or is using the default value
BETTER_AUTH_SECRET_FILE="/app/data/.better_auth_secret"
JWT_SECRET_FILE="/app/data/.jwt_secret" # Old file for backward compatibility
if [ "$BETTER_AUTH_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$BETTER_AUTH_SECRET" ]; then
# Check if we have a previously generated secret
if [ -f "$JWT_SECRET_FILE" ]; then
echo "Using previously generated JWT secret"
export JWT_SECRET=$(cat "$JWT_SECRET_FILE")
if [ -f "$BETTER_AUTH_SECRET_FILE" ]; then
echo "Using previously generated BETTER_AUTH_SECRET"
export BETTER_AUTH_SECRET=$(cat "$BETTER_AUTH_SECRET_FILE")
# Check for old JWT_SECRET file for backward compatibility
elif [ -f "$JWT_SECRET_FILE" ]; then
echo "Migrating from old JWT_SECRET to BETTER_AUTH_SECRET"
export BETTER_AUTH_SECRET=$(cat "$JWT_SECRET_FILE")
# Save to new file
echo "$BETTER_AUTH_SECRET" > "$BETTER_AUTH_SECRET_FILE"
chmod 600 "$BETTER_AUTH_SECRET_FILE"
# Optionally remove old file after successful migration
rm -f "$JWT_SECRET_FILE"
else
echo "Generating a secure random JWT secret"
echo "Generating a secure random BETTER_AUTH_SECRET"
# Try to generate a secure random string using OpenSSL
if command -v openssl >/dev/null 2>&1; then
GENERATED_SECRET=$(openssl rand -hex 32)
@@ -69,12 +80,38 @@ if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT
echo "OpenSSL not found, using fallback method for random generation"
GENERATED_SECRET=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1)
fi
export JWT_SECRET="$GENERATED_SECRET"
export BETTER_AUTH_SECRET="$GENERATED_SECRET"
# Save the secret to a file for persistence across container restarts
echo "$GENERATED_SECRET" > "$JWT_SECRET_FILE"
chmod 600 "$JWT_SECRET_FILE"
echo "$GENERATED_SECRET" > "$BETTER_AUTH_SECRET_FILE"
chmod 600 "$BETTER_AUTH_SECRET_FILE"
fi
echo "JWT_SECRET has been set to a secure random value"
echo "BETTER_AUTH_SECRET has been set to a secure random value"
fi
# Generate a secure ENCRYPTION_SECRET if one isn't provided
ENCRYPTION_SECRET_FILE="/app/data/.encryption_secret"
if [ -z "$ENCRYPTION_SECRET" ]; then
# Check if we have a previously generated secret
if [ -f "$ENCRYPTION_SECRET_FILE" ]; then
echo "Using previously generated ENCRYPTION_SECRET"
export ENCRYPTION_SECRET=$(cat "$ENCRYPTION_SECRET_FILE")
else
echo "Generating a secure random ENCRYPTION_SECRET"
# Generate a 48-character secret for encryption
if command -v openssl >/dev/null 2>&1; then
GENERATED_ENCRYPTION_SECRET=$(openssl rand -base64 36)
else
# Fallback to using /dev/urandom if openssl is not available
echo "OpenSSL not found, using fallback method for encryption secret generation"
GENERATED_ENCRYPTION_SECRET=$(head -c 36 /dev/urandom | base64 | tr -d '\n' | head -c 48)
fi
export ENCRYPTION_SECRET="$GENERATED_ENCRYPTION_SECRET"
# Save the secret to a file for persistence across container restarts
echo "$GENERATED_ENCRYPTION_SECRET" > "$ENCRYPTION_SECRET_FILE"
chmod 600 "$ENCRYPTION_SECRET_FILE"
fi
echo "ENCRYPTION_SECRET has been set to a secure random value"
fi
@@ -232,19 +269,7 @@ else
bun scripts/manage-db.ts fix
fi
# Run database migrations
echo "Running database migrations..."
# Update mirror_jobs table with new columns for resilience
if [ -f "dist/scripts/update-mirror-jobs-table.js" ]; then
echo "Updating mirror_jobs table..."
bun dist/scripts/update-mirror-jobs-table.js
elif [ -f "scripts/update-mirror-jobs-table.ts" ]; then
echo "Updating mirror_jobs table using TypeScript script..."
bun scripts/update-mirror-jobs-table.ts
else
echo "Warning: Could not find mirror_jobs table update script."
fi
echo "Database exists, checking integrity..."
fi
# Extract version from package.json and set as environment variable
@@ -255,6 +280,28 @@ fi
# Initialize configuration from environment variables if provided
echo "Checking for environment configuration..."
if [ -f "dist/scripts/startup-env-config.js" ]; then
echo "Loading configuration from environment variables..."
bun dist/scripts/startup-env-config.js
ENV_CONFIG_EXIT_CODE=$?
elif [ -f "scripts/startup-env-config.ts" ]; then
echo "Loading configuration from environment variables..."
bun scripts/startup-env-config.ts
ENV_CONFIG_EXIT_CODE=$?
else
echo "Environment configuration script not found. Skipping."
ENV_CONFIG_EXIT_CODE=0
fi
# Log environment config result
if [ $ENV_CONFIG_EXIT_CODE -eq 0 ]; then
echo "✅ Environment configuration loaded successfully"
else
echo "⚠️ Environment configuration loading completed with warnings"
fi
# Run startup recovery to handle any interrupted jobs
echo "Running startup recovery..."
if [ -f "dist/scripts/startup-recovery.js" ]; then

View File

@@ -0,0 +1,175 @@
# Better Auth Migration Guide
This document describes the migration from the legacy authentication system to Better Auth.
## Overview
Gitea Mirror has been migrated to use Better Auth, a modern authentication library that provides:
- Built-in support for email/password authentication
- Session management with secure cookies
- Database adapter with Drizzle ORM
- Ready for OAuth2, OIDC, and SSO integrations
- Type-safe authentication throughout the application
## Key Changes
### 1. Database Schema
New tables added:
- `sessions` - User session management
- `accounts` - Authentication providers (credentials, OAuth, etc.)
- `verification_tokens` - Email verification and password reset tokens
Modified tables:
- `users` - Added `emailVerified` field
### 2. Authentication Flow
**Login:**
- Users now log in with email instead of username
- Endpoint: `/api/auth/sign-in/email`
- Session cookies are automatically managed
**Registration:**
- Users register with username, email, and password
- Username is stored as an additional field
- Endpoint: `/api/auth/sign-up/email`
### 3. API Routes
All auth routes are now handled by Better Auth's catch-all handler:
- `/api/auth/[...all].ts` handles all authentication endpoints
Legacy routes have been backed up to `/src/pages/api/auth/legacy-backup/`
### 4. Session Management
Sessions are now managed by Better Auth:
- Middleware automatically populates `context.locals.user` and `context.locals.session`
- Use `useAuth()` hook in React components for client-side auth
- Sessions expire after 30 days by default
## Future OIDC/SSO Configuration
The project is now ready for OIDC and SSO integrations. To enable:
### 1. Enable SSO Plugin
```typescript
// src/lib/auth.ts
import { sso } from "better-auth/plugins/sso";
export const auth = betterAuth({
// ... existing config
plugins: [
sso({
provisionUser: async (data) => {
// Custom user provisioning logic
return data;
},
}),
],
});
```
### 2. Register OIDC Providers
```typescript
// Example: Register an OIDC provider
await authClient.sso.register({
issuer: "https://idp.example.com",
domain: "example.com",
clientId: "your-client-id",
clientSecret: "your-client-secret",
providerId: "example-provider",
});
```
### 3. Enable OIDC Provider Mode
To make Gitea Mirror act as an OIDC provider:
```typescript
// src/lib/auth.ts
import { oidcProvider } from "better-auth/plugins/oidc";
export const auth = betterAuth({
// ... existing config
plugins: [
oidcProvider({
loginPage: "/signin",
consentPage: "/oauth/consent",
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
},
}),
],
});
```
### 4. Database Migration for SSO
When enabling SSO/OIDC, run migrations to add required tables:
```bash
# Generate the schema
bun drizzle-kit generate
# Apply the migration
bun drizzle-kit migrate
```
New tables that will be added:
- `sso_providers` - SSO provider configurations
- `oauth_applications` - OAuth2 client applications
- `oauth_access_tokens` - OAuth2 access tokens
- `oauth_consents` - User consent records
## Environment Variables
Required environment variables:
```env
# Better Auth configuration
BETTER_AUTH_SECRET=your-secret-key
BETTER_AUTH_URL=http://localhost:3000
# Legacy (kept for compatibility)
JWT_SECRET=your-secret-key
```
## Migration Script
To migrate existing users to Better Auth:
```bash
bun run migrate:better-auth
```
This script:
1. Creates credential accounts for existing users
2. Moves password hashes to the accounts table
3. Preserves user creation dates
## Troubleshooting
### Login Issues
- Ensure users log in with email, not username
- Check that BETTER_AUTH_SECRET is set
- Verify database migrations have been applied
### Session Issues
- Clear browser cookies if experiencing session problems
- Check middleware is properly configured
- Ensure auth routes are accessible at `/api/auth/*`
### Development Tips
- Use `bun db:studio` to inspect database tables
- Check `/api/auth/session` to verify current session
- Enable debug logging in Better Auth for troubleshooting
## Resources
- [Better Auth Documentation](https://better-auth.com)
- [Better Auth Astro Integration](https://better-auth.com/docs/integrations/astro)
- [Better Auth Plugins](https://better-auth.com/docs/plugins)

206
docs/BUILD_GUIDE.md Normal file
View File

@@ -0,0 +1,206 @@
# Build Guide
This guide covers building the open-source version of Gitea Mirror.
## Prerequisites
- **Bun** >= 1.2.9 (primary runtime)
- **Node.js** >= 20 (for compatibility)
- **Git**
## Quick Start
```bash
# Clone repository
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
# Install dependencies
bun install
# Initialize database
bun run init-db
# Build for production
bun run build
# Start the application
bun run start
```
## Build Commands
| Command | Description |
|---------|-------------|
| `bun run build` | Production build |
| `bun run dev` | Development server |
| `bun run preview` | Preview production build |
| `bun test` | Run tests |
| `bun run cleanup-db` | Remove database files |
## Build Output
The build creates:
- `dist/` - Production-ready server files
- `.astro/` - Build cache (git-ignored)
- `data/` - SQLite database location
## Development Build
For active development with hot reload:
```bash
bun run dev
```
Access the application at http://localhost:4321
## Production Build
```bash
# Build
bun run build
# Test the build
bun run preview
# Run in production
bun run start
```
## Docker Build
```dockerfile
# Build Docker image
docker build -t gitea-mirror:latest .
# Run container
docker run -p 3000:3000 gitea-mirror:latest
```
## Environment Variables
Create a `.env` file:
```env
# Database
DATABASE_PATH=./data/gitea-mirror.db
# Authentication
JWT_SECRET=your-secret-here
# GitHub Configuration
GITHUB_TOKEN=ghp_...
GITHUB_WEBHOOK_SECRET=...
GITHUB_EXCLUDED_ORGS=org1,org2,org3 # Optional: Comma-separated list of organizations to exclude from sync
# Gitea Configuration
GITEA_URL=https://your-gitea.com
GITEA_TOKEN=...
```
## Common Build Issues
### Missing Dependencies
```bash
# Solution
bun install
```
### Database Not Initialized
```bash
# Solution
bun run init-db
```
### Port Already in Use
```bash
# Change port
PORT=3001 bun run dev
```
### Build Cache Issues
```bash
# Clear cache
rm -rf .astro/ dist/
bun run build
```
## Build Optimization
### Development Speed
- Use `bun run dev` for hot reload
- Skip type checking during rapid development
- Keep `.astro/` cache between builds
### Production Optimization
- Minification enabled automatically
- Tree shaking removes unused code
- Image optimization with Sharp
## Validation
After building, verify:
```bash
# Check build output
ls -la dist/
# Test server starts
bun run start
# Check health endpoint
curl http://localhost:3000/api/health
```
## CI/CD Build
Example GitHub Actions workflow:
```yaml
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run build
- run: bun test
```
## Troubleshooting
### Build Fails
1. Check Bun version: `bun --version`
2. Clear dependencies: `rm -rf node_modules && bun install`
3. Check for syntax errors: `bunx tsc --noEmit`
### Runtime Errors
1. Check environment variables
2. Verify database exists
3. Check file permissions
## Performance
Expected build times:
- Clean build: ~5-10 seconds
- Incremental build: ~2-5 seconds
- Development startup: ~1-2 seconds
## Next Steps
- Configure with [Configuration Guide](./CONFIGURATION.md)
- Deploy with [Deployment Guide](./DEPLOYMENT.md)
- Set up authentication with [SSO Guide](./SSO-OIDC-SETUP.md)

View File

@@ -0,0 +1,355 @@
# Development Workflow
This guide covers the development workflow for the open-source Gitea Mirror.
## Getting Started
### Prerequisites
- Bun >= 1.2.9
- Node.js >= 20
- Git
- GitHub account (for API access)
- Gitea instance (for testing)
### Initial Setup
1. **Clone the repository**:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
```
2. **Install dependencies**:
```bash
bun install
```
3. **Initialize database**:
```bash
bun run init-db
```
4. **Configure environment**:
```bash
cp .env.example .env
# Edit .env with your settings
```
5. **Start development server**:
```bash
bun run dev
```
## Development Commands
| Command | Description |
|---------|-------------|
| `bun run dev` | Start development server with hot reload |
| `bun run build` | Build for production |
| `bun run preview` | Preview production build |
| `bun test` | Run all tests |
| `bun test:watch` | Run tests in watch mode |
| `bun run db:studio` | Open database GUI |
## Project Structure
```
gitea-mirror/
├── src/
│ ├── components/ # React components
│ ├── pages/ # Astro pages & API routes
│ ├── lib/ # Core logic
│ │ ├── db/ # Database queries
│ │ ├── utils/ # Helper functions
│ │ └── modules/ # Module system
│ ├── hooks/ # React hooks
│ └── types/ # TypeScript types
├── public/ # Static assets
├── scripts/ # Utility scripts
└── tests/ # Test files
```
## Feature Development
### Adding a New Feature
1. **Create feature branch**:
```bash
git checkout -b feature/my-feature
```
2. **Plan your changes**:
- UI components in `/src/components/`
- API endpoints in `/src/pages/api/`
- Database queries in `/src/lib/db/queries/`
- Types in `/src/types/`
3. **Implement the feature**:
**Example: Adding a new API endpoint**
```typescript
// src/pages/api/my-endpoint.ts
import type { APIRoute } from 'astro';
import { getUserFromCookie } from '@/lib/auth-utils';
export const GET: APIRoute = async ({ request }) => {
const user = await getUserFromCookie(request);
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Your logic here
return new Response(JSON.stringify({ data: 'success' }), {
headers: { 'Content-Type': 'application/json' }
});
};
```
4. **Write tests**:
```typescript
// src/lib/my-feature.test.ts
import { describe, it, expect } from 'bun:test';
describe('My Feature', () => {
it('should work correctly', () => {
expect(myFunction()).toBe('expected');
});
});
```
5. **Update documentation**:
- Add JSDoc comments
- Update README if needed
- Document API changes
## Database Development
### Schema Changes
1. **Modify schema**:
```typescript
// src/lib/db/schema.ts
export const myTable = sqliteTable('my_table', {
id: text('id').primaryKey(),
name: text('name').notNull(),
createdAt: integer('created_at').notNull(),
});
```
2. **Generate migration**:
```bash
bun run db:generate
```
3. **Apply migration**:
```bash
bun run db:migrate
```
### Writing Queries
```typescript
// src/lib/db/queries/my-queries.ts
import { db } from '../index';
import { myTable } from '../schema';
export async function getMyData(userId: string) {
return db.select()
.from(myTable)
.where(eq(myTable.userId, userId));
}
```
## Testing
### Unit Tests
```bash
# Run all tests
bun test
# Run specific test file
bun test auth
# Watch mode
bun test:watch
# Coverage
bun test:coverage
```
### Manual Testing Checklist
- [ ] Feature works as expected
- [ ] No console errors
- [ ] Responsive on mobile
- [ ] Handles errors gracefully
- [ ] Loading states work
- [ ] Form validation works
- [ ] API returns correct status codes
## Debugging
### VSCode Configuration
Create `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "launch",
"name": "Debug Bun",
"program": "${workspaceFolder}/src/index.ts",
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development"
}
}
]
}
```
### Debug Logging
```typescript
// Development only logging
if (import.meta.env.DEV) {
console.log('[Debug]', data);
}
```
## Code Style
### TypeScript
- Use strict mode
- Define interfaces for all data structures
- Avoid `any` type
- Use proper error handling
### React Components
- Use functional components
- Implement proper loading states
- Handle errors with error boundaries
- Use TypeScript for props
### API Routes
- Always validate input
- Return proper status codes
- Use consistent error format
- Document with JSDoc
## Git Workflow
### Commit Messages
Follow conventional commits:
```
feat: add repository filtering
fix: resolve sync timeout issue
docs: update API documentation
style: format code with prettier
refactor: simplify auth logic
test: add user creation tests
chore: update dependencies
```
### Pull Request Process
1. Create feature branch
2. Make changes
3. Write/update tests
4. Update documentation
5. Create PR with description
6. Address review feedback
7. Squash and merge
## Performance
### Development Tips
- Use React DevTools
- Monitor bundle size
- Profile database queries
- Check memory usage
### Optimization
- Lazy load components
- Optimize images
- Use database indexes
- Cache API responses
## Common Issues
### Port Already in Use
```bash
# Use different port
PORT=3001 bun run dev
```
### Database Locked
```bash
# Reset database
bun run cleanup-db
bun run init-db
```
### Type Errors
```bash
# Check types
bunx tsc --noEmit
```
## Release Process
1. **Update version**:
```bash
npm version patch # or minor/major
```
2. **Update CHANGELOG.md**
3. **Build and test**:
```bash
bun run build
bun test
```
4. **Create release**:
```bash
git tag v2.23.0
git push origin v2.23.0
```
5. **Create GitHub release**
## Contributing
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to your fork
5. Create a Pull Request
## Resources
- [Astro Documentation](https://docs.astro.build)
- [Bun Documentation](https://bun.sh/docs)
- [Drizzle ORM](https://orm.drizzle.team)
- [React Documentation](https://react.dev)
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
## 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)

View File

@@ -0,0 +1,410 @@
# Environment Variables Documentation
This document provides a comprehensive list of all environment variables supported by Gitea Mirror. These can be used to configure the application via Docker or other deployment methods.
## Environment Variables and UI Interaction
When environment variables are set:
1. They are loaded on application startup
2. Values are stored in the database on first load
3. The UI will display these values and they can be modified
4. UI changes are saved to the database and persist
5. Environment variables provide initial defaults but don't override UI changes
**Note**: Some critical settings like `GITEA_LFS`, `MIRROR_RELEASES`, and `MIRROR_METADATA` will be visible and configurable in the UI even when set via environment variables.
## Table of Contents
- [Core Configuration](#core-configuration)
- [GitHub Configuration](#github-configuration)
- [Gitea Configuration](#gitea-configuration)
- [Mirror Options](#mirror-options)
- [Automation Configuration](#automation-configuration)
- [Database Cleanup Configuration](#database-cleanup-configuration)
- [Authentication Configuration](#authentication-configuration)
- [Docker Configuration](#docker-configuration)
## Core Configuration
Essential application settings required for running Gitea Mirror.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No |
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
## GitHub Configuration
Settings for connecting to and configuring GitHub repository sources.
### Basic Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITHUB_USERNAME` | Your GitHub username | - | - |
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
### Repository Selection
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
### Organization Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal) | `false` | `true`, `false` |
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
### Advanced Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SKIP_STARRED_ISSUES` | Enable lightweight mode for starred repos (skip issues) | `false` | `true`, `false` |
## Gitea Configuration
Settings for the destination Gitea instance.
### Connection Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITEA_URL` | Gitea instance URL | - | Valid URL |
| `GITEA_TOKEN` | Gitea access token | - | - |
| `GITEA_USERNAME` | Gitea username | - | - |
| `GITEA_ORGANIZATION` | Default organization for single-org strategy | `github-mirrors` | Any string |
### Repository Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITEA_ORG_VISIBILITY` | Default organization visibility | `public` | `public`, `private`, `limited`, `default` |
| `GITEA_MIRROR_INTERVAL` | Mirror sync interval - **automatically enables scheduled mirroring when set** | `8h` | Duration string (e.g., `30m`, `1h`, `8h`, `24h`, `1d`) or seconds |
| `GITEA_LFS` | Enable LFS support (requires LFS on Gitea server) - Shows in UI | `false` | `true`, `false` |
| `GITEA_CREATE_ORG` | Auto-create organizations | `true` | `true`, `false` |
| `GITEA_PRESERVE_VISIBILITY` | Preserve GitHub repo visibility in Gitea | `false` | `true`, `false` |
### Template Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITEA_TEMPLATE_OWNER` | Template repository owner | - | Any string |
| `GITEA_TEMPLATE_REPO` | Template repository name | - | Any string |
### Topic Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITEA_ADD_TOPICS` | Add topics to repositories | `true` | `true`, `false` |
| `GITEA_TOPIC_PREFIX` | Prefix for repository topics | - | Any string |
### Fork Handling
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITEA_FORK_STRATEGY` | How to handle forked repositories | `reference` | `skip`, `reference`, `full-copy` |
### Additional Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `GITEA_SKIP_TLS_VERIFY` | Skip TLS certificate verification (WARNING: insecure) | `false` | `true`, `false` |
## Mirror Options
Control what content gets mirrored from GitHub to Gitea.
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
| `RELEASE_LIMIT` | Maximum number of releases to mirror per repository | `10` | Number (1-100) |
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
| `MIRROR_ISSUES` | Mirror issues (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_PULL_REQUESTS` | Mirror pull requests (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_LABELS` | Mirror labels (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_MILESTONES` | Mirror milestones (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
## Automation Configuration
Configure automatic scheduled mirroring.
### Basic Schedule Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` |
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression. **Supports cron syntax for scheduled runs** (e.g., `"0 2 * * *"` for 2 AM daily) | `3600` | Number (seconds) or cron string |
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
> **🚀 Auto-Start Feature (v3.5.3+)**
> Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
> 1. **Import** all GitHub repositories on startup
> 2. **Mirror** them to Gitea immediately
> 3. **Continue syncing** at the configured interval
> 4. **Auto-discover** new repositories
> 5. **Clean up** deleted repositories (if configured)
>
> This eliminates the need for manual button clicks - perfect for Docker/Kubernetes deployments!
> **⏰ Scheduling with Cron Expressions**
> Use cron expressions in `SCHEDULE_INTERVAL` to run at specific times:
> - `"0 2 * * *"` - Daily at 2 AM
> - `"0 */6 * * *"` - Every 6 hours
> - `"0 0 * * 0"` - Weekly on Sunday at midnight
> - `"0 3 * * 1-5"` - Weekdays at 3 AM (Monday-Friday)
>
> This is useful for optimizing bandwidth usage during low-activity periods.
### Execution Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SCHEDULE_CONCURRENT` | Allow concurrent mirror operations | `false` | `true`, `false` |
| `SCHEDULE_BATCH_SIZE` | Number of repos to process in parallel | `10` | Number |
| `SCHEDULE_PAUSE_BETWEEN_BATCHES` | Pause between batches (milliseconds) | `5000` | Number |
### Retry Configuration
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SCHEDULE_RETRY_ATTEMPTS` | Number of retry attempts | `3` | Number |
| `SCHEDULE_RETRY_DELAY` | Delay between retries (milliseconds) | `60000` | Number |
| `SCHEDULE_TIMEOUT` | Max time for a mirror operation (milliseconds) | `3600000` | Number |
| `SCHEDULE_AUTO_RETRY` | Automatically retry failed operations | `true` | `true`, `false` |
### Update Detection
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `AUTO_IMPORT_REPOS` | Automatically discover and import new GitHub repositories during scheduled syncs | `true` | `true`, `false` |
| `SCHEDULE_ONLY_MIRROR_UPDATED` | Only mirror repos with updates | `false` | `true`, `false` |
| `SCHEDULE_UPDATE_INTERVAL` | Check for updates interval (milliseconds) | `86400000` | Number |
| `SCHEDULE_SKIP_RECENTLY_MIRRORED` | Skip recently mirrored repos | `true` | `true`, `false` |
| `SCHEDULE_RECENT_THRESHOLD` | Skip if mirrored within this time (milliseconds) | `3600000` | Number |
### Maintenance & Notifications
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SCHEDULE_CLEANUP_BEFORE_MIRROR` | Run cleanup before mirroring | `false` | `true`, `false` |
| `SCHEDULE_NOTIFY_ON_FAILURE` | Send notifications on failure | `true` | `true`, `false` |
| `SCHEDULE_NOTIFY_ON_SUCCESS` | Send notifications on success | `false` | `true`, `false` |
| `SCHEDULE_LOG_LEVEL` | Logging level | `info` | `error`, `warn`, `info`, `debug` |
| `SCHEDULE_TIMEZONE` | Timezone for scheduling | `UTC` | Valid timezone string |
## Database Cleanup Configuration
Configure automatic cleanup of old events and data.
### Basic Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_ENABLED` | Enable automatic cleanup | `false` | `true`, `false` |
| `CLEANUP_RETENTION_DAYS` | Days to keep events | `7` | Number |
### Repository Cleanup
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `true` | `true`, `false` |
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
**🛡️ Safety Features (Backup Protection)**:
- **GitHub Failures Don't Delete Backups**: Cleanup is automatically skipped if GitHub API returns errors (404, 403, connection issues)
- **Archive Never Deletes**: The `archive` action ALWAYS preserves repository data, it never deletes
- **Graceful Degradation**: If marking as archived fails, the repository remains fully accessible in Gitea
- **The Purpose of Backups**: Your mirrors are preserved even when GitHub sources disappear - that's the whole point!
**Archive Behavior (Aligned with Gitea API)**:
- **Regular repositories**: Uses Gitea's native archive feature (PATCH `/repos/{owner}/{repo}` with `archived: true`)
- Makes repository read-only while preserving all data
- **Mirror repositories**: Uses rename strategy (Gitea API returns 422 for archiving mirrors)
- Renamed with `[ARCHIVED]` prefix for clear identification
- Description updated with preservation notice and timestamp
- Mirror interval set to 8760h (1 year) to minimize sync attempts
- Repository remains fully accessible and cloneable
### Execution Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_BATCH_SIZE` | Number of items to process per batch | `10` | Number |
| `CLEANUP_PAUSE_BETWEEN_DELETES` | Pause between deletions (milliseconds) | `2000` | Number |
## Authentication Configuration
Configure authentication methods and SSO.
### Header Authentication (Reverse Proxy SSO)
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `HEADER_AUTH_ENABLED` | Enable header-based authentication | `false` | `true`, `false` |
| `HEADER_AUTH_USER_HEADER` | Header containing username | `X-Authentik-Username` | Header name |
| `HEADER_AUTH_EMAIL_HEADER` | Header containing email | `X-Authentik-Email` | Header name |
| `HEADER_AUTH_NAME_HEADER` | Header containing display name | `X-Authentik-Name` | Header name |
| `HEADER_AUTH_AUTO_PROVISION` | Auto-create users from headers | `false` | `true`, `false` |
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed email domains | - | Comma-separated domains |
## Docker Configuration
Settings specific to Docker deployments.
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `DOCKER_REGISTRY` | Docker registry URL | `ghcr.io` | Registry URL |
| `DOCKER_IMAGE` | Docker image name | `raylabshq/gitea-mirror:` | Image name |
| `DOCKER_TAG` | Docker image tag | `latest` | Tag name |
## Example Docker Compose Configuration
Here's an example of how to use these environment variables in a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
container_name: gitea-mirror
environment:
# Core Configuration
- NODE_ENV=production
- DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL:
- BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
# Additional access URLs (local network + SSO providers):
# - BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321,https://auth.provider.com
# GitHub Configuration
- GITHUB_USERNAME=your-username
- GITHUB_TOKEN=ghp_your_token_here
- PRIVATE_REPOSITORIES=true
- MIRROR_STARRED=true
- SKIP_FORKS=false
# Gitea Configuration
- GITEA_URL=http://gitea:3000
- GITEA_USERNAME=admin
- GITEA_TOKEN=your-gitea-token
- GITEA_ORGANIZATION=github-mirrors
- GITEA_ORG_VISIBILITY=public
# Mirror Options
- MIRROR_RELEASES=true
- MIRROR_WIKI=true
- MIRROR_METADATA=true
- MIRROR_ISSUES=true
- MIRROR_PULL_REQUESTS=true
# Automation
- SCHEDULE_ENABLED=true
- SCHEDULE_INTERVAL=3600
# Cleanup
- CLEANUP_ENABLED=true
- CLEANUP_RETENTION_DAYS=30
volumes:
- ./data:/app/data
ports:
- "4321:4321"
```
## Authentication URL Configuration
### Multiple Access URLs
To allow access to Gitea Mirror through multiple URLs (e.g., local IP and public domain), you need to configure both server and client settings:
**Example Configuration:**
```bash
# Primary URL (required) - where the auth server is hosted
BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
# Client-side URL (optional) - tells the browser where to send auth requests
# Set this to your primary domain when accessing from different origins
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
# Additional trusted origins (optional) - origins allowed to make auth requests
BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321
```
This setup allows you to:
- Access via local network IP: `http://10.10.20.45:4321`
- Access via public domain: `https://gitea-mirror.mydomain.tld`
- Auth requests from the IP will be sent to the domain (via `PUBLIC_BETTER_AUTH_URL`)
- Each origin requires separate login due to browser cookie isolation
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
1. **SSO/OIDC Providers**: When using external authentication providers (Google, Authentik, Okta)
2. **Reverse Proxies**: When running behind nginx, Traefik, or other proxies
3. **Cross-Origin Requests**: When the frontend and backend are on different domains
4. **Development**: When testing from different URLs
**Example Scenarios:**
```bash
# For Authentik SSO integration
BETTER_AUTH_TRUSTED_ORIGINS=https://authentik.company.com,https://auth.company.com
# For reverse proxy setup
BETTER_AUTH_TRUSTED_ORIGINS=https://proxy.internal,https://public.domain.com
# For development with multiple environments
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://192.168.1.100:3000
```
**Important Notes:**
- All URLs from `BETTER_AUTH_URL` are automatically trusted
- URLs must be complete with protocol (http/https)
- Multiple origins are separated by commas
- No trailing slashes needed
## Notes
1. **First Run**: Environment variables are loaded when the container starts. The configuration is applied after the first user account is created.
2. **UI Priority**: Manual changes made through the web UI will be preserved. Environment variables only set values for empty fields.
3. **Token Security**: All tokens are encrypted before being stored in the database.
4. **Auto-Enabling Features**: Certain environment variables automatically enable features when set:
- `GITEA_MIRROR_INTERVAL` - Automatically enables scheduled mirroring
- `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true` - Automatically enables repository cleanup
- `SCHEDULE_INTERVAL` or `DELAY` - Automatically enables the scheduler
5. **Backward Compatibility**: The `DELAY` variable is maintained for backward compatibility but `SCHEDULE_INTERVAL` is preferred.
6. **Required Scopes**: The GitHub token requires the following scopes:
- `repo` (full control of private repositories)
- `admin:org` (read organization data)
- Additional scopes may be required for specific features
For more examples and detailed configuration, see the `.env.example` file in the repository.

77
docs/EXTENDING.md Normal file
View File

@@ -0,0 +1,77 @@
# Extending Gitea Mirror
Gitea Mirror is designed with extensibility in mind through a module system.
## Module System
The application provides a module interface that allows extending functionality:
```typescript
export interface Module {
name: string;
version: string;
init(app: AppContext): Promise<void>;
cleanup?(): Promise<void>;
}
```
## Creating Custom Modules
You can create custom modules to add features:
```typescript
// my-module.ts
export class MyModule implements Module {
name = 'my-module';
version = '1.0.0';
async init(app: AppContext) {
// Add your functionality
app.addRoute('/api/my-endpoint', this.handler);
}
async handler(context) {
return new Response('Hello from my module!');
}
}
```
## Module Context
Modules receive an `AppContext` with:
- Database access
- Event system
- Route registration
- Configuration
## Private Extensions
If you're developing private extensions:
1. Create a separate package/repository
2. Implement the module interface
3. Use Bun's linking feature for development:
```bash
# In your extension
bun link
# In gitea-mirror
bun link your-extension
```
## Best Practices
- Keep modules focused on a single feature
- Use TypeScript for type safety
- Handle errors gracefully
- Clean up resources in `cleanup()`
- Document your module's API
## Community Modules
Share your modules with the community:
- Create a GitHub repository
- Tag it with `gitea-mirror-module`
- Submit a PR to list it in our docs
For more details on the module system, see the source code in `/src/lib/modules/`.

118
docs/README.md Normal file
View File

@@ -0,0 +1,118 @@
# Gitea Mirror Documentation
Welcome to the Gitea Mirror documentation. This guide covers everything you need to know about developing, building, and deploying the open-source version of Gitea Mirror.
## Documentation Overview
### Getting Started
- **[Development Workflow](./DEVELOPMENT_WORKFLOW.md)** - Set up your development environment and start contributing
- **[Build Guide](./BUILD_GUIDE.md)** - Build Gitea Mirror from source
- **[Configuration Guide](./CONFIGURATION.md)** - Configure all available options
### Deployment
- **[Deployment Guide](./DEPLOYMENT.md)** - Deploy to production environments
- **[Docker Guide](./DOCKER.md)** - Container-based deployment
- **[Reverse Proxy Setup](./REVERSE_PROXY.md)** - Configure with nginx/Caddy
### Features
- **[SSO/OIDC Setup](./SSO-OIDC-SETUP.md)** - Configure authentication providers
- **[Sponsor Integration](./SPONSOR_INTEGRATION.md)** - GitHub Sponsors integration
- **[Webhook Configuration](./WEBHOOKS.md)** - Set up GitHub webhooks
### Architecture
- **[Architecture Overview](./ARCHITECTURE.md)** - System design and components
- **[API Documentation](./API.md)** - REST API endpoints
- **[Database Schema](./DATABASE.md)** - SQLite structure
### Maintenance
- **[Migration Guide](../MIGRATION_GUIDE.md)** - Upgrade from previous versions
- **[Better Auth Migration](./BETTER_AUTH_MIGRATION.md)** - Migrate authentication system
- **[Troubleshooting](./TROUBLESHOOTING.md)** - Common issues and solutions
- **[Backup & Restore](./BACKUP.md)** - Data management
## Quick Start
1. **Clone and install**:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
bun install
```
2. **Configure**:
```bash
cp .env.example .env
# Edit .env with your GitHub and Gitea tokens
```
3. **Initialize and run**:
```bash
bun run init-db
bun run dev
```
4. **Access**: Open http://localhost:4321
## Key Features
- 🔄 **Automatic Syncing** - Keep repositories synchronized
- 🗂️ **Organization Support** - Mirror entire organizations
-**Starred Repos** - Mirror your starred repositories
- 🔐 **Self-Hosted** - Full control over your data
- 🚀 **Fast** - Built with Bun for optimal performance
- 🔒 **Secure** - JWT authentication, encrypted tokens
## Technology Stack
- **Runtime**: Bun
- **Framework**: Astro with React
- **Database**: SQLite with Drizzle ORM
- **Styling**: Tailwind CSS v4
- **Authentication**: Better Auth
## System Requirements
- Bun >= 1.2.9
- Node.js >= 20 (optional, for compatibility)
- SQLite 3
- 512MB RAM minimum
- 1GB disk space
## Contributing
We welcome contributions! Please see our [Contributing Guide](../CONTRIBUTING.md) for details.
### Development Setup
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
### Code of Conduct
Please read our [Code of Conduct](../CODE_OF_CONDUCT.md) before contributing.
## Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/gitea-mirror/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/gitea-mirror/discussions)
- **Wiki**: [GitHub Wiki](https://github.com/yourusername/gitea-mirror/wiki)
## Security
For security issues, please see [SECURITY.md](../SECURITY.md).
## License
Gitea Mirror is open source software licensed under the [MIT License](../LICENSE).
---
For detailed information on any topic, please refer to the specific documentation guides listed above.

View File

@@ -0,0 +1,91 @@
# GitHub Sponsors Integration
This guide shows how GitHub Sponsors is integrated into the open-source version of Gitea Mirror.
## Components
### GitHubSponsors Card
A card component that displays in the sidebar or dashboard:
```tsx
import { GitHubSponsors } from '@/components/sponsors/GitHubSponsors';
// In your layout or dashboard
<GitHubSponsors />
```
### SponsorButton
A smaller button for headers or navigation:
```tsx
import { SponsorButton } from '@/components/sponsors/GitHubSponsors';
// In your header
<SponsorButton />
```
## Integration Points
### 1. Dashboard Sidebar
Add the sponsor card to the dashboard sidebar for visibility:
```tsx
// src/components/layout/DashboardLayout.tsx
<aside>
{/* Other sidebar content */}
<GitHubSponsors />
</aside>
```
### 2. Header Navigation
Add the sponsor button to the main navigation:
```tsx
// src/components/layout/Header.tsx
<nav>
{/* Other nav items */}
<SponsorButton />
</nav>
```
### 3. Settings Page
Add a support section in settings:
```tsx
// src/components/settings/SupportSection.tsx
<Card>
<CardHeader>
<CardTitle>Support Development</CardTitle>
</CardHeader>
<CardContent>
<GitHubSponsors />
</CardContent>
</Card>
```
## Behavior
- **Only appears in self-hosted mode**: The components automatically hide in hosted mode
- **Non-intrusive**: Designed to be helpful without being annoying
- **Multiple options**: GitHub Sponsors, Buy Me a Coffee, and starring the repo
## Customization
You can customize the sponsor components by:
1. Updating the GitHub Sponsors URL
2. Adding/removing donation platforms
3. Changing the styling to match your theme
4. Adjusting the placement based on user feedback
## Best Practices
1. **Don't be pushy**: Show sponsor options tastefully
2. **Provide value first**: Ensure the tool is useful before asking for support
3. **Be transparent**: Explain how sponsorships help the project
4. **Thank sponsors**: Acknowledge supporters in README or releases

205
docs/SSO-OIDC-SETUP.md Normal file
View File

@@ -0,0 +1,205 @@
# SSO and OIDC Setup Guide
This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OIDC) provider functionality in Gitea Mirror.
## Overview
Gitea Mirror supports three authentication methods:
1. **Email & Password** - Traditional authentication (always enabled)
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
## Configuration
All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab.
## Setting up SSO (Single Sign-On)
SSO allows your users to sign in using external identity providers like Google, Okta, Azure AD, etc.
### Adding an SSO Provider
1. Navigate to Configuration → Authentication → SSO Providers
2. Click "Add Provider"
3. Fill in the provider details:
#### Required Fields
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
- **Domain**: The email domain for this provider (e.g., `example.com`)
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
- **Client ID**: The OAuth client ID from your provider
- **Client Secret**: The OAuth client secret from your provider
#### Auto-Discovery
If your provider supports OIDC discovery, you can:
1. Enter the Issuer URL
2. Click "Discover"
3. The system will automatically fetch the authorization and token endpoints
#### Manual Configuration
For providers without discovery support, manually enter:
- **Authorization Endpoint**: The OAuth authorization URL
- **Token Endpoint**: The OAuth token exchange URL
- **JWKS Endpoint**: The JSON Web Key Set URL (optional)
- **UserInfo Endpoint**: The user information endpoint (optional)
### Redirect URL
When configuring your SSO provider, use this redirect URL:
```
https://your-domain.com/api/auth/sso/callback/{provider-id}
```
Replace `{provider-id}` with your chosen Provider ID.
### Example: Google SSO Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new OAuth 2.0 Client ID
3. Add authorized redirect URI: `https://your-domain.com/api/auth/sso/callback/google-sso`
4. In Gitea Mirror:
- Issuer URL: `https://accounts.google.com`
- Domain: `your-company.com`
- Provider ID: `google-sso`
- Client ID: [Your Google Client ID]
- Client Secret: [Your Google Client Secret]
- Click "Discover" to auto-fill endpoints
### Example: Okta SSO Setup
1. In Okta Admin Console, create a new OIDC Web Application
2. Set redirect URI: `https://your-domain.com/api/auth/sso/callback/okta-sso`
3. In Gitea Mirror:
- Issuer URL: `https://your-okta-domain.okta.com`
- Domain: `your-company.com`
- Provider ID: `okta-sso`
- Client ID: [Your Okta Client ID]
- Client Secret: [Your Okta Client Secret]
- Click "Discover" to auto-fill endpoints
## Setting up OIDC Provider
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
### Creating OAuth Applications
1. Navigate to Configuration → Authentication → OAuth Applications
2. Click "Create Application"
3. Fill in the application details:
- **Application Name**: Display name for the application
- **Application Type**: Web, Mobile, or Desktop
- **Redirect URLs**: One or more redirect URLs (one per line)
4. After creation, you'll receive:
- **Client ID**: Share this with the application
- **Client Secret**: Keep this secure and share only once
### OIDC Endpoints
Applications can use these standard OIDC endpoints:
- **Discovery**: `https://your-domain.com/.well-known/openid-configuration`
- **Authorization**: `https://your-domain.com/api/auth/oauth2/authorize`
- **Token**: `https://your-domain.com/api/auth/oauth2/token`
- **UserInfo**: `https://your-domain.com/api/auth/oauth2/userinfo`
- **JWKS**: `https://your-domain.com/api/auth/jwks`
### Supported Scopes
- `openid` - Required, provides user ID
- `profile` - User's name, username, and profile picture
- `email` - User's email address and verification status
### Example: Configuring Another Application
For an application to use Gitea Mirror as its OIDC provider:
```javascript
// Example configuration for another app
const oidcConfig = {
issuer: 'https://gitea-mirror.example.com',
clientId: 'client_xxxxxxxxxxxxx',
clientSecret: 'secret_xxxxxxxxxxxxx',
redirectUri: 'https://myapp.com/auth/callback',
scope: 'openid profile email'
};
```
## User Experience
### Logging In with SSO
When SSO is configured:
1. Users see tabs for "Email" and "SSO" on the login page
2. In the SSO tab, they can:
- Click a specific provider button (if configured)
- Enter their work email to be redirected to the appropriate provider
### OAuth Consent Flow
When an application requests authentication:
1. Users are redirected to Gitea Mirror
2. If not logged in, they authenticate first
3. They see a consent screen showing:
- Application name
- Requested permissions
- Option to approve or deny
## Security Considerations
1. **Client Secrets**: Store OAuth client secrets securely
2. **Redirect URLs**: Only add trusted redirect URLs for applications
3. **Scopes**: Applications only receive the data for approved scopes
4. **Token Security**: Access tokens expire and can be revoked
## Troubleshooting
### SSO Login Issues
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
### OIDC Provider Issues
1. **Application not found**: Ensure the client ID is correct
2. **Invalid redirect URI**: The redirect URI must match exactly what's configured
3. **Consent not working**: Check browser cookies are enabled
## Managing Access
### Revoking SSO Access
Currently, SSO sessions are managed through the identity provider. To revoke access:
1. Log out of Gitea Mirror
2. Revoke access in your identity provider's settings
### Disabling OAuth Applications
To disable an application:
1. Go to Configuration → Authentication → OAuth Applications
2. Find the application
3. Click the delete button
This immediately prevents the application from authenticating new users.
## Best Practices
1. **Use HTTPS**: Always use HTTPS in production for security
2. **Regular Audits**: Periodically review configured SSO providers and OAuth applications
3. **Principle of Least Privilege**: Only grant necessary scopes to applications
4. **Monitor Usage**: Keep track of which applications are accessing your OIDC provider
5. **Secure Storage**: Store client secrets in a secure location, never in code
## Migration Notes
If migrating from the previous JWT-based authentication:
- Existing users remain unaffected
- Users can continue using email/password authentication
- SSO can be added as an additional authentication method

193
docs/SSO_TESTING.md Normal file
View File

@@ -0,0 +1,193 @@
# Local SSO Testing Guide
This guide explains how to test SSO authentication locally with Gitea Mirror.
## Option 1: Using Google OAuth (Recommended for Quick Testing)
### Setup Steps:
1. **Create a Google OAuth Application**
- Go to [Google Cloud Console](https://console.cloud.google.com/)
- Create a new project or select existing
- Enable Google+ API
- Go to "Credentials" → "Create Credentials" → "OAuth client ID"
- Choose "Web application"
- Add authorized redirect URIs:
- `http://localhost:3000/api/auth/sso/callback/google-sso`
- `http://localhost:9876/api/auth/sso/callback/google-sso`
2. **Configure in Gitea Mirror**
- Go to Configuration → Authentication tab
- Click "Add Provider"
- Select "OIDC / OAuth2"
- Fill in:
- Provider ID: `google-sso`
- Email Domain: `gmail.com` (or your domain)
- Issuer URL: `https://accounts.google.com`
- Click "Discover" to auto-fill endpoints
- Client ID: (from Google Console)
- Client Secret: (from Google Console)
- Save the provider
## Option 2: Using Keycloak (Local Identity Provider)
### Setup with Docker:
```bash
# Run Keycloak
docker run -d --name keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
# Access at http://localhost:8080
# Login with admin/admin
```
### Configure Keycloak:
1. Create a new realm (e.g., "gitea-mirror")
2. Create a client:
- Client ID: `gitea-mirror`
- Client Protocol: `openid-connect`
- Access Type: `confidential`
- Valid Redirect URIs: `http://localhost:*/api/auth/sso/callback/keycloak`
3. Get credentials from the "Credentials" tab
4. Create test users in "Users" section
### Configure in Gitea Mirror:
- Provider ID: `keycloak`
- Email Domain: `example.com`
- Issuer URL: `http://localhost:8080/realms/gitea-mirror`
- Client ID: `gitea-mirror`
- Client Secret: (from Keycloak)
- Click "Discover" to auto-fill endpoints
## Option 3: Using Mock SSO Provider (Development)
For testing without external dependencies, you can use a mock OIDC provider.
### Using oidc-provider-example:
```bash
# Clone and run mock provider
git clone https://github.com/panva/node-oidc-provider-example.git
cd node-oidc-provider-example
npm install
npm start
# Runs on http://localhost:3001
```
### Configure in Gitea Mirror:
- Provider ID: `mock-provider`
- Email Domain: `test.com`
- Issuer URL: `http://localhost:3001`
- Client ID: `foo`
- Client Secret: `bar`
- Authorization Endpoint: `http://localhost:3001/auth`
- Token Endpoint: `http://localhost:3001/token`
## Testing the SSO Flow
1. **Logout** from Gitea Mirror if logged in
2. Go to `/login`
3. Click on the **SSO** tab
4. Either:
- Click the provider button (e.g., "Sign in with gmail.com")
- Or enter your email and click "Continue with SSO"
5. You'll be redirected to the identity provider
6. Complete authentication
7. You'll be redirected back and logged in
## Troubleshooting
### Common Issues:
1. **"Invalid origin" error**
- Check that `trustedOrigins` in `/src/lib/auth.ts` includes your dev URL
- Restart the dev server after changes
2. **Provider not showing in login**
- Check browser console for errors
- Verify provider was saved successfully
- Check `/api/sso/providers` returns your providers
3. **Redirect URI mismatch**
- Ensure the redirect URI in your OAuth app matches exactly:
`http://localhost:PORT/api/auth/sso/callback/PROVIDER_ID`
4. **CORS errors**
- Add your identity provider domain to CORS allowed origins if needed
### Debug Mode:
Enable debug logging by setting environment variable:
```bash
DEBUG=better-auth:* bun run dev
```
## Testing Different Scenarios
### 1. New User Registration
- Use an email not in the system
- SSO should create a new user automatically
### 2. Existing User Login
- Create a user with email/password first
- Login with SSO using same email
- Should link to existing account
### 3. Domain-based Routing
- Configure multiple providers with different domains
- Test that entering email routes to correct provider
### 4. Organization Provisioning
- Set organizationId on provider
- Test that users are added to correct organization
## Security Testing
1. **Token Expiration**
- Wait for session to expire
- Test refresh flow
2. **Invalid State**
- Modify state parameter in callback
- Should reject authentication
3. **PKCE Flow**
- Enable/disable PKCE
- Verify code challenge works
## Using with Better Auth CLI
Better Auth provides CLI tools for testing:
```bash
# List registered providers
bun run auth:providers list
# Test provider configuration
bun run auth:providers test google-sso
```
## Environment Variables
For production-like testing:
```env
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key
```
## Next Steps
After successful SSO setup:
1. Test user attribute mapping
2. Configure role-based access
3. Set up SAML if needed
4. Test with your organization's actual IdP

16
drizzle.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/lib/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: "./data/gitea-mirror.db",
},
verbose: true,
strict: true,
migrations: {
table: "__drizzle_migrations",
schema: "main",
},
});

180
drizzle/0000_init.sql Normal file
View File

@@ -0,0 +1,180 @@
CREATE TABLE `accounts` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`user_id` text NOT NULL,
`provider_id` text NOT NULL,
`provider_user_id` text,
`access_token` text,
`refresh_token` text,
`expires_at` integer,
`password` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_accounts_account_id` ON `accounts` (`account_id`);--> statement-breakpoint
CREATE INDEX `idx_accounts_user_id` ON `accounts` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_accounts_provider` ON `accounts` (`provider_id`,`provider_user_id`);--> statement-breakpoint
CREATE TABLE `configs` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`github_config` text NOT NULL,
`gitea_config` text NOT NULL,
`include` text DEFAULT '["*"]' NOT NULL,
`exclude` text DEFAULT '[]' NOT NULL,
`schedule_config` text NOT NULL,
`cleanup_config` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `events` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`channel` text NOT NULL,
`payload` text NOT NULL,
`read` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_events_user_channel` ON `events` (`user_id`,`channel`);--> statement-breakpoint
CREATE INDEX `idx_events_created_at` ON `events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_events_read` ON `events` (`read`);--> statement-breakpoint
CREATE TABLE `mirror_jobs` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`repository_id` text,
`repository_name` text,
`organization_id` text,
`organization_name` text,
`details` text,
`status` text DEFAULT 'imported' NOT NULL,
`message` text NOT NULL,
`timestamp` integer DEFAULT (unixepoch()) NOT NULL,
`job_type` text DEFAULT 'mirror' NOT NULL,
`batch_id` text,
`total_items` integer,
`completed_items` integer DEFAULT 0,
`item_ids` text,
`completed_item_ids` text DEFAULT '[]',
`in_progress` integer DEFAULT false NOT NULL,
`started_at` integer,
`completed_at` integer,
`last_checkpoint` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_mirror_jobs_user_id` ON `mirror_jobs` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_mirror_jobs_batch_id` ON `mirror_jobs` (`batch_id`);--> statement-breakpoint
CREATE INDEX `idx_mirror_jobs_in_progress` ON `mirror_jobs` (`in_progress`);--> statement-breakpoint
CREATE INDEX `idx_mirror_jobs_job_type` ON `mirror_jobs` (`job_type`);--> statement-breakpoint
CREATE INDEX `idx_mirror_jobs_timestamp` ON `mirror_jobs` (`timestamp`);--> statement-breakpoint
CREATE TABLE `organizations` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`config_id` text NOT NULL,
`name` text NOT NULL,
`avatar_url` text NOT NULL,
`membership_role` text DEFAULT 'member' NOT NULL,
`is_included` integer DEFAULT true NOT NULL,
`destination_org` text,
`status` text DEFAULT 'imported' NOT NULL,
`last_mirrored` integer,
`error_message` text,
`repository_count` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_organizations_user_id` ON `organizations` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_organizations_config_id` ON `organizations` (`config_id`);--> statement-breakpoint
CREATE INDEX `idx_organizations_status` ON `organizations` (`status`);--> statement-breakpoint
CREATE INDEX `idx_organizations_is_included` ON `organizations` (`is_included`);--> statement-breakpoint
CREATE TABLE `repositories` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`config_id` text NOT NULL,
`name` text NOT NULL,
`full_name` text NOT NULL,
`url` text NOT NULL,
`clone_url` text NOT NULL,
`owner` text NOT NULL,
`organization` text,
`mirrored_location` text DEFAULT '',
`is_private` integer DEFAULT false NOT NULL,
`is_fork` integer DEFAULT false NOT NULL,
`forked_from` text,
`has_issues` integer DEFAULT false NOT NULL,
`is_starred` integer DEFAULT false NOT NULL,
`is_archived` integer DEFAULT false NOT NULL,
`size` integer DEFAULT 0 NOT NULL,
`has_lfs` integer DEFAULT false NOT NULL,
`has_submodules` integer DEFAULT false NOT NULL,
`language` text,
`description` text,
`default_branch` text NOT NULL,
`visibility` text DEFAULT 'public' NOT NULL,
`status` text DEFAULT 'imported' NOT NULL,
`last_mirrored` integer,
`error_message` text,
`destination_org` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_repositories_user_id` ON `repositories` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_repositories_config_id` ON `repositories` (`config_id`);--> statement-breakpoint
CREATE INDEX `idx_repositories_status` ON `repositories` (`status`);--> statement-breakpoint
CREATE INDEX `idx_repositories_owner` ON `repositories` (`owner`);--> statement-breakpoint
CREATE INDEX `idx_repositories_organization` ON `repositories` (`organization`);--> statement-breakpoint
CREATE INDEX `idx_repositories_is_fork` ON `repositories` (`is_fork`);--> statement-breakpoint
CREATE INDEX `idx_repositories_is_starred` ON `repositories` (`is_starred`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint
CREATE INDEX `idx_sessions_user_id` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_sessions_token` ON `sessions` (`token`);--> statement-breakpoint
CREATE INDEX `idx_sessions_expires_at` ON `sessions` (`expires_at`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
`username` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE TABLE `verification_tokens` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`identifier` text NOT NULL,
`type` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `verification_tokens_token_unique` ON `verification_tokens` (`token`);--> statement-breakpoint
CREATE INDEX `idx_verification_tokens_token` ON `verification_tokens` (`token`);--> statement-breakpoint
CREATE INDEX `idx_verification_tokens_identifier` ON `verification_tokens` (`identifier`);

View File

@@ -0,0 +1,64 @@
CREATE TABLE `oauth_access_tokens` (
`id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text,
`access_token_expires_at` integer NOT NULL,
`refresh_token_expires_at` integer,
`client_id` text NOT NULL,
`user_id` text NOT NULL,
`scopes` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_access_token` ON `oauth_access_tokens` (`access_token`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
CREATE TABLE `oauth_applications` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text NOT NULL,
`name` text NOT NULL,
`redirect_urls` text NOT NULL,
`metadata` text,
`type` text NOT NULL,
`disabled` integer DEFAULT false NOT NULL,
`user_id` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_applications_client_id_unique` ON `oauth_applications` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_applications_client_id` ON `oauth_applications` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_applications_user_id` ON `oauth_applications` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauth_consent` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`client_id` text NOT NULL,
`scopes` text NOT NULL,
`consent_given` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_oauth_consent_user_id` ON `oauth_consent` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_consent_client_id` ON `oauth_consent` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_consent_user_client` ON `oauth_consent` (`user_id`,`client_id`);--> statement-breakpoint
CREATE TABLE `sso_providers` (
`id` text PRIMARY KEY NOT NULL,
`issuer` text NOT NULL,
`domain` text NOT NULL,
`oidc_config` text NOT NULL,
`user_id` text NOT NULL,
`provider_id` text NOT NULL,
`organization_id` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `sso_providers_provider_id_unique` ON `sso_providers` (`provider_id`);--> statement-breakpoint
CREATE INDEX `idx_sso_providers_provider_id` ON `sso_providers` (`provider_id`);--> statement-breakpoint
CREATE INDEX `idx_sso_providers_domain` ON `sso_providers` (`domain`);--> statement-breakpoint
CREATE INDEX `idx_sso_providers_issuer` ON `sso_providers` (`issuer`);

View File

@@ -0,0 +1,10 @@
CREATE TABLE `verifications` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_verifications_identifier` ON `verifications` (`identifier`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `organizations` ADD `public_repository_count` integer;--> statement-breakpoint
ALTER TABLE `organizations` ADD `private_repository_count` integer;--> statement-breakpoint
ALTER TABLE `organizations` ADD `fork_repository_count` integer;

View File

@@ -0,0 +1,18 @@
CREATE TABLE `rate_limits` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`provider` text DEFAULT 'github' NOT NULL,
`limit` integer NOT NULL,
`remaining` integer NOT NULL,
`used` integer NOT NULL,
`reset` integer NOT NULL,
`retry_after` integer,
`status` text DEFAULT 'ok' NOT NULL,
`last_checked` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_rate_limits_user_provider` ON `rate_limits` (`user_id`,`provider`);--> statement-breakpoint
CREATE INDEX `idx_rate_limits_status` ON `rate_limits` (`status`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1752171873627,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1752173351102,
"tag": "0001_polite_exodus",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1753539600567,
"tag": "0002_bored_captain_cross",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1757390828679,
"tag": "0003_open_spacker_dave",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1757392620734,
"tag": "0004_grey_butterfly",
"breakpoints": true
}
]
}

9
env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference path="./.astro/types.d.ts" />
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user: import("better-auth").User | null;
session: import("better-auth").Session | null;
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "2.22.0",
"version": "3.6.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -16,8 +16,15 @@
"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",
"db:generate": "bun drizzle-kit generate",
"db:migrate": "bun drizzle-kit migrate",
"db:push": "bun drizzle-kit push",
"db:pull": "bun drizzle-kit pull",
"db:check": "bun drizzle-kit check",
"db:studio": "bun drizzle-kit studio",
"startup-recovery": "bun scripts/startup-recovery.ts",
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
"startup-env-config": "bun scripts/startup-env-config.ts",
"test-recovery": "bun scripts/test-recovery.ts",
"test-recovery-cleanup": "bun scripts/test-recovery.ts --cleanup",
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
@@ -30,67 +37,78 @@
"test:coverage": "bun test --coverage",
"astro": "bunx --bun astro"
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.20.5",
"devalue": "^5.3.2"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.3.0",
"@astrojs/node": "9.3.0",
"@astrojs/react": "^4.3.0",
"@astrojs/mdx": "4.3.5",
"@astrojs/node": "9.4.3",
"@astrojs/react": "^4.3.1",
"@better-auth/sso": "^1.3.8",
"@octokit/plugin-throttling": "^11.0.1",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"astro": "5.11.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"astro": "^5.13.6",
"bcryptjs": "^3.0.2",
"better-auth": "^1.3.9",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.44.2",
"dotenv": "^17.2.2",
"drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0",
"lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.3.8",
"typescript": "^5.9.2",
"uuid": "^13.0.0",
"vaul": "^1.1.2",
"zod": "^3.25.75"
"zod": "^4.1.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.2.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.6.0",
"@vitejs/plugin-react": "^5.0.2",
"drizzle-kit": "^0.31.4",
"jsdom": "^26.1.0",
"tsx": "^4.20.3",
"tsx": "^4.20.5",
"vitest": "^3.2.4"
},
"packageManager": "bun@1.2.18"
"packageManager": "bun@1.2.21"
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -57,7 +57,7 @@ http://<container-ip>:4321
```bash
git clone https://github.com/RayLabsHQ/gitea-mirror.git # if not already
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
curl -fsSL https://raw.githubusercontent.com/raylabshq/gitea-mirror:/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
chmod +x gitea-mirror-lxc-local.sh
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror \

View File

@@ -10,7 +10,7 @@
*/
import { db, mirrorJobs } from "../src/lib/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
// Parse command line arguments
const args = process.argv.slice(2);
@@ -21,18 +21,19 @@ async function fixInterruptedJobs() {
console.log("Checking for interrupted jobs...");
// Build the query
let query = db
.select()
.from(mirrorJobs)
.where(eq(mirrorJobs.inProgress, true));
const whereConditions = userId
? and(eq(mirrorJobs.inProgress, true), eq(mirrorJobs.userId, userId))
: eq(mirrorJobs.inProgress, true);
if (userId) {
console.log(`Filtering for user: ${userId}`);
query = query.where(eq(mirrorJobs.userId, userId));
}
// Find all in-progress jobs
const inProgressJobs = await query;
const inProgressJobs = await db
.select()
.from(mirrorJobs)
.where(whereConditions);
if (inProgressJobs.length === 0) {
console.log("No interrupted jobs found.");
@@ -45,7 +46,7 @@ async function fixInterruptedJobs() {
});
// Mark all in-progress jobs as failed
let updateQuery = db
await db
.update(mirrorJobs)
.set({
inProgress: false,
@@ -53,13 +54,7 @@ async function fixInterruptedJobs() {
status: "failed",
message: "Job interrupted and marked as failed by cleanup script"
})
.where(eq(mirrorJobs.inProgress, true));
if (userId) {
updateQuery = updateQuery.where(eq(mirrorJobs.userId, userId));
}
await updateQuery;
.where(whereConditions);
console.log(`✅ Successfully marked ${inProgressJobs.length} interrupted jobs as failed.`);
console.log("These jobs can now be deleted through the normal cleanup process.");

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bun
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import Database from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
// Create a minimal auth instance just for schema generation
const tempDb = new Database(":memory:");
const db = drizzle({ client: tempDb });
// Minimal auth config for schema generation
const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
},
});
// Generate the schema
// Note: $internal API is not available in current better-auth version
// const schema = auth.$internal.schema;
console.log("Better Auth Tables Required:");
console.log("============================");
// Convert Better Auth schema to Drizzle schema definitions
const drizzleSchemaCode = `// Better Auth Tables - Generated Schema
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// Sessions table
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
userId: text("user_id").notNull().references(() => users.id),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql\`(unixepoch())\`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql\`(unixepoch())\`),
}, (table) => {
return {
userIdIdx: index("idx_sessions_user_id").on(table.userId),
tokenIdx: index("idx_sessions_token").on(table.token),
expiresAtIdx: index("idx_sessions_expires_at").on(table.expiresAt),
};
});
// Accounts table (for OAuth providers and credentials)
export const accounts = sqliteTable("accounts", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
providerId: text("provider_id").notNull(),
providerUserId: text("provider_user_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
expiresAt: integer("expires_at", { mode: "timestamp" }),
password: text("password"), // For credential provider
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql\`(unixepoch())\`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql\`(unixepoch())\`),
}, (table) => {
return {
userIdIdx: index("idx_accounts_user_id").on(table.userId),
providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId),
};
});
// Verification tokens table
export const verificationTokens = sqliteTable("verification_tokens", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
identifier: text("identifier").notNull(),
type: text("type").notNull(), // email, password-reset, etc
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql\`(unixepoch())\`),
}, (table) => {
return {
tokenIdx: index("idx_verification_tokens_token").on(table.token),
identifierIdx: index("idx_verification_tokens_identifier").on(table.identifier),
};
});
// Future: SSO and OIDC Provider tables will be added when we enable those plugins
`;
console.log(drizzleSchemaCode);
// Output information about the schema
console.log("\n\nSummary:");
console.log("=========");
console.log("- Better Auth will modify the existing 'users' table");
console.log("- New tables required: sessions, accounts, verification_tokens");
console.log("\nNote: The 'users' table needs emailVerified field added");
tempDb.close();

View File

@@ -7,7 +7,7 @@ CONTAINER="gitea-test"
IMAGE="ubuntu:22.04"
INSTALL_DIR="/opt/gitea-mirror"
PORT=4321
JWT_SECRET="$(openssl rand -hex 32)"
BETTER_AUTH_SECRET="$(openssl rand -hex 32)"
BUN_ZIP="/tmp/bun-linux-x64.zip"
BUN_URL="https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
@@ -73,7 +73,7 @@ Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=$PORT
Environment=DATABASE_URL=file:data/gitea-mirror.db
Environment=JWT_SECRET=$JWT_SECRET
Environment=BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET
[Install]
WantedBy=multi-user.target
SERVICE

View File

@@ -79,11 +79,11 @@ async function investigateRepository() {
if (config.length > 0) {
const userConfig = config[0];
console.log(` User ID: ${userConfig.userId}`);
console.log(` GitHub Username: ${userConfig.githubConfig?.username || "Not set"}`);
console.log(` GitHub Owner: ${userConfig.githubConfig?.owner || "Not set"}`);
console.log(` Gitea URL: ${userConfig.giteaConfig?.url || "Not set"}`);
console.log(` Gitea Username: ${userConfig.giteaConfig?.username || "Not set"}`);
console.log(` Preserve Org Structure: ${userConfig.githubConfig?.preserveOrgStructure || false}`);
console.log(` Mirror Issues: ${userConfig.githubConfig?.mirrorIssues || false}`);
console.log(` Gitea Default Owner: ${userConfig.giteaConfig?.defaultOwner || "Not set"}`);
console.log(` Mirror Strategy: ${userConfig.githubConfig?.mirrorStrategy || "preserve"}`);
console.log(` Include Starred: ${userConfig.githubConfig?.includeStarred || false}`);
}
// Check for any active jobs
@@ -123,7 +123,7 @@ async function investigateRepository() {
try {
const giteaUrl = userConfig.giteaConfig?.url;
const giteaToken = userConfig.giteaConfig?.token;
const giteaUsername = userConfig.giteaConfig?.username;
const giteaUsername = userConfig.giteaConfig?.defaultOwner;
if (giteaUrl && giteaToken && giteaUsername) {
const checkUrl = `${giteaUrl}/api/v1/repos/${giteaUsername}/${repo.name}`;

View File

@@ -1,7 +1,12 @@
import fs from "fs";
import path from "path";
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";
// Command line arguments
const args = process.argv.slice(2);
@@ -13,750 +18,222 @@ if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Database paths
const rootDbFile = path.join(process.cwd(), "gitea-mirror.db");
const rootDevDbFile = path.join(process.cwd(), "gitea-mirror-dev.db");
const dataDbFile = path.join(dataDir, "gitea-mirror.db");
const dataDevDbFile = path.join(dataDir, "gitea-mirror-dev.db");
// Database path - ensure we use absolute path
const dbPath = path.join(dataDir, "gitea-mirror.db");
/**
* Ensure all required tables exist
* Initialize database with migrations
*/
async function ensureTablesExist() {
// Create or open the database
const db = new Database(dbPath);
const requiredTables = [
"users",
"configs",
"repositories",
"organizations",
"mirror_jobs",
"events",
];
for (const table of requiredTables) {
try {
// Check if table exists
const result = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${table}'`).get();
if (!result) {
console.warn(`⚠️ Table '${table}' is missing. Creating it now...`);
switch (table) {
case "users":
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
break;
case "configs":
db.exec(`
CREATE TABLE configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
include TEXT NOT NULL DEFAULT '["*"]',
exclude TEXT NOT NULL DEFAULT '[]',
schedule_config TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
break;
case "repositories":
db.exec(`
CREATE TABLE repositories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
clone_url TEXT NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
mirrored_location TEXT DEFAULT '',
is_private INTEGER NOT NULL DEFAULT 0,
is_fork INTEGER NOT NULL DEFAULT 0,
forked_from TEXT,
has_issues INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0,
has_lfs INTEGER NOT NULL DEFAULT 0,
has_submodules INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
)
`);
break;
case "organizations":
db.exec(`
CREATE TABLE organizations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
is_included INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
repository_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
)
`);
break;
case "mirror_jobs":
db.exec(`
CREATE TABLE mirror_jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
repository_id TEXT,
repository_name TEXT,
organization_id TEXT,
organization_name TEXT,
details TEXT,
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- New fields for job resilience
job_type TEXT NOT NULL DEFAULT 'mirror',
batch_id TEXT,
total_items INTEGER,
completed_items INTEGER DEFAULT 0,
item_ids TEXT, -- JSON array as text
completed_item_ids TEXT DEFAULT '[]', -- JSON array as text
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer
started_at TIMESTAMP,
completed_at TIMESTAMP,
last_checkpoint TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp);
`);
break;
case "events":
db.exec(`
CREATE TABLE events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel TEXT NOT NULL,
payload TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE INDEX idx_events_user_channel ON events(user_id, channel);
CREATE INDEX idx_events_created_at ON events(created_at);
CREATE INDEX idx_events_read ON events(read);
`);
break;
}
console.log(`✅ Table '${table}' created successfully.`);
}
} catch (error) {
console.error(`❌ Error checking table '${table}':`, error);
process.exit(1);
}
async function initDatabase() {
console.log("📦 Initializing database...");
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
}
// Migration: Add cleanup_config column to existing configs table
// Create SQLite instance
const sqlite = new Database(dbPath);
const db = drizzle({ client: sqlite });
// Run migrations
console.log("🔄 Running migrations...");
try {
const db = new Database(dbPath);
// Check if cleanup_config column exists
const tableInfo = db.query(`PRAGMA table_info(configs)`).all();
const hasCleanupConfig = tableInfo.some((column: any) => column.name === 'cleanup_config');
if (!hasCleanupConfig) {
console.log("Adding cleanup_config column to configs table...");
// Add the column with a default value
const defaultCleanupConfig = JSON.stringify({
enabled: false,
retentionDays: 7,
lastRun: null,
nextRun: null,
});
db.exec(`ALTER TABLE configs ADD COLUMN cleanup_config TEXT NOT NULL DEFAULT '${defaultCleanupConfig}'`);
console.log("✅ cleanup_config column added successfully.");
}
migrate(db, { migrationsFolder: "./drizzle" });
console.log("✅ Migrations completed successfully");
} catch (error) {
console.error("❌ Error during cleanup_config migration:", error);
// Don't exit here as this is not critical for basic functionality
console.error("❌ Error running migrations:", error);
throw error;
}
sqlite.close();
console.log("✅ Database initialized successfully");
}
/**
* Check database status
*/
async function checkDatabase() {
console.log("Checking database status...");
// Check for database files in the root directory (which is incorrect)
if (fs.existsSync(rootDbFile)) {
console.warn(
"⚠️ WARNING: Database file found in root directory: gitea-mirror.db"
);
console.warn("This file should be in the data directory.");
console.warn(
'Run "bun run manage-db fix" to fix this issue or "bun run cleanup-db" to remove it.'
);
console.log("🔍 Checking database status...");
if (!fs.existsSync(dbPath)) {
console.log("❌ Database does not exist at:", dbPath);
console.log("💡 Run 'bun run init-db' to create the database");
process.exit(1);
}
// Check if database files exist in the data directory (which is correct)
if (fs.existsSync(dataDbFile)) {
console.log(
"✅ Database file found in data directory: data/gitea-mirror.db"
);
// Check for users
try {
const db = new Database(dbPath);
// Check for users
const userCountResult = db.query(`SELECT COUNT(*) as count FROM users`).get();
const userCount = userCountResult?.count || 0;
if (userCount === 0) {
console.log(" No users found in the database.");
console.log(
"When you start the application, you will be directed to the signup page"
);
console.log("to create an initial admin account.");
} else {
console.log(`${userCount} user(s) found in the database.`);
console.log("The application will show the login page on startup.");
}
// Check for configurations
const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get();
const configCount = configCountResult?.count || 0;
if (configCount === 0) {
console.log(" No configurations found in the database.");
console.log(
"You will need to set up your GitHub and Gitea configurations after login."
);
} else {
console.log(
`${configCount} configuration(s) found in the database.`
);
}
} catch (error) {
console.error("❌ Error connecting to the database:", error);
console.warn(
'The database file might be corrupted. Consider running "bun run manage-db init" to recreate it.'
);
}
} else {
console.warn("⚠️ WARNING: Database file not found in data directory.");
console.warn('Run "bun run manage-db init" to create it.');
}
}
// Database schema updates and migrations have been removed
// since the application is not used by anyone yet
/**
* Initialize the database
*/
async function initializeDatabase() {
// Check if database already exists first
if (fs.existsSync(dataDbFile)) {
console.log("⚠️ Database already exists at data/gitea-mirror.db");
console.log(
'If you want to recreate the database, run "bun run cleanup-db" first.'
);
console.log(
'Or use "bun run manage-db reset-users" to just remove users without recreating tables.'
);
// Check if we can connect to it
try {
const db = new Database(dbPath);
db.query(`SELECT COUNT(*) as count FROM users`).get();
console.log("✅ Database is valid and accessible.");
return;
} catch (error) {
console.error("❌ Error connecting to the existing database:", error);
console.log(
"The database might be corrupted. Proceeding with reinitialization..."
);
}
}
console.log(`Initializing database at ${dbPath}...`);
const sqlite = new Database(dbPath);
const db = drizzle({ client: sqlite });
try {
const db = new Database(dbPath);
// Check tables
const tables = sqlite.query(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).all() as Array<{name: string}>;
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
console.log("\n📊 Tables found:");
for (const table of tables) {
const count = sqlite.query(`SELECT COUNT(*) as count FROM ${table.name}`).get() as {count: number};
console.log(` - ${table.name}: ${count.count} records`);
}
// NOTE: We no longer create a default admin user - user will create one via signup page
// Check migrations
const migrations = sqlite.query(
"SELECT * FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 5"
).all() as Array<{hash: string, created_at: number}>;
db.exec(`
CREATE TABLE IF NOT EXISTS configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
include TEXT NOT NULL DEFAULT '["*"]',
exclude TEXT NOT NULL DEFAULT '[]',
schedule_config TEXT NOT NULL,
cleanup_config TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
clone_url TEXT NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
mirrored_location TEXT DEFAULT '',
is_private INTEGER NOT NULL DEFAULT 0,
is_fork INTEGER NOT NULL DEFAULT 0,
forked_from TEXT,
has_issues INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0,
has_lfs INTEGER NOT NULL DEFAULT 0,
has_submodules INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
is_included INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
repository_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS mirror_jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
repository_id TEXT,
repository_name TEXT,
organization_id TEXT,
organization_name TEXT,
details TEXT,
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
channel TEXT NOT NULL,
payload TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_events_user_channel ON events(user_id, channel);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_read ON events(read);
`);
// Insert default config if none exists
const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get();
const configCount = configCountResult?.count || 0;
if (configCount === 0) {
// Get the first user
const firstUserResult = db.query(`SELECT id FROM users LIMIT 1`).get();
if (firstUserResult) {
const userId = firstUserResult.id;
const configId = uuidv4();
const githubConfig = JSON.stringify({
username: process.env.GITHUB_USERNAME || "",
token: process.env.GITHUB_TOKEN || "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: true,
useSpecificUser: false,
preserveOrgStructure: true,
skipStarredIssues: false,
});
const giteaConfig = JSON.stringify({
url: process.env.GITEA_URL || "",
token: process.env.GITEA_TOKEN || "",
username: process.env.GITEA_USERNAME || "",
organization: "",
visibility: "public",
starredReposOrg: "github",
});
const include = JSON.stringify(["*"]);
const exclude = JSON.stringify([]);
const scheduleConfig = JSON.stringify({
enabled: false,
interval: 3600,
lastRun: null,
nextRun: null,
});
const cleanupConfig = JSON.stringify({
enabled: false,
retentionDays: 7,
lastRun: null,
nextRun: null,
});
const stmt = db.prepare(`
INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, cleanup_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
configId,
userId,
"Default Configuration",
1,
githubConfig,
giteaConfig,
include,
exclude,
scheduleConfig,
cleanupConfig,
Date.now(),
Date.now()
);
if (migrations.length > 0) {
console.log("\n📋 Recent migrations:");
for (const migration of migrations) {
const date = new Date(migration.created_at);
console.log(` - ${migration.hash} (${date.toLocaleString()})`);
}
}
console.log("✅ Database initialization completed successfully.");
sqlite.close();
console.log("\n✅ Database check complete");
} catch (error) {
console.error("❌ Error initializing database:", error);
console.error("❌ Error checking database:", error);
sqlite.close();
process.exit(1);
}
}
/**
* Reset users in the database
* Reset user accounts (development only)
*/
async function resetUsers() {
console.log(`Resetting users in database at ${dbPath}...`);
try {
// Check if the database exists
const doesDbExist = fs.existsSync(dbPath);
if (!doesDbExist) {
console.log(
"❌ Database file doesn't exist. Run 'bun run manage-db init' first to create it."
);
return;
}
const db = new Database(dbPath);
// Count existing users
const userCountResult = db.query(`SELECT COUNT(*) as count FROM users`).get();
const userCount = userCountResult?.count || 0;
if (userCount === 0) {
console.log(" No users found in the database. Nothing to reset.");
return;
}
// Delete all users
db.exec(`DELETE FROM users`);
console.log(`✅ Deleted ${userCount} users from the database.`);
// Check dependent configurations that need to be removed
const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get();
const configCount = configCountResult?.count || 0;
if (configCount > 0) {
db.exec(`DELETE FROM configs`);
console.log(`✅ Deleted ${configCount} configurations.`);
}
// Check for dependent repositories
const repoCountResult = db.query(`SELECT COUNT(*) as count FROM repositories`).get();
const repoCount = repoCountResult?.count || 0;
if (repoCount > 0) {
db.exec(`DELETE FROM repositories`);
console.log(`✅ Deleted ${repoCount} repositories.`);
}
// Check for dependent organizations
const orgCountResult = db.query(`SELECT COUNT(*) as count FROM organizations`).get();
const orgCount = orgCountResult?.count || 0;
if (orgCount > 0) {
db.exec(`DELETE FROM organizations`);
console.log(`✅ Deleted ${orgCount} organizations.`);
}
// Check for dependent mirror jobs
const jobCountResult = db.query(`SELECT COUNT(*) as count FROM mirror_jobs`).get();
const jobCount = jobCountResult?.count || 0;
if (jobCount > 0) {
db.exec(`DELETE FROM mirror_jobs`);
console.log(`✅ Deleted ${jobCount} mirror jobs.`);
}
console.log(
"✅ Database has been reset. The application will now prompt for a new admin account setup on next run."
);
} catch (error) {
console.error("❌ Error resetting users:", error);
console.log("🗑️ Resetting all user accounts...");
if (!fs.existsSync(dbPath)) {
console.log("❌ Database does not exist");
process.exit(1);
}
const sqlite = new Database(dbPath);
const db = drizzle({ client: sqlite });
try {
// Delete all data in order of foreign key dependencies
await db.delete(events);
await db.delete(mirrorJobs);
await db.delete(repositories);
await db.delete(organizations);
await db.delete(configs);
await db.delete(users);
console.log("✅ All user accounts and related data have been removed");
sqlite.close();
} catch (error) {
console.error("❌ Error resetting users:", error);
sqlite.close();
process.exit(1);
}
}
/**
* Clean up database files
*/
async function cleanupDatabase() {
console.log("🧹 Cleaning up database files...");
const filesToRemove = [
dbPath,
path.join(dataDir, "gitea-mirror-dev.db"),
path.join(process.cwd(), "gitea-mirror.db"),
path.join(process.cwd(), "gitea-mirror-dev.db"),
];
for (const file of filesToRemove) {
if (fs.existsSync(file)) {
fs.unlinkSync(file);
console.log(` - Removed: ${file}`);
}
}
console.log("✅ Database cleanup complete");
}
/**
* Fix database location issues
*/
async function fixDatabaseIssues() {
console.log("Checking for database issues...");
async function fixDatabase() {
console.log("🔧 Fixing database location issues...");
// Legacy database paths
const rootDbFile = path.join(process.cwd(), "gitea-mirror.db");
const rootDevDbFile = path.join(process.cwd(), "gitea-mirror-dev.db");
const dataDevDbFile = path.join(dataDir, "gitea-mirror-dev.db");
// Check for database files in the root directory
// Check for databases in wrong locations
if (fs.existsSync(rootDbFile)) {
console.log("Found database file in root directory: gitea-mirror.db");
// If the data directory doesn't have the file, move it there
if (!fs.existsSync(dataDbFile)) {
console.log("Moving database file to data directory...");
fs.copyFileSync(rootDbFile, dataDbFile);
console.log("Database file moved successfully.");
console.log("📁 Found database in root directory");
if (!fs.existsSync(dbPath)) {
console.log(" → Moving to data directory...");
fs.renameSync(rootDbFile, dbPath);
console.log("✅ Database moved successfully");
} else {
console.log(
"Database file already exists in data directory. Checking for differences..."
);
// Compare file sizes to see which is newer/larger
const rootStats = fs.statSync(rootDbFile);
const dataStats = fs.statSync(dataDbFile);
if (
rootStats.size > dataStats.size ||
rootStats.mtime > dataStats.mtime
) {
console.log(
"Root database file is newer or larger. Backing up data directory file and replacing it..."
);
fs.copyFileSync(dataDbFile, `${dataDbFile}.backup-${Date.now()}`);
fs.copyFileSync(rootDbFile, dataDbFile);
console.log("Database file replaced successfully.");
}
console.log(" ⚠️ Database already exists in data directory");
console.log(" → Keeping existing data directory database");
fs.unlinkSync(rootDbFile);
console.log(" → Removed root directory database");
}
// Remove the root file
console.log("Removing database file from root directory...");
fs.unlinkSync(rootDbFile);
console.log("Root database file removed.");
}
// Do the same for dev database
// Clean up dev databases
if (fs.existsSync(rootDevDbFile)) {
console.log(
"Found development database file in root directory: gitea-mirror-dev.db"
);
// If the data directory doesn't have the file, move it there
if (!fs.existsSync(dataDevDbFile)) {
console.log("Moving development database file to data directory...");
fs.copyFileSync(rootDevDbFile, dataDevDbFile);
console.log("Development database file moved successfully.");
} else {
console.log(
"Development database file already exists in data directory. Checking for differences..."
);
// Compare file sizes to see which is newer/larger
const rootStats = fs.statSync(rootDevDbFile);
const dataStats = fs.statSync(dataDevDbFile);
if (
rootStats.size > dataStats.size ||
rootStats.mtime > dataStats.mtime
) {
console.log(
"Root development database file is newer or larger. Backing up data directory file and replacing it..."
);
fs.copyFileSync(dataDevDbFile, `${dataDevDbFile}.backup-${Date.now()}`);
fs.copyFileSync(rootDevDbFile, dataDevDbFile);
console.log("Development database file replaced successfully.");
}
}
// Remove the root file
console.log("Removing development database file from root directory...");
fs.unlinkSync(rootDevDbFile);
console.log("Root development database file removed.");
console.log(" → Removed root dev database");
}
if (fs.existsSync(dataDevDbFile)) {
fs.unlinkSync(dataDevDbFile);
console.log(" → Removed data dev database");
}
// Check if database files exist in the data directory
if (!fs.existsSync(dataDbFile)) {
console.warn(
"⚠️ WARNING: Production database file not found in data directory."
);
console.warn('Run "bun run manage-db init" to create it.');
} else {
console.log("✅ Production database file found in data directory.");
// Check if we can connect to the database
try {
// Try to query the database
const db = new Database(dbPath);
db.query(`SELECT 1 FROM sqlite_master LIMIT 1`).get();
console.log(`✅ Successfully connected to the database.`);
} catch (error) {
console.error("❌ Error connecting to the database:", error);
console.warn(
'The database file might be corrupted. Consider running "bun run manage-db init" to recreate it.'
);
}
}
console.log("Database check completed.");
console.log("✅ Database location fixed");
}
/**
* Main function to handle the command
* Auto mode - check and initialize if needed
*/
async function main() {
console.log(`Database Management Tool for Gitea Mirror`);
// Ensure all required tables exist
console.log("Ensuring all required tables exist...");
await ensureTablesExist();
switch (command) {
case "check":
await checkDatabase();
break;
case "init":
await initializeDatabase();
break;
case "fix":
await fixDatabaseIssues();
break;
case "reset-users":
await resetUsers();
break;
case "auto":
// Auto mode: check, fix, and initialize if needed
console.log("Running in auto mode: check, fix, and initialize if needed");
await fixDatabaseIssues();
if (!fs.existsSync(dataDbFile)) {
await initializeDatabase();
} else {
await checkDatabase();
}
break;
default:
console.log(`
Available commands:
check - Check database status
init - Initialize the database (only if it doesn't exist)
fix - Fix database location issues
reset-users - Remove all users and their data
auto - Automatic mode: check, fix, and initialize if needed
Usage: bun run manage-db [command]
`);
async function autoMode() {
if (!fs.existsSync(dbPath)) {
console.log("📦 Database not found, initializing...");
await initDatabase();
} else {
console.log("✅ Database already exists");
await checkDatabase();
}
}
main().catch((error) => {
console.error("Error during database management:", error);
process.exit(1);
});
// Execute command
switch (command) {
case "init":
await initDatabase();
break;
case "check":
await checkDatabase();
break;
case "fix":
await fixDatabase();
break;
case "reset-users":
await resetUsers();
break;
case "cleanup":
await cleanupDatabase();
break;
case "auto":
await autoMode();
break;
default:
console.log("Available commands:");
console.log(" init - Initialize database with migrations");
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(" cleanup - Remove all database files");
console.log(" auto - Auto initialize if needed");
process.exit(1);
}

View File

@@ -81,21 +81,23 @@ async function repairMirroredRepositories() {
try {
// Find repositories that might need repair
let query = db
.select()
.from(repositories)
.where(
or(
const whereConditions = specificRepo
? and(
or(
eq(repositories.status, "imported"),
eq(repositories.status, "failed")
),
eq(repositories.name, specificRepo)
)
: or(
eq(repositories.status, "imported"),
eq(repositories.status, "failed")
)
);
);
if (specificRepo) {
query = query.where(eq(repositories.name, specificRepo));
}
const repos = await query;
const repos = await db
.select()
.from(repositories)
.where(whereConditions);
if (repos.length === 0) {
if (!isStartupMode) {
@@ -137,7 +139,7 @@ async function repairMirroredRepositories() {
}
const userConfig = config[0];
const giteaUsername = userConfig.giteaConfig?.username;
const giteaUsername = userConfig.giteaConfig?.defaultOwner;
if (!giteaUsername) {
if (!isStartupMode) {

31
scripts/run-migration.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Database } from "bun:sqlite";
import { readFileSync } from "fs";
import path from "path";
const dbPath = path.join(process.cwd(), "data/gitea-mirror.db");
const db = new Database(dbPath);
// Read the migration file
const migrationPath = path.join(process.cwd(), "drizzle/0001_polite_exodus.sql");
const migration = readFileSync(migrationPath, "utf-8");
// Split by statement-breakpoint and execute each statement
const statements = migration.split("--> statement-breakpoint").map(s => s.trim()).filter(s => s);
try {
db.run("BEGIN TRANSACTION");
for (const statement of statements) {
console.log(`Executing: ${statement.substring(0, 50)}...`);
db.run(statement);
}
db.run("COMMIT");
console.log("Migration completed successfully!");
} catch (error) {
db.run("ROLLBACK");
console.error("Migration failed:", error);
process.exit(1);
} finally {
db.close();
}

180
scripts/setup-authentik-test.sh Executable file
View File

@@ -0,0 +1,180 @@
#!/bin/bash
# Setup script for testing Authentik SSO with Gitea Mirror
# This script helps configure Authentik for testing SSO integration
set -e
echo "======================================"
echo "Authentik SSO Test Environment Setup"
echo "======================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if docker and docker-compose are installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo -e "${RED}Docker Compose is not installed. Please install Docker Compose first.${NC}"
exit 1
fi
# Function to generate random secret
generate_secret() {
openssl rand -base64 32 | tr -d '\n' | tr -d '=' | tr -d '/' | tr -d '+'
}
# Function to wait for service
wait_for_service() {
local service=$1
local port=$2
local max_attempts=30
local attempt=1
echo -n "Waiting for $service to be ready"
while ! nc -z localhost $port 2>/dev/null; do
if [ $attempt -eq $max_attempts ]; then
echo -e "\n${RED}Timeout waiting for $service${NC}"
return 1
fi
echo -n "."
sleep 2
((attempt++))
done
echo -e " ${GREEN}Ready!${NC}"
return 0
}
# Parse command line arguments
ACTION=${1:-start}
case $ACTION in
start)
echo "Starting Authentik test environment..."
echo ""
# Check if .env.authentik exists, if not create it
if [ ! -f .env.authentik ]; then
echo "Creating .env.authentik with secure defaults..."
cat > .env.authentik << EOF
# Authentik Configuration
AUTHENTIK_SECRET_KEY=$(generate_secret)
AUTHENTIK_DB_PASSWORD=$(generate_secret)
AUTHENTIK_BOOTSTRAP_PASSWORD=admin-password
AUTHENTIK_BOOTSTRAP_EMAIL=admin@example.com
# Gitea Mirror Configuration
BETTER_AUTH_SECRET=$(generate_secret)
BETTER_AUTH_URL=http://localhost:4321
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321,http://localhost:9000
# URLs for testing
AUTHENTIK_URL=http://localhost:9000
GITEA_MIRROR_URL=http://localhost:4321
EOF
echo -e "${GREEN}Created .env.authentik with secure secrets${NC}"
echo ""
fi
# Load environment variables
source .env.authentik
# Start Authentik services
echo "Starting Authentik services..."
docker-compose -f docker-compose.authentik.yml --env-file .env.authentik up -d
# Wait for Authentik to be ready
echo ""
wait_for_service "Authentik" 9000
# Wait a bit more for initialization
echo "Waiting for Authentik to initialize..."
sleep 10
echo ""
echo -e "${GREEN}✓ Authentik is running!${NC}"
echo ""
echo "======================================"
echo "Authentik Access Information:"
echo "======================================"
echo "URL: http://localhost:9000"
echo "Admin Username: akadmin"
echo "Admin Password: admin-password"
echo ""
echo "======================================"
echo "Next Steps:"
echo "======================================"
echo "1. Access Authentik at http://localhost:9000"
echo "2. Login with akadmin / admin-password"
echo "3. Create OAuth2 Provider for Gitea Mirror:"
echo " - Name: gitea-mirror"
echo " - Redirect URIs:"
echo " http://localhost:4321/api/auth/callback/sso-provider"
echo " - Scopes: openid, profile, email"
echo ""
echo "4. Create Application:"
echo " - Name: Gitea Mirror"
echo " - Slug: gitea-mirror"
echo " - Provider: gitea-mirror (created above)"
echo ""
echo "5. Start Gitea Mirror with:"
echo " bun run dev"
echo ""
echo "6. Configure SSO in Gitea Mirror:"
echo " - Go to Settings → Authentication & SSO"
echo " - Add provider with:"
echo " - Issuer URL: http://localhost:9000/application/o/gitea-mirror/"
echo " - Client ID: (from Authentik provider)"
echo " - Client Secret: (from Authentik provider)"
echo ""
;;
stop)
echo "Stopping Authentik test environment..."
docker-compose -f docker-compose.authentik.yml down
echo -e "${GREEN}✓ Authentik stopped${NC}"
;;
clean)
echo "Cleaning up Authentik test environment..."
docker-compose -f docker-compose.authentik.yml down -v
echo -e "${GREEN}✓ Authentik data cleaned${NC}"
read -p "Remove .env.authentik file? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
rm -f .env.authentik
echo -e "${GREEN}✓ Configuration file removed${NC}"
fi
;;
logs)
docker-compose -f docker-compose.authentik.yml logs -f
;;
status)
echo "Authentik Service Status:"
echo "========================="
docker-compose -f docker-compose.authentik.yml ps
;;
*)
echo "Usage: $0 {start|stop|clean|logs|status}"
echo ""
echo "Commands:"
echo " start - Start Authentik test environment"
echo " stop - Stop Authentik services"
echo " clean - Stop and remove all data"
echo " logs - Show Authentik logs"
echo " status - Show service status"
exit 1
;;
esac

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bun
/**
* Startup environment configuration script
* This script loads configuration from environment variables before the application starts
* It ensures that Docker environment variables are properly populated in the database
*
* Usage:
* bun scripts/startup-env-config.ts
*/
import { initializeConfigFromEnv } from "../src/lib/env-config-loader";
async function runEnvConfigInitialization() {
console.log('=== Gitea Mirror Environment Configuration ===');
console.log('Loading configuration from environment variables...');
console.log('');
const startTime = Date.now();
try {
await initializeConfigFromEnv();
const endTime = Date.now();
const duration = endTime - startTime;
console.log(`✅ Environment configuration loaded successfully in ${duration}ms`);
process.exit(0);
} catch (error) {
const endTime = Date.now();
const duration = endTime - startTime;
console.error(`❌ Failed to load environment configuration after ${duration}ms:`, error);
console.error('Application will start anyway, but environment configuration was not loaded.');
// Exit with error code but allow startup to continue
process.exit(1);
}
}
// Handle process signals gracefully
process.on('SIGINT', () => {
console.log('\n⚠ Configuration loading interrupted by SIGINT');
process.exit(130);
});
process.on('SIGTERM', () => {
console.log('\n⚠ Configuration loading interrupted by SIGTERM');
process.exit(143);
});
// Run the environment configuration initialization
runEnvConfigInitialization();

View File

@@ -47,7 +47,6 @@ async function createTestJob(): Promise<string> {
jobType: "mirror",
totalItems: 10,
itemIds: ['item-1', 'item-2', 'item-3', 'item-4', 'item-5'],
completedItemIds: ['item-1', 'item-2'], // Simulate partial completion
inProgress: true,
});

View File

@@ -1,4 +1,3 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";

View File

@@ -4,50 +4,74 @@ import * as React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/hooks/useAuth';
import { useAuthMethods } from '@/hooks/useAuthMethods';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { authClient } from '@/lib/auth-client';
import { Separator } from '@/components/ui/separator';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [ssoEmail, setSsoEmail] = useState('');
const { login } = useAuth();
const { authMethods, isLoading: isLoadingMethods } = useAuthMethods();
// Determine which tab to show by default
const getDefaultTab = () => {
if (authMethods.emailPassword) return 'email';
if (authMethods.sso.enabled) return 'sso';
return 'email'; // fallback
};
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const username = formData.get('username') as string | null;
const email = formData.get('email') as string | null;
const password = formData.get('password') as string | null;
if (!username || !password) {
toast.error('Please enter both username and password');
if (!email || !password) {
toast.error('Please enter both email and password');
setIsLoading(false);
return;
}
const loginData = { username, password };
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData),
});
await login(email, password);
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1000);
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsLoading(false);
}
}
const data = await response.json();
if (response.ok) {
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showErrorToast(data.error || 'Login failed. Please try again.', toast);
async function handleSSOLogin(domain?: string, providerId?: string) {
setIsLoading(true);
try {
if (!domain && !ssoEmail) {
toast.error('Please enter your email or select a provider');
return;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
await authClient.signIn.sso({
email: ssoEmail || undefined,
domain: domain,
providerId: providerId,
callbackURL: `${baseURL}/`,
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
});
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -61,14 +85,9 @@ export function LoginForm() {
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img
src="/logo-light.svg"
src="/logo.png"
alt="Gitea Mirror Logo"
className="h-10 w-10 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-10 w-10 hidden dark:block"
className="h-8 w-10"
/>
</div>
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
@@ -76,45 +95,196 @@ export function LoginForm() {
Log in to manage your GitHub to Gitea mirroring
</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
name="username"
type="text"
required
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="Enter your username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
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="Enter your password"
disabled={isLoading}
/>
</div>
{isLoadingMethods ? (
<CardContent>
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</CardContent>
) : (
<>
{/* Show tabs only if multiple auth methods are available */}
{authMethods.sso.enabled && authMethods.emailPassword ? (
<Tabs defaultValue={getDefaultTab()} className="w-full">
<TabsList className="grid w-full grid-cols-2 mx-6" style={{ width: 'calc(100% - 3rem)' }}>
<TabsTrigger value="email">
<Mail className="h-4 w-4 mr-2" />
Email
</TabsTrigger>
<TabsTrigger value="sso">
<Globe className="h-4 w-4 mr-2" />
SSO
</TabsTrigger>
</TabsList>
<TabsContent value="email">
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
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="Enter your email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your password"
disabled={isLoading}
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
) : (
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
)}
</button>
</div>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</TabsContent>
<TabsContent value="sso">
<CardContent>
<div className="space-y-4">
{authMethods.sso.providers.length > 0 && (
<>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
Sign in with your organization account
</p>
{authMethods.sso.providers.map(provider => (
<Button
key={provider.id}
variant="outline"
className="w-full"
onClick={() => handleSSOLogin(provider.domain, provider.providerId)}
disabled={isLoading}
>
<Globe className="h-4 w-4 mr-2" />
Sign in with {provider.domain}
</Button>
))}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
</>
)}
<div>
<label htmlFor="sso-email" className="block text-sm font-medium mb-1">
Work Email
</label>
<input
id="sso-email"
type="email"
value={ssoEmail}
onChange={(e) => setSsoEmail(e.target.value)}
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="Enter your work email"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground mt-1">
We'll redirect you to your organization's SSO provider
</p>
</div>
</div>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => handleSSOLogin(undefined, undefined)}
disabled={isLoading || !ssoEmail}
>
{isLoading ? 'Redirecting...' : 'Continue with SSO'}
</Button>
</CardFooter>
</TabsContent>
</Tabs>
) : (
// Single auth method - show email/password only
<>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
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="Enter your email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
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="Enter your password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</>
)}
</>
)}
<div className="px-6 pb-6 text-center">
<p className="text-sm text-muted-foreground">
Don't have an account? Contact your administrator.

View File

@@ -0,0 +1,10 @@
import { LoginForm } from './LoginForm';
import Providers from '@/components/layout/Providers';
export function LoginPage() {
return (
<Providers>
<LoginForm />
</Providers>
);
}

View File

@@ -5,21 +5,25 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { Eye, EyeOff } from 'lucide-react';
export function SignupForm() {
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const { register } = useAuth();
async function handleSignup(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const username = formData.get('username') as string | null;
const email = formData.get('email') as string | null;
const password = formData.get('password') as string | null;
const confirmPassword = formData.get('confirmPassword') as string | null;
if (!username || !email || !password || !confirmPassword) {
if (!email || !password || !confirmPassword) {
toast.error('Please fill in all fields');
setIsLoading(false);
return;
@@ -31,28 +35,15 @@ export function SignupForm() {
return;
}
const signupData = { username, email, password };
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signupData),
});
const data = await response.json();
if (response.ok) {
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
showErrorToast(data.error || 'Failed to create account. Please try again.', toast);
}
// Derive username from email (part before @)
const username = email.split('@')[0];
await register(username, email, password);
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1500);
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -66,14 +57,9 @@ export function SignupForm() {
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img
src="/logo-light.svg"
src="/logo.png"
alt="Gitea Mirror Logo"
className="h-10 w-10 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-10 w-10 hidden dark:block"
className="h-8 w-10"
/>
</div>
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
@@ -84,20 +70,6 @@ export function SignupForm() {
<CardContent>
<form id="signup-form" onSubmit={handleSignup}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
name="username"
type="text"
required
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="Enter your username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
@@ -110,35 +82,64 @@ export function SignupForm() {
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="Enter your email"
disabled={isLoading}
autoFocus
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
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="Create a password"
disabled={isLoading}
/>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Create a password"
disabled={isLoading}
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
) : (
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
)}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
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="Confirm your password"
disabled={isLoading}
/>
<div className="relative">
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
required
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Confirm your password"
disabled={isLoading}
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
tabIndex={-1}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
) : (
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
)}
</button>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,10 @@
import { SignupForm } from './SignupForm';
import Providers from '@/components/layout/Providers';
export function SignupPage() {
return (
<Providers>
<SignupForm />
</Providers>
);
}

View File

@@ -122,12 +122,12 @@ export function AutomationSettings({
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Mirroring Section */}
{/* Automatic Syncing Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" />
Automatic Mirroring
Automatic Syncing
</h3>
{isAutoSavingSchedule && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -195,21 +195,27 @@ export function AutomationSettings({
<Clock className="h-3.5 w-3.5" />
Last sync
</span>
<span className="font-medium">
<span className="font-medium text-muted-foreground">
{scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"}
</span>
</div>
{scheduleConfig.enabled && scheduleConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync
</span>
<span className="font-medium">
{formatDate(scheduleConfig.nextRun)}
</span>
{scheduleConfig.enabled ? (
scheduleConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync
</span>
<span className="font-medium">
{formatDate(scheduleConfig.nextRun)}
</span>
</div>
)
) : (
<div className="text-xs text-muted-foreground">
Enable automatic syncing to schedule periodic repository updates
</div>
)}
</div>
@@ -307,23 +313,27 @@ export function AutomationSettings({
<Clock className="h-3.5 w-3.5" />
Last cleanup
</span>
<span className="font-medium">
<span className="font-medium text-muted-foreground">
{cleanupConfig.lastRun
? formatDate(cleanupConfig.lastRun)
: "Never"}
</span>
</div>
{cleanupConfig.enabled && cleanupConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next cleanup
</span>
<span className="font-medium">
{cleanupConfig.nextRun
? formatDate(cleanupConfig.nextRun)
: "Calculating..."}
</span>
{cleanupConfig.enabled ? (
cleanupConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next cleanup
</span>
<span className="font-medium">
{formatDate(cleanupConfig.nextRun)}
</span>
</div>
)
) : (
<div className="text-xs text-muted-foreground">
Enable automatic cleanup to optimize database storage
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm';
import { AutomationSettings } from './AutomationSettings';
import { SSOSettings } from './SSOSettings';
import type {
ConfigApiResponse,
GiteaConfig,
@@ -20,6 +21,7 @@ import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
type ConfigState = {
githubConfig: GitHubConfig;
@@ -44,19 +46,20 @@ export function ConfigTabs() {
token: '',
organization: 'github-mirrors',
visibility: 'public',
starredReposOrg: 'github',
starredReposOrg: 'starred',
preserveOrgStructure: false,
},
scheduleConfig: {
enabled: false,
interval: 3600,
enabled: false, // Don't set defaults here - will be loaded from API
interval: 0, // Will be replaced with actual value from API
},
cleanupConfig: {
enabled: false,
retentionDays: 604800, // 7 days in seconds
enabled: false, // Don't set defaults here - will be loaded from API
retentionDays: 0, // Will be replaced with actual value from API
},
mirrorOptions: {
mirrorReleases: false,
mirrorLFS: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
@@ -468,10 +471,14 @@ export function ConfigTabs() {
response.giteaConfig || config.giteaConfig,
scheduleConfig:
response.scheduleConfig || config.scheduleConfig,
cleanupConfig:
response.cleanupConfig || config.cleanupConfig,
mirrorOptions:
response.mirrorOptions || config.mirrorOptions,
cleanupConfig: {
...config.cleanupConfig,
...response.cleanupConfig, // Merge to preserve all fields
},
mirrorOptions: {
...config.mirrorOptions,
...response.mirrorOptions, // Merge to preserve all fields including new mirrorLFS
},
advancedOptions:
response.advancedOptions || config.advancedOptions,
});
@@ -601,65 +608,71 @@ export function ConfigTabs() {
</div>
</div>
{/* Content section - Grid layout */}
<div className="space-y-6">
{/* GitHub & Gitea connections - Side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
{/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList>
{/* Automation & Maintenance - Full width */}
<div>
<TabsContent value="connections" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
</TabsContent>
<TabsContent value="automation" className="space-y-4">
<AutomationSettings
scheduleConfig={config.scheduleConfig}
cleanupConfig={config.cleanupConfig}
@@ -674,8 +687,12 @@ export function ConfigTabs() {
isAutoSavingSchedule={isAutoSavingSchedule}
isAutoSavingCleanup={isAutoSavingCleanup}
/>
</div>
</div>
</TabsContent>
<TabsContent value="sso" className="space-y-4">
<SSOSettings />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -29,7 +29,8 @@ import {
BookOpen,
GitFork,
ChevronDown,
Funnel
Funnel,
HardDrive
} from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
import { cn } from "@/lib/utils";
@@ -56,7 +57,7 @@ export function GitHubMirrorSettings({
onGitHubConfigChange({ ...githubConfig, [field]: value });
};
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean) => {
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean | number) => {
onMirrorOptionsChange({ ...mirrorOptions, [field]: value });
};
@@ -311,16 +312,62 @@ export function GitHubMirrorSettings({
checked={mirrorOptions.mirrorReleases}
onCheckedChange={(checked) => handleMirrorChange('mirrorReleases', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<div className="flex items-center justify-between">
<div className="flex-1">
<Label
htmlFor="mirror-releases"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Tag className="h-3.5 w-3.5" />
Releases & Tags
</Label>
<p className="text-xs text-muted-foreground">
Include GitHub releases, tags, and associated assets
</p>
</div>
{mirrorOptions.mirrorReleases && (
<div className="flex items-center gap-2 ml-4">
<label htmlFor="release-limit" className="text-xs text-muted-foreground">
Latest
</label>
<input
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));
handleMirrorChange('releaseLimit', clampedValue);
}}
className="w-16 px-2 py-1 text-xs border border-input rounded bg-background text-foreground"
/>
<span className="text-xs text-muted-foreground">releases</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="mirror-lfs"
checked={mirrorOptions.mirrorLFS}
onCheckedChange={(checked) => handleMirrorChange('mirrorLFS', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="mirror-releases"
htmlFor="mirror-lfs"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Tag className="h-3.5 w-3.5" />
Releases & Tags
<HardDrive className="h-3.5 w-3.5" />
Git LFS (Large File Storage)
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
</Label>
<p className="text-xs text-muted-foreground">
Include GitHub releases, tags, and associated assets
Mirror Git LFS objects. Requires LFS to be enabled on your Gitea server and Git v2.1.2+
</p>
</div>
</div>
@@ -430,6 +477,31 @@ export function GitHubMirrorSettings({
>
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground" />
Pull Requests
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm">
<div className="space-y-2">
<p className="font-semibold">Pull Requests are mirrored as issues</p>
<p className="text-xs">
Due to Gitea API limitations, PRs cannot be created as actual pull requests.
Instead, they are mirrored as issues with:
</p>
<ul className="text-xs space-y-1 ml-3">
<li>• [PR #number] prefix in title</li>
<li>• Full PR description and metadata</li>
<li>• Commit history (up to 10 commits)</li>
<li>• File changes summary</li>
<li>• Diff preview (first 5 files)</li>
<li>• Review comments preserved</li>
<li>• Merge/close status tracking</li>
</ul>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
</div>

View File

@@ -44,11 +44,13 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
case "preserve":
newConfig.preserveOrgStructure = true;
newConfig.mirrorStrategy = "preserve";
newConfig.personalReposOrg = undefined; // Clear personal repos org in preserve mode
break;
case "single-org":
newConfig.preserveOrgStructure = false;
newConfig.mirrorStrategy = "single-org";
if (!newConfig.organization) {
// Reset to default if coming from mixed mode where it was personal repos org
if (config.mirrorStrategy === "mixed" || !newConfig.organization || newConfig.organization === "github-personal") {
newConfig.organization = "github-mirrors";
}
break;
@@ -60,8 +62,10 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
case "mixed":
newConfig.preserveOrgStructure = false;
newConfig.mirrorStrategy = "mixed";
if (!newConfig.organization) {
newConfig.organization = "github-mirrors";
// In mixed mode, organization field represents personal repos org
// Reset it to default if coming from single-org mode
if (config.mirrorStrategy === "single-org" || !newConfig.organization || newConfig.organization === "github-mirrors") {
newConfig.organization = "github-personal";
}
if (!newConfig.personalReposOrg) {
newConfig.personalReposOrg = "github-personal";

View File

@@ -1,226 +0,0 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { MirrorOptions } from "@/types/config";
import { RefreshCw, Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
interface MirrorOptionsFormProps {
config: MirrorOptions;
setConfig: React.Dispatch<React.SetStateAction<MirrorOptions>>;
onAutoSave?: (config: MirrorOptions) => Promise<void>;
isAutoSaving?: boolean;
}
export function MirrorOptionsForm({
config,
setConfig,
onAutoSave,
isAutoSaving = false,
}: MirrorOptionsFormProps) {
const handleChange = (name: string, checked: boolean) => {
let newConfig = { ...config };
if (name === "mirrorMetadata") {
newConfig.mirrorMetadata = checked;
// If disabling metadata, also disable all components
if (!checked) {
newConfig.metadataComponents = {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
};
}
} else if (name.startsWith("metadataComponents.")) {
const componentName = name.split(".")[1] as keyof typeof config.metadataComponents;
newConfig.metadataComponents = {
...config.metadataComponents,
[componentName]: checked,
};
} else {
newConfig = {
...config,
[name]: checked,
};
}
setConfig(newConfig);
// Auto-save
if (onAutoSave) {
onAutoSave(newConfig);
}
};
return (
<Card className="self-start">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center justify-between">
Mirror Options
{isAutoSaving && (
<div className="flex items-center text-sm text-muted-foreground">
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
<span className="text-xs">Auto-saving...</span>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Repository Content */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">Repository Content</h4>
<div className="flex items-center">
<Checkbox
id="mirror-releases"
checked={config.mirrorReleases}
onCheckedChange={(checked) =>
handleChange("mirrorReleases", Boolean(checked))
}
/>
<label
htmlFor="mirror-releases"
className="ml-2 text-sm select-none flex items-center"
>
Mirror releases
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-pointer text-muted-foreground">
<Info size={14} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
Include GitHub releases and tags in the mirror
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex items-center">
<Checkbox
id="mirror-metadata"
checked={config.mirrorMetadata}
onCheckedChange={(checked) =>
handleChange("mirrorMetadata", Boolean(checked))
}
/>
<label
htmlFor="mirror-metadata"
className="ml-2 text-sm select-none flex items-center"
>
Mirror metadata
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-pointer text-muted-foreground">
<Info size={14} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
Include issues, pull requests, labels, milestones, and wiki
</TooltipContent>
</Tooltip>
</label>
</div>
{/* Metadata Components */}
{config.mirrorMetadata && (
<div className="ml-6 space-y-3 border-l-2 border-muted pl-4">
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Metadata Components
</h5>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center">
<Checkbox
id="metadata-issues"
checked={config.metadataComponents.issues}
onCheckedChange={(checked) =>
handleChange("metadataComponents.issues", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-issues"
className="ml-2 text-sm select-none"
>
Issues
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-pullRequests"
checked={config.metadataComponents.pullRequests}
onCheckedChange={(checked) =>
handleChange("metadataComponents.pullRequests", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-pullRequests"
className="ml-2 text-sm select-none"
>
Pull requests
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-labels"
checked={config.metadataComponents.labels}
onCheckedChange={(checked) =>
handleChange("metadataComponents.labels", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-labels"
className="ml-2 text-sm select-none"
>
Labels
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-milestones"
checked={config.metadataComponents.milestones}
onCheckedChange={(checked) =>
handleChange("metadataComponents.milestones", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-milestones"
className="ml-2 text-sm select-none"
>
Milestones
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-wiki"
checked={config.metadataComponents.wiki}
onCheckedChange={(checked) =>
handleChange("metadataComponents.wiki", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-wiki"
className="ml-2 text-sm select-none"
>
Wiki
</label>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -104,7 +104,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
id="destinationOrg"
value={destinationOrg || ""}
onChange={(e) => onDestinationOrgChange(e.target.value)}
placeholder="github-mirrors"
placeholder={strategy === "mixed" ? "github-personal" : "github-mirrors"}
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
@@ -114,32 +114,6 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
}
</p>
</div>
) : strategy === "preserve" ? (
<div className="space-y-1">
<Label htmlFor="personalReposOrg" className="text-sm font-normal flex items-center gap-2">
Personal Repos Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Override where your personal repositories are mirrored (leave empty to use your username)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
id="personalReposOrg"
value={personalReposOrg || ""}
onChange={(e) => onPersonalReposOrgChange(e.target.value)}
placeholder="my-personal-mirrors"
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
Override destination for your personal repos
</p>
</div>
) : (
<div className="hidden md:block" />
)}

View File

@@ -0,0 +1,727 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try {
const url = new URL(issuer);
return allowedHosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`));
} catch {
return false; // Return false if the URL is invalid
}
}
interface SSOProvider {
id: string;
issuer: string;
domain: string;
providerId: string;
organizationId?: string;
oidcConfig?: {
clientId: string;
clientSecret: string;
authorizationEndpoint: string;
tokenEndpoint: string;
jwksEndpoint?: string;
userInfoEndpoint?: string;
discoveryEndpoint?: string;
scopes?: string[];
pkce?: boolean;
};
samlConfig?: {
entryPoint: string;
cert: string;
callbackUrl?: string;
audience?: string;
wantAssertionsSigned?: boolean;
signatureAlgorithm?: string;
digestAlgorithm?: string;
identifierFormat?: string;
};
mapping?: {
id: string;
email: string;
emailVerified?: string;
name?: string;
image?: string;
firstName?: string;
lastName?: string;
};
createdAt: string;
updatedAt: string;
}
export function SSOSettings() {
const [providers, setProviders] = useState<SSOProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showProviderDialog, setShowProviderDialog] = useState(false);
const [addingProvider, setAddingProvider] = useState(false);
const [isDiscovering, setIsDiscovering] = useState(false);
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
const [editingProvider, setEditingProvider] = useState<SSOProvider | null>(null);
// Form states for new provider
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
const [providerForm, setProviderForm] = useState({
// Common fields
issuer: '',
domain: '',
providerId: '',
organizationId: '',
// OIDC fields
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'] as string[],
pkce: true,
// SAML fields
entryPoint: '',
cert: '',
callbackUrl: '',
audience: '',
wantAssertionsSigned: true,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [providersRes, headerAuthStatus] = await Promise.all([
apiRequest<SSOProvider[] | { providers: SSOProvider[] }>('/sso/providers'),
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
]);
setProviders(Array.isArray(providersRes) ? providersRes : providersRes?.providers || []);
setHeaderAuthEnabled(headerAuthStatus.enabled);
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsLoading(false);
}
};
const discoverOIDC = async () => {
if (!providerForm.issuer) {
toast.error('Please enter an issuer URL');
return;
}
setIsDiscovering(true);
try {
const discovered = await apiRequest<any>('/sso/discover', {
method: 'POST',
data: { issuer: providerForm.issuer },
});
setProviderForm(prev => ({
...prev,
authorizationEndpoint: discovered.authorizationEndpoint || '',
tokenEndpoint: discovered.tokenEndpoint || '',
jwksEndpoint: discovered.jwksEndpoint || '',
userInfoEndpoint: discovered.userInfoEndpoint || '',
discoveryEndpoint: discovered.discoveryEndpoint || `${providerForm.issuer}/.well-known/openid-configuration`,
domain: discovered.suggestedDomain || prev.domain,
}));
toast.success('OIDC configuration discovered successfully');
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDiscovering(false);
}
};
const createProvider = async () => {
setAddingProvider(true);
try {
const requestData: any = {
providerId: providerForm.providerId,
issuer: providerForm.issuer,
domain: providerForm.domain,
organizationId: providerForm.organizationId || undefined,
providerType,
};
if (providerType === 'oidc') {
requestData.clientId = providerForm.clientId;
requestData.clientSecret = providerForm.clientSecret;
requestData.authorizationEndpoint = providerForm.authorizationEndpoint;
requestData.tokenEndpoint = providerForm.tokenEndpoint;
requestData.jwksEndpoint = providerForm.jwksEndpoint;
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
requestData.scopes = providerForm.scopes;
requestData.pkce = providerForm.pkce;
} else {
requestData.entryPoint = providerForm.entryPoint;
requestData.cert = providerForm.cert;
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
requestData.audience = providerForm.audience || window.location.origin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm;
requestData.identifierFormat = providerForm.identifierFormat;
}
if (editingProvider) {
// Update existing provider
const updatedProvider = await apiRequest<SSOProvider>(`/sso/providers?id=${editingProvider.id}`, {
method: 'PUT',
data: requestData,
});
setProviders(providers.map(p => p.id === editingProvider.id ? updatedProvider : p));
toast.success('SSO provider updated successfully');
} else {
// Create new provider
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
method: 'POST',
data: requestData,
});
setProviders([...providers, newProvider]);
toast.success('SSO provider created successfully');
}
setShowProviderDialog(false);
setEditingProvider(null);
setProviderForm({
issuer: '',
domain: '',
providerId: '',
organizationId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'] as string[],
pkce: true,
entryPoint: '',
cert: '',
callbackUrl: '',
audience: '',
wantAssertionsSigned: true,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
} catch (error) {
showErrorToast(error, toast);
} finally {
setAddingProvider(false);
}
};
const startEditProvider = (provider: SSOProvider) => {
setEditingProvider(provider);
setProviderType(provider.samlConfig ? 'saml' : 'oidc');
if (provider.oidcConfig) {
setProviderForm({
...providerForm,
providerId: provider.providerId,
issuer: provider.issuer,
domain: provider.domain,
organizationId: provider.organizationId || '',
clientId: provider.oidcConfig.clientId || '',
clientSecret: provider.oidcConfig.clientSecret || '',
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint || '',
tokenEndpoint: provider.oidcConfig.tokenEndpoint || '',
jwksEndpoint: provider.oidcConfig.jwksEndpoint || '',
userInfoEndpoint: provider.oidcConfig.userInfoEndpoint || '',
discoveryEndpoint: provider.oidcConfig.discoveryEndpoint || '',
scopes: provider.oidcConfig.scopes || ['openid', 'email', 'profile'],
pkce: provider.oidcConfig.pkce !== false,
});
}
setShowProviderDialog(true);
};
const deleteProvider = async (id: string) => {
try {
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
setProviders(providers.filter(p => p.id !== id));
toast.success('Provider deleted successfully');
} catch (error) {
showErrorToast(error, toast);
}
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header with status indicators */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold">Authentication & SSO</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure how users authenticate with your application
</p>
</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm text-muted-foreground">
{providers.length} Provider{providers.length !== 1 ? 's' : ''} configured
</span>
</div>
</div>
{/* Authentication Methods Overview */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Email & Password - Always enabled */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Email & Password</span>
<Badge variant="secondary" className="text-xs">Default</Badge>
</div>
<span className="text-xs text-muted-foreground">Always enabled</span>
</div>
{/* Header Authentication Status */}
{headerAuthEnabled && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Header Authentication</span>
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
</div>
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
</div>
)}
{/* SSO Providers Status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm font-medium">SSO/OIDC Providers</span>
</div>
<span className="text-xs text-muted-foreground">
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
</span>
</div>
</div>
{/* Header Auth Info */}
{headerAuthEnabled && (
<Alert className="mt-4">
<Shield className="h-4 w-4" />
<AlertDescription className="text-xs">
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* SSO Providers */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold">External Identity Providers</CardTitle>
<CardDescription className="text-sm">
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
</CardDescription>
</div>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Provider
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
<DialogDescription>
{editingProvider
? 'Update the configuration for this identity provider'
: 'Configure an external identity provider for user authentication'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-1 -mx-1">
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
<TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
</TabsList>
{/* Common Fields */}
<div className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="providerId">Provider ID</Label>
<Input
id="providerId"
value={providerForm.providerId}
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
placeholder="google-sso"
disabled={!!editingProvider}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain">Email Domain</Label>
<Input
id="domain"
value={providerForm.domain}
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
placeholder="example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="issuer">Issuer URL</Label>
<div className="flex gap-2">
<Input
id="issuer"
value={providerForm.issuer}
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
/>
{providerType === 'oidc' && (
<Button
variant="outline"
onClick={discoverOIDC}
disabled={isDiscovering}
>
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
<Input
id="organizationId"
value={providerForm.organizationId}
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
placeholder="org_123"
/>
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
</div>
</div>
<TabsContent value="oidc" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
id="clientId"
value={providerForm.clientId}
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clientSecret">Client Secret</Label>
<Input
id="clientSecret"
type="password"
value={providerForm.clientSecret}
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
<Input
id="authEndpoint"
value={providerForm.authorizationEndpoint}
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
placeholder="https://accounts.google.com/o/oauth2/auth"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
<Input
id="tokenEndpoint"
value={providerForm.tokenEndpoint}
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
placeholder="https://oauth2.googleapis.com/token"
/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">OAuth Scopes</Label>
<MultiSelect
options={[
{ label: "OpenID", value: "openid" },
{ label: "Email", value: "email" },
{ label: "Profile", value: "profile" },
{ label: "Offline Access", value: "offline_access" },
]}
selected={providerForm.scopes}
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
placeholder="Select scopes..."
/>
<p className="text-xs text-muted-foreground">
Select the OAuth scopes to request from the provider
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="pkce"
checked={providerForm.pkce}
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
/>
<Label htmlFor="pkce">Enable PKCE</Label>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
<p className="text-xs text-muted-foreground">
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
</p>
)}
</div>
</AlertDescription>
</Alert>
</TabsContent>
<TabsContent value="saml" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="entryPoint">SAML Entry Point</Label>
<Input
id="entryPoint"
value={providerForm.entryPoint}
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
placeholder="https://idp.example.com/sso"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cert">X.509 Certificate</Label>
<Textarea
id="cert"
value={providerForm.cert}
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
rows={6}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="wantAssertionsSigned"
checked={providerForm.wantAssertionsSigned}
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
/>
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
</div>
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button
variant="outline"
onClick={() => {
setShowProviderDialog(false);
setEditingProvider(null);
// Reset form
setProviderForm({
issuer: '',
domain: '',
providerId: '',
organizationId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'] as string[],
pkce: true,
entryPoint: '',
cert: '',
callbackUrl: '',
audience: '',
wantAssertionsSigned: true,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
}}
>
Cancel
</Button>
<Button onClick={createProvider} disabled={addingProvider}>
{addingProvider ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{editingProvider ? 'Updating...' : 'Creating...'}
</>
) : (
editingProvider ? 'Update Provider' : 'Create Provider'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{providers.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<h3 className="mt-4 text-lg font-medium">No SSO providers configured</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
Enable Single Sign-On by adding an external identity provider like Google, Azure AD, or any OIDC-compliant service.
</p>
<div className="mt-6">
<Button onClick={() => setShowProviderDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Provider
</Button>
</div>
</div>
) : (
<div className="space-y-3">
{providers.map(provider => (
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
<Badge variant="outline" className="text-xs">
{provider.samlConfig ? 'SAML' : 'OIDC'}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
<div className="space-y-2">
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Issuer:</span>
<span className="text-muted-foreground break-all">{provider.issuer}</span>
</div>
{provider.oidcConfig && (
<>
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Client ID:</span>
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
</div>
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Scopes:</span>
<div className="flex flex-wrap gap-1">
{provider.oidcConfig.scopes.map(scope => (
<Badge key={scope} variant="secondary" className="text-xs">
{scope}
</Badge>
))}
</div>
</div>
)}
</>
)}
{provider.samlConfig && (
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Entry Point:</span>
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
</div>
)}
{provider.organizationId && (
<div className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground min-w-[80px]">Organization:</span>
<span className="text-muted-foreground">{provider.organizationId}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={() => startEditProvider(provider)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteProvider(provider.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -83,7 +83,7 @@ export function ScheduleConfigForm({
htmlFor="enabled"
className="select-none ml-2 block text-sm font-medium"
>
Enable Automatic Mirroring
Enable Automatic Syncing
</label>
</div>
@@ -93,7 +93,7 @@ export function ScheduleConfigForm({
htmlFor="interval"
className="block text-sm font-medium mb-1.5"
>
Mirroring Interval
Sync Interval
</label>
<Select
@@ -122,7 +122,7 @@ export function ScheduleConfigForm({
</Select>
<p className="text-xs text-muted-foreground mt-1">
How often the mirroring process should run.
How often the sync process should run.
</p>
<div className="mt-2 p-2 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">

View File

@@ -9,6 +9,7 @@ import { apiRequest, showErrorToast } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { useSSE } from "@/hooks/useSEE";
import { toast } from "sonner";
import { useEffect as useEffectForToasts } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
@@ -16,6 +17,46 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
if (!date) return "Never";
const now = new Date();
const syncDate = new Date(date);
const diffMs = now.getTime() - syncDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
// Show relative time for recent syncs
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
// For older syncs, show week count
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
// For even older, show month count
const diffMonths = Math.floor(diffDays / 30);
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
}
// Helper function to format full timestamp
function formatFullTimestamp(date: Date | null): string {
if (!date) return "";
return new Date(date).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true
}).replace(',', '');
}
export function Dashboard() {
const { user } = useAuth();
const { registerRefreshCallback } = useLiveRefresh();
@@ -65,6 +106,51 @@ export function Dashboard() {
onMessage: handleNewMessage,
});
// Setup rate limit event listener for toast notifications
useEffectForToasts(() => {
if (!user?.id) return;
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
eventSource.addEventListener("rate-limit", (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case "warning":
// 80% threshold warning
toast.warning("GitHub API Rate Limit Warning", {
description: data.message,
duration: 8000,
});
break;
case "exceeded":
// 100% rate limit exceeded
toast.error("GitHub API Rate Limit Exceeded", {
description: data.message,
duration: 10000,
});
break;
case "resumed":
// Rate limit reset notification
toast.success("Rate Limit Reset", {
description: "API operations have resumed.",
duration: 5000,
});
break;
}
} catch (error) {
console.error("Error parsing rate limit event:", error);
}
});
return () => {
eventSource.close();
};
}, [user?.id]);
// Extract fetchDashboardData as a stable callback
const fetchDashboardData = useCallback(async (showToast = false) => {
try {
@@ -193,7 +279,7 @@ export function Dashboard() {
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</div>
@@ -206,7 +292,7 @@ export function Dashboard() {
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</div>
@@ -236,30 +322,19 @@ export function Dashboard() {
/>
<StatusCard
title="Last Sync"
value={
lastSync
? new Date(lastSync).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "N/A"
}
value={formatLastSyncTime(lastSync)}
icon={<Clock className="h-4 w-4" />}
description="Last successful sync"
description={formatFullTimestamp(lastSync)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-6 items-start">
<div className="w-full lg:w-1/2">
<RepositoryList repositories={repositories} />
<RepositoryList repositories={repositories.slice(0, 8)} />
</div>
<div className="w-full lg:w-1/2">
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
<RecentActivity activities={activities.slice(0, 10)} />
<RecentActivity activities={activities.slice(0, 8)} />
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react";
interface RecentActivityProps {
activities: MirrorJob[];
@@ -16,32 +17,46 @@ export function RecentActivity({ activities }: RecentActivityProps) {
<a href="/activity">View All</a>
</Button>
</CardHeader>
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
<div className="flex flex-col divide-y divide-border">
{activities.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity</p>
) : (
activities.map((activity, index) => (
<div key={index} className="flex items-start gap-x-4 py-4">
<div className="relative mt-1">
<CardContent>
{activities.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Clock className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No recent activity</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Activity will appear here when you start mirroring repositories.
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<a href="/activity">
<Activity className="h-3.5 w-3.5 mr-1.5" />
View History
</a>
</Button>
</div>
</div>
) : (
<div className="flex flex-col divide-y divide-border">
{activities.map((activity, index) => (
<div key={index} className="flex items-center gap-x-3 py-3.5">
<div className="relative flex-shrink-0">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status
)}`}
/>
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none break-words">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{activity.message}
</p>
<p className="text-xs text-muted-foreground">
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(activity.timestamp)}
</p>
</div>
</div>
</div>
))
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);

View File

@@ -47,14 +47,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
return (
<Card className="w-full">
{/* calculating the max height based non the other elements and sizing styles */}
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild>
<a href="/repositories">View All</a>
</Button>
</CardHeader>
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
<CardContent>
{repositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
@@ -71,89 +70,80 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
{repositories.map((repo, index) => (
<div
key={index}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-x-4 py-4"
className="flex items-center gap-x-3 py-3.5"
>
<div className="flex-1">
<div className="flex items-center flex-wrap gap-2">
<h4 className="text-sm font-medium break-all">{repo.name}</h4>
{repo.isPrivate && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{repo.owner}
</span>
{repo.organization && (
<span className="text-xs text-muted-foreground">
{repo.organization}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:ml-auto">
<div className="relative flex-shrink-0">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-xs capitalize w-[3rem] sm:w-auto">
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
{repo.status}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-medium truncate">{repo.name}</h4>
{repo.isPrivate && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
Private
</span>
)}
{repo.isForked && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
Fork
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
<span className="truncate">{repo.owner}</span>
{repo.organization && (
<>
<span>/</span>
<span className="truncate">{repo.organization}</span>
</>
)}
</div>
</div>
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-medium mr-2
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' :
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 dark:text-green-400' :
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' :
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 dark:text-red-400' :
'bg-muted text-muted-foreground'}`}>
{repo.status}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{(() => {
const giteaUrl = getGiteaRepoUrl(repo);
const giteaEnabled = giteaUrl && ['mirrored', 'synced'].includes(repo.status);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (repo.status === 'imported') {
tooltip = "Repository not yet mirrored to Gitea";
} else if (repo.status === 'failed') {
tooltip = "Repository mirroring failed";
} else if (repo.status === 'mirroring') {
tooltip = "Repository is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea repository not available";
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
return giteaEnabled ? (
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
title="View on Gitea"
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled title="Not mirrored yet">
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>

View File

@@ -7,7 +7,7 @@ import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu, LogOut } from "lucide-react";
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,9 +19,12 @@ interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
onNavigate?: (page: string) => void;
onMenuClick: () => void;
onToggleCollapse?: () => void;
isSidebarCollapsed?: boolean;
isSidebarOpen?: boolean;
}
export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
const { user, logout, isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -63,18 +66,38 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
return (
<header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-2">
{/* Hamburger Menu Button - Mobile Only */}
<div className="flex items-center lg:gap-12 md:gap-6 gap-4">
{/* Sidebar Toggle - Mobile uses slide-in, Medium uses collapse */}
<Button
variant="outline"
size="lg"
className="lg:hidden"
size="icon"
className="md:hidden h-10 w-10"
onClick={onMenuClick}
>
<Menu className="h-5 w-5" />
{isSidebarOpen ? (
<PanelRightOpen className="h-5 w-5" />
) : (
<PanelRightClose className="h-5 w-5" />
)}
<span className="sr-only">Toggle menu</span>
</Button>
{/* Sidebar Collapse Toggle - Only on medium screens (768px - 1280px) */}
<Button
variant="ghost"
size="icon"
className="hidden md:flex xl:hidden h-10 w-10"
onClick={onToggleCollapse}
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isSidebarCollapsed ? (
<PanelRightClose className="h-5 w-5" />
) : (
<PanelRightOpen className="h-5 w-5" />
)}
<span className="sr-only">Toggle sidebar</span>
</Button>
<button
onClick={() => {
if (currentPage !== 'dashboard') {
@@ -85,14 +108,9 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo-light.svg"
src="/logo.png"
alt="Gitea Mirror Logo"
className="h-6 w-6 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 hidden dark:block"
className="h-5 w-6"
/>
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
</button>
@@ -129,9 +147,9 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
<Avatar className="h-full w-full">
<AvatarImage src="" alt="@shadcn" />
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
{(user.name || user.email || "U").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>

View File

@@ -45,13 +45,20 @@ function AppWithProviders({ page: initialPage }: AppProps) {
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
const [navigationKey, setNavigationKey] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Check if we're on medium screens (768px - 1280px)
if (typeof window !== 'undefined') {
return window.innerWidth >= 768 && window.innerWidth < 1280;
}
return false;
});
useRepoSync({
userId: user?.id,
enabled: user?.syncEnabled,
interval: user?.syncInterval,
lastSync: user?.lastSync,
nextSync: user?.nextSync,
enabled: false, // TODO: Get from config
interval: 3600, // TODO: Get from config
lastSync: null,
nextSync: null,
});
// Handle navigation from sidebar
@@ -83,6 +90,23 @@ function AppWithProviders({ page: initialPage }: AppProps) {
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Handle window resize to auto-collapse sidebar on medium screens
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
// Auto-collapse on medium screens (768px - 1280px)
if (width >= 768 && width < 1280) {
setSidebarCollapsed(true);
} else if (width >= 1280) {
// Expand on large screens
setSidebarCollapsed(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Show loading state only during initial auth/config loading
const isInitialLoading = authLoading || (configLoading && !user);
@@ -97,6 +121,15 @@ function AppWithProviders({ page: initialPage }: AppProps) {
);
}
// Redirect to login if not authenticated
if (!authLoading && !user) {
// Use window.location for client-side redirect
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return null;
}
return (
<NavigationContext.Provider value={{ navigationKey }}>
<main className="flex min-h-screen flex-col">
@@ -104,14 +137,21 @@ function AppWithProviders({ page: initialPage }: AppProps) {
currentPage={currentPage}
onNavigate={handleNavigation}
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
isSidebarCollapsed={sidebarCollapsed}
isSidebarOpen={sidebarOpen}
/>
<div className="flex flex-1 relative">
<Sidebar
onNavigate={handleNavigation}
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
onClose={() => setSidebarOpen(false)}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<section className="flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full lg:w-[calc(100%-16rem)]">
<section className={`flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full transition-all duration-200 ${
sidebarCollapsed ? 'md:w-[calc(100%-5rem)] xl:w-[calc(100%-16rem)]' : 'md:w-[calc(100%-16rem)]'
}`}>
{currentPage === "dashboard" && <Dashboard />}
{currentPage === "repositories" && <Repository />}
{currentPage === "organizations" && <Organization />}

View File

@@ -3,15 +3,23 @@ import { cn } from "@/lib/utils";
import { ExternalLink } from "lucide-react";
import { links } from "@/data/Sidebar";
import { VersionInfo } from "./VersionInfo";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface SidebarProps {
className?: string;
onNavigate?: (page: string) => void;
isOpen: boolean;
isCollapsed?: boolean;
onClose: () => void;
onToggleCollapse?: () => void;
}
export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, onClose, onToggleCollapse }: SidebarProps) {
const [currentPath, setCurrentPath] = useState<string>("");
useEffect(() => {
@@ -53,7 +61,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
onNavigate?.(pageName);
// Close sidebar on mobile after navigation
if (window.innerWidth < 1024) {
if (window.innerWidth < 768) {
onClose();
}
};
@@ -63,7 +71,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
{/* Mobile Backdrop */}
{isOpen && (
<div
className="fixed inset-0 backdrop-blur-sm z-40 lg:hidden"
className="fixed inset-0 backdrop-blur-sm z-40 md:hidden"
onClick={onClose}
/>
)}
@@ -71,54 +79,126 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
{/* Sidebar */}
<aside
className={cn(
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-background border-r flex flex-col h-full lg:h-[calc(100vh-4.5rem)] transition-transform duration-200 ease-in-out lg:translate-x-0",
"fixed md:static inset-y-0 left-0 z-50 bg-background border-r flex flex-col h-full md:h-[calc(100vh-4.5rem)] transition-all duration-200 ease-in-out md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full",
isCollapsed ? "md:w-20 xl:w-64" : "w-64",
className
)}
>
<div className="flex flex-col h-full">
<nav className="flex flex-col gap-y-1 lg:gap-y-1 pl-2 pr-3 pt-4 flex-shrink-0">
<nav className={cn(
"flex flex-col pt-4 flex-shrink-0",
isCollapsed
? "md:gap-y-2 md:items-center md:px-2 xl:gap-y-1 xl:items-stretch xl:pl-2 xl:pr-3 gap-y-1 pl-2 pr-3"
: "gap-y-1 pl-2 pr-3"
)}>
{links.map((link, index) => {
const isActive = currentPath === link.href;
const Icon = link.icon;
return (
const button = (
<button
key={index}
onClick={(e) => handleNavigation(link.href, e)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-3 lg:py-2 text-sm lg:text-sm font-medium transition-colors w-full text-left",
"flex items-center rounded-md text-sm font-medium transition-colors w-full",
isCollapsed
? "md:h-12 md:w-12 md:justify-center md:p-0 xl:h-auto xl:w-full xl:justify-start xl:px-3 xl:py-2 h-auto px-3 py-3"
: "px-3 py-3 md:py-2",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-5 w-5 lg:h-4 lg:w-4" />
{link.label}
<Icon className={cn(
"flex-shrink-0",
isCollapsed
? "md:h-5 md:w-5 md:mr-0 xl:h-4 xl:w-4 xl:mr-3 h-5 w-5 mr-3"
: "h-5 w-5 md:h-4 md:w-4 mr-3"
)} />
<span className={cn(
"transition-all duration-200",
isCollapsed ? "md:hidden xl:inline" : "inline"
)}>
{link.label}
</span>
</button>
);
// Wrap in tooltip when collapsed on medium screens
if (isCollapsed) {
return (
<TooltipProvider key={index}>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent side="right" className="hidden md:block xl:hidden">
{link.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
})}
</nav>
<div className="flex-1 min-h-0" />
<div className="px-4 py-4 flex-shrink-0">
<div className="rounded-md bg-muted p-3 lg:p-3">
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-3 lg:mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
>
Documentation
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
</a>
<div className={cn(
"py-4 flex-shrink-0",
isCollapsed ? "md:px-2 xl:px-4 px-4" : "px-4"
)}>
<div className={cn(
"rounded-md bg-muted transition-all duration-200",
isCollapsed ? "md:p-0 xl:p-3 p-3" : "p-3"
)}>
<div className={cn(
isCollapsed ? "md:hidden xl:block" : "block"
)}>
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-3 md:mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
>
Documentation
<ExternalLink className="h-3.5 w-3.5 md:h-3 md:w-3" />
</a>
</div>
{/* Icon-only help button for collapsed state on medium screens */}
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center justify-center rounded-md hover:bg-accent transition-colors",
isCollapsed ? "md:h-12 md:w-12 xl:hidden hidden" : "hidden"
)}
>
<ExternalLink className="h-5 w-5" />
</a>
</TooltipTrigger>
<TooltipContent side="right">
Documentation
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className={cn(
isCollapsed ? "md:hidden xl:block" : "block"
)}>
<VersionInfo />
</div>
<VersionInfo />
</div>
</div>
</aside>

View File

@@ -0,0 +1,72 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Coffee, Sparkles } from "lucide-react";
import { isSelfHostedMode } from "@/lib/deployment-mode";
export function SponsorCard() {
// Only show in self-hosted mode
if (!isSelfHostedMode()) {
return null;
}
return (
<div className="mt-auto p-4 border-t">
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 border-purple-500/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Heart className="w-4 h-4 text-pink-500" />
Support Development
</CardTitle>
<CardDescription className="text-xs">
Help us improve Gitea Mirror
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Gitea Mirror is open source and free. Your sponsorship helps us maintain and improve it.
</p>
<div className="space-y-2">
<Button
className="w-full h-8 text-xs"
size="sm"
asChild
>
<a
href="https://github.com/sponsors/RayLabsHQ"
target="_blank"
rel="noopener noreferrer"
>
<Heart className="w-3 h-3 mr-2" />
Sponsor on GitHub
</a>
</Button>
<Button
className="w-full h-8 text-xs"
size="sm"
variant="outline"
asChild
>
<a
href="https://buymeacoffee.com/raylabs"
target="_blank"
rel="noopener noreferrer"
>
<Coffee className="w-3 h-3 mr-2" />
Buy us a coffee
</a>
</Button>
</div>
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Pro features available in hosted version
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { authClient } from '@/lib/auth-client';
import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast, Toaster } from 'sonner';
import { Shield, User, Mail, ChevronRight, AlertTriangle, Loader2 } from 'lucide-react';
import { isValidRedirectUri, parseRedirectUris } from '@/lib/utils/oauth-validation';
interface OAuthApplication {
id: string;
clientId: string;
name: string;
redirectURLs: string;
type: string;
}
interface ConsentRequest {
clientId: string;
scope: string;
state?: string;
redirectUri?: string;
}
export default function ConsentPage() {
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [application, setApplication] = useState<OAuthApplication | null>(null);
const [scopes, setScopes] = useState<string[]>([]);
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadConsentDetails();
}, []);
const loadConsentDetails = async () => {
try {
const params = new URLSearchParams(window.location.search);
const clientId = params.get('client_id');
const scope = params.get('scope');
const redirectUri = params.get('redirect_uri');
if (!clientId) {
setError('Invalid authorization request: missing client ID');
return;
}
// Fetch application details
const apps = await apiRequest<OAuthApplication[]>('/sso/applications');
const app = apps.find(a => a.clientId === clientId);
if (!app) {
setError('Invalid authorization request: unknown application');
return;
}
// Validate redirect URI if provided
if (redirectUri) {
const authorizedUris = parseRedirectUris(app.redirectURLs);
if (!isValidRedirectUri(redirectUri, authorizedUris)) {
setError('Invalid authorization request: unauthorized redirect URI');
return;
}
}
setApplication(app);
// Parse requested scopes
const requestedScopes = scope ? scope.split(' ').filter(s => s) : ['openid'];
setScopes(requestedScopes);
// By default, select all requested scopes
setSelectedScopes(new Set(requestedScopes));
} catch (error) {
console.error('Failed to load consent details:', error);
setError('Failed to load authorization details');
} finally {
setIsLoading(false);
}
};
const handleConsent = async (accept: boolean) => {
setIsSubmitting(true);
try {
const result = await authClient.oauth2.consent({
accept,
});
if (result.error) {
throw new Error(result.error.message || 'Consent failed');
}
// The consent method should handle the redirect
if (!accept) {
// If denied, redirect back to the application with error
const params = new URLSearchParams(window.location.search);
const redirectUri = params.get('redirect_uri');
if (redirectUri && application) {
// Validate redirect URI against authorized URIs
const authorizedUris = parseRedirectUris(application.redirectURLs);
if (isValidRedirectUri(redirectUri, authorizedUris)) {
try {
// Parse and reconstruct the URL to ensure it's safe
const url = new URL(redirectUri);
url.searchParams.set('error', 'access_denied');
// Safe to redirect - URI has been validated and sanitized
window.location.href = url.toString();
} catch (e) {
console.error('Failed to parse redirect URI:', e);
setError('Invalid redirect URI');
}
} else {
console.error('Unauthorized redirect URI:', redirectUri);
setError('Invalid redirect URI');
}
}
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsSubmitting(false);
}
};
const toggleScope = (scope: string) => {
// openid scope is always required
if (scope === 'openid') return;
const newSelected = new Set(selectedScopes);
if (newSelected.has(scope)) {
newSelected.delete(scope);
} else {
newSelected.add(scope);
}
setSelectedScopes(newSelected);
};
const getScopeDescription = (scope: string): { name: string; description: string; icon: any } => {
const scopeDescriptions: Record<string, { name: string; description: string; icon: any }> = {
openid: {
name: 'Basic Information',
description: 'Your user ID (required)',
icon: User,
},
profile: {
name: 'Profile Information',
description: 'Your name, username, and profile picture',
icon: User,
},
email: {
name: 'Email Address',
description: 'Your email address and verification status',
icon: Mail,
},
};
return scopeDescriptions[scope] || {
name: scope,
description: `Access to ${scope} information`,
icon: Shield,
};
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-2xl">Authorization Error</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button
variant="outline"
className="w-full"
onClick={() => window.history.back()}
>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Shield className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Authorize {application?.name}</CardTitle>
<CardDescription>
This application is requesting access to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm font-medium mb-2">Requested permissions:</p>
<div className="space-y-3">
{scopes.map(scope => {
const scopeInfo = getScopeDescription(scope);
const Icon = scopeInfo.icon;
const isRequired = scope === 'openid';
return (
<div key={scope} className="flex items-start space-x-3">
<Checkbox
id={scope}
checked={selectedScopes.has(scope)}
onCheckedChange={() => toggleScope(scope)}
disabled={isRequired || isSubmitting}
/>
<div className="flex-1">
<Label
htmlFor={scope}
className="flex items-center gap-2 font-medium cursor-pointer"
>
<Icon className="h-4 w-4" />
{scopeInfo.name}
{isRequired && (
<span className="text-xs text-muted-foreground">(required)</span>
)}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{scopeInfo.description}
</p>
</div>
</div>
);
})}
</div>
</div>
<Separator />
<div className="text-sm text-muted-foreground">
<p className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
You'll be redirected to {application?.type === 'web' ? 'the website' : 'the application'}
</p>
<p className="flex items-center gap-1 mt-1">
<ChevronRight className="h-3 w-3" />
You can revoke access at any time in your account settings
</p>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => handleConsent(false)}
disabled={isSubmitting}
>
Deny
</Button>
<Button
className="flex-1"
onClick={() => handleConsent(true)}
disabled={isSubmitting || selectedScopes.size === 0}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Authorizing...
</>
) : (
'Authorize'
)}
</Button>
</CardFooter>
</Card>
</div>
<Toaster />
</>
);
}

View File

@@ -196,6 +196,63 @@ export function Organization() {
}
};
const handleIgnoreOrg = async ({ orgId, ignore }: { orgId: string; ignore: boolean }) => {
try {
if (!user || !user.id) {
return;
}
const org = organizations.find(o => o.id === orgId);
// Check if organization is currently being processed
if (ignore && org && (org.status === "mirroring")) {
toast.warning("Cannot ignore organization while it's being processed");
return;
}
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
const newStatus = ignore ? "ignored" : "imported";
const response = await apiRequest<{ success: boolean; organization?: Organization; error?: string }>(
`/organizations/${orgId}/status`,
{
method: "PATCH",
data: {
status: newStatus,
userId: user.id
},
}
);
if (response.success) {
toast.success(ignore
? `Organization will be ignored in future operations`
: `Organization included for mirroring`
);
// Update local state
setOrganizations((prevOrgs) =>
prevOrgs.map((org) =>
org.id === orgId ? { ...org, status: newStatus } : org
)
);
} else {
toast.error(response.error || `Failed to ${ignore ? 'ignore' : 'include'} organization`);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : `Error ${ignore ? 'ignoring' : 'including'} organization`
);
} finally {
setLoadingOrgIds((prev) => {
const newSet = new Set(prev);
newSet.delete(orgId);
return newSet;
});
}
};
const handleAddOrganization = async ({
org,
role,
@@ -248,10 +305,10 @@ export function Organization() {
return;
}
// Filter out organizations that are already mirrored to avoid duplicate operations
// Filter out organizations that are already mirrored or ignored to avoid duplicate operations
const eligibleOrgs = organizations.filter(
(org) =>
org.status !== "mirroring" && org.status !== "mirrored" && org.id
org.status !== "mirroring" && org.status !== "mirrored" && org.status !== "ignored" && org.id
);
if (eligibleOrgs.length === 0) {
@@ -652,6 +709,7 @@ export function Organization() {
setFilter={setFilter}
loadingOrgIds={loadingOrgIds}
onMirror={handleMirrorOrg}
onIgnore={handleIgnoreOrg}
onAddOrganization={() => setIsDialogOpen(true)}
onRefresh={async () => {
await fetchOrganizations(false);

View File

@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
@@ -11,6 +11,14 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface OrganizationListProps {
organizations: Organization[];
@@ -18,6 +26,7 @@ interface OrganizationListProps {
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
loadingOrgIds: Set<string>;
onAddOrganization?: () => void;
onRefresh?: () => Promise<void>;
@@ -34,6 +43,8 @@ const getStatusBadge = (status: string | null) => {
return { variant: "default" as const, label: "Mirrored", icon: Check };
case "failed":
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
case "ignored":
return { variant: "outline" as const, label: "Ignored", icon: Ban };
default:
return { variant: "secondary" as const, label: "Unknown", icon: null };
}
@@ -45,6 +56,7 @@ export function OrganizationList({
filter,
setFilter,
onMirror,
onIgnore,
loadingOrgIds,
onAddOrganization,
onRefresh,
@@ -197,16 +209,39 @@ export function OrganizationList({
{statusBadge.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
}`}
>
{org.membershipRole}
</span>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
}`}
>
{org.membershipRole}
</span>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-semibold">{org.repositoryCount}</span>
<span className="ml-1">repos</span>
{/* Repository breakdown for mobile - only show non-zero counts */}
{(() => {
const parts = [];
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
parts.push(`${org.publicRepositoryCount} pub`);
}
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
parts.push(`${org.privateRepositoryCount} priv`);
}
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
parts.push(`${org.forkRepositoryCount} fork`);
}
return parts.length > 0 ? (
<span className="ml-1">({parts.join(' | ')})</span>
) : null;
})()}
</div>
</div>
</div>
@@ -215,7 +250,7 @@ export function OrganizationList({
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
currentDestination={org.destinationOrg ?? undefined}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
@@ -260,7 +295,7 @@ export function OrganizationList({
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
currentDestination={org.destinationOrg ?? undefined}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
@@ -276,32 +311,29 @@ export function OrganizationList({
</span>
</div>
{/* Repository breakdown */}
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
</div>
) : (
<div className="flex items-center gap-3">
{org.publicRepositoryCount !== undefined && (
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="text-muted-foreground">
{org.publicRepositoryCount} public
{/* Repository breakdown - only show non-zero counts */}
{(() => {
const counts = [];
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
counts.push(`${org.publicRepositoryCount} public`);
}
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
counts.push(`${org.privateRepositoryCount} private`);
}
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
counts.push(`${org.forkRepositoryCount} ${org.forkRepositoryCount === 1 ? 'fork' : 'forks'}`);
}
return counts.length > 0 ? (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{counts.map((count, index) => (
<span key={index} className={index > 0 ? "border-l pl-3" : ""}>
{count}
</span>
</div>
)}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
<span className="text-muted-foreground">
{org.privateRepositoryCount} private
</span>
</div>
)}
</div>
)}
))}
</div>
) : null;
})()}
</div>
</div>
</div>
@@ -309,61 +341,95 @@ export function OrganizationList({
{/* Mobile Actions */}
<div className="flex flex-col gap-3 sm:hidden">
<div className="flex items-center gap-2">
{org.status === "imported" && (
{org.status === "ignored" ? (
<Button
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
variant="outline"
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: false })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="default" disabled variant="outline" className="w-full h-10">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring...
</Button>
)}
{org.status === "mirrored" && (
<Button size="default" disabled variant="secondary" className="w-full h-10">
<Check className="h-4 w-4 mr-2" />
Mirrored
Include Organization
</Button>
) : (
<>
{org.status === "imported" && (
<Button
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="default" disabled variant="outline" className="w-full h-10">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring...
</Button>
)}
{org.status === "mirrored" && (
<Button size="default" disabled variant="secondary" className="w-full h-10">
<Check className="h-4 w-4 mr-2" />
Mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
</>
)}
{org.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
{/* Dropdown menu for additional actions */}
{org.status !== "ignored" && org.status !== "mirroring" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
>
<Ban className="h-4 w-4 mr-2" />
Ignore Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
@@ -425,59 +491,92 @@ export function OrganizationList({
{/* Desktop Actions */}
<div className="hidden sm:flex items-center justify-between mt-4">
<div className="flex items-center gap-2">
{org.status === "imported" && (
{org.status === "ignored" ? (
<Button
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
variant="outline"
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: false })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting mirror...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="default" disabled variant="outline">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring in progress...
</Button>
)}
{org.status === "mirrored" && (
<Button size="default" disabled variant="secondary">
<Check className="h-4 w-4 mr-2" />
Successfully mirrored
Include Organization
</Button>
) : (
<>
{org.status === "imported" && (
<Button
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting mirror...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="default" disabled variant="outline">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring in progress...
</Button>
)}
{org.status === "mirrored" && (
<Button size="default" disabled variant="secondary">
<Check className="h-4 w-4 mr-2" />
Successfully mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
</>
)}
{org.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
{/* Dropdown menu for additional actions */}
{org.status !== "ignored" && org.status !== "mirroring" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={isLoading}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
>
<Ban className="h-4 w-4 mr-2" />
Ignore Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

View File

@@ -48,8 +48,8 @@ export function InlineDestinationEditor({
if (repository.organization) {
return repository.organization;
}
// For personal repos, check if personalReposOrg is configured
if (!repository.organization && giteaConfig?.personalReposOrg) {
// For personal repos, check if personalReposOrg is configured (but not in preserve mode)
if (!repository.organization && giteaConfig?.personalReposOrg && strategy !== 'preserve') {
return giteaConfig.personalReposOrg;
}
// Default to the gitea username or owner

View File

@@ -9,7 +9,7 @@ import {
type RepositoryApiResponse,
type RepoStatus,
} from "@/types/Repository";
import { apiRequest, showErrorToast } from "@/lib/utils";
import { apiRequest, showErrorToast, getStatusColor } from "@/lib/utils";
import {
Select,
SelectContent,
@@ -18,7 +18,7 @@ import {
SelectValue,
} from "../ui/select";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter } from "lucide-react";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import {
Drawer,
@@ -183,7 +183,9 @@ export default function Repository() {
);
if (response.success) {
toast.success(`Mirroring started for repository ID: ${repoId}`);
const repo = repositories.find(r => r.id === repoId);
const repoName = repo?.fullName || `repository ${repoId}`;
toast.success(`Mirroring started for ${repoName}`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
@@ -210,10 +212,13 @@ export default function Repository() {
return;
}
// Filter out repositories that are already mirroring to avoid duplicate operations. also filter out mirrored (mirrored can be synced and not mirrored again)
// Filter out repositories that are already mirroring, mirrored, or ignored
const eligibleRepos = repositories.filter(
(repo) =>
repo.status !== "mirroring" && repo.status !== "mirrored" && repo.id //not ignoring failed ones because we want to retry them if not mirrored. if mirrored, gitea fucnion handlers will silently ignore them
repo.status !== "mirroring" &&
repo.status !== "mirrored" &&
repo.status !== "ignored" && // Skip ignored repositories
repo.id
);
if (eligibleRepos.length === 0) {
@@ -400,6 +405,80 @@ export default function Repository() {
}
};
const handleBulkSkip = async (skip: boolean) => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = skip
? selectedRepos.filter(repo =>
repo.status !== "ignored" &&
repo.status !== "mirroring" &&
repo.status !== "syncing"
)
: selectedRepos.filter(repo => repo.status === "ignored");
if (eligibleRepos.length === 0) {
toast.info(`No eligible repositories to ${skip ? "ignore" : "include"} 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 {
// Update each repository's status
const newStatus = skip ? "ignored" : "imported";
const promises = repoIds.map(repoId =>
apiRequest<{ success: boolean; repository?: Repository; error?: string }>(
`/repositories/${repoId}/status`,
{
method: "PATCH",
data: { status: newStatus, userId: user?.id },
}
)
);
const results = await Promise.allSettled(promises);
const successCount = results.filter(r => r.status === "fulfilled" && (r.value as any).success).length;
if (successCount > 0) {
toast.success(`${successCount} repositories ${skip ? "ignored" : "included"}`);
// Update local state for successful updates
const successfulRepoIds = new Set<string>();
results.forEach((result, index) => {
if (result.status === "fulfilled" && (result.value as any).success) {
successfulRepoIds.add(repoIds[index]);
}
});
setRepositories(prevRepos =>
prevRepos.map(repo => {
if (repo.id && successfulRepoIds.has(repo.id)) {
return { ...repo, status: newStatus as any };
}
return repo;
})
);
setSelectedRepoIds(new Set());
}
if (successCount < repoIds.length) {
toast.error(`Failed to ${skip ? "ignore" : "include"} ${repoIds.length - successCount} repositories`);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
try {
if (!user || !user.id) {
@@ -419,7 +498,9 @@ export default function Repository() {
});
if (response.success) {
toast.success(`Syncing started for repository ID: ${repoId}`);
const repo = repositories.find(r => r.id === repoId);
const repoName = repo?.fullName || `repository ${repoId}`;
toast.success(`Syncing started for ${repoName}`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
@@ -440,6 +521,58 @@ export default function Repository() {
}
};
const handleSkipRepo = async ({ repoId, skip }: { repoId: string; skip: boolean }) => {
try {
if (!user || !user.id) {
return;
}
// Check if repository is currently being processed
const repo = repositories.find(r => r.id === repoId);
if (skip && repo && (repo.status === "mirroring" || repo.status === "syncing")) {
toast.warning("Cannot skip repository while it's being processed");
return;
}
// Set loading state
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
newSet.add(repoId);
return newSet;
});
const newStatus = skip ? "ignored" : "imported";
// Update repository status via API
const response = await apiRequest<{ success: boolean; repository?: Repository; error?: string }>(
`/repositories/${repoId}/status`,
{
method: "PATCH",
data: { status: newStatus, userId: user.id },
}
);
if (response.success && response.repository) {
toast.success(`Repository ${skip ? "ignored" : "included"}`);
setRepositories(prevRepos =>
prevRepos.map(repo =>
repo.id === repoId ? response.repository! : repo
)
);
} else {
showErrorToast(response.error || `Error ${skip ? "ignoring" : "including"} repository`, toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
newSet.delete(repoId);
return newSet;
});
}
};
const handleRetryRepoAction = async ({ repoId }: { repoId: string }) => {
try {
if (!user || !user.id) {
@@ -459,7 +592,9 @@ export default function Repository() {
});
if (response.success) {
toast.success(`Retrying job for repository ID: ${repoId}`);
const repo = repositories.find(r => r.id === repoId);
const repoName = repo?.fullName || `repository ${repoId}`;
toast.success(`Retrying job for ${repoName}`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
@@ -543,7 +678,6 @@ export default function Repository() {
if (selectedRepoIds.size === 0) return [];
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const statuses = new Set(selectedRepos.map(repo => repo.status));
const actions = [];
@@ -562,10 +696,35 @@ export default function Repository() {
actions.push('retry');
}
// Check if any selected repos can be ignored
if (selectedRepos.some(repo => repo.status !== "ignored")) {
actions.push('ignore');
}
// Check if any selected repos can be included (unignored)
if (selectedRepos.some(repo => repo.status === "ignored")) {
actions.push('include');
}
return actions;
};
const availableActions = getAvailableActions();
// Get counts for eligible repositories for each action
const getActionCounts = () => {
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
return {
mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed").length,
sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").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,
};
};
const actionCounts = getActionCounts();
// Check if any filters are active
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
@@ -707,12 +866,7 @@ export default function Repository() {
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${
status === "synced" ? "bg-green-500" :
status === "failed" ? "bg-red-500" :
status === "syncing" ? "bg-blue-500" :
"bg-yellow-500"
}`} />
<span className={`h-2 w-2 rounded-full ${getStatusColor(status)}`} />
)}
{status === "all"
? "All statuses"
@@ -814,12 +968,7 @@ export default function Repository() {
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${
status === "synced" ? "bg-green-500" :
status === "failed" ? "bg-red-500" :
status === "syncing" ? "bg-blue-500" :
"bg-yellow-500"
}`} />
<span className={`h-2 w-2 rounded-full ${getStatusColor(status)}`} />
)}
{status === "all"
? "All statuses"
@@ -877,7 +1026,7 @@ export default function Repository() {
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
Mirror ({actionCounts.mirror})
</Button>
)}
@@ -889,7 +1038,7 @@ export default function Repository() {
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
Sync ({actionCounts.sync})
</Button>
)}
@@ -904,6 +1053,30 @@ export default function Repository() {
Retry
</Button>
)}
{availableActions.includes('ignore') && (
<Button
variant="ghost"
size="default"
onClick={() => handleBulkSkip(true)}
disabled={loadingRepoIds.size > 0}
>
<Ban className="h-4 w-4 mr-2" />
Ignore
</Button>
)}
{availableActions.includes('include') && (
<Button
variant="outline"
size="default"
onClick={() => handleBulkSkip(false)}
disabled={loadingRepoIds.size > 0}
>
<Check className="h-4 w-4 mr-2" />
Include
</Button>
)}
</>
)}
</div>
@@ -936,7 +1109,7 @@ export default function Repository() {
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
<span>Mirror </span>({selectedRepoIds.size})
<span>Mirror </span>({actionCounts.mirror})
</Button>
)}
@@ -948,7 +1121,7 @@ export default function Repository() {
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Sync </span>({selectedRepoIds.size})
<span className="hidden sm:inline">Sync </span>({actionCounts.sync})
</Button>
)}
@@ -963,6 +1136,30 @@ export default function Repository() {
Retry
</Button>
)}
{availableActions.includes('ignore') && (
<Button
variant="ghost"
size="sm"
onClick={() => handleBulkSkip(true)}
disabled={loadingRepoIds.size > 0}
>
<Ban className="h-4 w-4 mr-2" />
Ignore
</Button>
)}
{availableActions.includes('include') && (
<Button
variant="outline"
size="sm"
onClick={() => handleBulkSkip(false)}
disabled={loadingRepoIds.size > 0}
>
<Check className="h-4 w-4 mr-2" />
Include
</Button>
)}
</div>
</div>
)}
@@ -994,6 +1191,7 @@ export default function Repository() {
onMirror={handleMirrorRepo}
onSync={handleSyncRepo}
onRetry={handleRetryRepoAction}
onSkip={handleSkipRepo}
loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}

View File

@@ -1,11 +1,11 @@
import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock } from "lucide-react";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
import { formatDate, getStatusColor } from "@/lib/utils";
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils";
import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
@@ -19,6 +19,12 @@ import {
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface RepositoryTableProps {
repositories: Repository[];
@@ -29,6 +35,7 @@ interface RepositoryTableProps {
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
onSkip: ({ repoId, skip }: { repoId: string; skip: boolean }) => Promise<void>;
loadingRepoIds: Set<string>;
selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void;
@@ -44,6 +51,7 @@ export default function RepositoryTable({
onMirror,
onSync,
onRetry,
onSkip,
loadingRepoIds,
selectedRepoIds,
onSelectionChange,
@@ -220,12 +228,21 @@ export default function RepositoryTable({
{/* Status & Last Mirrored */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm font-medium capitalize">{repo.status}</span>
</div>
<Badge
className={`capitalize
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400' :
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
'bg-muted hover:bg-muted/80'}`}
variant="secondary"
>
{repo.status}
</Badge>
<span className="text-xs text-muted-foreground">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
{formatLastSyncTime(repo.lastMirrored)}
</span>
</div>
</div>
@@ -297,6 +314,31 @@ export default function RepositoryTable({
</Button>
)}
{/* Ignore/Include button */}
{repo.status === "ignored" ? (
<Button
size="default"
variant="outline"
onClick={() => repo.id && onSkip({ repoId: repo.id, skip: false })}
disabled={isLoading}
className="w-full h-10"
>
<Check className="h-4 w-4 mr-2" />
Include Repository
</Button>
) : (
<Button
size="default"
variant="ghost"
onClick={() => repo.id && onSkip({ repoId: repo.id, skip: true })}
disabled={isLoading}
className="w-full h-10"
>
<Ban className="h-4 w-4 mr-2" />
Ignore Repository
</Button>
)}
{/* External links */}
<div className="flex gap-2">
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
@@ -368,7 +410,7 @@ export default function RepositoryTable({
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
@@ -395,7 +437,7 @@ export default function RepositoryTable({
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 flex-[2.5]">
<div className="h-full p-3 flex-[2.3]">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-3 w-24 mt-1" />
</div>
@@ -488,7 +530,7 @@ export default function RepositoryTable({
aria-label="Select all repositories"
/>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
@@ -546,8 +588,7 @@ export default function RepositoryTable({
</div>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
@@ -588,22 +629,22 @@ export default function RepositoryTable({
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
{formatLastSyncTime(repo.lastMirrored)}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div className="h-full p-3 flex items-center flex-[1]">
{repo.status === "failed" && repo.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
<Badge
variant="destructive"
className="cursor-help capitalize"
>
{repo.status}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
@@ -611,10 +652,19 @@ export default function RepositoryTable({
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
<Badge
className={`capitalize
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400' :
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
'bg-muted hover:bg-muted/80'}`}
variant="secondary"
>
{repo.status}
</Badge>
)}
</div>
{/* Actions */}
@@ -625,6 +675,7 @@ export default function RepositoryTable({
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })}
/>
</div>
{/* Links */}
@@ -734,54 +785,108 @@ function RepoActionButton({
onMirror,
onSync,
onRetry,
onSkip,
}: {
repo: { id: string; status: string };
isLoading: boolean;
onMirror: () => void;
onSync: () => void;
onRetry: () => void;
onSkip: (skip: boolean) => void;
}) {
let label = "";
let icon = <></>;
let onClick = () => {};
let disabled = isLoading;
if (repo.status === "failed") {
label = "Retry";
icon = <RotateCcw className="h-4 w-4 mr-1" />;
onClick = onRetry;
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
label = "Sync";
icon = <RefreshCw className="h-4 w-4 mr-1" />;
onClick = onSync;
disabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
label = "Mirror";
icon = <FlipHorizontal className="h-4 w-4 mr-1" />;
onClick = onMirror;
disabled ||= repo.status === "mirroring";
} else {
return null; // unsupported status
// For ignored repos, show an "Include" action
if (repo.status === "ignored") {
return (
<Button
variant="outline"
disabled={isLoading}
onClick={() => onSkip(false)}
className="min-w-[80px] justify-start"
>
<Check className="h-4 w-4 mr-1" />
Include
</Button>
);
}
// For actionable statuses, show action + dropdown for skip
let primaryLabel = "";
let primaryIcon = <></>;
let primaryOnClick = () => {};
let primaryDisabled = isLoading;
let showPrimaryAction = true;
if (repo.status === "failed") {
primaryLabel = "Retry";
primaryIcon = <RotateCcw className="h-4 w-4" />;
primaryOnClick = onRetry;
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
primaryLabel = "Sync";
primaryIcon = <RefreshCw className="h-4 w-4" />;
primaryOnClick = onSync;
primaryDisabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
primaryLabel = "Mirror";
primaryIcon = <FlipHorizontal className="h-4 w-4" />;
primaryOnClick = onMirror;
primaryDisabled ||= repo.status === "mirroring";
} else {
showPrimaryAction = false;
}
// If there's no primary action, just show ignore button
if (!showPrimaryAction) {
return (
<Button
variant="ghost"
disabled={isLoading}
onClick={() => onSkip(true)}
className="min-w-[80px] justify-start"
>
<Ban className="h-4 w-4 mr-1" />
Ignore
</Button>
);
}
// Show primary action with dropdown for skip option
return (
<Button
variant="ghost"
disabled={disabled}
onClick={onClick}
className="min-w-[80px] justify-start"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
{label}
</>
) : (
<>
{icon}
{label}
</>
)}
</Button>
<DropdownMenu>
<div className="flex">
<Button
variant="ghost"
disabled={primaryDisabled}
onClick={primaryOnClick}
className="min-w-[80px] justify-start rounded-r-none"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
{primaryLabel}
</>
) : (
<>
{primaryIcon}
<span className="ml-1">{primaryLabel}</span>
</>
)}
</Button>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
disabled={isLoading}
className="rounded-l-none px-2 border-l"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSkip(true)}>
<Ban className="h-4 w-4 mr-2" />
Ignore Repository
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,105 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Coffee, Zap } from "lucide-react";
import { isSelfHostedMode } from "@/lib/deployment-mode";
export function GitHubSponsors() {
// Only show in self-hosted mode
if (!isSelfHostedMode()) {
return null;
}
return (
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-purple-900 dark:text-purple-100">
<Heart className="w-5 h-5 text-pink-500" />
Support Gitea Mirror
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-purple-800 dark:text-purple-200">
Gitea Mirror is open source and free to use. If you find it helpful,
consider supporting the project!
</p>
<div className="space-y-2">
<Button
variant="default"
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
asChild
>
<a
href="https://github.com/sponsors/RayLabsHQ"
target="_blank"
rel="noopener noreferrer"
>
<Heart className="w-4 h-4 mr-2" />
Become a Sponsor
</a>
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
className="border-purple-300 hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900"
asChild
>
<a
href="https://github.com/RayLabsHQ/gitea-mirror"
target="_blank"
rel="noopener noreferrer"
>
Star on GitHub
</a>
</Button>
<Button
variant="outline"
size="sm"
className="border-purple-300 hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900"
asChild
>
<a
href="https://buymeacoffee.com/raylabs"
target="_blank"
rel="noopener noreferrer"
>
<Coffee className="w-4 h-4 mr-1" />
Buy Coffee
</a>
</Button>
</div>
</div>
<div className="text-xs text-purple-600 dark:text-purple-300 space-y-1">
<p className="flex items-center gap-1">
<Zap className="w-3 h-3" />
Your support helps maintain and improve the project
</p>
</div>
</CardContent>
</Card>
);
}
// Smaller inline sponsor button for headers/navbars
export function SponsorButton() {
if (!isSelfHostedMode()) {
return null;
}
return (
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/sponsors/RayLabsHQ"
target="_blank"
rel="noopener noreferrer"
>
<Heart className="w-4 h-4 mr-2" />
Sponsor
</a>
</Button>
);
}

View File

@@ -0,0 +1,137 @@
import * as React from "react"
import { X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface MultiSelectProps {
options: { label: string; value: string }[]
selected: string[]
onChange: (selected: string[]) => void
placeholder?: string
className?: string
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = "Select items...",
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const handleUnselect = (item: string) => {
onChange(selected.filter((i) => i !== item))
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={`w-full justify-between ${selected.length > 0 ? "h-full" : ""} ${className}`}
>
<div className="flex gap-1 flex-wrap">
{selected.length > 0 ? (
selected.map((item) => (
<Badge
variant="secondary"
key={item}
className="mr-1 mb-1"
onClick={(e) => {
e.stopPropagation()
handleUnselect(item)
}}
>
{options.find((option) => option.value === item)?.label || item}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(item)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleUnselect(item)
}}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
))
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command className={className}>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => {
onChange(
selected.includes(option.value)
? selected.filter((item) => item !== option.value)
: [...selected, option.value]
)
setOpen(true)
}}
>
<div
className={`mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary ${
selected.includes(option.value)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
}`}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span>{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
ProgressProps
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

147
src/hooks/useAuth-legacy.ts Normal file
View File

@@ -0,0 +1,147 @@
import * as React from "react";
import {
useState,
useEffect,
createContext,
useContext,
type Context,
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
interface AuthContextType {
user: ExtendedUser | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
AuthContextType | undefined
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Function to refetch the user data
const refreshUser = async () => {
// not using loading state to keep the ui seamless and refresh the data in bg
// setIsLoading(true);
try {
const user = await authApi.getCurrentUser();
setUser(user);
} catch (err: any) {
setUser(null);
console.error("Failed to refresh user data", err);
} finally {
// setIsLoading(false);
}
};
// Automatically check the user status when the app loads
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authApi.getCurrentUser();
console.log("User data fetched:", user);
setUser(user);
} catch (err: any) {
setUser(null);
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
} else {
window.location.href = "/login";
}
console.error("Auth check failed", err);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.login(username, password);
setUser(user);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
throw err;
} finally {
setIsLoading(false);
}
};
const register = async (
username: string,
email: string,
password: string
) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.register(username, email, password);
setUser(user);
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
throw err;
} finally {
setIsLoading(false);
}
};
const logout = async () => {
setIsLoading(true);
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
} catch (err) {
console.error("Logout error:", err);
} finally {
setIsLoading(false);
}
};
// Create the context value with the added refreshUser function
const contextValue = {
user,
isLoading,
error,
login,
register,
logout,
refreshUser,
};
// Return the provider with the context value
return React.createElement(
AuthContext.Provider,
{ value: contextValue },
children
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -6,21 +6,22 @@ import {
useContext,
type Context,
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client";
interface AuthContextType {
user: ExtendedUser | null;
user: AuthUser | null;
session: Session | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
login: (email: string, password: string, username?: string) => Promise<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
refreshUser: () => Promise<void>;
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
@@ -28,60 +29,32 @@ const AuthContext: Context<AuthContextType | undefined> = createContext<
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const betterAuthSession = useBetterAuthSession();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Function to refetch the user data
const refreshUser = async () => {
// not using loading state to keep the ui seamless and refresh the data in bg
// setIsLoading(true);
try {
const user = await authApi.getCurrentUser();
setUser(user);
} catch (err: any) {
setUser(null);
console.error("Failed to refresh user data", err);
} finally {
// setIsLoading(false);
}
};
// Derive user and session from Better Auth hook
const user = betterAuthSession.data?.user || null;
const session = betterAuthSession.data || null;
// Automatically check the user status when the app loads
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authApi.getCurrentUser();
// Don't do any redirects here - let the pages handle their own redirect logic
console.log("User data fetched:", user);
setUser(user);
} catch (err: any) {
setUser(null);
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
} else {
window.location.href = "/login";
}
console.error("Auth check failed", err);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
const login = async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.login(username, password);
setUser(user);
const result = await authClient.signIn.email({
email,
password,
callbackURL: "/",
});
if (result.error) {
throw new Error(result.error.message || "Login failed");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
const message = err instanceof Error ? err.message : "Login failed";
setError(message);
throw err;
} finally {
setIsLoading(false);
@@ -96,10 +69,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsLoading(true);
setError(null);
try {
const user = await authApi.register(username, email, password);
setUser(user);
const result = await authClient.signUp.email({
email,
password,
name: username, // Better Auth uses 'name' field for display name
callbackURL: "/",
});
if (result.error) {
throw new Error(result.error.message || "Registration failed");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
const message = err instanceof Error ? err.message : "Registration failed";
setError(message);
throw err;
} finally {
setIsLoading(false);
@@ -109,9 +91,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = async () => {
setIsLoading(true);
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = "/login";
},
},
});
} catch (err) {
console.error("Logout error:", err);
} finally {
@@ -119,10 +105,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
// Create the context value with the added refreshUser function
const refreshUser = async () => {
// Better Auth automatically handles session refresh
// We can force a refetch if needed
await betterAuthSession.refetch();
};
// Create the context value
const contextValue = {
user,
isLoading,
user: user as AuthUser | null,
session: session as Session | null,
isLoading: isLoading || betterAuthSession.isPending,
error,
login,
register,
@@ -145,3 +138,6 @@ export function useAuth() {
}
return context;
}
// Export the Better Auth session hook for direct use when needed
export { useBetterAuthSession };

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { apiRequest } from '@/lib/utils';
interface AuthMethods {
emailPassword: boolean;
sso: {
enabled: boolean;
providers: Array<{
id: string;
providerId: string;
domain: string;
}>;
};
oidc: {
enabled: boolean;
};
}
export function useAuthMethods() {
const [authMethods, setAuthMethods] = useState<AuthMethods>({
emailPassword: true,
sso: {
enabled: false,
providers: [],
},
oidc: {
enabled: false,
},
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadAuthMethods();
}, []);
const loadAuthMethods = async () => {
try {
// Check SSO providers - use public endpoint since this is used on login page
const providers = await apiRequest<any[]>('/sso/providers/public').catch(() => []);
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
setAuthMethods({
emailPassword: true, // Always enabled
sso: {
enabled: providers.length > 0,
providers: providers.map(p => ({
id: p.id,
providerId: p.providerId,
domain: p.domain,
})),
},
oidc: {
enabled: applications.length > 0,
},
});
} catch (error) {
// If we can't load auth methods, default to email/password only
console.error('Failed to load auth methods:', error);
} finally {
setIsLoading(false);
}
};
return { authMethods, isLoading };
}

63
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
// This allows the client to connect to the auth server even when accessed from different origins
baseURL: (() => {
let url: string | undefined;
// Check for public environment variable first (for client-side access)
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
}
// Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
} catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
}
}
// Fall back to current origin if running in browser
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin;
}
// Default for SSR - always return a valid URL
return 'http://localhost:4321';
})(),
basePath: '/api/auth', // Explicitly set the base path
plugins: [
oidcClient(),
ssoClient(),
],
});
// Export commonly used methods for convenience
export const {
signIn,
signUp,
signOut,
useSession,
sendVerificationEmail,
resetPassword,
requestPasswordReset,
getSession
} = authClient;
// Export types - directly use the types from better-auth
export type Session = BetterAuthSession & {
user: BetterAuthUser & {
username?: string | null;
};
};
export type AuthUser = BetterAuthUser & {
username?: string | null;
};

139
src/lib/auth-header.ts Normal file
View File

@@ -0,0 +1,139 @@
import { db, users } from "./db";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export interface HeaderAuthConfig {
enabled: boolean;
userHeader: string;
emailHeader?: string;
nameHeader?: string;
autoProvision: boolean;
allowedDomains?: string[];
}
// Default configuration - DISABLED by default
export const defaultHeaderAuthConfig: HeaderAuthConfig = {
enabled: false,
userHeader: "X-Authentik-Username", // Common header name
emailHeader: "X-Authentik-Email",
nameHeader: "X-Authentik-Name",
autoProvision: false,
allowedDomains: [],
};
// Get header auth config from environment or database
export function getHeaderAuthConfig(): HeaderAuthConfig {
// Check environment variables for header auth config
const envConfig: Partial<HeaderAuthConfig> = {
enabled: process.env.HEADER_AUTH_ENABLED === "true",
userHeader: process.env.HEADER_AUTH_USER_HEADER || defaultHeaderAuthConfig.userHeader,
emailHeader: process.env.HEADER_AUTH_EMAIL_HEADER || defaultHeaderAuthConfig.emailHeader,
nameHeader: process.env.HEADER_AUTH_NAME_HEADER || defaultHeaderAuthConfig.nameHeader,
autoProvision: process.env.HEADER_AUTH_AUTO_PROVISION === "true",
allowedDomains: process.env.HEADER_AUTH_ALLOWED_DOMAINS?.split(",").map(d => d.trim()),
};
return {
...defaultHeaderAuthConfig,
...envConfig,
};
}
// Check if header authentication is enabled
export function isHeaderAuthEnabled(): boolean {
const config = getHeaderAuthConfig();
return config.enabled === true;
}
// Extract user info from headers
export function extractUserFromHeaders(headers: Headers): {
username?: string;
email?: string;
name?: string;
} | null {
const config = getHeaderAuthConfig();
if (!config.enabled) {
return null;
}
const username = headers.get(config.userHeader);
const email = config.emailHeader ? headers.get(config.emailHeader) : undefined;
const name = config.nameHeader ? headers.get(config.nameHeader) : undefined;
if (!username) {
return null;
}
// If allowed domains are configured, check email domain
if (config.allowedDomains && config.allowedDomains.length > 0 && email) {
const domain = email.split("@")[1];
if (!config.allowedDomains.includes(domain)) {
console.warn(`Header auth rejected: email domain ${domain} not in allowed list`);
return null;
}
}
return {
username: username || undefined,
email: email || undefined,
name: name || undefined
};
}
// Find or create user from header auth
export async function authenticateWithHeaders(headers: Headers) {
const userInfo = extractUserFromHeaders(headers);
if (!userInfo || !userInfo.username) {
return null;
}
const config = getHeaderAuthConfig();
// Try to find existing user by username or email
let existingUser = await db
.select()
.from(users)
.where(eq(users.username, userInfo.username))
.limit(1);
if (existingUser.length === 0 && userInfo.email) {
existingUser = await db
.select()
.from(users)
.where(eq(users.email, userInfo.email))
.limit(1);
}
if (existingUser.length > 0) {
return existingUser[0];
}
// If auto-provisioning is disabled, don't create new users
if (!config.autoProvision) {
console.warn(`Header auth: User ${userInfo.username} not found and auto-provisioning is disabled`);
return null;
}
// Create new user if auto-provisioning is enabled
try {
const newUser = {
id: nanoid(),
username: userInfo.username,
email: userInfo.email || `${userInfo.username}@header-auth.local`,
emailVerified: true, // Trust the auth provider
name: userInfo.name || userInfo.username,
createdAt: new Date(),
updatedAt: new Date(),
};
await db.insert(users).values(newUser);
console.log(`Header auth: Auto-provisioned new user ${userInfo.username}`);
return newUser;
} catch (error) {
console.error("Failed to auto-provision user from header auth:", error);
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More