Merge pull request #44 from RayLabsHQ/v3

V3
This commit is contained in:
ARUNAVO RAY
2025-07-17 16:26:11 +05:30
committed by GitHub
110 changed files with 21545 additions and 1933 deletions

View File

@@ -1,19 +1,40 @@
# 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
# 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=arunavo4/gitea-mirror
DOCKER_TAG=latest
# ===========================================
# MIRROR CONFIGURATION (Optional)
# Can also be configured via web UI
# ===========================================
# GitHub Configuration
# GITHUB_USERNAME=your-github-username
# GITHUB_TOKEN=your-github-personal-access-token
# SKIP_FORKS=false
@@ -25,6 +46,8 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
# PRESERVE_ORG_STRUCTURE=false
# ONLY_MIRROR_ORGS=false
# SKIP_STARRED_ISSUES=false
# Gitea Configuration
# GITEA_URL=http://gitea:3000
# GITEA_TOKEN=your-local-gitea-token
# GITEA_USERNAME=your-local-gitea-username
@@ -32,15 +55,27 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
# GITEA_ORG_VISIBILITY=public
# DELAY=3600
# 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
# ===========================================
# OPTIONAL FEATURES
# ===========================================
# Database Cleanup Configuration
# CLEANUP_ENABLED=false
# CLEANUP_RETENTION_DAYS=7
# 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
# TLS/SSL Configuration
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
# ===========================================
# 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

1
.gitignore vendored
View File

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

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

@@ -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

248
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,248 @@
# Migration Guide
This guide covers database migrations and version upgrades for Gitea Mirror.
## Version 3.0 Migration Guide
### Overview of v3 Changes
Version 3.0 introduces significant security improvements and authentication changes:
- **Token Encryption**: All GitHub and Gitea tokens are now encrypted in the database
- **Better Auth**: Complete authentication system overhaul with session-based auth
- **SSO/OIDC Support**: Enterprise authentication options
- **Enhanced Security**: Improved error handling and security practices
### Breaking Changes in v3
#### 1. Authentication System Overhaul
- Users now log in with **email** instead of username
- Session-based authentication replaces JWT tokens
- New auth endpoints: `/api/auth/[...all]` instead of `/api/auth/login`
- Password reset may be required for existing users
#### 2. Token Encryption
- All stored GitHub and Gitea tokens are encrypted using AES-256-GCM
- Requires encryption secret configuration
- Existing unencrypted tokens must be migrated
#### 3. Environment Variables
**Required changes:**
- `JWT_SECRET``BETTER_AUTH_SECRET` (backward compatible)
- New: `BETTER_AUTH_URL` (required)
- New: `ENCRYPTION_SECRET` (recommended)
#### 4. Database Schema Updates
New tables added:
- `sessions` - User session management
- `accounts` - Authentication accounts
- `verification_tokens` - Email verification
- `oauth_applications` - OAuth app registrations
- `sso_providers` - SSO configuration
### Migration Steps from v2 to v3
**⚠️ IMPORTANT: Backup your database before upgrading!**
```bash
cp data/gitea-mirror.db data/gitea-mirror.db.backup
```
#### Automated Migration (Docker Compose)
For Docker Compose users, v3 migration is **fully automated**:
1. **Update your docker-compose.yml** to use v3:
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:v3
```
2. **Pull and restart the container**:
```bash
docker compose pull
docker compose down
docker compose up -d
```
**That's it!** The container will automatically:
- ✅ Generate BETTER_AUTH_SECRET (from existing JWT_SECRET if available)
- ✅ Generate ENCRYPTION_SECRET for token encryption
- ✅ Create Better Auth database tables
- ✅ Migrate existing users to Better Auth system
- ✅ Encrypt all stored GitHub/Gitea tokens
- ✅ Apply all necessary database migrations
#### Manual Migration (Non-Docker)
#### Step 1: Update Environment Variables
Add to your `.env` file:
```bash
# Set your application URL (required)
BETTER_AUTH_URL=http://localhost:4321 # or your production URL
# Optional: These will be auto-generated if not provided
# BETTER_AUTH_SECRET=your-existing-jwt-secret # Will use existing JWT_SECRET
# ENCRYPTION_SECRET=your-48-character-secret # Will be auto-generated
```
#### Step 2: Stop the Application
```bash
# Stop your running instance
pkill -f "bun run start" # or your process manager command
```
#### Step 3: Update to v3
```bash
# Pull latest changes
git pull origin v3
# Install dependencies
bun install
```
#### Step 4: Run Migrations
```bash
# Option 1: Automatic migration on startup
bun run build
bun run start # Migrations run automatically
# Option 2: Manual migration
bun run migrate:better-auth # Migrate users to Better Auth
bun run migrate:encrypt-tokens # Encrypt stored tokens
```
### Post-Migration Tasks
1. **All users must log in again** - Sessions are invalidated
2. **Users log in with email** - Not username anymore
3. **Check token encryption** - Verify GitHub/Gitea connections still work
4. **Update API integrations** - Switch to new auth endpoints
### Troubleshooting v3 Migration
#### Users Can't Log In
- Ensure they're using email, not username
- They may need to reset password if migration failed
- Check Better Auth migration logs
#### Token Decryption Errors
- Verify ENCRYPTION_SECRET is set correctly
- Re-run token encryption migration
- Users may need to re-enter tokens
#### Database Errors
- Ensure all migrations completed
- Check disk space for new tables
- Review migration logs in console
### Rollback Procedure
If migration fails:
```bash
# Stop application
pkill -f "bun run start"
# Restore database backup
cp data/gitea-mirror.db.backup data/gitea-mirror.db
# Checkout previous version
git checkout v2.22.0
# Restart with old version
bun run start
```
---
## Drizzle Kit Migration Guide
This project uses Drizzle Kit for database migrations, providing better schema management and migration tracking.
## Overview
- **Database**: SQLite (with preparation for future PostgreSQL migration)
- **ORM**: Drizzle ORM with Drizzle Kit for migrations
- **Schema Location**: `/src/lib/db/schema.ts`
- **Migrations Folder**: `/drizzle`
- **Configuration**: `/drizzle.config.ts`
## Available Commands
### Database Management
- `bun run init-db` - Initialize database with all migrations
- `bun run check-db` - Check database status and recent migrations
- `bun run reset-users` - Remove all users and related data
- `bun run cleanup-db` - Remove database files
### Drizzle Kit Commands
- `bun run db:generate` - Generate new migration files from schema changes
- `bun run db:migrate` - Apply pending migrations to database
- `bun run db:push` - Push schema changes directly (development)
- `bun run db:pull` - Pull schema from database
- `bun run db:check` - Check for migration issues
- `bun run db:studio` - Open Drizzle Studio for database browsing
## Making Schema Changes
1. **Update Schema**: Edit `/src/lib/db/schema.ts`
2. **Generate Migration**: Run `bun run db:generate`
3. **Review Migration**: Check the generated SQL in `/drizzle` folder
4. **Apply Migration**: Run `bun run db:migrate` or restart the application
## Migration Process
The application automatically runs migrations on startup:
- Checks for pending migrations
- Creates migrations table if needed
- Applies all pending migrations in order
- Tracks migration history
## Schema Organization
### Tables
- `users` - User authentication and accounts
- `configs` - GitHub/Gitea configurations
- `repositories` - Repository mirror tracking
- `organizations` - GitHub organizations
- `mirror_jobs` - Job tracking with resilience
- `events` - Real-time event notifications
### Indexes
All performance-critical indexes are automatically created:
- User lookups
- Repository status queries
- Organization filtering
- Job tracking
- Event channels
## Future PostgreSQL Migration
The setup is designed for easy PostgreSQL migration:
1. Update `drizzle.config.ts`:
```typescript
export default defineConfig({
dialect: "postgresql",
schema: "./src/lib/db/schema.ts",
out: "./drizzle",
dbCredentials: {
connectionString: process.env.DATABASE_URL,
},
});
```
2. Update connection in `/src/lib/db/index.ts`
3. Generate new migrations: `bun run db:generate`
4. Apply to PostgreSQL: `bun run db:migrate`
## Troubleshooting
### Migration Errors
- Check `/drizzle` folder for migration files
- Verify database permissions
- Review migration SQL for conflicts
### Schema Conflicts
- Use `bun run db:check` to identify issues
- Review generated migrations before applying
- Keep schema.ts as single source of truth

100
README.md
View File

@@ -170,6 +170,12 @@ bun run dev
- Override individual repository destinations in the table view
- Starred repositories automatically go to a dedicated organization
## 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
@@ -193,6 +199,90 @@ bun run build
- **APIs**: GitHub (Octokit), Gitea REST API
- **Auth**: JWT tokens with bcryptjs password hashing
## 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` or `JWT_SECRET` if not set
### Password Security
- User passwords are hashed using bcrypt (via Better Auth)
- Never stored in plaintext
- Secure session management with JWT tokens
### Migration
If upgrading from a version without token encryption:
```bash
bun run migrate:encrypt-tokens
```
## 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
## Contributing
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
@@ -201,6 +291,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)

115
bun.lock
View File

@@ -33,6 +33,7 @@
"@types/react-dom": "^19.1.6",
"astro": "5.11.0",
"bcryptjs": "^3.0.2",
"better-auth": "^1.2.12",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -58,9 +59,11 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.2.18",
"@types/jsonwebtoken": "^9.0.10",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.6.0",
"drizzle-kit": "^0.31.4",
"jsdom": "^26.1.0",
"tsx": "^4.20.3",
"vitest": "^3.2.4",
@@ -134,6 +137,10 @@
"@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="],
"@better-auth/utils": ["@better-auth/utils@0.2.5", "", { "dependencies": { "typescript": "^5.8.2", "uncrypto": "^0.1.3" } }, "sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.0.2", "", {}, "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA=="],
@@ -146,6 +153,8 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
@@ -162,6 +171,10 @@
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="],
@@ -220,6 +233,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@@ -270,8 +285,14 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="],
"@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -304,6 +325,16 @@
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.3.16", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw=="],
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA=="],
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.3.15", "", { "dependencies": { "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w=="],
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
@@ -452,6 +483,10 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.1.2", "", {}, "sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.1.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8" } }, "sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
@@ -506,6 +541,8 @@
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
@@ -600,6 +637,8 @@
"array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -618,6 +657,10 @@
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"better-auth": ["better-auth@1.2.12", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.8", "defu": "^6.1.4", "jose": "^6.0.11", "kysely": "^0.28.2", "nanostores": "^0.11.3", "zod": "^3.24.1" } }, "sha512-YicCyjQ+lxb7YnnaCewrVOjj3nPVa0xcfrOJK7k5MLMX9Mt9UnJ8GYaVQNHOHLyVxl92qc3C758X1ihqAUzm4w=="],
"better-call": ["better-call@1.0.12", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-ssq5OfB9Ungv2M1WVrRnMBomB0qz1VKuhkY2WxjHaLtlsHoSe9EPolj1xf7xf8LY9o3vfk3Rx6rCWI4oVHeBRg=="],
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
"boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="],
@@ -630,6 +673,10 @@
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
@@ -742,6 +789,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="],
"drizzle-orm": ["drizzle-orm@0.44.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-zGAqBzWWkVSFjZpwPOrmCrgO++1kZ5H/rZ4qTGeGOe18iXGVJWf3WPfHOVwFIbmi8kHjfJstC6rJomzGx8g/dQ=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -770,6 +819,8 @@
"esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -926,6 +977,8 @@
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -948,6 +1001,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.2", "", {}, "sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -1134,6 +1189,8 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
"neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -1202,6 +1259,10 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
@@ -1286,6 +1347,8 @@
"rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="],
"rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -1304,6 +1367,8 @@
"server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
@@ -1324,6 +1389,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1576,6 +1643,8 @@
"@babel/template/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
@@ -1626,6 +1695,8 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
@@ -1654,6 +1725,50 @@
"@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"boxen/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],

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

@@ -15,7 +15,7 @@ services:
- 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}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
interval: 30s

View File

@@ -66,7 +66,7 @@ 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}

View File

@@ -28,7 +28,10 @@ services:
- 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:-}
@@ -49,6 +52,13 @@ services:
- DELAY=${DELAY:-3600}
# 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

@@ -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
@@ -245,6 +282,69 @@ else
else
echo "Warning: Could not find mirror_jobs table update script."
fi
# Run v3 migrations if needed
echo "Checking for v3 migrations..."
# Check if we need to run Better Auth migration (check if accounts table exists)
if ! sqlite3 /app/data/gitea-mirror.db "SELECT name FROM sqlite_master WHERE type='table' AND name='accounts';" | grep -q accounts; then
echo "🔄 v3 Migration: Creating Better Auth tables..."
# Create Better Auth tables
sqlite3 /app/data/gitea-mirror.db <<EOF
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
accountId TEXT NOT NULL,
providerId TEXT NOT NULL,
accessToken TEXT,
refreshToken TEXT,
expiresAt INTEGER,
password TEXT,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
token TEXT NOT NULL,
expiresAt INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS verification_tokens (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
token TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_userId ON accounts(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_verification_identifier_token ON verification_tokens(identifier, token);
EOF
fi
# Run Better Auth user migration
if [ -f "dist/scripts/migrate-better-auth.js" ]; then
echo "🔄 v3 Migration: Migrating users to Better Auth..."
bun dist/scripts/migrate-better-auth.js
elif [ -f "scripts/migrate-better-auth.ts" ]; then
echo "🔄 v3 Migration: Migrating users to Better Auth..."
bun scripts/migrate-better-auth.ts
fi
# Run token encryption migration
if [ -f "dist/scripts/migrate-tokens-encryption.js" ]; then
echo "🔄 v3 Migration: Encrypting stored tokens..."
bun dist/scripts/migrate-tokens-encryption.js
elif [ -f "scripts/migrate-tokens-encryption.ts" ]; then
echo "🔄 v3 Migration: Encrypting stored tokens..."
bun scripts/migrate-tokens-encryption.ts
fi
fi
# Extract version from package.json and set as environment variable

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)

205
docs/BUILD_GUIDE.md Normal file
View File

@@ -0,0 +1,205 @@
# 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=...
# 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)

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 Mirroring** - 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

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`);

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,20 @@
{
"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
}
]
}

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;
}
}

9087
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
},
"scripts": {
"setup": "bun install && bun run manage-db init",
"dev": "bunx --bun astro dev",
"dev": "bunx --bun astro dev --port 4567",
"dev:clean": "bun run cleanup-db && bun run manage-db init && bunx --bun astro dev",
"build": "bunx --bun astro build",
"cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db",
@@ -16,6 +16,14 @@
"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",
"migrate:better-auth": "bun scripts/migrate-to-better-auth.ts",
"migrate:encrypt-tokens": "bun scripts/migrate-tokens-encryption.ts",
"startup-recovery": "bun scripts/startup-recovery.ts",
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
"test-recovery": "bun scripts/test-recovery.ts",
@@ -60,6 +68,7 @@
"@types/react-dom": "^19.1.6",
"astro": "5.11.0",
"bcryptjs": "^3.0.2",
"better-auth": "^1.2.12",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -85,9 +94,11 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.2.18",
"@types/jsonwebtoken": "^9.0.10",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.6.0",
"drizzle-kit": "^0.31.4",
"jsdom": "^26.1.0",
"tsx": "^4.20.3",
"vitest": "^3.2.4"

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

@@ -0,0 +1,100 @@
#!/usr/bin/env bun
import { db } from "../src/lib/db";
import { accounts } from "../src/lib/db/schema";
import { sql } from "drizzle-orm";
console.log("🔄 Starting Better Auth migration...");
async function migrateToBetterAuth() {
try {
// Check if migration is needed
const existingAccounts = await db.select().from(accounts).limit(1);
if (existingAccounts.length > 0) {
console.log("✓ Better Auth migration already completed");
return;
}
// Check if we have old users table with passwords
// This query checks if password column exists in users table
const hasPasswordColumn = await db.get<{ count: number }>(
sql`SELECT COUNT(*) as count FROM pragma_table_info('users') WHERE name = 'password'`
);
if (!hasPasswordColumn || hasPasswordColumn.count === 0) {
console.log(" Users table doesn't have password column - migration may have already been done");
// Check if we have any users without accounts
const usersWithoutAccounts = await db.all<{ id: string; email: string }>(
sql`SELECT u.id, u.email FROM users u LEFT JOIN accounts a ON u.id = a.user_id WHERE a.id IS NULL`
);
if (usersWithoutAccounts.length === 0) {
console.log("✓ All users have accounts - migration complete");
return;
}
console.log(`⚠️ Found ${usersWithoutAccounts.length} users without accounts - they may need to reset passwords`);
return;
}
// Get all users with password hashes using raw SQL since the schema doesn't have password
const allUsersWithPasswords = await db.all<{ id: string; email: string; username: string; password: string }>(
sql`SELECT id, email, username, password FROM users WHERE password IS NOT NULL`
);
if (allUsersWithPasswords.length === 0) {
console.log(" No users with passwords to migrate");
return;
}
console.log(`📊 Found ${allUsersWithPasswords.length} users to migrate`);
// Migrate each user
for (const user of allUsersWithPasswords) {
try {
// Create Better Auth account entry
await db.insert(accounts).values({
id: crypto.randomUUID(),
userId: user.id,
accountId: user.email, // Use email as account ID
providerId: "credential", // Better Auth credential provider
providerUserId: null,
accessToken: null,
refreshToken: null,
expiresAt: null,
password: user.password, // Move password hash to accounts table
createdAt: new Date(),
updatedAt: new Date()
});
console.log(`✓ Migrated user: ${user.email}`);
} catch (error) {
console.error(`❌ Failed to migrate user ${user.email}:`, error);
// Continue with other users even if one fails
}
}
// Remove password column from users table if it exists
console.log("🔄 Cleaning up old password column...");
try {
// SQLite doesn't support DROP COLUMN directly, so we need to recreate the table
// For now, we'll just leave it as is since it's not harmful
console.log(" Password column left in users table for compatibility");
} catch (error) {
console.error("⚠️ Could not remove password column:", error);
}
console.log("✅ Better Auth migration completed successfully");
// Verify migration
const migratedAccounts = await db.select().from(accounts);
console.log(`📊 Total accounts after migration: ${migratedAccounts.length}`);
} catch (error) {
console.error("❌ Better Auth migration failed:", error);
process.exit(1);
}
}
// Run migration
migrateToBetterAuth();

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bun
import { db, users, accounts } from "../src/lib/db";
import { eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
/**
* Migrate existing users to Better Auth schema
*
* This script:
* 1. Moves existing password hashes from users table to accounts table
* 2. Updates user data to match Better Auth requirements
* 3. Creates credential accounts for existing users
*/
async function migrateUsers() {
console.log("🔄 Starting user migration to Better Auth...");
try {
// Get all existing users
const existingUsers = await db.select().from(users);
if (existingUsers.length === 0) {
console.log("✅ No users to migrate");
return;
}
console.log(`Found ${existingUsers.length} users to migrate`);
for (const user of existingUsers) {
console.log(`\nMigrating user: ${user.username} (${user.email})`);
// Check if user already has a credential account
const existingAccount = await db
.select()
.from(accounts)
.where(
eq(accounts.userId, user.id) &&
eq(accounts.providerId, "credential")
)
.limit(1);
if (existingAccount.length > 0) {
console.log("✓ User already migrated");
continue;
}
// Create credential account with existing password hash
const accountId = uuidv4();
await db.insert(accounts).values({
id: accountId,
accountId: accountId,
userId: user.id,
providerId: "credential",
providerUserId: user.email, // Use email as provider user ID
// password: user.password, // Password is not in users table anymore
createdAt: user.createdAt,
updatedAt: user.updatedAt,
});
console.log("✓ Created credential account");
// Update user name field if it's null (Better Auth uses 'name' field)
// Note: Better Auth expects a 'name' field, but we're using username
// This is handled by our additional fields configuration
}
console.log("\n✅ User migration completed successfully!");
// Summary
const migratedAccounts = await db
.select()
.from(accounts)
.where(eq(accounts.providerId, "credential"));
console.log(`\nMigration Summary:`);
console.log(`- Total users: ${existingUsers.length}`);
console.log(`- Migrated accounts: ${migratedAccounts.length}`);
} catch (error) {
console.error("❌ Migration failed:", error);
process.exit(1);
}
}
// Run migration
migrateUsers();

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bun
/**
* Migration script to encrypt existing GitHub and Gitea tokens in the database
* Run with: bun run scripts/migrate-tokens-encryption.ts
*/
import { db, configs } from "../src/lib/db";
import { eq } from "drizzle-orm";
import { encrypt, isEncrypted, migrateToken } from "../src/lib/utils/encryption";
async function migrateTokens() {
console.log("Starting token encryption migration...");
try {
// Fetch all configs
const allConfigs = await db.select().from(configs);
console.log(`Found ${allConfigs.length} configurations to check`);
let migratedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const config of allConfigs) {
try {
let githubUpdated = false;
let giteaUpdated = false;
// Parse configs
const githubConfig = typeof config.githubConfig === "string"
? JSON.parse(config.githubConfig)
: config.githubConfig;
const giteaConfig = typeof config.giteaConfig === "string"
? JSON.parse(config.giteaConfig)
: config.giteaConfig;
// Check and migrate GitHub token
if (githubConfig.token) {
if (!isEncrypted(githubConfig.token)) {
console.log(`Encrypting GitHub token for config ${config.id} (user: ${config.userId})`);
githubConfig.token = encrypt(githubConfig.token);
githubUpdated = true;
} else {
console.log(`GitHub token already encrypted for config ${config.id}`);
}
}
// Check and migrate Gitea token
if (giteaConfig.token) {
if (!isEncrypted(giteaConfig.token)) {
console.log(`Encrypting Gitea token for config ${config.id} (user: ${config.userId})`);
giteaConfig.token = encrypt(giteaConfig.token);
giteaUpdated = true;
} else {
console.log(`Gitea token already encrypted for config ${config.id}`);
}
}
// Update config if any tokens were migrated
if (githubUpdated || giteaUpdated) {
await db
.update(configs)
.set({
githubConfig,
giteaConfig,
updatedAt: new Date(),
})
.where(eq(configs.id, config.id));
migratedCount++;
console.log(`✓ Config ${config.id} updated successfully`);
} else {
skippedCount++;
}
} catch (error) {
errorCount++;
console.error(`✗ Error processing config ${config.id}:`, error);
}
}
console.log("\n=== Migration Summary ===");
console.log(`Total configs: ${allConfigs.length}`);
console.log(`Migrated: ${migratedCount}`);
console.log(`Skipped (already encrypted): ${skippedCount}`);
console.log(`Errors: ${errorCount}`);
if (errorCount > 0) {
console.error("\n⚠ Some configs failed to migrate. Please check the errors above.");
process.exit(1);
} else {
console.log("\n✅ Token encryption migration completed successfully!");
}
} catch (error) {
console.error("Fatal error during migration:", error);
process.exit(1);
}
}
// Verify environment setup
function verifyEnvironment() {
const requiredEnvVars = ["ENCRYPTION_SECRET", "JWT_SECRET", "BETTER_AUTH_SECRET"];
const availableSecrets = requiredEnvVars.filter(varName => process.env[varName]);
if (availableSecrets.length === 0) {
console.error("❌ No encryption secret found!");
console.error("Please set one of the following environment variables:");
console.error(" - ENCRYPTION_SECRET (recommended)");
console.error(" - JWT_SECRET");
console.error(" - BETTER_AUTH_SECRET");
process.exit(1);
}
console.log(`Using encryption secret from: ${availableSecrets[0]}`);
}
// Main execution
async function main() {
console.log("=== Gitea Mirror Token Encryption Migration ===\n");
// Verify environment
verifyEnvironment();
// Run migration
await migrateTokens();
process.exit(0);
}
main().catch((error) => {
console.error("Unexpected error:", error);
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();
}

View File

@@ -47,7 +47,7 @@ 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
completedItems: 2, // Simulate partial completion
inProgress: true,
});

View File

@@ -4,50 +4,70 @@ 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 } from 'lucide-react';
export function LoginForm() {
const [isLoading, setIsLoading] = 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) {
setIsLoading(true);
try {
if (!domain && !ssoEmail) {
toast.error('Please enter your email or select a provider');
return;
}
await authClient.signIn.sso({
email: ssoEmail || undefined,
domain: domain,
callbackURL: '/',
});
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -76,45 +96,182 @@ 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>
<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>
</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)}
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()}
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,22 @@ 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';
export function SignupForm() {
const [isLoading, setIsLoading] = 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 +32,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 {
@@ -84,20 +72,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,6 +84,7 @@ 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>

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

@@ -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;
@@ -601,65 +603,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 +682,12 @@ export function ConfigTabs() {
isAutoSavingSchedule={isAutoSavingSchedule}
isAutoSavingCleanup={isAutoSavingCleanup}
/>
</div>
</div>
</TabsContent>
<TabsContent value="sso" className="space-y-4">
<SSOSettings />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,426 @@
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast } from 'sonner';
import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy, Shield, Info } from 'lucide-react';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '../ui/skeleton';
import { Badge } from '../ui/badge';
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;
mapping: {
id: string;
email: string;
emailVerified: string;
name: string;
image: string;
};
};
createdAt: string;
updatedAt: string;
}
export function SSOSettings() {
const [providers, setProviders] = useState<SSOProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showProviderDialog, setShowProviderDialog] = useState(false);
const [isDiscovering, setIsDiscovering] = useState(false);
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
// Form states for new provider
const [providerForm, setProviderForm] = useState({
issuer: '',
domain: '',
providerId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [providersRes, headerAuthStatus] = await Promise.all([
apiRequest<SSOProvider[]>('/sso/providers'),
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
]);
setProviders(providersRes);
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 || '',
domain: discovered.suggestedDomain || prev.domain,
}));
toast.success('OIDC configuration discovered successfully');
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDiscovering(false);
}
};
const createProvider = async () => {
try {
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
method: 'POST',
data: {
...providerForm,
mapping: {
id: 'sub',
email: 'email',
emailVerified: 'email_verified',
name: 'name',
image: 'picture',
},
},
});
setProviders([...providers, newProvider]);
setShowProviderDialog(false);
setProviderForm({
issuer: '',
domain: '',
providerId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
});
toast.success('SSO provider created successfully');
} catch (error) {
showErrorToast(error, toast);
}
};
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);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
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>
<h3 className="text-lg font-semibold">Authentication & SSO</h3>
<p className="text-sm text-muted-foreground">
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 className="mb-6">
<CardHeader>
<CardTitle className="text-base">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>External Identity Providers</CardTitle>
<CardDescription>
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">
<DialogHeader>
<DialogTitle>Add SSO Provider</DialogTitle>
<DialogDescription>
Configure an external OIDC provider for user authentication
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<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="https://accounts.google.com"
/>
<Button
variant="outline"
onClick={discoverOIDC}
disabled={isDiscovering}
>
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
value={providerForm.domain}
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
placeholder="example.com"
/>
</div>
<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"
/>
</div>
</div>
<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>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>
Cancel
</Button>
<Button onClick={createProvider}>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-4">
{providers.map(provider => (
<Card key={provider.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{provider.providerId}</h4>
<p className="text-sm text-muted-foreground">{provider.domain}</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteProvider(provider.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium">Issuer</p>
<p className="text-muted-foreground">{provider.issuer}</p>
</div>
<div>
<p className="font-medium">Client ID</p>
<p className="text-muted-foreground font-mono">{provider.oidcConfig.clientId}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -129,9 +129,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

@@ -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

@@ -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>
);
}

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,
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
const providers = await apiRequest<any[]>('/sso/providers').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 };
}

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

@@ -0,0 +1,28 @@
import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
// The base URL is optional when running on the same domain
// Better Auth will use the current domain by default
plugins: [
oidcClient(),
ssoClient(),
],
});
// Export commonly used methods for convenience
export const {
signIn,
signUp,
signOut,
useSession,
sendVerificationEmail,
resetPassword,
requestPasswordReset,
getSession
} = authClient;
// Export types
export type Session = Awaited<ReturnType<typeof authClient.getSession>>["data"];
export type AuthUser = Session extends { user: infer U } ? U : never;

70
src/lib/auth-config.ts Normal file
View File

@@ -0,0 +1,70 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { oidcProvider } from "better-auth/plugins";
import { sso } from "better-auth/plugins/sso";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
// This function will be called with the actual database instance
export function createAuth(db: BunSQLiteDatabase) {
return betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true, // Our tables use plural names (users, not user)
}),
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Authentication methods
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // We'll enable this later
sendResetPassword: async ({ user, url, token }, request) => {
// TODO: Implement email sending for password reset
console.log("Password reset requested for:", user.email);
console.log("Reset URL:", url);
},
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration
user: {
additionalFields: {
// We can add custom fields here if needed
},
},
// Plugins for OIDC/SSO support
plugins: [
// SSO plugin for OIDC client support
sso({
provisionUser: async (data) => {
// Custom user provisioning logic for SSO users
console.log("Provisioning SSO user:", data);
return data;
},
}),
// OIDC Provider plugin (for future use when we want to be an OIDC provider)
oidcProvider({
loginPage: "/signin",
consentPage: "/oauth/consent",
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
],
});
}

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

@@ -0,0 +1,135 @@
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, email, name };
}
// 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;
}
}

View File

@@ -0,0 +1,179 @@
/**
* Example OIDC/SSO Configuration for Better Auth
*
* This file demonstrates how to enable OIDC and SSO features in Gitea Mirror.
* To use: Copy this file to auth-oidc-config.ts and update the auth.ts import.
*/
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { sso } from "better-auth/plugins/sso";
import { oidcProvider } from "better-auth/plugins/oidc";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
export function createAuthWithOIDC(db: BunSQLiteDatabase) {
return betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true,
}),
// Base configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
basePath: "/api/auth",
// Email/Password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration with additional fields
user: {
additionalFields: {
username: {
type: "string",
required: true,
defaultValue: "user",
input: true,
}
},
},
// OAuth2 providers (examples)
socialProviders: {
github: {
enabled: !!process.env.GITHUB_OAUTH_CLIENT_ID,
clientId: process.env.GITHUB_OAUTH_CLIENT_ID!,
clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET!,
},
google: {
enabled: !!process.env.GOOGLE_OAUTH_CLIENT_ID,
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
},
},
// Plugins
plugins: [
// SSO Plugin - For OIDC/SAML client functionality
sso({
// Auto-provision users from SSO providers
provisionUser: async (data) => {
console.log("Provisioning SSO user:", data.email);
// Custom logic to set username from email
const username = data.email.split('@')[0];
return {
...data,
username,
};
},
// Organization provisioning for enterprise SSO
organizationProvisioning: {
disabled: false,
defaultRole: "member",
getRole: async (user) => {
// Custom logic to determine user role
// For admin emails, grant admin role
if (user.email?.endsWith('@admin.example.com')) {
return 'admin';
}
return 'member';
},
},
}),
// OIDC Provider Plugin - Makes Gitea Mirror an OIDC provider
oidcProvider({
// Login page for OIDC authentication flow
loginPage: "/login",
// Consent page for OAuth2 authorization
consentPage: "/oauth/consent",
// Allow dynamic client registration
allowDynamicClientRegistration: false,
// OIDC metadata configuration
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
authorization_endpoint: "/api/auth/oauth2/authorize",
token_endpoint: "/api/auth/oauth2/token",
userinfo_endpoint: "/api/auth/oauth2/userinfo",
jwks_uri: "/api/auth/jwks",
},
// Additional user info claims
getAdditionalUserInfoClaim: (user, scopes) => {
const claims: Record<string, any> = {};
// Add custom claims based on scopes
if (scopes.includes('profile')) {
claims.username = user.username;
claims.preferred_username = user.username;
}
if (scopes.includes('gitea')) {
// Add Gitea-specific claims
claims.gitea_admin = false; // Customize based on your logic
claims.gitea_repos = []; // Could fetch user's repositories
}
return claims;
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Add your OIDC client domains here
],
});
}
// Environment variables needed:
/*
# OAuth2 Providers (optional)
GITHUB_OAUTH_CLIENT_ID=your-github-client-id
GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
GOOGLE_OAUTH_CLIENT_ID=your-google-client-id
GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret
# SSO Configuration (when registering providers)
SSO_PROVIDER_ISSUER=https://idp.example.com
SSO_PROVIDER_CLIENT_ID=your-client-id
SSO_PROVIDER_CLIENT_SECRET=your-client-secret
*/
// Example: Registering an SSO provider programmatically
/*
import { authClient } from "./auth-client";
// Register corporate SSO
await authClient.sso.register({
issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
domain: "company.com",
clientId: process.env.AZURE_CLIENT_ID!,
clientSecret: process.env.AZURE_CLIENT_SECRET!,
providerId: "azure-ad",
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
});
*/

99
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,99 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { oidcProvider } from "better-auth/plugins";
import { sso } from "better-auth/plugins/sso";
import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
export const auth = betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true, // Our tables use plural names (users, not user)
schema, // Pass the schema explicitly
}),
// Secret for signing tokens
secret: process.env.BETTER_AUTH_SECRET,
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:4321",
basePath: "/api/auth", // Specify the base path for auth endpoints
// Authentication methods
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // We'll enable this later
sendResetPassword: async ({ user, url, token }, request) => {
// TODO: Implement email sending for password reset
console.log("Password reset requested for:", user.email);
console.log("Reset URL:", url);
},
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration
user: {
additionalFields: {
// Keep the username field from our existing schema
username: {
type: "string",
required: false,
input: false, // Don't show in signup form - we'll derive from email
}
},
},
// Plugins configuration
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// Customize user info claims based on scopes
getAdditionalUserInfoClaim: (user, scopes) => {
const claims: Record<string, any> = {};
if (scopes.includes("profile")) {
claims.username = user.username;
}
return claims;
},
}),
// SSO plugin - allows users to authenticate with external OIDC providers
sso({
// Provision new users when they sign in with SSO
provisionUser: async (user) => {
// Derive username from email if not provided
const username = user.name || user.email?.split('@')[0] || 'user';
return {
...user,
username,
};
},
// Organization provisioning settings
organizationProvisioning: {
disabled: false,
defaultRole: "member",
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:4321",
],
});
// Export type for use in other parts of the app
export type Auth = typeof auth;

View File

@@ -18,9 +18,9 @@ export const ENV = {
return "sqlite://data/gitea-mirror.db";
},
// JWT secret for authentication
JWT_SECRET:
process.env.JWT_SECRET || "your-secret-key-change-this-in-production",
// Better Auth secret for authentication
BETTER_AUTH_SECRET:
process.env.BETTER_AUTH_SECRET || "your-secret-key-change-this-in-production",
// Server host and port
HOST: process.env.HOST || "localhost",

102
src/lib/db/adapter.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Database adapter for SQLite
* For the self-hosted version of Gitea Mirror
*/
import { drizzle as drizzleSqlite } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import * as schema from './schema';
export type DatabaseClient = ReturnType<typeof createDatabase>;
/**
* Create SQLite database connection
*/
export function createDatabase() {
const dbPath = process.env.DATABASE_PATH || './data/gitea-mirror.db';
// Ensure directory exists
const fs = require('fs');
const path = require('path');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create SQLite connection
const sqlite = new Database(dbPath);
// Enable foreign keys and WAL mode for better performance
sqlite.exec('PRAGMA foreign_keys = ON');
sqlite.exec('PRAGMA journal_mode = WAL');
sqlite.exec('PRAGMA synchronous = NORMAL');
sqlite.exec('PRAGMA cache_size = -2000'); // 2MB cache
sqlite.exec('PRAGMA temp_store = MEMORY');
// Create Drizzle instance with SQLite
const db = drizzleSqlite(sqlite, {
schema,
logger: process.env.NODE_ENV === 'development',
});
return {
db,
client: sqlite,
type: 'sqlite' as const,
// Helper methods
async close() {
sqlite.close();
},
async healthCheck() {
try {
sqlite.query('SELECT 1').get();
return true;
} catch {
return false;
}
},
async transaction<T>(fn: (tx: any) => Promise<T>) {
return db.transaction(fn);
},
};
}
// Create singleton instance
let dbInstance: DatabaseClient | null = null;
/**
* Get database instance (singleton)
*/
export function getDatabase(): DatabaseClient {
if (!dbInstance) {
dbInstance = createDatabase();
}
return dbInstance;
}
/**
* Close database connection
*/
export async function closeDatabase() {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
}
}
// Export convenience references
export const { db, client, type: dbType } = getDatabase();
// Re-export schema for convenience
export * from './schema';
/**
* Database migration utilities
*/
export async function runMigrations() {
const { migrate } = await import('drizzle-orm/bun-sqlite/migrator');
await migrate(db, { migrationsFolder: './drizzle' });
}

View File

@@ -1,489 +1,85 @@
import { z } from "zod";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import fs from "fs";
import path from "path";
import { configSchema } from "./schema";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Skip database initialization in test environment
let db: ReturnType<typeof drizzle>;
const dbPath = path.join(dataDir, "gitea-mirror.db");
if (process.env.NODE_ENV !== "test") {
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
}
const dbPath = path.join(dataDir, "gitea-mirror.db");
// Create SQLite database instance using Bun's native driver
let sqlite: Database;
try {
sqlite = new Database(dbPath);
console.log("Successfully connected to SQLite database using Bun's native driver");
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
}
// Ensure all required tables exist
ensureTablesExist(sqlite);
// Run migrations
runMigrations(sqlite);
} catch (error) {
console.error("Error opening database:", error);
throw error;
}
/**
* Run database migrations
*/
function runMigrations(db: Database) {
// Create SQLite database instance using Bun's native driver
let sqlite: Database;
try {
// Migration 1: Add destination_org column to organizations table
const orgTableInfo = db.query("PRAGMA table_info(organizations)").all() as Array<{name: string}>;
const hasDestinationOrg = orgTableInfo.some(col => col.name === 'destination_org');
if (!hasDestinationOrg) {
console.log("🔄 Running migration: Adding destination_org column to organizations table");
db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT");
console.log("✅ Migration completed: destination_org column added");
}
// Migration 2: Add destination_org column to repositories table
const repoTableInfo = db.query("PRAGMA table_info(repositories)").all() as Array<{name: string}>;
const hasRepoDestinationOrg = repoTableInfo.some(col => col.name === 'destination_org');
if (!hasRepoDestinationOrg) {
console.log("🔄 Running migration: Adding destination_org column to repositories table");
db.exec("ALTER TABLE repositories ADD COLUMN destination_org TEXT");
console.log("✅ Migration completed: destination_org column added to repositories");
}
sqlite = new Database(dbPath);
console.log("Successfully connected to SQLite database using Bun's native driver");
} catch (error) {
console.error("Error running migrations:", error);
// Don't throw - migrations should be non-breaking
console.error("Error opening database:", error);
throw error;
}
}
/**
* Ensure all required tables exist in the database
*/
function ensureTablesExist(db: Database) {
const requiredTables = [
"users",
"configs",
"repositories",
"organizations",
"mirror_jobs",
"events",
];
// Create drizzle instance with the SQLite client
db = drizzle({ client: sqlite });
for (const table of requiredTables) {
/**
* Run Drizzle migrations
*/
function runDrizzleMigrations() {
try {
// Check if table exists
const result = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${table}'`).get();
console.log("🔄 Checking for pending migrations...");
// Check if migrations table exists
const migrationsTableExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
.get();
if (!result) {
console.warn(`⚠️ Table '${table}' is missing. Creating it now...`);
createTable(db, table);
console.log(`✅ Table '${table}' created successfully`);
if (!migrationsTableExists) {
console.log("📦 First time setup - running initial migrations...");
}
// Run migrations using Drizzle migrate function
migrate(db, { migrationsFolder: "./drizzle" });
console.log("✅ Database migrations completed successfully");
} catch (error) {
console.error(`❌ Error checking/creating table '${table}':`, error);
console.error("❌ Error running migrations:", error);
throw error;
}
}
// Run Drizzle migrations after db is initialized
runDrizzleMigrations();
}
/**
* Create a specific table with its schema
*/
function createTable(db: Database, tableName: string) {
switch (tableName) {
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;
export { db };
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,
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)
)
`);
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,
language TEXT,
description TEXT,
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)
)
`);
// Create indexes for repositories
db.exec(`
CREATE INDEX IF NOT EXISTS idx_repositories_user_id ON repositories(user_id);
CREATE INDEX IF NOT EXISTS idx_repositories_config_id ON repositories(config_id);
CREATE INDEX IF NOT EXISTS idx_repositories_status ON repositories(status);
CREATE INDEX IF NOT EXISTS idx_repositories_owner ON repositories(owner);
CREATE INDEX IF NOT EXISTS idx_repositories_organization ON repositories(organization);
CREATE INDEX IF NOT EXISTS idx_repositories_is_fork ON repositories(is_fork);
CREATE INDEX IF NOT EXISTS idx_repositories_is_starred ON repositories(is_starred);
`);
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,
destination_org 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)
)
`);
// Create indexes for organizations
db.exec(`
CREATE INDEX IF NOT EXISTS idx_organizations_user_id ON organizations(user_id);
CREATE INDEX IF NOT EXISTS idx_organizations_config_id ON organizations(config_id);
CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status);
CREATE INDEX IF NOT EXISTS idx_organizations_is_included ON organizations(is_included);
`);
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 mirror_jobs
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)
)
`);
// Create indexes for events
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);
`);
break;
default:
throw new Error(`Unknown table: ${tableName}`);
}
}
// Create drizzle instance with the SQLite client
export const db = drizzle({ client: sqlite });
// Simple async wrapper around SQLite API for compatibility
// This maintains backward compatibility with existing code
export const client = {
async execute(sql: string, params?: any[]) {
try {
const stmt = sqlite.query(sql);
if (/^\s*select/i.test(sql)) {
const rows = stmt.all(params ?? []);
return { rows } as { rows: any[] };
}
stmt.run(params ?? []);
return { rows: [] } as { rows: any[] };
} catch (error) {
console.error(`Error executing SQL: ${sql}`, error);
throw error;
}
},
};
// Define the tables
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
password: text("password").notNull(),
email: text("email").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
// New table for event notifications (replacing Redis pub/sub)
export const events = sqliteTable("events", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
channel: text("channel").notNull(),
payload: text("payload", { mode: "json" }).notNull(),
read: integer("read", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
const githubSchema = configSchema.shape.githubConfig;
const giteaSchema = configSchema.shape.giteaConfig;
const scheduleSchema = configSchema.shape.scheduleConfig;
const cleanupSchema = configSchema.shape.cleanupConfig;
export const configs = sqliteTable("configs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
githubConfig: text("github_config", { mode: "json" })
.$type<z.infer<typeof githubSchema>>()
.notNull(),
giteaConfig: text("gitea_config", { mode: "json" })
.$type<z.infer<typeof giteaSchema>>()
.notNull(),
include: text("include", { mode: "json" })
.$type<string[]>()
.notNull()
.default(["*"]),
exclude: text("exclude", { mode: "json" })
.$type<string[]>()
.notNull()
.default([]),
scheduleConfig: text("schedule_config", { mode: "json" })
.$type<z.infer<typeof scheduleSchema>>()
.notNull(),
cleanupConfig: text("cleanup_config", { mode: "json" })
.$type<z.infer<typeof cleanupSchema>>()
.notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const repositories = sqliteTable("repositories", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
fullName: text("full_name").notNull(),
url: text("url").notNull(),
cloneUrl: text("clone_url").notNull(),
owner: text("owner").notNull(),
organization: text("organization"),
mirroredLocation: text("mirrored_location").default(""),
isPrivate: integer("is_private", { mode: "boolean" })
.notNull()
.default(false),
isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false),
forkedFrom: text("forked_from"),
hasIssues: integer("has_issues", { mode: "boolean" })
.notNull()
.default(false),
isStarred: integer("is_starred", { mode: "boolean" })
.notNull()
.default(false),
isArchived: integer("is_archived", { mode: "boolean" })
.notNull()
.default(false),
size: integer("size").notNull().default(0),
hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false),
hasSubmodules: integer("has_submodules", { mode: "boolean" })
.notNull()
.default(false),
defaultBranch: text("default_branch").notNull(),
visibility: text("visibility").notNull().default("public"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const mirrorJobs = sqliteTable("mirror_jobs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
repositoryId: text("repository_id"),
repositoryName: text("repository_name"),
organizationId: text("organization_id"),
organizationName: text("organization_name"),
details: text("details"),
status: text("status").notNull().default("imported"),
message: text("message").notNull(),
timestamp: integer("timestamp", { mode: "timestamp" })
.notNull()
.default(new Date()),
// New fields for job resilience
jobType: text("job_type").notNull().default("mirror"),
batchId: text("batch_id"),
totalItems: integer("total_items"),
completedItems: integer("completed_items").default(0),
itemIds: text("item_ids", { mode: "json" }).$type<string[]>(),
completedItemIds: text("completed_item_ids", { mode: "json" }).$type<string[]>().default([]),
inProgress: integer("in_progress", { mode: "boolean" }).notNull().default(false),
startedAt: integer("started_at", { mode: "timestamp" }),
completedAt: integer("completed_at", { mode: "timestamp" }),
lastCheckpoint: integer("last_checkpoint", { mode: "timestamp" }),
});
export const organizations = sqliteTable("organizations", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
avatarUrl: text("avatar_url").notNull(),
membershipRole: text("membership_role").notNull().default("member"),
isIncluded: integer("is_included", { mode: "boolean" })
.notNull()
.default(true),
// Override destination organization for this GitHub org's repos
destinationOrg: text("destination_org"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
repositoryCount: integer("repository_count").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
// Export all table definitions from schema
export {
users,
events,
configs,
repositories,
mirrorJobs,
organizations,
sessions,
accounts,
verificationTokens,
oauthApplications,
oauthAccessTokens,
oauthConsent,
ssoProviders
} from "./schema";

View File

@@ -1,75 +0,0 @@
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- Configurations table
CREATE TABLE IF NOT EXISTS configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
schedule_config TEXT NOT NULL,
include TEXT NOT NULL,
exclude TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- Repositories table
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
is_private BOOLEAN NOT NULL,
is_fork BOOLEAN NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
mirrored_location TEXT DEFAULT '',
has_issues BOOLEAN NOT NULL,
is_starred BOOLEAN NOT NULL,
status TEXT NOT NULL,
error_message TEXT,
last_mirrored DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
);
-- Organizations table
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
is_included BOOLEAN NOT NULL,
repository_count INTEGER NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
);
-- Mirror jobs table
CREATE TABLE IF NOT EXISTS mirror_jobs (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
repository_id TEXT,
status TEXT NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME,
log TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL
);

View File

@@ -1,182 +1,615 @@
import { z } from "zod";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// User schema
// ===== Zod Validation Schemas =====
export const userSchema = z.object({
id: z.string().uuid().optional(),
username: z.string().min(3),
password: z.string().min(8).optional(), // Hashed password
id: z.string(),
username: z.string(),
password: z.string(),
email: z.string().email(),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
emailVerified: z.boolean().default(false),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type User = z.infer<typeof userSchema>;
export const githubConfigSchema = z.object({
owner: z.string(),
type: z.enum(["personal", "organization"]),
token: z.string(),
includeStarred: z.boolean().default(false),
includeForks: z.boolean().default(true),
includeArchived: z.boolean().default(false),
includePrivate: z.boolean().default(true),
includePublic: z.boolean().default(true),
includeOrganizations: z.array(z.string()).default([]),
starredReposOrg: z.string().optional(),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user"]).default("preserve"),
defaultOrg: z.string().optional(),
});
export const giteaConfigSchema = z.object({
url: z.string().url(),
token: z.string(),
defaultOwner: z.string(),
mirrorInterval: z.string().default("8h"),
lfs: z.boolean().default(false),
wiki: z.boolean().default(false),
visibility: z
.enum(["public", "private", "limited", "default"])
.default("default"),
createOrg: z.boolean().default(true),
templateOwner: z.string().optional(),
templateRepo: z.string().optional(),
addTopics: z.boolean().default(true),
topicPrefix: z.string().optional(),
preserveVisibility: z.boolean().default(true),
forkStrategy: z
.enum(["skip", "reference", "full-copy"])
.default("reference"),
});
export const scheduleConfigSchema = z.object({
enabled: z.boolean().default(false),
interval: z.string().default("0 2 * * *"),
concurrent: z.boolean().default(false),
batchSize: z.number().default(10),
pauseBetweenBatches: z.number().default(5000),
retryAttempts: z.number().default(3),
retryDelay: z.number().default(60000),
timeout: z.number().default(3600000),
autoRetry: z.boolean().default(true),
cleanupBeforeMirror: z.boolean().default(false),
notifyOnFailure: z.boolean().default(true),
notifyOnSuccess: z.boolean().default(false),
logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"),
timezone: z.string().default("UTC"),
onlyMirrorUpdated: z.boolean().default(false),
updateInterval: z.number().default(86400000),
skipRecentlyMirrored: z.boolean().default(true),
recentThreshold: z.number().default(3600000),
});
export const cleanupConfigSchema = z.object({
enabled: z.boolean().default(false),
deleteFromGitea: z.boolean().default(false),
deleteIfNotInGitHub: z.boolean().default(true),
protectedRepos: z.array(z.string()).default([]),
dryRun: z.boolean().default(true),
orphanedRepoAction: z
.enum(["skip", "archive", "delete"])
.default("archive"),
batchSize: z.number().default(10),
pauseBetweenDeletes: z.number().default(2000),
});
// Configuration schema
export const configSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid(),
name: z.string().min(1),
id: z.string(),
userId: z.string(),
name: z.string(),
isActive: z.boolean().default(true),
githubConfig: z.object({
username: z.string().min(1),
token: z.string().optional(),
skipForks: z.boolean().default(false),
privateRepositories: z.boolean().default(false),
mirrorIssues: z.boolean().default(false),
mirrorWiki: z.boolean().default(false),
mirrorStarred: z.boolean().default(false),
useSpecificUser: z.boolean().default(false),
singleRepo: z.string().optional(),
includeOrgs: z.array(z.string()).default([]),
excludeOrgs: z.array(z.string()).default([]),
mirrorPublicOrgs: z.boolean().default(false),
publicOrgs: z.array(z.string()).default([]),
skipStarredIssues: z.boolean().default(false),
}),
giteaConfig: z.object({
username: z.string().min(1),
url: z.string().url(),
token: z.string().min(1),
organization: z.string().optional(),
visibility: z.enum(["public", "private", "limited"]).default("public"),
starredReposOrg: z.string().default("github"),
preserveOrgStructure: z.boolean().default(false),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).optional(),
personalReposOrg: z.string().optional(), // Override destination for personal repos
}),
githubConfig: githubConfigSchema,
giteaConfig: giteaConfigSchema,
include: z.array(z.string()).default(["*"]),
exclude: z.array(z.string()).default([]),
scheduleConfig: z.object({
enabled: z.boolean().default(false),
interval: z.number().min(1).default(3600), // in seconds
lastRun: z.date().optional(),
nextRun: z.date().optional(),
}),
cleanupConfig: z.object({
enabled: z.boolean().default(false),
retentionDays: z.number().min(1).default(604800), // in seconds (default: 7 days)
lastRun: z.date().optional(),
nextRun: z.date().optional(),
}),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
scheduleConfig: scheduleConfigSchema,
cleanupConfig: cleanupConfigSchema,
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Config = z.infer<typeof configSchema>;
// Repository schema
export const repositorySchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
configId: z.string().uuid(),
name: z.string().min(1),
fullName: z.string().min(1),
id: z.string(),
userId: z.string(),
configId: z.string(),
name: z.string(),
fullName: z.string(),
url: z.string().url(),
cloneUrl: z.string().url(),
owner: z.string().min(1),
organization: z.string().optional(),
owner: z.string(),
organization: z.string().optional().nullable(),
mirroredLocation: z.string().default(""),
isPrivate: z.boolean().default(false),
isForked: z.boolean().default(false),
forkedFrom: z.string().optional(),
forkedFrom: z.string().optional().nullable(),
hasIssues: z.boolean().default(false),
isStarred: z.boolean().default(false),
isArchived: z.boolean().default(false),
size: z.number(),
size: z.number().default(0),
hasLFS: z.boolean().default(false),
hasSubmodules: z.boolean().default(false),
language: z.string().optional().nullable(),
description: z.string().optional().nullable(),
defaultBranch: z.string(),
visibility: repositoryVisibilityEnum.default("public"),
status: repoStatusEnum.default("imported"),
lastMirrored: z.date().optional(),
errorMessage: z.string().optional(),
mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored
destinationOrg: z.string().optional(), // Custom destination organization override
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
visibility: z.enum(["public", "private", "internal"]).default("public"),
status: z
.enum([
"imported",
"mirroring",
"mirrored",
"failed",
"skipped",
"deleting",
"deleted",
"syncing",
"synced",
])
.default("imported"),
lastMirrored: z.coerce.date().optional().nullable(),
errorMessage: z.string().optional().nullable(),
destinationOrg: z.string().optional().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Repository = z.infer<typeof repositorySchema>;
// Mirror job schema
export const mirrorJobSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
repositoryId: z.string().uuid().optional(),
repositoryName: z.string().optional(),
organizationId: z.string().uuid().optional(),
organizationName: z.string().optional(),
details: z.string().optional(),
status: repoStatusEnum.default("imported"),
id: z.string(),
userId: z.string(),
repositoryId: z.string().optional().nullable(),
repositoryName: z.string().optional().nullable(),
organizationId: z.string().optional().nullable(),
organizationName: z.string().optional().nullable(),
details: z.string().optional().nullable(),
status: z
.enum([
"imported",
"mirroring",
"mirrored",
"failed",
"skipped",
"deleting",
"deleted",
"syncing",
"synced",
])
.default("imported"),
message: z.string(),
timestamp: z.date().default(() => new Date()),
// New fields for job resilience
jobType: z.enum(["mirror", "sync", "retry"]).default("mirror"),
batchId: z.string().uuid().optional(), // Group related jobs together
totalItems: z.number().optional(), // Total number of items to process
completedItems: z.number().optional(), // Number of items completed
itemIds: z.array(z.string()).optional(), // IDs of items to process
completedItemIds: z.array(z.string()).optional(), // IDs of completed items
inProgress: z.boolean().default(false), // Whether the job is currently running
startedAt: z.date().optional(), // When the job started
completedAt: z.date().optional(), // When the job completed
lastCheckpoint: z.date().optional(), // Last time progress was saved
timestamp: z.coerce.date(),
jobType: z.enum(["mirror", "cleanup", "import"]).default("mirror"),
batchId: z.string().optional().nullable(),
totalItems: z.number().optional().nullable(),
completedItems: z.number().default(0),
itemIds: z.array(z.string()).optional().nullable(),
completedItemIds: z.array(z.string()).default([]),
inProgress: z.boolean().default(false),
startedAt: z.coerce.date().optional().nullable(),
completedAt: z.coerce.date().optional().nullable(),
lastCheckpoint: z.coerce.date().optional().nullable(),
});
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
// Organization schema
export const organizationSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
configId: z.string().uuid(),
avatarUrl: z.string().url(),
name: z.string().min(1),
membershipRole: membershipRoleEnum.default("member"),
isIncluded: z.boolean().default(false),
status: repoStatusEnum.default("imported"),
lastMirrored: z.date().optional(),
errorMessage: z.string().optional(),
id: z.string(),
userId: z.string(),
configId: z.string(),
name: z.string(),
avatarUrl: z.string(),
membershipRole: z.enum(["admin", "member", "owner"]).default("member"),
isIncluded: z.boolean().default(true),
destinationOrg: z.string().optional().nullable(),
status: z
.enum([
"imported",
"mirroring",
"mirrored",
"failed",
"skipped",
"deleting",
"deleted",
"syncing",
"synced",
])
.default("imported"),
lastMirrored: z.coerce.date().optional().nullable(),
errorMessage: z.string().optional().nullable(),
repositoryCount: z.number().default(0),
publicRepositoryCount: z.number().optional(),
privateRepositoryCount: z.number().optional(),
forkRepositoryCount: z.number().optional(),
// Override destination organization for this GitHub org's repos
destinationOrg: z.string().optional(),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Organization = z.infer<typeof organizationSchema>;
// Event schema (for SQLite-based pub/sub)
export const eventSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid(),
channel: z.string().min(1),
id: z.string(),
userId: z.string(),
channel: z.string(),
payload: z.any(),
read: z.boolean().default(false),
createdAt: z.date().default(() => new Date()),
createdAt: z.coerce.date(),
});
export type Event = z.infer<typeof eventSchema>;
// ===== Drizzle Table Definitions =====
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
// Custom fields
username: text("username"),
});
export const events = sqliteTable("events", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
channel: text("channel").notNull(),
payload: text("payload", { mode: "json" }).notNull(),
read: integer("read", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
userChannelIdx: index("idx_events_user_channel").on(table.userId, table.channel),
createdAtIdx: index("idx_events_created_at").on(table.createdAt),
readIdx: index("idx_events_read").on(table.read),
};
});
export const configs = sqliteTable("configs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
githubConfig: text("github_config", { mode: "json" })
.$type<z.infer<typeof githubConfigSchema>>()
.notNull(),
giteaConfig: text("gitea_config", { mode: "json" })
.$type<z.infer<typeof giteaConfigSchema>>()
.notNull(),
include: text("include", { mode: "json" })
.$type<string[]>()
.notNull()
.default(sql`'["*"]'`),
exclude: text("exclude", { mode: "json" })
.$type<string[]>()
.notNull()
.default(sql`'[]'`),
scheduleConfig: text("schedule_config", { mode: "json" })
.$type<z.infer<typeof scheduleConfigSchema>>()
.notNull(),
cleanupConfig: text("cleanup_config", { mode: "json" })
.$type<z.infer<typeof cleanupConfigSchema>>()
.notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const repositories = sqliteTable("repositories", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
fullName: text("full_name").notNull(),
url: text("url").notNull(),
cloneUrl: text("clone_url").notNull(),
owner: text("owner").notNull(),
organization: text("organization"),
mirroredLocation: text("mirrored_location").default(""),
isPrivate: integer("is_private", { mode: "boolean" })
.notNull()
.default(false),
isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false),
forkedFrom: text("forked_from"),
hasIssues: integer("has_issues", { mode: "boolean" })
.notNull()
.default(false),
isStarred: integer("is_starred", { mode: "boolean" })
.notNull()
.default(false),
isArchived: integer("is_archived", { mode: "boolean" })
.notNull()
.default(false),
size: integer("size").notNull().default(0),
hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false),
hasSubmodules: integer("has_submodules", { mode: "boolean" })
.notNull()
.default(false),
language: text("language"),
description: text("description"),
defaultBranch: text("default_branch").notNull(),
visibility: text("visibility").notNull().default("public"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
destinationOrg: text("destination_org"),
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_repositories_user_id").on(table.userId),
configIdIdx: index("idx_repositories_config_id").on(table.configId),
statusIdx: index("idx_repositories_status").on(table.status),
ownerIdx: index("idx_repositories_owner").on(table.owner),
organizationIdx: index("idx_repositories_organization").on(table.organization),
isForkedIdx: index("idx_repositories_is_fork").on(table.isForked),
isStarredIdx: index("idx_repositories_is_starred").on(table.isStarred),
};
});
export const mirrorJobs = sqliteTable("mirror_jobs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
repositoryId: text("repository_id"),
repositoryName: text("repository_name"),
organizationId: text("organization_id"),
organizationName: text("organization_name"),
details: text("details"),
status: text("status").notNull().default("imported"),
message: text("message").notNull(),
timestamp: integer("timestamp", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
// Job resilience fields
jobType: text("job_type").notNull().default("mirror"),
batchId: text("batch_id"),
totalItems: integer("total_items"),
completedItems: integer("completed_items").default(0),
itemIds: text("item_ids", { mode: "json" }).$type<string[]>(),
completedItemIds: text("completed_item_ids", { mode: "json" })
.$type<string[]>()
.default(sql`'[]'`),
inProgress: integer("in_progress", { mode: "boolean" })
.notNull()
.default(false),
startedAt: integer("started_at", { mode: "timestamp" }),
completedAt: integer("completed_at", { mode: "timestamp" }),
lastCheckpoint: integer("last_checkpoint", { mode: "timestamp" }),
}, (table) => {
return {
userIdIdx: index("idx_mirror_jobs_user_id").on(table.userId),
batchIdIdx: index("idx_mirror_jobs_batch_id").on(table.batchId),
inProgressIdx: index("idx_mirror_jobs_in_progress").on(table.inProgress),
jobTypeIdx: index("idx_mirror_jobs_job_type").on(table.jobType),
timestampIdx: index("idx_mirror_jobs_timestamp").on(table.timestamp),
};
});
export const organizations = sqliteTable("organizations", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
avatarUrl: text("avatar_url").notNull(),
membershipRole: text("membership_role").notNull().default("member"),
isIncluded: integer("is_included", { mode: "boolean" })
.notNull()
.default(true),
destinationOrg: text("destination_org"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
repositoryCount: integer("repository_count").notNull().default(0),
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_organizations_user_id").on(table.userId),
configIdIdx: index("idx_organizations_config_id").on(table.configId),
statusIdx: index("idx_organizations_status").on(table.status),
isIncludedIdx: index("idx_organizations_is_included").on(table.isIncluded),
};
});
// ===== Better Auth Tables =====
// 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(),
accountId: text("account_id").notNull(),
userId: text("user_id").notNull().references(() => users.id),
providerId: text("provider_id").notNull(),
providerUserId: text("provider_user_id"), // Make nullable for email/password auth
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 {
accountIdIdx: index("idx_accounts_account_id").on(table.accountId),
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),
};
});
// ===== OIDC Provider Tables =====
// OAuth Applications table
export const oauthApplications = sqliteTable("oauth_applications", {
id: text("id").primaryKey(),
clientId: text("client_id").notNull().unique(),
clientSecret: text("client_secret").notNull(),
name: text("name").notNull(),
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
metadata: text("metadata"), // JSON string
type: text("type").notNull(), // web, mobile, etc
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
userId: text("user_id"), // Optional - owner of the application
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
clientIdIdx: index("idx_oauth_applications_client_id").on(table.clientId),
userIdIdx: index("idx_oauth_applications_user_id").on(table.userId),
};
});
// OAuth Access Tokens table
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
id: text("id").primaryKey(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
clientId: text("client_id").notNull(),
userId: text("user_id").notNull().references(() => users.id),
scopes: text("scopes").notNull(), // Comma-separated list
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
accessTokenIdx: index("idx_oauth_access_tokens_access_token").on(table.accessToken),
userIdIdx: index("idx_oauth_access_tokens_user_id").on(table.userId),
clientIdIdx: index("idx_oauth_access_tokens_client_id").on(table.clientId),
};
});
// OAuth Consent table
export const oauthConsent = sqliteTable("oauth_consent", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
clientId: text("client_id").notNull(),
scopes: text("scopes").notNull(), // Comma-separated list
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
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_oauth_consent_user_id").on(table.userId),
clientIdIdx: index("idx_oauth_consent_client_id").on(table.clientId),
userClientIdx: index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
};
});
// ===== SSO Provider Tables =====
// SSO Providers table
export const ssoProviders = sqliteTable("sso_providers", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
domain: text("domain").notNull(),
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
userId: text("user_id").notNull(), // Admin who created this provider
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
providerIdIdx: index("idx_sso_providers_provider_id").on(table.providerId),
domainIdx: index("idx_sso_providers_domain").on(table.domain),
issuerIdx: index("idx_sso_providers_issuer").on(table.issuer),
};
});
// Export type definitions
export type User = z.infer<typeof userSchema>;
export type Config = z.infer<typeof configSchema>;
export type Repository = z.infer<typeof repositorySchema>;
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
export type Organization = z.infer<typeof organizationSchema>;
export type Event = z.infer<typeof eventSchema>;

View File

@@ -0,0 +1,22 @@
/**
* Deployment mode utilities
* Supports both self-hosted and hosted versions
*/
export const DEPLOYMENT_MODE = process.env.DEPLOYMENT_MODE || 'selfhosted';
export const isSelfHostedMode = () => DEPLOYMENT_MODE === 'selfhosted';
export const isHostedMode = () => DEPLOYMENT_MODE === 'hosted';
/**
* Feature flags for self-hosted version
*/
export const features = {
// Core features available
githubSync: true,
giteaMirroring: true,
scheduling: true,
multiUser: true,
githubSponsors: true,
unlimitedRepos: true,
};

256
src/lib/events/realtime.ts Normal file
View File

@@ -0,0 +1,256 @@
/**
* Real-time event system using EventEmitter
* For the self-hosted version
*/
import { EventEmitter } from 'events';
export interface RealtimeEvent {
type: string;
userId?: string;
data: any;
timestamp: number;
}
/**
* Real-time event bus for local instance
*/
export class RealtimeEventBus extends EventEmitter {
private channels = new Map<string, Set<(event: RealtimeEvent) => void>>();
private userChannels = new Map<string, string[]>();
constructor() {
super();
}
/**
* Handle incoming events
*/
private handleIncomingEvent(channel: string, event: RealtimeEvent) {
// Emit to local listeners
this.emit(channel, event);
// Call channel-specific handlers
const handlers = this.channels.get(channel);
if (handlers) {
handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
console.error('Error in event handler:', error);
}
});
}
}
/**
* Subscribe to a channel
*/
async subscribe(channel: string, handler?: (event: RealtimeEvent) => void) {
// Add handler if provided
if (handler) {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
this.channels.get(channel)!.add(handler);
}
// Add local listener
if (!this.listenerCount(channel)) {
this.on(channel, (event) => this.handleIncomingEvent(channel, event));
}
}
/**
* Subscribe to user-specific channels
*/
async subscribeUser(userId: string) {
const channels = [
`user:${userId}`,
`user:${userId}:notifications`,
`user:${userId}:updates`,
];
this.userChannels.set(userId, channels);
for (const channel of channels) {
await this.subscribe(channel);
}
}
/**
* Unsubscribe from a channel
*/
async unsubscribe(channel: string, handler?: (event: RealtimeEvent) => void) {
// Remove handler if provided
if (handler) {
this.channels.get(channel)?.delete(handler);
// Remove channel if no handlers left
if (this.channels.get(channel)?.size === 0) {
this.channels.delete(channel);
}
}
// Remove local listener if no handlers
if (!this.channels.has(channel)) {
this.removeAllListeners(channel);
}
}
/**
* Unsubscribe from user channels
*/
async unsubscribeUser(userId: string) {
const channels = this.userChannels.get(userId) || [];
for (const channel of channels) {
await this.unsubscribe(channel);
}
this.userChannels.delete(userId);
}
/**
* Publish an event
*/
async publish(channel: string, event: Omit<RealtimeEvent, 'timestamp'>) {
const fullEvent: RealtimeEvent = {
...event,
timestamp: Date.now(),
};
// Emit locally
this.handleIncomingEvent(channel, fullEvent);
}
/**
* Broadcast to all users
*/
async broadcast(event: Omit<RealtimeEvent, 'timestamp'>) {
await this.publish('broadcast', event);
}
/**
* Send event to specific user
*/
async sendToUser(userId: string, event: Omit<RealtimeEvent, 'timestamp' | 'userId'>) {
await this.publish(`user:${userId}`, {
...event,
userId,
});
}
/**
* Send activity update
*/
async sendActivity(activity: {
userId: string;
action: string;
resource: string;
resourceId: string;
details?: any;
}) {
const event = {
type: 'activity',
data: activity,
};
// Send to user
await this.sendToUser(activity.userId, event);
// Also publish to activity channel
await this.publish('activity', {
...event,
userId: activity.userId,
});
}
/**
* Get event statistics
*/
getStats() {
return {
channels: this.channels.size,
listeners: Array.from(this.channels.values()).reduce(
(sum, handlers) => sum + handlers.size,
0
),
userChannels: this.userChannels.size,
};
}
}
// Global event bus instance
export const eventBus = new RealtimeEventBus();
/**
* React hook for subscribing to events
*/
export function useRealtimeEvents(
channel: string,
handler: (event: RealtimeEvent) => void,
deps: any[] = []
) {
if (typeof window !== 'undefined') {
const { useEffect } = require('react');
useEffect(() => {
eventBus.subscribe(channel, handler);
return () => {
eventBus.unsubscribe(channel, handler);
};
}, deps);
}
}
/**
* Server-sent events endpoint handler
*/
export async function createSSEHandler(userId: string) {
const encoder = new TextEncoder();
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
// Send initial connection event
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
);
// Subscribe to user channels
await eventBus.subscribeUser(userId);
// Create event handler
const handleEvent = (event: RealtimeEvent) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
);
};
// Subscribe to channels
eventBus.on(`user:${userId}`, handleEvent);
// Keep connection alive with heartbeat
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
}, 30000);
// Cleanup on close
return () => {
clearInterval(heartbeat);
eventBus.off(`user:${userId}`, handleEvent);
eventBus.unsubscribeUser(userId);
};
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}

View File

@@ -11,6 +11,7 @@ import { httpPost, httpGet } from "./http-client";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
import { eq, and } from "drizzle-orm";
import { decryptConfigTokens } from "./utils/config-encryption";
/**
* Helper function to get organization configuration including destination override
@@ -183,12 +184,15 @@ export const isRepoPresentInGitea = async ({
throw new Error("Gitea config is required.");
}
// Decrypt config tokens for API usage
const decryptedConfig = decryptConfigTokens(config as Config);
// Check if the repository exists at the specified owner location
const response = await fetch(
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
},
}
);
@@ -371,7 +375,7 @@ export const mirrorGithubRepoToGitea = async ({
service: "git",
},
{
Authorization: `token ${config.giteaConfig.token}`,
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
@@ -392,7 +396,7 @@ export const mirrorGithubRepoToGitea = async ({
config,
octokit,
repository,
isRepoInOrg: false,
giteaOwner: repoOwner,
});
}
@@ -476,11 +480,14 @@ export async function getOrCreateGiteaOrg({
try {
console.log(`Attempting to get or create Gitea organization: ${orgName}`);
// Decrypt config tokens for API usage
const decryptedConfig = decryptConfigTokens(config as Config);
const orgRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
"Content-Type": "application/json",
},
}
@@ -533,7 +540,7 @@ export async function getOrCreateGiteaOrg({
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
method: "POST",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -720,7 +727,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
private: repository.isPrivate,
},
{
Authorization: `token ${config.giteaConfig.token}`,
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
@@ -741,7 +748,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
config,
octokit,
repository,
isRepoInOrg: true,
giteaOwner: orgName,
});
}
@@ -1074,6 +1081,9 @@ export const syncGiteaRepo = async ({
throw new Error("Gitea config is required.");
}
// Decrypt config tokens for API usage
const decryptedConfig = decryptConfigTokens(config as Config);
console.log(`Syncing repository ${repository.name}`);
// Mark repo as "syncing" in DB
@@ -1183,12 +1193,12 @@ export const mirrorGitRepoIssuesToGitea = async ({
config,
octokit,
repository,
isRepoInOrg,
giteaOwner,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
isRepoInOrg: boolean;
giteaOwner: string;
}) => {
//things covered here are- issue, title, body, labels, comments and assignees
if (
@@ -1200,9 +1210,8 @@ export const mirrorGitRepoIssuesToGitea = async ({
throw new Error("Missing GitHub or Gitea configuration.");
}
const repoOrigin = isRepoInOrg
? repository.organization
: config.githubConfig.username;
// Decrypt config tokens for API usage
const decryptedConfig = decryptConfigTokens(config as Config);
const [owner, repo] = repository.fullName.split("/");
@@ -1232,7 +1241,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
// Get existing labels from Gitea
const giteaLabelsRes = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`,
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`,
{
Authorization: `token ${config.giteaConfig.token}`,
}
@@ -1264,7 +1273,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
} else {
try {
const created = await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${
repository.name
}/labels`,
{ name, color: "#ededed" }, // Default color
@@ -1301,7 +1310,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
// Create the issue in Gitea
const createdIssue = await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${
repository.name
}/issues`,
issuePayload,
@@ -1328,7 +1337,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
comments,
async (comment) => {
await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${
repository.name
}/issues/${createdIssue.data.number}/comments`,
{

184
src/lib/modules/registry.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Module registry implementation
* Manages loading and access to modular features
*/
import type {
Module,
ModuleRegistry,
AppContext,
RouteHandler,
Middleware,
DatabaseAdapter,
EventEmitter
} from './types';
// Module registry for extensibility
/**
* Simple event emitter implementation
*/
class SimpleEventEmitter implements EventEmitter {
private events: Map<string, Set<Function>> = new Map();
on(event: string, handler: (...args: any[]) => void): void {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(handler);
}
off(event: string, handler: (...args: any[]) => void): void {
this.events.get(event)?.delete(handler);
}
emit(event: string, ...args: any[]): void {
this.events.get(event)?.forEach(handler => {
try {
handler(...args);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
}
/**
* Module manager class
*/
export class ModuleManager {
private modules: Map<string, Module> = new Map();
private routes: Map<string, RouteHandler> = new Map();
private middlewares: Middleware[] = [];
private events = new SimpleEventEmitter();
private initialized = false;
/**
* Get app context for modules
*/
private getAppContext(): AppContext {
return {
addRoute: (path, handler) => this.addRoute(path, handler),
addMiddleware: (middleware) => this.middlewares.push(middleware),
db: this.getDatabaseAdapter(),
events: this.events,
modules: this.getRegistry(),
};
}
/**
* Get database adapter based on deployment mode
*/
private getDatabaseAdapter(): DatabaseAdapter {
// This would be implemented to use SQLite or PostgreSQL
// based on deployment mode
return {
query: async (sql, params) => [],
execute: async (sql, params) => {},
transaction: async (fn) => fn(),
};
}
/**
* Register a module
*/
async register(module: Module): Promise<void> {
if (this.modules.has(module.name)) {
console.warn(`Module ${module.name} is already registered`);
return;
}
try {
await module.init(this.getAppContext());
this.modules.set(module.name, module);
console.log(`Module ${module.name} registered successfully`);
} catch (error) {
console.error(`Failed to register module ${module.name}:`, error);
throw error;
}
}
/**
* Unregister a module
*/
async unregister(moduleName: string): Promise<void> {
const module = this.modules.get(moduleName);
if (!module) return;
if (module.cleanup) {
await module.cleanup();
}
this.modules.delete(moduleName);
// Remove routes registered by this module
// This would need to track which module registered which routes
}
/**
* Add a route handler
*/
private addRoute(path: string, handler: RouteHandler): void {
this.routes.set(path, handler);
}
/**
* Get route handler for a path
*/
getRouteHandler(path: string): RouteHandler | null {
return this.routes.get(path) || null;
}
/**
* Get all middleware
*/
getMiddleware(): Middleware[] {
return [...this.middlewares];
}
/**
* Get module registry
*/
getRegistry(): ModuleRegistry {
const registry: ModuleRegistry = {};
// Copy all modules to registry
for (const [name, module] of this.modules) {
registry[name] = module;
}
return registry;
}
/**
* Get a specific module
*/
get<K extends keyof ModuleRegistry>(name: K): ModuleRegistry[K] | null {
return this.getRegistry()[name] || null;
}
/**
* Check if a module is loaded
*/
has(name: string): boolean {
return this.modules.has(name);
}
/**
* Emit an event to all modules
*/
emit(event: string, ...args: any[]): void {
this.events.emit(event, ...args);
}
}
// Global module manager instance
export const modules = new ModuleManager();
// Initialize modules on app start
export async function initializeModules() {
// Load core modules here if any
// Emit initialization complete event
modules.emit('modules:initialized');
}

86
src/lib/modules/types.d.ts vendored Normal file
View File

@@ -0,0 +1,86 @@
/**
* Module system type definitions
* These interfaces allow for extensibility and plugins
*/
import type { APIContext } from 'astro';
import type { ComponentType, LazyExoticComponent } from 'react';
/**
* Base module interface that all modules must implement
*/
export interface Module {
/** Unique module identifier */
name: string;
/** Module version */
version: string;
/** Initialize the module with app context */
init(app: AppContext): Promise<void>;
/** Cleanup when module is unloaded */
cleanup?(): Promise<void>;
}
/**
* Application context passed to modules
*/
export interface AppContext {
/** Register API routes */
addRoute(path: string, handler: RouteHandler): void;
/** Register middleware */
addMiddleware(middleware: Middleware): void;
/** Access to database (abstracted) */
db: DatabaseAdapter;
/** Event emitter for cross-module communication */
events: EventEmitter;
/** Access to other modules */
modules: ModuleRegistry;
}
/**
* Route handler type
*/
export type RouteHandler = (context: APIContext) => Promise<Response> | Response;
/**
* Middleware type
*/
export type Middleware = (context: APIContext, next: () => Promise<Response>) => Promise<Response>;
/**
* Database adapter interface (abstract away implementation)
*/
export interface DatabaseAdapter {
query<T>(sql: string, params?: any[]): Promise<T[]>;
execute(sql: string, params?: any[]): Promise<void>;
transaction<T>(fn: () => Promise<T>): Promise<T>;
}
/**
* Event emitter for cross-module communication
*/
export interface EventEmitter {
on(event: string, handler: (...args: any[]) => void): void;
off(event: string, handler: (...args: any[]) => void): void;
emit(event: string, ...args: any[]): void;
}
/**
* Example module interfaces
* These are examples of how modules can be structured
*/
export interface FeatureModule extends Module {
/** React components provided by the module */
components?: Record<string, LazyExoticComponent<ComponentType<any>>>;
/** API methods provided by the module */
api?: Record<string, (...args: any[]) => Promise<any>>;
/** Lifecycle hooks */
hooks?: {
onInit?: () => Promise<void>;
onUserAction?: (action: string, data: any) => Promise<void>;
};
}
/**
* Module registry interface
*/
export interface ModuleRegistry {
[key: string]: Module | undefined;
}
export interface User {
id: string;
email: string;
name?: string;
username?: string;
}
//# sourceMappingURL=types.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAEhE;;GAEG;AACH,MAAM,WAAW,MAAM;IACrB,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IAEb,qBAAqB;IACrB,OAAO,EAAE,MAAM,CAAC;IAEhB,6CAA6C;IAC7C,IAAI,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAErC,sCAAsC;IACtC,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,0BAA0B;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI,CAAC;IAEpD,0BAA0B;IAC1B,aAAa,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAE5C,sCAAsC;IACtC,EAAE,EAAE,eAAe,CAAC;IAEpB,mDAAmD;IACnD,MAAM,EAAE,YAAY,CAAC;IAErB,8BAA8B;IAC9B,OAAO,EAAE,cAAc,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,UAAU,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;AAEjF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,CACvB,OAAO,EAAE,UAAU,EACnB,IAAI,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC1B,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEvB;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;IACpD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAClD;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC5D,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC3C;AAED;;;GAGG;AAGH,MAAM,WAAW,aAAc,SAAQ,MAAM;IAC3C,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAErE,yCAAyC;IACzC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IAEvD,sBAAsB;IACtB,KAAK,CAAC,EAAE;QACN,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;QAC7B,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;KAC7D,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CACnC;AAGD,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB"}

5
src/lib/modules/types.js Normal file
View File

@@ -0,0 +1,5 @@
/**
* Module system type definitions
* These interfaces allow for extensibility and plugins
*/
export {};

110
src/lib/modules/types.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Module system type definitions
* These interfaces allow for extensibility and plugins
*/
import type { APIContext } from 'astro';
import type { ComponentType, LazyExoticComponent } from 'react';
/**
* Base module interface that all modules must implement
*/
export interface Module {
/** Unique module identifier */
name: string;
/** Module version */
version: string;
/** Initialize the module with app context */
init(app: AppContext): Promise<void>;
/** Cleanup when module is unloaded */
cleanup?(): Promise<void>;
}
/**
* Application context passed to modules
*/
export interface AppContext {
/** Register API routes */
addRoute(path: string, handler: RouteHandler): void;
/** Register middleware */
addMiddleware(middleware: Middleware): void;
/** Access to database (abstracted) */
db: DatabaseAdapter;
/** Event emitter for cross-module communication */
events: EventEmitter;
/** Access to other modules */
modules: ModuleRegistry;
}
/**
* Route handler type
*/
export type RouteHandler = (context: APIContext) => Promise<Response> | Response;
/**
* Middleware type
*/
export type Middleware = (
context: APIContext,
next: () => Promise<Response>
) => Promise<Response>;
/**
* Database adapter interface (abstract away implementation)
*/
export interface DatabaseAdapter {
query<T>(sql: string, params?: any[]): Promise<T[]>;
execute(sql: string, params?: any[]): Promise<void>;
transaction<T>(fn: () => Promise<T>): Promise<T>;
}
/**
* Event emitter for cross-module communication
*/
export interface EventEmitter {
on(event: string, handler: (...args: any[]) => void): void;
off(event: string, handler: (...args: any[]) => void): void;
emit(event: string, ...args: any[]): void;
}
/**
* Example module interfaces
* These are examples of how modules can be structured
*/
// Example: Feature module with components
export interface FeatureModule extends Module {
/** React components provided by the module */
components?: Record<string, LazyExoticComponent<ComponentType<any>>>;
/** API methods provided by the module */
api?: Record<string, (...args: any[]) => Promise<any>>;
/** Lifecycle hooks */
hooks?: {
onInit?: () => Promise<void>;
onUserAction?: (action: string, data: any) => Promise<void>;
};
}
/**
* Module registry interface
*/
export interface ModuleRegistry {
[key: string]: Module | undefined;
}
// Generic types that modules might use
export interface User {
id: string;
email: string;
name?: string;
username?: string;
}

View File

@@ -11,6 +11,7 @@ import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository';
import type { Repository } from './db/schema';
import { getDecryptedGitHubToken } from './utils/config-encryption';
// Recovery state tracking
let recoveryInProgress = false;
@@ -262,7 +263,8 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
// Create GitHub client with error handling
let octokit;
try {
octokit = createGitHubClient(config.githubConfig.token);
const decryptedToken = getDecryptedGitHubToken(config);
octokit = createGitHubClient(decryptedToken);
} catch (error) {
throw new Error(`Failed to create GitHub client: ${error instanceof Error ? error.message : String(error)}`);
}

View File

@@ -9,6 +9,15 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {
@@ -185,7 +194,7 @@ export async function apiRequest<T>(
}
}
export const getStatusColor = (status: RepoStatus): string => {
export const getStatusColor = (status: string): string => {
switch (status) {
case "imported":
return "bg-blue-500"; // Info/primary-like
@@ -199,6 +208,12 @@ export const getStatusColor = (status: RepoStatus): string => {
return "bg-indigo-500"; // Sync in progress
case "synced":
return "bg-teal-500"; // Sync complete
case "skipped":
return "bg-gray-500"; // Skipped
case "deleting":
return "bg-orange-500"; // Deleting
case "deleted":
return "bg-gray-600"; // Deleted
default:
return "bg-gray-400"; // Unknown/neutral
}

View File

@@ -0,0 +1,58 @@
import type { APIRoute, APIContext } from "astro";
import { auth } from "@/lib/auth";
/**
* Get authenticated user from request
* @param request - The request object from Astro API route
* @returns The authenticated user or null if not authenticated
*/
export async function getAuthenticatedUser(request: Request) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
return session ? session.user : null;
} catch (error) {
console.error("Error getting session:", error);
return null;
}
}
/**
* Require authentication for API routes
* Returns an error response if user is not authenticated
* @param context - The API context from Astro
* @returns Object with user if authenticated, or error response if not
*/
export async function requireAuth(context: APIContext) {
const user = await getAuthenticatedUser(context.request);
if (!user) {
return {
user: null,
response: new Response(
JSON.stringify({
success: false,
error: "Unauthorized - Please log in",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
),
};
}
return { user, response: null };
}
/**
* Get user ID from authenticated session
* @param request - The request object from Astro API route
* @returns The user ID or null if not authenticated
*/
export async function getAuthenticatedUserId(request: Request): Promise<string | null> {
const user = await getAuthenticatedUser(request);
return user?.id || null;
}

View File

@@ -0,0 +1,52 @@
import { decrypt } from "./encryption";
import type { Config } from "@/types/config";
/**
* Decrypts tokens in a config object for use in API calls
* @param config The config object with potentially encrypted tokens
* @returns Config object with decrypted tokens
*/
export function decryptConfigTokens(config: Config): Config {
const decryptedConfig = { ...config };
// Deep clone the config objects
if (config.githubConfig) {
decryptedConfig.githubConfig = { ...config.githubConfig };
if (config.githubConfig.token) {
decryptedConfig.githubConfig.token = decrypt(config.githubConfig.token);
}
}
if (config.giteaConfig) {
decryptedConfig.giteaConfig = { ...config.giteaConfig };
if (config.giteaConfig.token) {
decryptedConfig.giteaConfig.token = decrypt(config.giteaConfig.token);
}
}
return decryptedConfig;
}
/**
* Gets a decrypted GitHub token from config
* @param config The config object
* @returns Decrypted GitHub token
*/
export function getDecryptedGitHubToken(config: Config): string {
if (!config.githubConfig?.token) {
throw new Error("GitHub token not found in config");
}
return decrypt(config.githubConfig.token);
}
/**
* Gets a decrypted Gitea token from config
* @param config The config object
* @returns Decrypted Gitea token
*/
export function getDecryptedGiteaToken(config: Config): string {
if (!config.giteaConfig?.token) {
throw new Error("Gitea token not found in config");
}
return decrypt(config.giteaConfig.token);
}

169
src/lib/utils/encryption.ts Normal file
View File

@@ -0,0 +1,169 @@
import * as crypto from "crypto";
// Encryption configuration
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16; // 128 bits
const SALT_LENGTH = 32; // 256 bits
const TAG_LENGTH = 16; // 128 bits
const KEY_LENGTH = 32; // 256 bits
const ITERATIONS = 100000; // PBKDF2 iterations
// Get or generate encryption key
function getEncryptionKey(): Buffer {
const secret = process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("No encryption secret found. Please set ENCRYPTION_SECRET environment variable.");
}
// Use a static salt derived from the secret for consistent key generation
// This ensures the same key is generated across application restarts
const salt = crypto.createHash('sha256').update('gitea-mirror-salt' + secret).digest();
return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
}
export interface EncryptedData {
encrypted: string;
iv: string;
salt: string;
tag: string;
version: number;
}
/**
* Encrypts sensitive data like API tokens
* @param plaintext The data to encrypt
* @returns Encrypted data with metadata
*/
export function encrypt(plaintext: string): string {
if (!plaintext) {
return '';
}
try {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const salt = crypto.randomBytes(SALT_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
const encryptedData: EncryptedData = {
encrypted: encrypted.toString('base64'),
iv: iv.toString('base64'),
salt: salt.toString('base64'),
tag: tag.toString('base64'),
version: 1
};
// Return as base64 encoded JSON for easy storage
return Buffer.from(JSON.stringify(encryptedData)).toString('base64');
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypts encrypted data
* @param encryptedString The encrypted data string
* @returns Decrypted plaintext
*/
export function decrypt(encryptedString: string): string {
if (!encryptedString) {
return '';
}
try {
// Check if it's already plaintext (for backward compatibility during migration)
if (!isEncrypted(encryptedString)) {
return encryptedString;
}
const encryptedData: EncryptedData = JSON.parse(
Buffer.from(encryptedString, 'base64').toString('utf8')
);
const key = getEncryptionKey();
const iv = Buffer.from(encryptedData.iv, 'base64');
const tag = Buffer.from(encryptedData.tag, 'base64');
const encrypted = Buffer.from(encryptedData.encrypted, 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString('utf8');
} catch (error) {
// If decryption fails, check if it's plaintext (backward compatibility)
try {
JSON.parse(Buffer.from(encryptedString, 'base64').toString('utf8'));
throw error; // It was encrypted but failed to decrypt
} catch {
// Not encrypted, return as-is for backward compatibility
console.warn('Token appears to be unencrypted, returning as-is for backward compatibility');
return encryptedString;
}
}
}
/**
* Checks if a string is encrypted
* @param value The string to check
* @returns true if encrypted, false otherwise
*/
export function isEncrypted(value: string): boolean {
if (!value) {
return false;
}
try {
const decoded = Buffer.from(value, 'base64').toString('utf8');
const data = JSON.parse(decoded);
return data.version === 1 && data.encrypted && data.iv && data.tag;
} catch {
return false;
}
}
/**
* Migrates unencrypted tokens to encrypted format
* @param token The token to migrate
* @returns Encrypted token if it wasn't already encrypted
*/
export function migrateToken(token: string): string {
if (!token || isEncrypted(token)) {
return token;
}
return encrypt(token);
}
/**
* Generates a secure random token
* @param length Token length in bytes (default: 32)
* @returns Hex encoded random token
*/
export function generateSecureToken(length: number = 32): string {
return crypto.randomBytes(length).toString('hex');
}
/**
* Hashes a value using SHA-256 (for non-reversible values like API keys for comparison)
* @param value The value to hash
* @returns Hex encoded hash
*/
export function hashValue(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}

View File

@@ -0,0 +1,85 @@
import { describe, test, expect } from "bun:test";
import { isValidRedirectUri, parseRedirectUris } from "./oauth-validation";
describe("OAuth Validation", () => {
describe("parseRedirectUris", () => {
test("parses comma-separated URIs", () => {
const result = parseRedirectUris("https://app1.com,https://app2.com, https://app3.com ");
expect(result).toEqual([
"https://app1.com",
"https://app2.com",
"https://app3.com"
]);
});
test("handles empty string", () => {
expect(parseRedirectUris("")).toEqual([]);
});
test("filters out empty values", () => {
const result = parseRedirectUris("https://app1.com,,https://app2.com,");
expect(result).toEqual(["https://app1.com", "https://app2.com"]);
});
});
describe("isValidRedirectUri", () => {
test("validates exact match", () => {
const authorizedUris = ["https://app.example.com/callback"];
expect(isValidRedirectUri("https://app.example.com/callback", authorizedUris)).toBe(true);
expect(isValidRedirectUri("https://app.example.com/other", authorizedUris)).toBe(false);
});
test("validates wildcard paths", () => {
const authorizedUris = ["https://app.example.com/*"];
expect(isValidRedirectUri("https://app.example.com/", authorizedUris)).toBe(true);
expect(isValidRedirectUri("https://app.example.com/callback", authorizedUris)).toBe(true);
expect(isValidRedirectUri("https://app.example.com/deep/path", authorizedUris)).toBe(true);
// Different domain should fail
expect(isValidRedirectUri("https://evil.com/callback", authorizedUris)).toBe(false);
});
test("validates protocol", () => {
const authorizedUris = ["https://app.example.com/callback"];
// HTTP instead of HTTPS should fail
expect(isValidRedirectUri("http://app.example.com/callback", authorizedUris)).toBe(false);
});
test("validates host and port", () => {
const authorizedUris = ["https://app.example.com:3000/callback"];
// Different port should fail
expect(isValidRedirectUri("https://app.example.com/callback", authorizedUris)).toBe(false);
expect(isValidRedirectUri("https://app.example.com:3000/callback", authorizedUris)).toBe(true);
expect(isValidRedirectUri("https://app.example.com:4000/callback", authorizedUris)).toBe(false);
});
test("handles invalid URIs", () => {
const authorizedUris = ["not-a-valid-uri", "https://valid.com"];
// Invalid redirect URI
expect(isValidRedirectUri("not-a-valid-uri", authorizedUris)).toBe(false);
// Valid redirect URI with invalid authorized URI should still work if it matches valid one
expect(isValidRedirectUri("https://valid.com", authorizedUris)).toBe(true);
});
test("handles empty inputs", () => {
expect(isValidRedirectUri("", ["https://app.com"])).toBe(false);
expect(isValidRedirectUri("https://app.com", [])).toBe(false);
});
test("prevents open redirect attacks", () => {
const authorizedUris = ["https://app.example.com/callback"];
// Various attack vectors
expect(isValidRedirectUri("https://app.example.com.evil.com/callback", authorizedUris)).toBe(false);
expect(isValidRedirectUri("https://app.example.com@evil.com/callback", authorizedUris)).toBe(false);
expect(isValidRedirectUri("//evil.com/callback", authorizedUris)).toBe(false);
expect(isValidRedirectUri("https:evil.com/callback", authorizedUris)).toBe(false);
});
});
});

View File

@@ -0,0 +1,59 @@
/**
* Validates a redirect URI against a list of authorized URIs
* @param redirectUri The redirect URI to validate
* @param authorizedUris List of authorized redirect URIs
* @returns true if the redirect URI is authorized, false otherwise
*/
export function isValidRedirectUri(redirectUri: string, authorizedUris: string[]): boolean {
if (!redirectUri || authorizedUris.length === 0) {
return false;
}
try {
// Parse the redirect URI to ensure it's valid
const redirectUrl = new URL(redirectUri);
return authorizedUris.some(authorizedUri => {
try {
// Handle wildcard paths (e.g., https://example.com/*)
if (authorizedUri.endsWith('/*')) {
const baseUri = authorizedUri.slice(0, -2);
const baseUrl = new URL(baseUri);
// Check protocol, host, and port match
return redirectUrl.protocol === baseUrl.protocol &&
redirectUrl.host === baseUrl.host &&
redirectUrl.pathname.startsWith(baseUrl.pathname);
}
// Handle exact match
const authorizedUrl = new URL(authorizedUri);
// For exact match, everything must match including path and query params
return redirectUrl.href === authorizedUrl.href;
} catch {
// If authorized URI is not a valid URL, treat as invalid
return false;
}
});
} catch {
// If redirect URI is not a valid URL, it's invalid
return false;
}
}
/**
* Parses a comma-separated list of redirect URIs and trims whitespace
* @param redirectUrls Comma-separated list of redirect URIs
* @returns Array of trimmed redirect URIs
*/
export function parseRedirectUris(redirectUrls: string): string[] {
if (!redirectUrls) {
return [];
}
return redirectUrls
.split(',')
.map(uri => uri.trim())
.filter(uri => uri.length > 0);
}

View File

@@ -3,6 +3,8 @@ import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from '.
import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
import { setupSignalHandlers } from './lib/signal-handlers';
import { auth } from './lib/auth';
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
// Flag to track if recovery has been initialized
let recoveryInitialized = false;
@@ -11,6 +13,52 @@ let cleanupServiceStarted = false;
let shutdownManagerInitialized = false;
export const onRequest = defineMiddleware(async (context, next) => {
// First, try Better Auth session (cookie-based)
try {
const session = await auth.api.getSession({
headers: context.request.headers,
});
if (session) {
context.locals.user = session.user;
context.locals.session = session.session;
} else {
// No cookie session, check for header authentication
if (isHeaderAuthEnabled()) {
const headerUser = await authenticateWithHeaders(context.request.headers);
if (headerUser) {
// Create a session-like object for header auth
context.locals.user = {
id: headerUser.id,
email: headerUser.email,
emailVerified: headerUser.emailVerified,
name: headerUser.name || headerUser.username,
username: headerUser.username,
createdAt: headerUser.createdAt,
updatedAt: headerUser.updatedAt,
};
context.locals.session = {
id: `header-${headerUser.id}`,
userId: headerUser.id,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day
ipAddress: context.request.headers.get('x-forwarded-for') || context.clientAddress,
userAgent: context.request.headers.get('user-agent'),
};
} else {
context.locals.user = null;
context.locals.session = null;
}
} else {
context.locals.user = null;
context.locals.session = null;
}
}
} catch (error) {
// If there's an error getting the session, set to null
context.locals.user = null;
context.locals.session = null;
}
// Initialize shutdown manager and signal handlers first
if (!shutdownManagerInitialized) {
try {

View File

@@ -0,0 +1,10 @@
import { auth } from "@/lib/auth";
import type { APIRoute } from "astro";
export const ALL: APIRoute = async (ctx) => {
// If you want to use rate limiting, make sure to set the 'x-forwarded-for' header
// to the request headers from the context
// ctx.request.headers.set("x-forwarded-for", ctx.clientAddress);
return auth.handler(ctx.request);
};

View File

@@ -0,0 +1,30 @@
import type { APIRoute } from "astro";
import { db, users } from "@/lib/db";
import { sql } from "drizzle-orm";
export const GET: APIRoute = async () => {
try {
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ userCount }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -0,0 +1,79 @@
import type { APIRoute } from "astro";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { nanoid } from "nanoid";
export const GET: APIRoute = async ({ request }) => {
try {
// Get Better Auth configuration info
const info = {
baseURL: auth.options.baseURL,
basePath: auth.options.basePath,
trustedOrigins: auth.options.trustedOrigins,
emailPasswordEnabled: auth.options.emailAndPassword?.enabled,
userFields: auth.options.user?.additionalFields,
databaseConfig: {
usePlural: true,
provider: "sqlite"
}
};
return new Response(JSON.stringify({
success: true,
config: info
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// Log full error details server-side for debugging
console.error("Debug endpoint error:", error);
// Only return safe error information to the client
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "An unexpected error occurred"
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
export const POST: APIRoute = async ({ request }) => {
try {
// Test creating a user directly
const userId = nanoid();
const now = new Date();
await db.insert(users).values({
id: userId,
email: "test2@example.com",
emailVerified: false,
username: "test2",
// Let the database handle timestamps with defaults
});
return new Response(JSON.stringify({
success: true,
userId,
message: "User created successfully"
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// Log full error details server-side for debugging
console.error("Debug endpoint error:", error);
// Only return safe error information to the client
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "An unexpected error occurred"
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -0,0 +1,16 @@
import type { APIRoute } from "astro";
import { getHeaderAuthConfig } from "@/lib/auth-header";
export const GET: APIRoute = async () => {
const config = getHeaderAuthConfig();
return new Response(JSON.stringify({
enabled: config.enabled,
userHeader: config.userHeader,
autoProvision: config.autoProvision,
hasAllowedDomains: config.allowedDomains && config.allowedDomains.length > 0,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -0,0 +1,13 @@
# Legacy Auth Routes Backup
These files are the original authentication routes before migrating to Better Auth.
They are kept here as a reference during the migration process.
## Migration Notes
- `index.ts` - Handled user session validation and getting current user
- `login.ts` - Handled user login with email/password
- `logout.ts` - Handled user logout and session cleanup
- `register.ts` - Handled new user registration
All these endpoints are now handled by Better Auth through the catch-all route `[...all].ts`.

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, users, configs, client } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { db, users, configs } from "@/lib/db";
import { eq, and, sql } from "drizzle-orm";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
@@ -10,10 +10,10 @@ export const GET: APIRoute = async ({ request, cookies }) => {
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
if (!token) {
const userCountResult = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {

View File

@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { calculateCleanupInterval } from "@/lib/cleanup-service";
import { createSecureErrorResponse } from "@/lib/utils";
import { mapUiToDbConfig, mapDbToUiConfig } from "@/lib/utils/config-mapper";
import { encrypt, decrypt, migrateToken } from "@/lib/utils/encryption";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -55,17 +56,27 @@ export const POST: APIRoute = async ({ request }) => {
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub.token) {
mappedGithubConfig.token = existingGithub.token;
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea.token) {
mappedGiteaConfig.token = existingGitea.token;
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
}
}
// Encrypt tokens before saving
if (mappedGithubConfig.token) {
mappedGithubConfig.token = encrypt(mappedGithubConfig.token);
}
if (mappedGiteaConfig.token) {
mappedGiteaConfig.token = encrypt(mappedGiteaConfig.token);
}
// Process schedule config - set/update nextRun if enabled, clear if disabled
const processedScheduleConfig = { ...scheduleConfig };
@@ -279,15 +290,54 @@ export const GET: APIRoute = async ({ request }) => {
// Map database structure to UI structure
const dbConfig = config[0];
const uiConfig = mapDbToUiConfig(dbConfig);
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
// Decrypt tokens before sending to UI
try {
const githubConfig = typeof dbConfig.githubConfig === "string"
? JSON.parse(dbConfig.githubConfig)
: dbConfig.githubConfig;
const giteaConfig = typeof dbConfig.giteaConfig === "string"
? JSON.parse(dbConfig.giteaConfig)
: dbConfig.giteaConfig;
// Decrypt tokens
if (githubConfig.token) {
githubConfig.token = decrypt(githubConfig.token);
}
if (giteaConfig.token) {
giteaConfig.token = decrypt(giteaConfig.token);
}
// Create modified config with decrypted tokens
const decryptedConfig = {
...dbConfig,
githubConfig,
giteaConfig
};
const uiConfig = mapDbToUiConfig(decryptedConfig);
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Failed to decrypt tokens:", error);
// Return config without decrypting tokens if there's an error
const uiConfig = mapDbToUiConfig(dbConfig);
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
} catch (error) {
return createSecureErrorResponse(error, "config fetch", 500);
}

View File

@@ -9,6 +9,7 @@ import { type MembershipRole } from "@/types/organizations";
import { createSecureErrorResponse } from "@/lib/utils";
import { processWithResilience } from "@/lib/utils/concurrency";
import { v4 as uuidv4 } from "uuid";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -71,7 +72,8 @@ export const POST: APIRoute = async ({ request }) => {
}
// Create a single Octokit instance to be reused
const octokit = createGitHubClient(config.githubConfig.token);
const decryptedToken = getDecryptedGitHubToken(config);
const octokit = createGitHubClient(decryptedToken);
// Define the concurrency limit - adjust based on API rate limits
// Using a lower concurrency for organizations since each org might contain many repos

View File

@@ -9,6 +9,7 @@ import {
getGiteaRepoOwnerAsync,
} from "@/lib/gitea";
import { createGitHubClient } from "@/lib/github";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { processWithResilience } from "@/lib/utils/concurrency";
import { createSecureErrorResponse } from "@/lib/utils";
@@ -73,7 +74,8 @@ export const POST: APIRoute = async ({ request }) => {
}
// Create a single Octokit instance to be reused
const octokit = createGitHubClient(config.githubConfig.token);
const decryptedToken = getDecryptedGitHubToken(config);
const octokit = createGitHubClient(decryptedToken);
// Define the concurrency limit - adjust based on API rate limits
const CONCURRENCY_LIMIT = 3;

View File

@@ -13,6 +13,7 @@ import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
import { processWithRetry } from "@/lib/utils/concurrency";
import { createMirrorJob } from "@/lib/helpers";
import { createSecureErrorResponse } from "@/lib/utils";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -71,8 +72,11 @@ export const POST: APIRoute = async ({ request }) => {
// Start background retry with parallel processing
setTimeout(async () => {
// Create a single Octokit instance to be reused if needed
const octokit = config.githubConfig.token
? createGitHubClient(config.githubConfig.token)
const decryptedToken = config.githubConfig.token
? getDecryptedGitHubToken(config)
: null;
const octokit = decryptedToken
? createGitHubClient(decryptedToken)
: null;
// Define the concurrency limit - adjust based on API rate limits

View File

@@ -2,36 +2,17 @@ import type { APIRoute } from "astro";
import { db, organizations } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import jwt from "jsonwebtoken";
import { requireAuth } from "@/lib/utils/auth-helpers";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
export const PATCH: APIRoute = async (context) => {
try {
// Get token from Authorization header or cookies
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
// Check authentication
const { user, response } = await requireAuth(context);
if (response) return response;
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = user!.id;
// Verify token and get user ID
let userId: string;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
userId = decoded.id;
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const orgId = params.id;
const orgId = context.params.id;
if (!orgId) {
return new Response(JSON.stringify({ error: "Organization ID is required" }), {
status: 400,
@@ -39,7 +20,7 @@ export const PATCH: APIRoute = async ({ request, params, cookies }) => {
});
}
const body = await request.json();
const body = await context.request.json();
const { destinationOrg } = body;
// Validate that the organization belongs to the user

View File

@@ -2,36 +2,17 @@ import type { APIRoute } from "astro";
import { db, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import jwt from "jsonwebtoken";
import { requireAuth } from "@/lib/utils/auth-helpers";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
export const PATCH: APIRoute = async (context) => {
try {
// Get token from Authorization header or cookies
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
// Check authentication
const { user, response } = await requireAuth(context);
if (response) return response;
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = user!.id;
// Verify token and get user ID
let userId: string;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
userId = decoded.id;
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const repoId = params.id;
const repoId = context.params.id;
if (!repoId) {
return new Response(JSON.stringify({ error: "Repository ID is required" }), {
status: 400,
@@ -39,7 +20,7 @@ export const PATCH: APIRoute = async ({ request, params, cookies }) => {
});
}
const body = await request.json();
const body = await context.request.json();
const { destinationOrg } = body;
// Validate that the repository belongs to the user

View File

@@ -0,0 +1,176 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, oauthApplications } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { generateRandomString } from "@/lib/utils";
// GET /api/sso/applications - List all OAuth applications
export async function GET(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const applications = await db.select().from(oauthApplications);
// Don't send client secrets in list response
const sanitizedApps = applications.map(app => ({
...app,
clientSecret: undefined,
}));
return new Response(JSON.stringify(sanitizedApps), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// POST /api/sso/applications - Create a new OAuth application
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const body = await context.request.json();
const { name, redirectURLs, type = "web", metadata } = body;
// Validate required fields
if (!name || !redirectURLs || redirectURLs.length === 0) {
return new Response(
JSON.stringify({ error: "Name and at least one redirect URL are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Generate client credentials
const clientId = `client_${generateRandomString(32)}`;
const clientSecret = `secret_${generateRandomString(48)}`;
// Insert new application
const [newApp] = await db
.insert(oauthApplications)
.values({
id: nanoid(),
clientId,
clientSecret,
name,
redirectURLs: Array.isArray(redirectURLs) ? redirectURLs.join(",") : redirectURLs,
type,
metadata: metadata ? JSON.stringify(metadata) : null,
userId: user.id,
disabled: false,
})
.returning();
return new Response(JSON.stringify(newApp), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// PUT /api/sso/applications/:id - Update an OAuth application
export async function PUT(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const appId = url.pathname.split("/").pop();
if (!appId) {
return new Response(
JSON.stringify({ error: "Application ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const body = await context.request.json();
const { name, redirectURLs, disabled, metadata } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (redirectURLs !== undefined) {
updateData.redirectURLs = Array.isArray(redirectURLs)
? redirectURLs.join(",")
: redirectURLs;
}
if (disabled !== undefined) updateData.disabled = disabled;
if (metadata !== undefined) updateData.metadata = JSON.stringify(metadata);
const [updated] = await db
.update(oauthApplications)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(oauthApplications.id, appId))
.returning();
if (!updated) {
return new Response(JSON.stringify({ error: "Application not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ...updated, clientSecret: undefined }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// DELETE /api/sso/applications/:id - Delete an OAuth application
export async function DELETE(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const appId = url.searchParams.get("id");
if (!appId) {
return new Response(
JSON.stringify({ error: "Application ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const deleted = await db
.delete(oauthApplications)
.where(eq(oauthApplications.id, appId))
.returning();
if (deleted.length === 0) {
return new Response(JSON.stringify({ error: "Application not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}

View File

@@ -0,0 +1,69 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
// POST /api/sso/discover - Discover OIDC configuration from issuer URL
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const { issuer } = await context.request.json();
if (!issuer) {
return new Response(JSON.stringify({ error: "Issuer URL is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Ensure issuer URL ends without trailing slash for well-known discovery
const cleanIssuer = issuer.replace(/\/$/, "");
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
try {
// Fetch OIDC discovery document
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new Error(`Failed to fetch discovery document: ${response.status}`);
}
const config = await response.json();
// Extract the essential endpoints
const discoveredConfig = {
issuer: config.issuer || cleanIssuer,
authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint,
userInfoEndpoint: config.userinfo_endpoint,
jwksEndpoint: config.jwks_uri,
// Additional useful fields
scopes: config.scopes_supported || ["openid", "profile", "email"],
responseTypes: config.response_types_supported || ["code"],
grantTypes: config.grant_types_supported || ["authorization_code"],
// Suggested domain from issuer
suggestedDomain: new URL(cleanIssuer).hostname.replace("www.", ""),
};
return new Response(JSON.stringify(discoveredConfig), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("OIDC discovery error:", error);
return new Response(
JSON.stringify({
error: "Failed to discover OIDC configuration",
details: error instanceof Error ? error.message : "Unknown error"
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
} catch (error) {
return createSecureErrorResponse(error, "SSO discover API");
}
}

View File

@@ -0,0 +1,152 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, ssoProviders } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
// GET /api/sso/providers - List all SSO providers
export async function GET(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const providers = await db.select().from(ssoProviders);
return new Response(JSON.stringify(providers), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// POST /api/sso/providers - Create a new SSO provider
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const body = await context.request.json();
const {
issuer,
domain,
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
mapping,
providerId,
organizationId,
} = body;
// Validate required fields
if (!issuer || !domain || !providerId) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Check if provider ID already exists
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.providerId, providerId))
.limit(1);
if (existing.length > 0) {
return new Response(
JSON.stringify({ error: "Provider ID already exists" }),
{
status: 409,
headers: { "Content-Type": "application/json" },
}
);
}
// Create OIDC config object
const oidcConfig = {
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
mapping: mapping || {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
};
// Insert new provider
const [newProvider] = await db
.insert(ssoProviders)
.values({
id: nanoid(),
issuer,
domain,
oidcConfig: JSON.stringify(oidcConfig),
userId: user.id,
providerId,
organizationId,
})
.returning();
return new Response(JSON.stringify(newProvider), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// DELETE /api/sso/providers - Delete a provider by ID
export async function DELETE(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const providerId = url.searchParams.get("id");
if (!providerId) {
return new Response(
JSON.stringify({ error: "Provider ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const deleted = await db
.delete(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.returning();
if (deleted.length === 0) {
return new Response(JSON.stringify({ error: "Provider not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}

View File

@@ -10,6 +10,7 @@ import {
getGithubStarredRepositories,
} from "@/lib/github";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
export const POST: APIRoute = async ({ request }) => {
const url = new URL(request.url);
@@ -33,21 +34,21 @@ export const POST: APIRoute = async ({ request }) => {
});
}
const token = config.githubConfig?.token;
if (!token) {
if (!config.githubConfig?.token) {
return jsonResponse({
data: { error: "GitHub token is missing in config" },
status: 400,
});
}
const octokit = createGitHubClient(token);
// Decrypt the GitHub token before using it
const decryptedToken = getDecryptedGitHubToken(config);
const octokit = createGitHubClient(decryptedToken);
// Fetch GitHub data in parallel
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
getGithubRepositories({ octokit, config }),
config.githubConfig?.mirrorStarred
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
getGithubOrganizations({ octokit, config }),

View File

@@ -0,0 +1,467 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Advanced</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Advanced Topics</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Advanced configuration options, deployment strategies, troubleshooting, and performance optimization for Gitea Mirror.
</p>
</div>
<!-- Environment Variables -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Environment Variables</h2>
<p class="text-muted-foreground mb-6">
Gitea Mirror can be configured using environment variables. These are particularly useful for containerized deployments.
</p>
<div class="bg-muted/30 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3 font-semibold">Variable</th>
<th class="text-left p-3 font-semibold">Description</th>
<th class="text-left p-3 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{[
{ var: 'NODE_ENV', desc: 'Application environment', default: 'production' },
{ var: 'PORT', desc: 'Server port', default: '4321' },
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
].map((item, i) => (
<tr class={i % 2 === 0 ? 'bg-muted/20' : ''}>
<td class="p-3 font-mono text-xs">{item.var}</td>
<td class="p-3">{item.desc}</td>
<td class="p-3 text-muted-foreground">{item.default}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Database Management -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Database Management</h2>
<p class="text-muted-foreground mb-6">
Gitea Mirror uses SQLite for data storage. The database is automatically created on first run.
</p>
<h3 class="text-xl font-semibold mb-4">Database Commands</h3>
<div class="space-y-4">
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Initialize Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run init-db</code>
</div>
<p class="text-sm text-muted-foreground">Creates or recreates the database schema</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Check Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run check-db</code>
</div>
<p class="text-sm text-muted-foreground">Verifies database integrity and displays statistics</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Fix Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run fix-db</code>
</div>
<p class="text-sm text-muted-foreground">Attempts to repair common database issues</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Backup Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">cp data/gitea-mirror.db data/gitea-mirror.db.backup</code>
</div>
<p class="text-sm text-muted-foreground">Always backup before major changes</p>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 mt-8">Database Schema Management</h3>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Drizzle Kit</p>
<p class="text-sm">Database schema is managed with Drizzle ORM. Use these commands for schema changes:</p>
<ul class="mt-2 space-y-1 text-sm">
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit generate</code> - Generate migration files</li>
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit push</code> - Apply schema changes directly</li>
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit studio</code> - Open database browser</li>
</ul>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Performance Optimization -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Performance Optimization</h2>
<h3 class="text-xl font-semibold mb-4">Mirroring Performance</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{[
{
title: 'Batch Operations',
tips: [
'Mirror multiple repositories at once',
'Use organization-level mirroring',
'Schedule mirroring during off-peak hours'
]
},
{
title: 'Network Optimization',
tips: [
'Use SSH URLs when possible',
'Enable Git LFS only when needed',
'Consider repository size limits'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-3">{section.title}</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.tips.map(tip => (
<li class="flex gap-2">
<span>•</span>
<span>{tip}</span>
</li>
))}
</ul>
</div>
))}
</div>
<h3 class="text-xl font-semibold mb-4">Database Performance</h3>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Regular Maintenance</h4>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Enable automatic cleanup in Configuration → Automation</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Periodically vacuum the SQLite database: <code class="bg-amber-500/10 px-1 rounded">sqlite3 data/gitea-mirror.db "VACUUM;"</code></span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Monitor database size and clean old events regularly</span>
</li>
</ul>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Reverse Proxy Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
<p class="text-muted-foreground mb-6">
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
</p>
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`server {
listen 80;
server_name gitea-mirror.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name gitea-mirror.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:4321;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSE endpoint needs special handling
location /api/sse {
proxy_pass http://localhost:4321;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_read_timeout 86400;
}
}`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror.example.com {
reverse_proxy localhost:4321
}`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Monitoring and Health Checks -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Monitoring and Health Checks</h2>
<h3 class="text-xl font-semibold mb-4">Health Check Endpoint</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Monitor application health using the built-in endpoint:</p>
<div class="bg-muted/30 rounded p-3 mb-4">
<code class="text-sm">GET /api/health</code>
</div>
<p class="text-sm font-semibold mb-2">Response:</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`{
"status": "ok",
"timestamp": "2024-01-15T10:30:00Z",
"database": "connected",
"version": "1.0.0"
}`}</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Monitoring with Prometheus</h3>
<p class="text-sm text-muted-foreground mb-4">
While Gitea Mirror doesn't have built-in Prometheus metrics, you can monitor it using:
</p>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span>•</span>
<span>Blackbox exporter for endpoint monitoring</span>
</li>
<li class="flex gap-2">
<span>•</span>
<span>Node exporter for system metrics</span>
</li>
<li class="flex gap-2">
<span>•</span>
<span>Custom scripts to check database metrics</span>
</li>
</ul>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Backup and Recovery -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Backup and Recovery</h2>
<h3 class="text-xl font-semibold mb-4">What to Backup</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Essential Files</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
<li class="font-mono">• data/gitea-mirror.db</li>
<li class="font-mono">• .env (if using)</li>
<li class="font-mono">• Custom CA certificates</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Optional Files</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
<li class="font-mono">• Docker volumes</li>
<li class="font-mono">• Custom configurations</li>
<li class="font-mono">• Logs for auditing</li>
</ul>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Backup Script Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`#!/bin/bash
BACKUP_DIR="/backups/gitea-mirror"
DATE=$(date +%Y%m%d_%H%M%S)
# Create backup directory
mkdir -p "$BACKUP_DIR/$DATE"
# Backup database
cp data/gitea-mirror.db "$BACKUP_DIR/$DATE/"
# Backup environment
cp .env "$BACKUP_DIR/$DATE/" 2>/dev/null || true
# Create tarball
tar -czf "$BACKUP_DIR/backup_$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE"
# Clean up
rm -rf "$BACKUP_DIR/$DATE"
# Keep only last 7 backups
ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting Guide -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting Guide</h2>
<div class="space-y-4">
{[
{
issue: 'Application won\'t start',
solutions: [
'Check port availability: `lsof -i :4321`',
'Verify environment variables are set correctly',
'Check database file permissions',
'Review logs for startup errors'
]
},
{
issue: 'Authentication failures',
solutions: [
'Ensure BETTER_AUTH_SECRET is set and consistent',
'Check BETTER_AUTH_URL matches your deployment',
'Clear browser cookies and try again',
'Verify database contains user records'
]
},
{
issue: 'Mirroring failures',
solutions: [
'Test GitHub/Gitea connections individually',
'Verify access tokens have correct permissions',
'Check network connectivity and firewall rules',
'Review Activity Log for detailed error messages'
]
},
{
issue: 'Performance issues',
solutions: [
'Check database size and run cleanup',
'Monitor system resources (CPU, memory, disk)',
'Reduce concurrent mirroring operations',
'Consider upgrading deployment resources'
]
}
].map(item => (
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">{item.issue}</h4>
<ul class="space-y-1 text-sm">
{item.solutions.map(solution => (
<li class="flex gap-2">
<span class="text-primary">→</span>
<span>{solution}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Migration Guide -->
<section>
<h2 class="text-2xl font-bold mb-6">Migration Guide</h2>
<h3 class="text-xl font-semibold mb-4">Migrating from JWT to Better Auth</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<p class="mb-4">If you're upgrading from an older version using JWT authentication:</p>
<ol class="space-y-3 text-sm">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">1</span>
<div>
<strong>Backup your database</strong>
<p class="text-muted-foreground">Always create a backup before migration</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">2</span>
<div>
<strong>Update environment variables</strong>
<p class="text-muted-foreground">Replace JWT_SECRET with BETTER_AUTH_SECRET</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">3</span>
<div>
<strong>Run database migrations</strong>
<p class="text-muted-foreground">New auth tables will be created automatically</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">4</span>
<div>
<strong>Users will need to log in again</strong>
<p class="text-muted-foreground">Previous sessions will be invalidated</p>
</div>
</li>
</ol>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -47,7 +47,8 @@ import MainLayout from '../../layouts/main.astro';
{ name: 'Shadcn UI', desc: 'UI component library built on Tailwind CSS' },
{ name: 'SQLite', desc: 'Database for storing configuration, state, and events' },
{ name: 'Bun', desc: 'JavaScript runtime and package manager' },
{ name: 'Drizzle ORM', desc: 'Type-safe ORM for database interactions' }
{ name: 'Drizzle ORM', desc: 'Type-safe ORM for database interactions' },
{ name: 'Better Auth', desc: 'Modern authentication library with SSO/OIDC support' }
].map(tech => (
<div class="flex items-start gap-3">
<div class="w-2 h-2 rounded-full bg-primary mt-2"></div>
@@ -184,7 +185,8 @@ import MainLayout from '../../layouts/main.astro';
<div class="space-y-3">
{[
'Authentication and user management',
'Authentication with Better Auth (email/password, SSO, OIDC)',
'OAuth2/OIDC provider functionality',
'GitHub API integration',
'Gitea API integration',
'Mirroring operations and job queue',
@@ -213,11 +215,13 @@ import MainLayout from '../../layouts/main.astro';
<div class="space-y-3">
{[
'User accounts and authentication data',
'User accounts and authentication data (Better Auth)',
'OAuth applications and SSO provider configurations',
'GitHub and Gitea configuration',
'Repository and organization information',
'Mirroring job history and status',
'Event notifications and their read status'
'Event notifications and their read status',
'OAuth tokens and consent records'
].map(item => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
@@ -238,7 +242,7 @@ import MainLayout from '../../layouts/main.astro';
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<ol class="space-y-4">
{[
{ title: 'User Authentication', desc: 'Users authenticate through the frontend, which communicates with the backend to validate credentials.' },
{ title: 'User Authentication', desc: 'Users authenticate via Better Auth using email/password, SSO providers, or as OIDC clients.' },
{ title: 'Configuration', desc: 'Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.' },
{ title: 'Repository Discovery', desc: 'The backend queries the GitHub API to discover repositories based on user configuration.' },
{ title: 'Mirroring Process', desc: 'When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.' },

View File

@@ -0,0 +1,535 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<span>Authentication</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Authentication & SSO Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Configure authentication methods including email/password, Single Sign-On (SSO), and OIDC provider functionality for Gitea Mirror.
</p>
</div>
<!-- Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Authentication Overview</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="text-base leading-relaxed">
Gitea Mirror uses <strong>Better Auth</strong>, a modern authentication library that supports multiple authentication methods.
All authentication settings can be configured through the web UI without editing configuration files.
</p>
</div>
<h3 class="text-lg font-semibold mb-4">Supported Authentication Methods</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
icon: '✉️',
title: 'Email & Password',
desc: 'Traditional authentication with email and password. Always enabled by default.',
status: 'Always Enabled'
},
{
icon: '🌐',
title: 'Single Sign-On (SSO)',
desc: 'Allow users to sign in using external OIDC providers like Google, Okta, or Azure AD.',
status: 'Optional'
},
{
icon: '🔑',
title: 'OIDC Provider',
desc: 'Act as an OIDC provider, allowing other applications to authenticate through Gitea Mirror.',
status: 'Optional'
}
].map(method => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="text-2xl mb-3">{method.icon}</div>
<h4 class="font-semibold mb-2">{method.title}</h4>
<p class="text-sm text-muted-foreground mb-3">{method.desc}</p>
<span class={`text-xs px-2 py-1 rounded-full ${method.status === 'Always Enabled' ? 'bg-green-500/10 text-green-600 dark:text-green-500' : 'bg-blue-500/10 text-blue-600 dark:text-blue-500'}`}>
{method.status}
</span>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Accessing Authentication Settings -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Accessing Authentication Settings</h2>
<ol class="space-y-3">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</span>
<span>Navigate to the <strong>Configuration</strong> page</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</span>
<span>Click on the <strong>Authentication</strong> tab</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</span>
<span>Configure SSO providers or OAuth applications as needed</span>
</li>
</ol>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- SSO Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Single Sign-On (SSO) Configuration</h2>
<p class="text-muted-foreground mb-6">
SSO allows your users to authenticate using external identity providers. This is useful for organizations that already have centralized authentication systems.
</p>
<h3 class="text-xl font-semibold mb-4">Adding an SSO Provider</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<h4 class="font-semibold mb-4">Required Information</h4>
<div class="space-y-4">
{[
{ name: 'Issuer URL', desc: 'The OIDC issuer URL of your provider', example: 'https://accounts.google.com' },
{ name: 'Domain', desc: 'The email domain for this provider', example: 'example.com' },
{ name: 'Provider ID', desc: 'A unique identifier for this provider', example: 'google-sso' },
{ name: 'Client ID', desc: 'OAuth client ID from your provider', example: '123456789.apps.googleusercontent.com' },
{ name: 'Client Secret', desc: 'OAuth client secret from your provider', example: 'GOCSPX-...' }
].map(field => (
<div class="border-l-2 border-muted pl-4">
<div class="flex items-baseline gap-2 mb-1">
<strong class="text-sm">{field.name}</strong>
<span class="text-xs text-muted-foreground">Required</span>
</div>
<p class="text-sm text-muted-foreground">{field.desc}</p>
<code class="text-xs bg-muted px-2 py-0.5 rounded mt-1 inline-block">{field.example}</code>
</div>
))}
</div>
</div>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Auto-Discovery</p>
<p class="text-sm">Most OIDC providers support auto-discovery. Simply enter the Issuer URL and click "Discover" to automatically populate the endpoint URLs.</p>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Redirect URL Configuration</h3>
<div class="bg-muted/30 rounded-lg p-4">
<p class="text-sm mb-2">When configuring your SSO provider, use this redirect URL:</p>
<code class="bg-muted rounded px-3 py-2 block">https://your-domain.com/api/auth/sso/callback/{`{provider-id}`}</code>
<p class="text-xs text-muted-foreground mt-2">Replace <code>{`{provider-id}`}</code> with your chosen Provider ID (e.g., google-sso)</p>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Example SSO Configurations -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Example SSO Configurations</h2>
<!-- Google Example -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<img src="https://www.google.com/favicon.ico" alt="Google" class="w-5 h-5" />
Google SSO
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Create OAuth Client in Google Cloud Console</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Go to <a href="https://console.cloud.google.com/" class="text-primary hover:underline">Google Cloud Console</a></li>
<li>• Create a new OAuth 2.0 Client ID</li>
<li>• Add authorized redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/google-sso</code></li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://accounts.google.com</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>google-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Google Client ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Google Client Secret]</code></div>
</div>
</div>
</li>
<li>
<strong>3. Use Auto-Discovery</strong>
<p class="text-sm text-muted-foreground mt-1">Click "Discover" to automatically populate the endpoint URLs</p>
</li>
</ol>
</div>
</div>
<!-- Okta Example -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<span class="w-5 h-5 bg-blue-600 rounded flex items-center justify-center text-white text-xs font-bold">O</span>
Okta SSO
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Create OIDC Application in Okta</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• In Okta Admin Console, create a new OIDC Web Application</li>
<li>• Set Sign-in redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/okta-sso</code></li>
<li>• Note the Client ID and Client Secret</li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://your-okta-domain.okta.com</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>okta-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Okta Client ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Okta Client Secret]</code></div>
</div>
</div>
</li>
</ol>
</div>
</div>
<!-- Azure AD Example -->
<div>
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<span class="w-5 h-5 bg-blue-500 rounded flex items-center justify-center text-white text-xs">M</span>
Azure AD / Microsoft Entra ID
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Register Application in Azure Portal</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Go to Azure Portal → Azure Active Directory → App registrations</li>
<li>• Create a new registration</li>
<li>• Add redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/azure-sso</code></li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://login.microsoftonline.com/{`{tenant-id}`}/v2.0</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>azure-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Application ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Client Secret]</code></div>
</div>
</div>
</li>
</ol>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- OIDC Provider Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">OIDC Provider Configuration</h2>
<p class="text-muted-foreground mb-6">
The OIDC Provider feature allows Gitea Mirror to act as an authentication provider for other applications.
This is useful when you want to centralize authentication through Gitea Mirror.
</p>
<h3 class="text-xl font-semibold mb-4">Creating OAuth Applications</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</span>
<div>
<strong>Navigate to OAuth Applications</strong>
<p class="text-sm text-muted-foreground mt-1">Go to Configuration → Authentication → OAuth Applications</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</span>
<div>
<strong>Create New Application</strong>
<p class="text-sm text-muted-foreground mt-1">Click "Create Application" and provide:</p>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Application Name</li>
<li>• Application Type (Web, Mobile, or Desktop)</li>
<li>• Redirect URLs (one per line)</li>
</ul>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</span>
<div>
<strong>Save Credentials</strong>
<p class="text-sm text-muted-foreground mt-1">You'll receive a Client ID and Client Secret. Store these securely!</p>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">OIDC Endpoints</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<p class="text-sm mb-3">Applications can use these standard OIDC endpoints:</p>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<strong class="w-32">Discovery:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/.well-known/openid-configuration</code>
</div>
<div class="flex gap-2">
<strong class="w-32">Authorization:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/authorize</code>
</div>
<div class="flex gap-2">
<strong class="w-32">Token:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/token</code>
</div>
<div class="flex gap-2">
<strong class="w-32">UserInfo:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/userinfo</code>
</div>
<div class="flex gap-2">
<strong class="w-32">JWKS:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/jwks</code>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Supported Scopes</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ scope: 'openid', desc: 'Required - provides user ID', claims: 'sub' },
{ scope: 'profile', desc: 'User profile information', claims: 'name, username, picture' },
{ scope: 'email', desc: 'Email address', claims: 'email, email_verified' }
].map(item => (
<div class="bg-card rounded-lg border border-border p-4">
<code class="text-sm font-semibold text-primary">{item.scope}</code>
<p class="text-sm text-muted-foreground mt-2">{item.desc}</p>
<p class="text-xs text-muted-foreground mt-2">Claims: {item.claims}</p>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- User Experience -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">User Experience</h2>
<h3 class="text-xl font-semibold mb-4">Login Flow with SSO</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="mb-4">When SSO is configured, users will see authentication options on the login page:</p>
<ol class="space-y-2 text-sm">
<li class="flex gap-2"><span class="font-semibold">1.</span> Email & Password tab for traditional login</li>
<li class="flex gap-2"><span class="font-semibold">2.</span> SSO tab with provider buttons or email input</li>
<li class="flex gap-2"><span class="font-semibold">3.</span> Automatic redirect to the appropriate provider</li>
<li class="flex gap-2"><span class="font-semibold">4.</span> Return to Gitea Mirror after successful authentication</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">OAuth Consent Flow</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<p class="mb-4">When an application requests authentication through Gitea Mirror:</p>
<ol class="space-y-2 text-sm">
<li class="flex gap-2"><span class="font-semibold">1.</span> User is redirected to Gitea Mirror</li>
<li class="flex gap-2"><span class="font-semibold">2.</span> Login prompt if not already authenticated</li>
<li class="flex gap-2"><span class="font-semibold">3.</span> Consent screen showing requested permissions</li>
<li class="flex gap-2"><span class="font-semibold">4.</span> User approves or denies the request</li>
<li class="flex gap-2"><span class="font-semibold">5.</span> Redirect back to the application with auth code</li>
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Security Considerations -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Security Considerations</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
icon: '🔒',
title: 'Client Secrets',
items: [
'Store OAuth client secrets securely',
'Never commit secrets to version control',
'Rotate secrets regularly'
]
},
{
icon: '🔗',
title: 'Redirect URLs',
items: [
'Only add trusted redirect URLs',
'Use HTTPS in production',
'Validate exact URL matches'
]
},
{
icon: '🛡️',
title: 'Scopes & Permissions',
items: [
'Grant minimum required scopes',
'Review requested permissions',
'Users can revoke access anytime'
]
},
{
icon: '⏱️',
title: 'Token Security',
items: [
'Access tokens have expiration',
'Refresh tokens for long-lived access',
'Tokens can be revoked'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<div class="flex items-center gap-3 mb-3">
<span class="text-2xl">{section.icon}</span>
<h4 class="font-semibold">{section.title}</h4>
</div>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.items.map(item => (
<li class="flex gap-2">
<span>•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting</h2>
<div class="space-y-4">
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">SSO Login Issues</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>"Invalid origin" error:</strong> Check that your Gitea Mirror URL matches the configured redirect URI
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>"Provider not found" error:</strong> Ensure the provider is properly configured and saved
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Redirect loop:</strong> Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
</div>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">OIDC Provider Issues</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Application not found:</strong> Ensure the client ID is correct and the app is not disabled
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Invalid redirect URI:</strong> The redirect URI must match exactly what's configured
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Consent not working:</strong> Check browser cookies are enabled and not blocked
</div>
</li>
</ul>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Migration from JWT -->
<section>
<h2 class="text-2xl font-bold mb-6">Migration from JWT Authentication</h2>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-2">For Existing Users</p>
<ul class="space-y-1 text-sm">
<li>• Email/password authentication continues to work</li>
<li>• No action required from existing users</li>
<li>• SSO can be added as an additional option</li>
<li>• JWT_SECRET is no longer required in environment variables</li>
</ul>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>

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