diff --git a/certs/README.md b/certs/README.md index dcf4243..98c0a94 100644 --- a/certs/README.md +++ b/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. \ No newline at end of file +#### 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 + ``` + +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 +``` \ No newline at end of file diff --git a/docs/SSO-OIDC-SETUP.md b/docs/SSO-OIDC-SETUP.md new file mode 100644 index 0000000..e1626fa --- /dev/null +++ b/docs/SSO-OIDC-SETUP.md @@ -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 \ No newline at end of file diff --git a/drizzle/0001_polite_exodus.sql b/drizzle/0001_polite_exodus.sql new file mode 100644 index 0000000..5204a57 --- /dev/null +++ b/drizzle/0001_polite_exodus.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..92545f6 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1722 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4e9ce026-e4e3-4a68-a7f2-37ac7747e2a3", + "prevId": "7782b8ba-bdae-42e8-b8a7-614f8be30a58", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_accounts_account_id": { + "name": "idx_accounts_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "idx_accounts_user_id": { + "name": "idx_accounts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_accounts_provider": { + "name": "idx_accounts_provider", + "columns": [ + "provider_id", + "provider_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "configs": { + "name": "configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "github_config": { + "name": "github_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gitea_config": { + "name": "gitea_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "include": { + "name": "include", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"*\"]'" + }, + "exclude": { + "name": "exclude", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "schedule_config": { + "name": "schedule_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanup_config": { + "name": "cleanup_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "configs_user_id_users_id_fk": { + "name": "configs_user_id_users_id_fk", + "tableFrom": "configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_events_user_channel": { + "name": "idx_events_user_channel", + "columns": [ + "user_id", + "channel" + ], + "isUnique": false + }, + "idx_events_created_at": { + "name": "idx_events_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_events_read": { + "name": "idx_events_read", + "columns": [ + "read" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mirror_jobs": { + "name": "mirror_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mirror'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_items": { + "name": "completed_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_item_ids": { + "name": "completed_item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checkpoint": { + "name": "last_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mirror_jobs_user_id": { + "name": "idx_mirror_jobs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_batch_id": { + "name": "idx_mirror_jobs_batch_id", + "columns": [ + "batch_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_in_progress": { + "name": "idx_mirror_jobs_in_progress", + "columns": [ + "in_progress" + ], + "isUnique": false + }, + "idx_mirror_jobs_job_type": { + "name": "idx_mirror_jobs_job_type", + "columns": [ + "job_type" + ], + "isUnique": false + }, + "idx_mirror_jobs_timestamp": { + "name": "idx_mirror_jobs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mirror_jobs_user_id_users_id_fk": { + "name": "mirror_jobs_user_id_users_id_fk", + "tableFrom": "mirror_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_tokens": { + "name": "oauth_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_access_tokens_access_token": { + "name": "idx_oauth_access_tokens_access_token", + "columns": [ + "access_token" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_user_id": { + "name": "idx_oauth_access_tokens_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_client_id": { + "name": "idx_oauth_access_tokens_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_applications": { + "name": "oauth_applications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "oauth_applications_client_id_unique": { + "name": "oauth_applications_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "idx_oauth_applications_client_id": { + "name": "idx_oauth_applications_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_applications_user_id": { + "name": "idx_oauth_applications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent_given": { + "name": "consent_given", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_consent_user_id": { + "name": "idx_oauth_consent_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_consent_client_id": { + "name": "idx_oauth_consent_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_consent_user_client": { + "name": "idx_oauth_consent_user_client", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_user_id_users_id_fk": { + "name": "oauth_consent_user_id_users_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "is_included": { + "name": "is_included", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_count": { + "name": "repository_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_organizations_user_id": { + "name": "idx_organizations_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_organizations_config_id": { + "name": "idx_organizations_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_organizations_status": { + "name": "idx_organizations_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_organizations_is_included": { + "name": "idx_organizations_is_included", + "columns": [ + "is_included" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organizations_user_id_users_id_fk": { + "name": "organizations_user_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organizations_config_id_configs_id_fk": { + "name": "organizations_config_id_configs_id_fk", + "tableFrom": "organizations", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mirrored_location": { + "name": "mirrored_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_fork": { + "name": "is_fork", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_issues": { + "name": "has_issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_starred": { + "name": "is_starred", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_lfs": { + "name": "has_lfs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_submodules": { + "name": "has_submodules", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_repositories_user_id": { + "name": "idx_repositories_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_repositories_config_id": { + "name": "idx_repositories_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_repositories_status": { + "name": "idx_repositories_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_repositories_owner": { + "name": "idx_repositories_owner", + "columns": [ + "owner" + ], + "isUnique": false + }, + "idx_repositories_organization": { + "name": "idx_repositories_organization", + "columns": [ + "organization" + ], + "isUnique": false + }, + "idx_repositories_is_fork": { + "name": "idx_repositories_is_fork", + "columns": [ + "is_fork" + ], + "isUnique": false + }, + "idx_repositories_is_starred": { + "name": "idx_repositories_is_starred", + "columns": [ + "is_starred" + ], + "isUnique": false + } + }, + "foreignKeys": { + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "repositories_config_id_configs_id_fk": { + "name": "repositories_config_id_configs_id_fk", + "tableFrom": "repositories", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "columns": [ + "provider_id" + ], + "isUnique": true + }, + "idx_sso_providers_provider_id": { + "name": "idx_sso_providers_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": false + }, + "idx_sso_providers_domain": { + "name": "idx_sso_providers_domain", + "columns": [ + "domain" + ], + "isUnique": false + }, + "idx_sso_providers_issuer": { + "name": "idx_sso_providers_issuer", + "columns": [ + "issuer" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_verification_tokens_token": { + "name": "idx_verification_tokens_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_verification_tokens_identifier": { + "name": "idx_verification_tokens_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1df77d8..300aa73 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1752171873627, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1752173351102, + "tag": "0001_polite_exodus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/run-migration.ts b/scripts/run-migration.ts new file mode 100644 index 0000000..48a7c04 --- /dev/null +++ b/scripts/run-migration.ts @@ -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(); +} \ No newline at end of file diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 421f5ab..82a6884 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -5,14 +5,27 @@ 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) { e.preventDefault(); @@ -42,6 +55,26 @@ export function LoginForm() { } } + 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 { + setIsLoading(false); + } + } + return ( <> @@ -63,45 +96,182 @@ export function LoginForm() { Log in to manage your GitHub to Gitea mirroring - -
-
-
- - -
-
- - -
+ + {isLoadingMethods ? ( + +
+
- -
- - - + + ) : ( + <> + {/* Show tabs only if multiple auth methods are available */} + {authMethods.sso.enabled && authMethods.emailPassword ? ( + + + + + Email + + + + SSO + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+ + + +
+ {authMethods.sso.providers.length > 0 && ( + <> +
+

+ Sign in with your organization account +

+ {authMethods.sso.providers.map(provider => ( + + ))} +
+ +
+
+ +
+
+ Or +
+
+ + )} + +
+ + 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} + /> +

+ We'll redirect you to your organization's SSO provider +

+
+
+
+ + + +
+
+ ) : ( + // Single auth method - show email/password only + <> + +
+
+
+ + +
+
+ + +
+
+
+
+ + + + + )} + + )} +

Don't have an account? Contact your administrator. diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index d6fdbbf..a373a3f 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -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() {

- {/* Content section - Grid layout */} -
- {/* GitHub & Gitea connections - Side by side */} -
- - 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} - /> - - setConfig(prev => ({ - ...prev, - giteaConfig: - typeof update === 'function' - ? update(prev.giteaConfig) - : update, - })) - } - onAutoSave={autoSaveGiteaConfig} - isAutoSaving={isAutoSavingGitea} - githubUsername={config.githubConfig.username} - /> -
+ {/* Content section - Tabs layout */} + + + Connections + Automation + Authentication + - {/* Automation & Maintenance - Full width */} -
+ +
+ + 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} + /> + + setConfig(prev => ({ + ...prev, + giteaConfig: + typeof update === 'function' + ? update(prev.giteaConfig) + : update, + })) + } + onAutoSave={autoSaveGiteaConfig} + isAutoSaving={isAutoSavingGitea} + githubUsername={config.githubConfig.username} + /> +
+
+ + -
-
+ + + + + + ); } diff --git a/src/components/config/SSOSettings.tsx b/src/components/config/SSOSettings.tsx new file mode 100644 index 0000000..e17ce2c --- /dev/null +++ b/src/components/config/SSOSettings.tsx @@ -0,0 +1,634 @@ +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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +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 } from 'lucide-react'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '../ui/skeleton'; + +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; +} + +interface OAuthApplication { + id: string; + clientId: string; + clientSecret?: string; + name: string; + redirectURLs: string; + type: string; + disabled: boolean; + metadata?: string; + createdAt: string; + updatedAt: string; +} + +export function SSOSettings() { + const [activeTab, setActiveTab] = useState('providers'); + const [providers, setProviders] = useState([]); + const [applications, setApplications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showProviderDialog, setShowProviderDialog] = useState(false); + const [showAppDialog, setShowAppDialog] = useState(false); + const [isDiscovering, setIsDiscovering] = useState(false); + + // Form states for new provider + const [providerForm, setProviderForm] = useState({ + issuer: '', + domain: '', + providerId: '', + clientId: '', + clientSecret: '', + authorizationEndpoint: '', + tokenEndpoint: '', + jwksEndpoint: '', + userInfoEndpoint: '', + }); + + // Form states for new application + const [appForm, setAppForm] = useState({ + name: '', + redirectURLs: '', + type: 'web', + }); + + // Authentication methods state + const [authMethods, setAuthMethods] = useState({ + emailPassword: true, + sso: false, + oidc: false, + }); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setIsLoading(true); + try { + const [providersRes, appsRes] = await Promise.all([ + apiRequest('/sso/providers'), + apiRequest('/sso/applications'), + ]); + setProviders(providersRes); + setApplications(appsRes); + + // Set auth methods based on what's configured + setAuthMethods({ + emailPassword: true, // Always enabled + sso: providersRes.length > 0, + oidc: appsRes.length > 0, + }); + } 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('/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('/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'); + + // Enable SSO auth method + setAuthMethods(prev => ({ ...prev, sso: true })); + } 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'); + + // Disable SSO if no providers left + if (providers.length === 1) { + setAuthMethods(prev => ({ ...prev, sso: false })); + } + } catch (error) { + showErrorToast(error, toast); + } + }; + + const createApplication = async () => { + try { + const newApp = await apiRequest('/sso/applications', { + method: 'POST', + data: { + ...appForm, + redirectURLs: appForm.redirectURLs.split('\n').filter(url => url.trim()), + }, + }); + + setApplications([...applications, newApp]); + setShowAppDialog(false); + setAppForm({ + name: '', + redirectURLs: '', + type: 'web', + }); + toast.success('OAuth application created successfully'); + + // Enable OIDC auth method + setAuthMethods(prev => ({ ...prev, oidc: true })); + } catch (error) { + showErrorToast(error, toast); + } + }; + + const deleteApplication = async (id: string) => { + try { + await apiRequest(`/sso/applications?id=${id}`, { method: 'DELETE' }); + setApplications(applications.filter(a => a.id !== id)); + toast.success('Application deleted successfully'); + + // Disable OIDC if no applications left + if (applications.length === 1) { + setAuthMethods(prev => ({ ...prev, oidc: false })); + } + } catch (error) { + showErrorToast(error, toast); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Authentication Methods Card */} + + + Authentication Methods + + Choose which authentication methods are available for users + + + +
+
+ +

+ Traditional email and password authentication +

+
+ +
+ + + +
+
+ +

+ Allow users to sign in with external OIDC providers +

+
+ +
+ + + +
+
+ +

+ Allow other applications to authenticate through this app +

+
+ +
+
+
+ + {/* SSO Configuration Tabs */} + + + SSO Providers + OAuth Applications + + + + + +
+
+ SSO Providers + + Configure external OIDC providers for user authentication + +
+ + + + + + + Add SSO Provider + + Configure an external OIDC provider for user authentication + + +
+
+ +
+ setProviderForm(prev => ({ ...prev, issuer: e.target.value }))} + placeholder="https://accounts.google.com" + /> + +
+
+ +
+
+ + setProviderForm(prev => ({ ...prev, domain: e.target.value }))} + placeholder="example.com" + /> +
+
+ + setProviderForm(prev => ({ ...prev, providerId: e.target.value }))} + placeholder="google-sso" + /> +
+
+ +
+
+ + setProviderForm(prev => ({ ...prev, clientId: e.target.value }))} + /> +
+
+ + setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))} + /> +
+
+ +
+ + setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))} + placeholder="https://accounts.google.com/o/oauth2/auth" + /> +
+ +
+ + setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))} + placeholder="https://oauth2.googleapis.com/token" + /> +
+ + + + + Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'} + + +
+ + + + +
+
+
+
+ + {providers.length === 0 ? ( + + + No SSO providers configured. Add a provider to enable SSO authentication. + + + ) : ( +
+ {providers.map(provider => ( + + +
+
+

{provider.providerId}

+

{provider.domain}

+
+ +
+
+ +
+
+

Issuer

+

{provider.issuer}

+
+
+

Client ID

+

{provider.oidcConfig.clientId}

+
+
+
+
+ ))} +
+ )} +
+
+
+ + + + +
+
+ OAuth Applications + + Applications that can authenticate users through this OIDC provider + +
+ + + + + + + Create OAuth Application + + Register a new application that can use this service for authentication + + +
+
+ + setAppForm(prev => ({ ...prev, name: e.target.value }))} + placeholder="My Application" + /> +
+ +
+ + +
+ +
+ +