mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-01-28 13:20:54 +03:00
67
.env.example
67
.env.example
@@ -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
1
.gitignore
vendored
@@ -31,3 +31,4 @@ certs/*.crt
|
||||
certs/*.pem
|
||||
certs/*.cer
|
||||
!certs/README.md
|
||||
|
||||
|
||||
182
CONTRIBUTING.md
Normal file
182
CONTRIBUTING.md
Normal 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! 🎉
|
||||
@@ -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
248
MIGRATION_GUIDE.md
Normal 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
100
README.md
@@ -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
115
bun.lock
@@ -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=="],
|
||||
|
||||
337
certs/README.md
337
certs/README.md
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
175
docs/BETTER_AUTH_MIGRATION.md
Normal file
175
docs/BETTER_AUTH_MIGRATION.md
Normal 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
205
docs/BUILD_GUIDE.md
Normal 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)
|
||||
355
docs/DEVELOPMENT_WORKFLOW.md
Normal file
355
docs/DEVELOPMENT_WORKFLOW.md
Normal 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
77
docs/EXTENDING.md
Normal 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
118
docs/README.md
Normal 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.
|
||||
91
docs/SPONSOR_INTEGRATION.md
Normal file
91
docs/SPONSOR_INTEGRATION.md
Normal 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
205
docs/SSO-OIDC-SETUP.md
Normal 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
16
drizzle.config.ts
Normal 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
180
drizzle/0000_init.sql
Normal 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`);
|
||||
64
drizzle/0001_polite_exodus.sql
Normal file
64
drizzle/0001_polite_exodus.sql
Normal 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`);
|
||||
1290
drizzle/meta/0000_snapshot.json
Normal file
1290
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1722
drizzle/meta/0001_snapshot.json
Normal file
1722
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal 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
9
env.d.ts
vendored
Normal 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
9087
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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"
|
||||
|
||||
@@ -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.");
|
||||
|
||||
110
scripts/generate-better-auth-schema.ts
Normal file
110
scripts/generate-better-auth-schema.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
100
scripts/migrate-better-auth.ts
Normal file
100
scripts/migrate-better-auth.ts
Normal 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();
|
||||
87
scripts/migrate-to-better-auth.ts
Normal file
87
scripts/migrate-to-better-auth.ts
Normal 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();
|
||||
135
scripts/migrate-tokens-encryption.ts
Normal file
135
scripts/migrate-tokens-encryption.ts
Normal 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);
|
||||
});
|
||||
@@ -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
31
scripts/run-migration.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
10
src/components/auth/LoginPage.tsx
Normal file
10
src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { LoginForm } from './LoginForm';
|
||||
import Providers from '@/components/layout/Providers';
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<LoginForm />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
10
src/components/auth/SignupPage.tsx
Normal file
10
src/components/auth/SignupPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SignupForm } from './SignupForm';
|
||||
import Providers from '@/components/layout/Providers';
|
||||
|
||||
export function SignupPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<SignupForm />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
426
src/components/config/SSOSettings.tsx
Normal file
426
src/components/config/SSOSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
72
src/components/layout/SponsorCard.tsx
Normal file
72
src/components/layout/SponsorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/components/oauth/ConsentPage.tsx
Normal file
307
src/components/oauth/ConsentPage.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
src/components/sponsors/GitHubSponsors.tsx
Normal file
105
src/components/sponsors/GitHubSponsors.tsx
Normal 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
147
src/hooks/useAuth-legacy.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
65
src/hooks/useAuthMethods.ts
Normal file
65
src/hooks/useAuthMethods.ts
Normal 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
28
src/lib/auth-client.ts
Normal 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
70
src/lib/auth-config.ts
Normal 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
135
src/lib/auth-header.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
179
src/lib/auth-oidc-config.example.ts
Normal file
179
src/lib/auth-oidc-config.example.ts
Normal 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
99
src/lib/auth.ts
Normal 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;
|
||||
@@ -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
102
src/lib/db/adapter.ts
Normal 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' });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>;
|
||||
22
src/lib/deployment-mode.ts
Normal file
22
src/lib/deployment-mode.ts
Normal 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
256
src/lib/events/realtime.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
184
src/lib/modules/registry.ts
Normal 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
86
src/lib/modules/types.d.ts
vendored
Normal 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
|
||||
1
src/lib/modules/types.d.ts.map
Normal file
1
src/lib/modules/types.d.ts.map
Normal 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
5
src/lib/modules/types.js
Normal 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
110
src/lib/modules/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
58
src/lib/utils/auth-helpers.ts
Normal file
58
src/lib/utils/auth-helpers.ts
Normal 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;
|
||||
}
|
||||
52
src/lib/utils/config-encryption.ts
Normal file
52
src/lib/utils/config-encryption.ts
Normal 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
169
src/lib/utils/encryption.ts
Normal 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');
|
||||
}
|
||||
85
src/lib/utils/oauth-validation.test.ts
Normal file
85
src/lib/utils/oauth-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
src/lib/utils/oauth-validation.ts
Normal file
59
src/lib/utils/oauth-validation.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
10
src/pages/api/auth/[...all].ts
Normal file
10
src/pages/api/auth/[...all].ts
Normal 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);
|
||||
};
|
||||
30
src/pages/api/auth/check-users.ts
Normal file
30
src/pages/api/auth/check-users.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
79
src/pages/api/auth/debug.ts
Normal file
79
src/pages/api/auth/debug.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
16
src/pages/api/auth/header-status.ts
Normal file
16
src/pages/api/auth/header-status.ts
Normal 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" },
|
||||
});
|
||||
};
|
||||
13
src/pages/api/auth/legacy-backup/README.md
Normal file
13
src/pages/api/auth/legacy-backup/README.md
Normal 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`.
|
||||
@@ -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" }), {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
176
src/pages/api/sso/applications.ts
Normal file
176
src/pages/api/sso/applications.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
69
src/pages/api/sso/discover.ts
Normal file
69
src/pages/api/sso/discover.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
152
src/pages/api/sso/providers.ts
Normal file
152
src/pages/api/sso/providers.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
467
src/pages/docs/advanced.astro
Normal file
467
src/pages/docs/advanced.astro
Normal 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">←</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>
|
||||
@@ -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.' },
|
||||
|
||||
535
src/pages/docs/authentication.astro
Normal file
535
src/pages/docs/authentication.astro
Normal 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">←</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
Reference in New Issue
Block a user