Compare commits

..

38 Commits

Author SHA1 Message Date
Arunavo Ray
aaf8dc6fe4 Bump version to 2.1.0 2025-05-22 11:05:07 +05:30
ARUNAVO RAY
cda78bc0f5 Merge pull request #13 from arunavo4/fix-bun-permissions-in-lxc
Fix Bun permissions issue in LXC container installer
2025-05-22 09:15:49 +05:30
Arunavo Ray
9ccd656734 Update documentation: revise architecture, configuration, and quick start guides to reflect new features and deployment options 2025-05-22 09:12:32 +05:30
Arunavo Ray
8b5c5d8ed2 Update README to include event management scripts and LXC deployment details 2025-05-22 09:06:47 +05:30
Arunavo Ray
1ab642c9e7 Update LXC deployment scripts: replace installer script with Proxmox-specific script and update README references 2025-05-22 09:04:05 +05:30
Arunavo Ray
1eae725535 Update LXC deployment guide references and remove outdated documentation 2025-05-22 08:56:53 +05:30
Arunavo Ray
5bf52c806f Update README and add LXC deployment guide; enhance LXC installer scripts 2025-05-22 08:53:19 +05:30
Arunavo Ray
a15178d2cd Implement health check API and update health check commands in Docker and Docker Compose 2025-05-22 08:39:07 +05:30
Arunavo Ray
32ef9124a7 Remove sqlite3 dependency from package.json 2025-05-22 08:12:26 +05:30
Arunavo Ray
161685b966 Add directory permission check before creating symlink in systemd service setup 2025-05-21 22:30:15 +05:30
Arunavo Ray
0cf95b2a0e Improve error handling and permission checks in LXC installer 2025-05-21 22:26:50 +05:30
Arunavo Ray
c896194aeb Fix Bun permissions issue in LXC container installer 2025-05-21 22:19:43 +05:30
Arunavo Ray
315d892cf4 Bump version to 2.0.0 2025-05-21 22:09:10 +05:30
Arunavo Ray
b7eaa94ca2 Update README for improved structure and clarity 2025-05-21 14:32:34 +05:30
Arunavo Ray
52dbe6a2d9 Refactor code structure for improved readability and maintainability 2025-05-21 14:23:42 +05:30
ARUNAVO RAY
e423d78cf9 Merge pull request #11 from arunavo4/feature/lxc-container-support
Add LXC container support with single-command installer
2025-05-21 14:08:59 +05:30
Arunavo Ray
f6b51414a0 Remove unnecessary daemon-reload from README based on PR feedback 2025-05-21 14:06:41 +05:30
Arunavo Ray
8a35c0368f Address Copilot suggestions for LXC installer improvements 2025-05-21 13:58:07 +05:30
Arunavo Ray
6f64838b55 Add LXC container support with single-command installer 2025-05-21 13:49:41 +05:30
Arunavo Ray
f37867ea0c feat: remove project status section from README.md 2025-05-21 11:34:01 +05:30
Arunavo Ray
4aa7e665ac feat: remove Redis dependencies and cleanup scripts after migrating to SQLite 2025-05-21 11:31:22 +05:30
ARUNAVO RAY
4b570f555a Merge pull request #9 from arunavo4/bun
Migrate to Bun
2025-05-21 11:23:08 +05:30
Arunavo Ray
97676f3b04 feat: add skeleton loaders for improved loading state in Dashboard and Header components 2025-05-21 11:19:37 +05:30
Arunavo Ray
04e8b817d3 feat: add event cleanup scripts and Docker Compose setup for automated maintenance 2025-05-21 02:25:05 +05:30
Arunavo Ray
6d13ff29ca feat: migrate from Redis to SQLite for event handling and notifications 2025-05-20 19:09:48 +05:30
Arunavo Ray
c179953649 feat: enhance SSE connection with reconnect logic and error handling
- Updated `useSSE` hook to include max reconnect attempts and exponential backoff for reconnections.
- Improved error handling for SSE messages and connection errors.
- Added connection status reset on successful connection.

fix: improve SQLite database connection handling

- Simplified database initialization and connection logic.
- Ensured the database file is created if it doesn't exist.

fix: enhance Redis client connection with retry strategy

- Implemented exponential backoff for Redis connection retries.
- Added event handlers for connection success and error handling.

feat: improve SSE API endpoint with robust Redis connection management

- Added connection retry logic for Redis in the SSE API.
- Implemented heartbeat messages to keep the connection alive.
- Enhanced error handling for Redis subscription and connection attempts.
2025-05-20 18:33:56 +05:30
Arunavo Ray
eb2d76a4b7 refactor: migrate database handling to Bun's SQLite and ensure data directory exists 2025-05-20 16:39:47 +05:30
Arunavo Ray
145bee8d96 chore: update dependencies to latest versions for improved stability and performance 2025-05-20 16:14:06 +05:30
Arunavo Ray
cad72da016 Update Docker and workflow configurations for bun.lock handling 2025-05-20 16:03:40 +05:30
Arunavo Ray
4a01a351f0 Refactor code structure for improved readability and maintainability 2025-05-20 15:57:46 +05:30
ARUNAVO RAY
98973adfe5 Merge pull request #8 from arunavo4/ray/test-code-and-migrate-ci-to-bun
Switch Dockerfile to Bun
2025-05-20 15:45:13 +05:30
ARUNAVO RAY
f6b5df472a chore: switch docker build to bun 2025-05-20 15:43:41 +05:30
ARUNAVO RAY
b09cabd154 Merge pull request #6 from arunavo4/l9s9sl-ray/migrate-project-to-bun-with-redis-and-sqlite
Switch package management to Bun
2025-05-20 15:19:45 +05:30
ARUNAVO RAY
f9c77bbee0 Merge branch 'bun' into l9s9sl-ray/migrate-project-to-bun-with-redis-and-sqlite 2025-05-20 15:18:55 +05:30
ARUNAVO RAY
e95f1d99b5 Merge pull request #5 from arunavo4/ray/migrate-project-to-bun-with-redis-and-sqlite
Migrate project to Bun runtime
2025-05-20 15:17:41 +05:30
ARUNAVO RAY
d5b0102080 chore: switch to bun package manager 2025-05-20 15:16:51 +05:30
ARUNAVO RAY
94aff30dda feat: migrate to bun 2025-05-20 14:58:00 +05:30
Arunavo Ray
38206e7d3d Update README.md to include additional badges and enhance quick start instructions 2025-05-19 19:21:53 +05:30
45 changed files with 3929 additions and 8489 deletions

View File

@@ -5,10 +5,10 @@
# Node.js
node_modules
# We don't exclude bun.lock* as it's needed for the build
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
# Build outputs
dist
@@ -62,4 +62,3 @@ logs
# Cache
.cache
.npm
.pnpm-store

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -24,8 +24,7 @@ This workflow runs on all branches and pull requests. It:
- On push to any branch (except changes to README.md and docs)
- On pull requests to any branch (except changes to README.md and docs)
**Key features:**
- Uses pnpm for faster dependency installation
- Uses Bun for dependency installation
- Caches dependencies to speed up builds
- Uploads build artifacts for 7 days

View File

@@ -16,31 +16,32 @@ jobs:
build-and-test:
name: Build and Test Astro Project
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v3
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
version: 10
run_install: false
bun-version: '1.2.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Check lockfile and install dependencies
run: |
# Check if bun.lock exists, if not check for bun.lockb
if [ -f "bun.lock" ]; then
echo "Using existing bun.lock file"
elif [ -f "bun.lockb" ]; then
echo "Found bun.lockb, creating symlink to bun.lock"
ln -s bun.lockb bun.lock
fi
bun install
- name: Run tests
run: pnpm test
run: bunx vitest run
- name: Build Astro project
run: pnpm build
run: bunx astro build
- name: Upload build artifacts
uses: actions/upload-artifact@v4

View File

@@ -18,11 +18,6 @@ jobs:
contents: write
packages: write
services:
redis:
image: redis:7-alpine
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4

View File

@@ -7,14 +7,14 @@ on:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'pnpm-lock.yaml'
- 'bun.lock*'
pull_request:
branches: [ main ]
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'pnpm-lock.yaml'
- 'bun.lock*'
schedule:
- cron: '0 0 * * 0' # Run weekly on Sunday at midnight

View File

@@ -1,82 +1,47 @@
# syntax=docker/dockerfile:1.4
FROM node:lts-alpine AS base
ENV PNPM_HOME=/usr/local/bin
ENV PATH=$PNPM_HOME:$PATH
RUN apk add --no-cache libc6-compat
FROM oven/bun:1.2.9-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite
# -----------------------------------
# ----------------------------
FROM base AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ gcc
COPY package.json ./
COPY bun.lock* ./
RUN bun install --frozen-lockfile
RUN --mount=type=cache,target=/root/.npm \
corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* ./
# Full dev install
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# -----------------------------------
FROM base AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++ gcc
RUN --mount=type=cache,target=/root/.npm \
corepack enable && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
# ----------------------------
FROM deps AS builder
COPY . .
RUN pnpm build
# Compile TypeScript scripts to JavaScript
RUN bun run build
RUN mkdir -p dist/scripts && \
for script in scripts/*.ts; do \
node_modules/.bin/tsc --outDir dist/scripts --module commonjs --target es2020 --esModuleInterop $script || true; \
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
done
# -----------------------------------
# ----------------------------
FROM deps AS pruner
WORKDIR /app
RUN bun install --production --frozen-lockfile
# Prune dev dependencies and just keep the production bits
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm prune --prod
# -----------------------------------
# ----------------------------
FROM base AS runner
WORKDIR /app
# Only copy production node_modules and built output
COPY --from=pruner /app/node_modules ./node_modules
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/data ./data
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db
# Make entrypoint executable
RUN chmod +x /app/docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]
RUN apk add --no-cache wget sqlite && \
mkdir -p /app/data && \
addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 gitea-mirror && \
chown -R gitea-mirror:nodejs /app/data
COPY --from=builder --chown=gitea-mirror:nodejs /app/dist ./dist
COPY --from=pruner --chown=gitea-mirror:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=gitea-mirror:nodejs /app/package.json ./package.json
COPY --from=builder --chown=gitea-mirror:nodejs /app/scripts ./scripts
RUN chmod +x ./docker-entrypoint.sh && \
mkdir -p /app/data && \
addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 gitea-mirror && \
chown -R gitea-mirror:nodejs /app/data
USER gitea-mirror
@@ -84,10 +49,6 @@ VOLUME /app/data
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/ || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
# Create a startup script that initializes the database before starting the application
COPY --from=builder --chown=gitea-mirror:nodejs /app/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
CMD ["./docker-entrypoint.sh"]
ENTRYPOINT ["./docker-entrypoint.sh"]

259
README.md
View File

@@ -1,11 +1,34 @@
# Gitea Mirror
<p align="center">
<i>A modern web application for automatically mirroring repositories from GitHub to your self-hosted Gitea instance.</i><br>
<sub>Designed for developers, teams, and organizations who want to retain full control of their code while still collaborating on GitHub.</sub>
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
<h1>Gitea Mirror</h1>
<p><i>A modern web app for automatically mirroring repositories from GitHub to your self-hosted Gitea.</i></p>
<p align="center">
<a href="https://github.com/arunavo4/gitea-mirror/releases/latest"><img src="https://img.shields.io/github/v/tag/arunavo4/gitea-mirror?label=release" alt="release"/></a>
<a href="https://github.com/arunavo4/gitea-mirror/actions/workflows/astro-build-test.yml"><img src="https://img.shields.io/github/actions/workflow/status/arunavo4/gitea-mirror/astro-build-test.yml?branch=main" alt="build"/></a>
<a href="https://github.com/arunavo4/gitea-mirror/pkgs/container/gitea-mirror"><img src="https://img.shields.io/badge/ghcr.io-container-blue?logo=github" alt="container"/></a>
<a href="https://github.com/arunavo4/gitea-mirror/blob/main/LICENSE"><img src="https://img.shields.io/github/license/arunavo4/gitea-mirror" alt="license"/></a>
</p>
</p>
## 🚀 Quick Start
```bash
# Using Docker (recommended)
docker compose --profile production up -d
# Using Bun
bun run setup && bun run dev
# Using LXC Containers
# For Proxmox VE (online)
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh | bash
# For local testing (offline-friendly)
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.sh
````
See the [LXC Container Deployment Guide](scripts/README-lxc.md).
<p align="center">
<img src=".github/assets/dashboard.png" alt="Dashboard" width="80%"/>
</p>
@@ -50,7 +73,7 @@ See the [Quick Start Guide](docs/quickstart.md) for detailed instructions on get
### Prerequisites
- Node.js 22 or later
- Bun 1.2.9 or later
- A GitHub account with a personal access token
- A Gitea instance with an access token
@@ -77,7 +100,7 @@ Before running the application in production mode for the first time, you need t
```bash
# Initialize the database for production mode
pnpm setup
bun run setup
```
This will create the necessary tables. On first launch, you'll be guided through creating your admin account with a secure password.
@@ -95,13 +118,13 @@ Gitea Mirror provides multi-architecture Docker images that work on both ARM64 (
docker compose --profile production up -d
# For development mode (requires configuration)
# Ensure you have run pnpm setup first
# Ensure you have run bun run setup first
docker compose -f docker-compose.dev.yml up -d
```
> [!IMPORTANT]
> **Docker Compose is the recommended method for running Gitea Mirror** as it automatically sets up the required Redis sidecar service that the application depends on.
> **Docker Compose is the recommended method for running Gitea Mirror** as it provides a consistent environment with proper volume management for the SQLite database.
> [!NOTE]
@@ -109,19 +132,15 @@ docker compose -f docker-compose.dev.yml up -d
##### Using Pre-built Images from GitHub Container Registry
If you want to run the container directly without Docker Compose, you'll need to set up a Redis instance separately:
If you want to run the container directly without Docker Compose:
```bash
# First, start a Redis container
docker run -d --name gitea-mirror-redis redis:alpine
# Pull the latest multi-architecture image
docker pull ghcr.io/arunavo4/gitea-mirror:latest
# Run the application with a link to the Redis container
# Note: The REDIS_URL environment variable is required and must point to the Redis container
docker run -d -p 4321:4321 --link gitea-mirror-redis:redis \
-e REDIS_URL=redis://redis:6379 \
# Run the application with a volume for persistent data
docker run -d -p 4321:4321 \
-v gitea-mirror-data:/app/data \
ghcr.io/arunavo4/gitea-mirror:latest
```
@@ -148,6 +167,40 @@ docker compose --profile production up -d
See [Docker build documentation](./scripts/README-docker.md) for more details.
##### Using LXC Containers
Gitea Mirror offers two deployment options for LXC containers:
**1. Proxmox VE (online, recommended for production)**
```bash
# One-command installation on Proxmox VE
# Optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh | bash
```
**2. Local testing (offline-friendly, works on developer laptops)**
```bash
# Download the script
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
chmod +x gitea-mirror-lxc-local.sh
# Run with your local repo directory
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./gitea-mirror-lxc-local.sh
```
Both scripts:
- Set up a privileged Ubuntu 22.04 LXC container
- Install Bun runtime environment
- Build the application
- Configure a systemd service
- Start the service automatically
The application includes a health check endpoint at `/api/health` for monitoring.
See the [LXC Container Deployment Guide](scripts/README-lxc.md) for detailed instructions.
##### Building Your Own Image
For manual Docker builds (without the helper script):
@@ -180,7 +233,6 @@ The Docker container can be configured with the following environment variables:
- `HOST`: Host to bind to (default: `0.0.0.0`)
- `PORT`: Port to listen on (default: `4321`)
- `JWT_SECRET`: Secret key for JWT token generation (important for security)
- `REDIS_URL`: URL for Redis connection (required, default: none). When using Docker Compose, this should be set to `redis://redis:6379` to connect to the Redis container.
#### Manual Installation
@@ -191,40 +243,40 @@ git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror
# Quick setup (installs dependencies and initializes the database)
pnpm setup
bun run setup
# Development Mode Options
# Run in development mode
pnpm dev
bun run dev
# Run in development mode with clean database (removes existing DB first)
pnpm dev:clean
bun run dev:clean
# Production Mode Options
# Build the application
pnpm build
bun run build
# Preview the production build
pnpm preview
bun run preview
# Start the production server (default)
pnpm start
bun run start
# Start the production server with a clean setup
pnpm start:fresh
bun run start:fresh
# Database Management
# Initialize the database
pnpm init-db
bun run init-db
# Reset users for testing first-time signup
pnpm reset-users
bun run reset-users
# Check database status
pnpm check-db
bun run check-db
```
### Configuration
@@ -239,7 +291,7 @@ Key configuration options include:
- Scheduling options for automatic mirroring
> [!IMPORTANT]
> **Redis is a required component for Gitea Mirror** as it's used for job queuing and caching.
> **SQLite is the only database required for Gitea Mirror**, handling both data storage and real-time event notifications.
## 🚀 Development
@@ -247,10 +299,10 @@ Key configuration options include:
```bash
# Install dependencies
pnpm setup
bun run setup
# Start the development server
pnpm dev
bun run dev
```
@@ -330,12 +382,12 @@ docker compose -f docker-compose.dev.yml up -d
> [!TIP]
> You can also create a `.env` file with your GitHub and Gitea credentials:
>
>
> ```env
> # GitHub credentials
> GITHUB_TOKEN=your-github-token
> GITHUB_USERNAME=your-github-username
>
>
> # Gitea credentials (will be set up after you create a user in the local Gitea instance)
> GITEA_TOKEN=your-local-gitea-token
> GITEA_USERNAME=your-local-gitea-username
@@ -344,10 +396,10 @@ docker compose -f docker-compose.dev.yml up -d
## Technologies Used
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
- **Backend**: Node.js
- **Database**: SQLite (default) or PostgreSQL
- **Caching/Queue**: Redis
- **Backend**: Bun
- **Database**: SQLite (handles both data storage and event notifications)
- **API Integration**: GitHub API (Octokit), Gitea API
- **Deployment Options**: Docker containers, LXC containers (Proxmox VE and local testing)
## Contributing
@@ -357,27 +409,6 @@ Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Project Status
This project is now complete and ready for production use with version 1.0.0. All planned features have been implemented, thoroughly tested, and optimized for performance:
- ✅ User-friendly dashboard with status overview
- ✅ Repository management interface
- ✅ Organization management interface
- ✅ Configuration management for GitHub and Gitea
- ✅ Scheduling and automation
- ✅ Activity logging and monitoring
- ✅ Responsive design for all screen sizes
- ✅ Modern toast notifications for better user feedback
- ✅ First-time user signup experience
- ✅ Better error handling and user guidance
- ✅ Comprehensive error handling
- ✅ Unit tests for components and API
- ✅ Direct GitHub to Gitea mirroring (no external dependencies)
- ✅ Docker and docker-compose support for easy deployment
- ✅ Multi-architecture support (ARM64 and x86_64)
- ✅ Light/dark mode toggle
- ✅ Persistent configuration storage
## Troubleshooting
@@ -385,14 +416,14 @@ This project is now complete and ready for production use with version 1.0.0. Al
> [!WARNING]
> If you encounter network-related warnings or errors when running Docker Compose, such as:
>
>
> ```
> WARN[0095] a network with name gitea-network exists but was not created by compose.
> Set `external: true` to use an existing network
> ```
>
>
> or
>
>
> ```
> network gitea-network was found but has incorrect label com.docker.compose.network set to "" (expected: "gitea-network")
> ```
@@ -416,7 +447,7 @@ Try the following steps:
> [!TIP]
> If you need to share the network with other Docker Compose projects, you can modify the `docker-compose.dev.yml` file to mark the network as external:
>
>
> ```yaml
> networks:
> gitea-network:
@@ -424,62 +455,60 @@ Try the following steps:
> external: true
> ```
### Redis Connection Issues
> [!CAUTION]
> If the application fails to connect to Redis with errors like `ECONNREFUSED 127.0.0.1:6379`, ensure:
>
> 1. The Redis container is running:
> ```bash
> docker ps | grep redis
> ```
> 2. The `REDIS_URL` environment variable is correctly set to `redis://redis:6379` in your Docker Compose file.
> 3. Both the application and Redis containers are on the same Docker network.
> 4. If running without Docker Compose, ensure you've started a Redis container and linked it properly:
> ```bash
> # Start Redis container
> docker run -d --name gitea-mirror-redis redis:alpine
> # Run application with link to Redis
> docker run -d -p 4321:4321 --link gitea-mirror-redis:redis \
> -e REDIS_URL=redis://redis:6379 \
> ghcr.io/arunavo4/gitea-mirror:latest
> ```
#### Improving Redis Connection Resilience
### Database Persistence
> [!TIP]
> For better Redis connection handling, you can modify the `src/lib/redis.ts` file to include retry logic and better error handling:
> The application uses SQLite for all data storage and event notifications. Make sure the database file is properly mounted when using Docker:
>
> ```bash
> # Run with a volume for persistent data storage
> docker run -d -p 4321:4321 \
> -v gitea-mirror-data:/app/data \
> ghcr.io/arunavo4/gitea-mirror:latest
> ```
>
> For homelab/self-hosted setups, you can use the provided Docker Compose file with automatic event cleanup:
>
> ```bash
> # Clone the repository
> git clone https://github.com/arunavo4/gitea-mirror.git
> cd gitea-mirror
>
> # Start the application with Docker Compose
> docker-compose -f docker-compose.homelab.yml up -d
> ```
>
> This setup includes a cron job that runs daily to clean up old events and prevent the database from growing too large.
```typescript
import Redis from "ioredis";
// Connect to Redis using REDIS_URL environment variable or default to redis://redis:6379
const redisUrl = process.env.REDIS_URL ?? 'redis://redis:6379';
#### Database Maintenance
console.log(`Connecting to Redis at: ${redisUrl}`);
// Configure Redis client with connection options
const redisOptions = {
retryStrategy: (times) => {
// Retry with exponential backoff up to 30 seconds
const delay = Math.min(times * 100, 3000);
console.log(`Redis connection attempt ${times} failed. Retrying in ${delay}ms...`);
return delay;
},
maxRetriesPerRequest: 5,
enableReadyCheck: true,
connectTimeout: 10000,
};
export const redis = new Redis(redisUrl, redisOptions);
export const redisPublisher = new Redis(redisUrl, redisOptions);
export const redisSubscriber = new Redis(redisUrl, redisOptions);
// Log connection events
redis.on('connect', () => console.log('Redis client connected'));
redis.on('error', (err) => console.error('Redis client error:', err));
```
> [!TIP]
> For database maintenance, you can use the provided scripts:
>
> ```bash
> # Check database integrity
> bun run check-db
>
> # Fix database issues
> bun run fix-db
>
> # Reset user accounts (for development)
> bun run reset-users
>
> # Clean up old events (keeps last 7 days by default)
> bun run cleanup-events
>
> # Clean up old events with custom retention period (e.g., 30 days)
> bun run cleanup-events 30
> ```
>
> For automated maintenance, consider setting up a cron job to run the cleanup script periodically:
>
> ```bash
> # Add this to your crontab (runs daily at 2 AM)
> 0 2 * * * cd /path/to/gitea-mirror && bun run cleanup-events
> ```
> [!NOTE]
@@ -494,13 +523,13 @@ redis.on('error', (err) => console.error('Redis client error:', err));
> [!TIP]
> If containers are not starting properly, check their health status:
>
>
> ```bash
> docker ps --format "{{.Names}}: {{.Status}}"
> ```
>
>
> For more detailed logs:
>
>
> ```bash
> docker logs gitea-mirror-dev
> ```

View File

@@ -11,7 +11,12 @@ export default defineConfig({
mode: 'standalone',
}),
vite: {
plugins: [tailwindcss()]
plugins: [tailwindcss()],
build: {
rollupOptions: {
external: ['bun']
}
}
},
integrations: [react()]
});

1847
bun.lock Normal file

File diff suppressed because it is too large Load Diff

4
crontab Normal file
View File

@@ -0,0 +1,4 @@
# Run event cleanup daily at 2 AM
0 2 * * * cd /app && bun run cleanup-events 30 >> /app/data/cleanup-events.log 2>&1
# Empty line at the end is required for cron to work properly

View File

@@ -51,7 +51,6 @@ services:
- gitea-mirror-data:/app/data
depends_on:
- gitea
- redis
environment:
- NODE_ENV=development
- DATABASE_URL=file:data/gitea-mirror.db
@@ -75,7 +74,6 @@ services:
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
- REDIS_URL=redis://redis:6379
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/"]
interval: 30s
@@ -85,16 +83,7 @@ services:
networks:
- gitea-network
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- gitea-network
# Define named volumes for data persistence
volumes:
@@ -102,8 +91,6 @@ volumes:
gitea-config: # Gitea config volume
gitea-mirror-data: # Gitea Mirror database volume
redis-data:
# Define networks
networks:
gitea-network:

View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
gitea-mirror:
image: ghcr.io/arunavo4/gitea-mirror:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "4321:4321"
volumes:
- gitea-mirror-data:/app/data
# Mount the crontab file
- ./crontab:/etc/cron.d/gitea-mirror-cron
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=4321
- DATABASE_URL=sqlite://data/gitea-mirror.db
- DELAY=${DELAY:-3600}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4321/api/health"]
interval: 1m
timeout: 10s
retries: 3
start_period: 30s
# Install cron in the container and set up the cron job
command: >
sh -c "
apt-get update && apt-get install -y cron curl &&
chmod 0644 /etc/cron.d/gitea-mirror-cron &&
crontab /etc/cron.d/gitea-mirror-cron &&
service cron start &&
bun dist/server/entry.mjs
"
# Define named volumes for database persistence
volumes:
gitea-mirror-data: # Database volume

View File

@@ -19,8 +19,6 @@ services:
- "4321:4321"
volumes:
- gitea-mirror-data:/app/data
depends_on:
- redis
environment:
- NODE_ENV=production
- DATABASE_URL=file:data/gitea-mirror.db
@@ -44,7 +42,6 @@ services:
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
- REDIS_URL=redis://redis:6379
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/"]
interval: 30s
@@ -53,16 +50,6 @@ services:
start_period: 15s
profiles: ["production"]
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
# Define named volumes for database persistence
volumes:
gitea-mirror-data: # Database volume
redis-data:

View File

@@ -5,19 +5,19 @@ set -e
# Ensure data directory exists
mkdir -p /app/data
# If pnpm is available, run setup (for dev images), else run node init directly
if command -v pnpm >/dev/null 2>&1; then
echo "Running pnpm setup (if needed)..."
pnpm setup || true
# If bun is available, run setup (for dev images)
if command -v bun >/dev/null 2>&1; then
echo "Running bun setup (if needed)..."
bun run setup || true
fi
# Initialize the database if it doesn't exist
if [ ! -f "/app/data/gitea-mirror.db" ]; then
echo "Initializing database..."
if [ -f "dist/scripts/init-db.js" ]; then
node dist/scripts/init-db.js
bun dist/scripts/init-db.js
elif [ -f "dist/scripts/manage-db.js" ]; then
node dist/scripts/manage-db.js init
bun dist/scripts/manage-db.js init
else
echo "Warning: Could not find database initialization scripts in dist/scripts."
echo "Creating and initializing database manually..."
@@ -113,15 +113,29 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
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)
);
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);
EOF
echo "Database initialized with required tables."
fi
else
echo "Database already exists, checking for issues..."
if [ -f "dist/scripts/fix-db-issues.js" ]; then
node dist/scripts/fix-db-issues.js
bun dist/scripts/fix-db-issues.js
elif [ -f "dist/scripts/manage-db.js" ]; then
node dist/scripts/manage-db.js fix
bun dist/scripts/manage-db.js fix
fi
# Since the application is not used by anyone yet, we've removed the schema updates and migrations
@@ -130,4 +144,4 @@ fi
# Start the application
echo "Starting Gitea Mirror..."
exec node ./dist/server/entry.mjs
exec bun ./dist/server/entry.mjs

View File

@@ -1,87 +1,84 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "1.0.0",
"version": "2.1.0",
"engines": {
"node": ">=22.0.0"
"bun": ">=1.2.9"
},
"scripts": {
"setup": "pnpm install && pnpm manage-db init",
"dev": "astro dev",
"dev:clean": "pnpm cleanup-db && pnpm manage-db init && astro dev",
"build": "astro build",
"setup": "bun install && bun run manage-db init",
"dev": "bunx --bun astro dev",
"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",
"manage-db": "tsx scripts/manage-db.ts",
"init-db": "tsx scripts/manage-db.ts init",
"check-db": "tsx scripts/manage-db.ts check",
"fix-db": "tsx scripts/manage-db.ts fix",
"reset-users": "tsx scripts/manage-db.ts reset-users",
"preview": "astro preview",
"start": "node dist/server/entry.mjs",
"start:fresh": "pnpm cleanup-db && pnpm manage-db init && node dist/server/entry.mjs",
"test": "vitest run",
"test:watch": "vitest",
"astro": "astro"
"manage-db": "bun scripts/manage-db.ts",
"init-db": "bun scripts/manage-db.ts init",
"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",
"cleanup-events": "bun scripts/cleanup-events.ts",
"preview": "bunx --bun astro preview",
"start": "bun dist/server/entry.mjs",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
"test": "bunx --bun vitest run",
"test:watch": "bunx --bun vitest",
"astro": "bunx --bun astro"
},
"dependencies": {
"@astrojs/mdx": "^4.2.6",
"@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.7",
"@libsql/client": "^0.15.4",
"@octokit/rest": "^21.1.1",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@tailwindcss/vite": "^4.1.3",
"@tailwindcss/vite": "^4.1.7",
"@tanstack/react-virtual": "^3.13.8",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"astro": "^5.7.10",
"axios": "^1.8.4",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"astro": "^5.7.13",
"axios": "^1.9.0",
"bcryptjs": "^3.0.2",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.41.0",
"drizzle-orm": "^0.43.1",
"fuse.js": "^7.1.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.488.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.3",
"superagent": "^10.2.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.3",
"tw-animate-css": "^1.2.5",
"superagent": "^10.2.1",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"uuid": "^11.1.0",
"zod": "^3.24.2"
"zod": "^3.25.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.9",
"@types/superagent": "^8.1.9",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.0",
"better-sqlite3": "^9.6.0",
"@vitejs/plugin-react": "^4.4.1",
"jsdom": "^26.1.0",
"tsx": "^4.19.3",
"vitest": "^3.1.1"
"tsx": "^4.19.4",
"vitest": "^3.1.4"
},
"packageManager": "pnpm@10.10.0"
"packageManager": "bun@1.2.9"
}

7713
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ The script uses environment variables from the `.env` file in the project root:
3. Using with docker-compose:
```bash
# Ensure dependencies are installed and database is initialized
pnpm setup
bun run setup
# First build the image
./scripts/build-docker.sh --load

131
scripts/README-lxc.md Normal file
View File

@@ -0,0 +1,131 @@
# LXC Container Deployment Guide
## Overview
Run **Gitea Mirror** in an isolated LXC container, either:
1. **Online, on a Proxmox VE host** script pulls everything from GitHub
2. **Offline / LAN-only, on a developer laptop** script pushes your local checkout + Bun ZIP
---
## 1. Proxmox VE (online, recommended for prod)
### Prerequisites
* Proxmox VE node with the default `vmbr0` bridge
* Root shell on the node
* Ubuntu 22.04 LXC template present (`pveam update && pveam download ...`)
### One-command install
```bash
# optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh)"
```
What it does:
* Creates **privileged** CT `$CTID` with nesting enabled
* Installs curl / git / Bun (official installer)
* Clones & builds `arunavo4/gitea-mirror`
* Writes a root-run systemd service and starts it
* Prints the container IP + random `JWT_SECRET`
Browse to:
```
http://<container-ip>:4321
```
---
## 2. Local testing (LXD on a workstation, works offline)
### Prerequisites
* `lxd` installed (`sudo apt install lxd`; `lxd init --auto`)
* Your repo cloned locally e.g. `~/Development/gitea-mirror`
* Bun ZIP downloaded once:
`https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip`
### Offline installer script
```bash
git clone https://github.com/arunavo4/gitea-mirror.git # if not already
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
chmod +x gitea-mirror-lxc-local.sh
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror \
./gitea-mirror-lxc-local.sh
```
What it does:
* Launches privileged LXC `gitea-test` (`lxc launch ubuntu:22.04 ...`)
* Pushes **Bun ZIP** + tarred **local repo** into `/opt`
* Unpacks, builds, initializes DB
* Symlinks both `bun` and `bunx``/usr/local/bin`
* Creates a root systemd unit and starts it
Access from host:
```
http://$(lxc exec gitea-test -- hostname -I | awk '{print $1}'):4321
```
(Optional) forward to host localhost:
```bash
sudo lxc config device add gitea-test mirror proxy \
listen=tcp:0.0.0.0:4321 connect=tcp:127.0.0.1:4321
```
---
## Health-check endpoint
Gitea Mirror includes a built-in health check endpoint at `/api/health` that provides:
- System status and uptime
- Database connectivity check
- Memory usage statistics
- Environment information
You can use this endpoint for monitoring your deployment:
```bash
# Basic check (returns 200 OK if healthy)
curl -I http://<container-ip>:4321/api/health
# Detailed health information (JSON)
curl http://<container-ip>:4321/api/health
```
---
## Troubleshooting
| Check | Command |
| -------------- | ----------------------------------------------------- |
| Service status | `systemctl status gitea-mirror` |
| Live logs | `journalctl -u gitea-mirror -f` |
| Verify Bun | `bun --version && bunx --version` |
| DB perms | `chown -R root:root /opt/gitea-mirror/data` (Proxmox) |
---
## Connecting LXC and Docker Containers
If you need your LXC container to communicate with Docker containers:
1. On your host machine, create a bridge network:
```bash
docker network create gitea-network
```
2. Find the bridge interface created by Docker:
```bash
ip a | grep docker
# Look for something like docker0 or br-xxxxxxxx
```
3. In Proxmox, edit the LXC container's network configuration to use this bridge.

View File

@@ -1,12 +1,14 @@
# Scripts Directory
This folder contains utility scripts for database management.
This folder contains utility scripts for database management, event management, Docker builds, and LXC container deployment.
## Database Management Tool (manage-db.ts)
## Database Management
### Database Management Tool (manage-db.ts)
This is a consolidated database management tool that handles all database-related operations. It combines the functionality of the previous separate scripts into a single, more intelligent script that can check, fix, and initialize the database as needed.
### Features
#### Features
- **Check Mode**: Validates the existence and integrity of the database
- **Init Mode**: Creates the database only if it doesn't already exist
@@ -14,45 +16,106 @@ This is a consolidated database management tool that handles all database-relate
- **Reset Users Mode**: Removes all users and their data
- **Auto Mode**: Automatically checks, fixes, and initializes the database if needed
## Running the Database Management Tool
#### Running the Database Management Tool
You can execute the database management tool using your package manager with various commands:
```bash
# Checks database status (default action if no command is specified, equivalent to 'pnpm check-db')
pnpm manage-db
# Checks database status (default action if no command is specified)
bun run manage-db
# Check database status
pnpm check-db
bun run check-db
# Initialize the database (only if it doesn't exist)
pnpm init-db
bun run init-db
# Fix database location issues
pnpm fix-db
bun run fix-db
# Automatic check, fix, and initialize if needed
pnpm db-auto
bun run db-auto
# Reset all users (for testing signup flow)
pnpm reset-users
# Update the database schema to the latest version
pnpm update-schema
bun run reset-users
# Remove database files completely
pnpm cleanup-db
bun run cleanup-db
# Complete setup (install dependencies and initialize database)
pnpm setup
bun run setup
# Start development server with a fresh database
pnpm dev:clean
bun run dev:clean
# Start production server with a fresh database
pnpm start:fresh
bun run start:fresh
```
## Database File Location
#### Database File Location
The database file should be located in the `./data/gitea-mirror.db` directory. If the file is found in the root directory, the fix mode will move it to the correct location.
## Event Management
The following scripts help manage events in the SQLite database:
### Event Inspection (check-events.ts)
Displays all events currently stored in the database.
```bash
bun scripts/check-events.ts
```
### Event Cleanup (cleanup-events.ts)
Removes old events from the database to prevent it from growing too large.
```bash
# Remove events older than 7 days (default)
bun scripts/cleanup-events.ts
# Remove events older than X days
bun scripts/cleanup-events.ts 14
```
This script can be scheduled to run periodically (e.g., daily) using cron or another scheduler.
### Mark Events as Read (mark-events-read.ts)
Marks all unread events as read.
```bash
bun scripts/mark-events-read.ts
```
### Make Events Appear Older (make-events-old.ts)
For testing purposes, this script modifies event timestamps to make them appear older.
```bash
bun scripts/make-events-old.ts
```
## Deployment Scripts
### Docker Deployment
- **build-docker.sh**: Builds the Docker image for the application
- **docker-diagnostics.sh**: Provides diagnostic information for Docker deployments
### LXC Container Deployment
Two scripts are provided for deploying Gitea Mirror in LXC containers:
1. **gitea-mirror-lxc-proxmox.sh**: For online deployment on a Proxmox VE host
- Pulls everything from GitHub
- Creates a privileged container with the application
- Sets up systemd service
2. **gitea-mirror-lxc-local.sh**: For offline/LAN-only deployment on a developer laptop
- Pushes your local checkout + Bun ZIP to the container
- Useful for testing without internet access
For detailed instructions on LXC deployment, see [README-lxc.md](./README-lxc.md).

38
scripts/check-events.ts Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bun
/**
* Script to check events in the database
*/
import { Database } from "bun:sqlite";
import path from "path";
import fs from "fs";
// Define the database path
const dataDir = path.join(process.cwd(), "data");
if (!fs.existsSync(dataDir)) {
console.error("Data directory not found:", dataDir);
process.exit(1);
}
const dbPath = path.join(dataDir, "gitea-mirror.db");
if (!fs.existsSync(dbPath)) {
console.error("Database file not found:", dbPath);
process.exit(1);
}
// Open the database
const db = new Database(dbPath);
// Check if the events table exists
const tableExists = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get();
if (!tableExists) {
console.error("Events table does not exist");
process.exit(1);
}
// Get all events
const events = db.query("SELECT * FROM events").all();
console.log("Events in the database:");
console.log(JSON.stringify(events, null, 2));

43
scripts/cleanup-events.ts Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bun
/**
* Script to clean up old events from the database
* This script should be run periodically (e.g., daily) to prevent the events table from growing too large
*
* Usage:
* bun scripts/cleanup-events.ts [days]
*
* Where [days] is the number of days to keep events (default: 7)
*/
import { cleanupOldEvents } from "../src/lib/events";
// Parse command line arguments
const args = process.argv.slice(2);
const daysToKeep = args.length > 0 ? parseInt(args[0], 10) : 7;
if (isNaN(daysToKeep) || daysToKeep < 1) {
console.error("Error: Days to keep must be a positive number");
process.exit(1);
}
async function runCleanup() {
try {
console.log(`Starting event cleanup (retention: ${daysToKeep} days)...`);
// Call the cleanupOldEvents function from the events module
const result = await cleanupOldEvents(daysToKeep);
console.log(`Cleanup summary:`);
console.log(`- Read events deleted: ${result.readEventsDeleted}`);
console.log(`- Unread events deleted: ${result.unreadEventsDeleted}`);
console.log(`- Total events deleted: ${result.readEventsDeleted + result.unreadEventsDeleted}`);
console.log("Event cleanup completed successfully");
} catch (error) {
console.error("Error running event cleanup:", error);
process.exit(1);
}
}
// Run the cleanup
runCleanup();

4
scripts/docker-diagnostics.sh Executable file → Normal file
View File

@@ -105,12 +105,12 @@ echo -e "${BLUE} Recommendations ${NC}"
echo -e "${BLUE}=====================================================${NC}"
echo -e "\n${YELLOW}For local development:${NC}"
echo -e "1. ${GREEN}pnpm setup${NC} (initialize database and install dependencies)"
echo -e "1. ${GREEN}bun run setup${NC} (initialize database and install dependencies)"
echo -e "2. ${GREEN}./scripts/build-docker.sh --load${NC} (build and load into Docker)"
echo -e "3. ${GREEN}docker-compose -f docker-compose.dev.yml up -d${NC} (start the development container)"
echo -e "\n${YELLOW}For production deployment (using Docker Compose):${NC}"
echo -e "1. ${GREEN}pnpm setup${NC} (if not already done, to ensure database schema is ready)"
echo -e "1. ${GREEN}bun run setup${NC} (if not already done, to ensure database schema is ready)"
echo -e "2. ${GREEN}docker-compose --profile production up -d${NC} (start the production container)"
echo -e "\n${YELLOW}For CI/CD builds:${NC}"

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# gitea-mirror-lxc-local.sh (offline, local repo, verbose)
set -euo pipefail
CONTAINER="gitea-test"
IMAGE="ubuntu:22.04"
INSTALL_DIR="/opt/gitea-mirror"
PORT=4321
JWT_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"
LOCAL_REPO_DIR="${LOCAL_REPO_DIR:-./gitea-mirror}"
REPO_TAR="/tmp/gitea-mirror-local.tar.gz"
need() { command -v "$1" >/dev/null || { echo "Missing $1"; exit 1; }; }
need curl; need lxc; need tar; need unzip
# ── build host artefacts ────────────────────────────────────────────────
[[ -d $LOCAL_REPO_DIR ]] || { echo "❌ LOCAL_REPO_DIR not found"; exit 1; }
[[ -f $LOCAL_REPO_DIR/package.json ]] || { echo "❌ package.json missing"; exit 1; }
[[ -f $BUN_ZIP ]] || curl -L --retry 5 --retry-delay 5 -o "$BUN_ZIP" "$BUN_URL"
tar -czf "$REPO_TAR" -C "$(dirname "$LOCAL_REPO_DIR")" "$(basename "$LOCAL_REPO_DIR")"
# ── ensure container exists ─────────────────────────────────────────────
lxd init --auto >/dev/null 2>&1 || true
lxc info "$CONTAINER" >/dev/null 2>&1 || lxc launch "$IMAGE" "$CONTAINER"
echo "🔧 installing base packages…"
sudo lxc exec "$CONTAINER" -- bash -c 'set -ex; apt update; apt install -y unzip tar openssl sqlite3'
echo "⬆️ pushing artefacts…"
sudo lxc file push "$BUN_ZIP" "$CONTAINER/opt/"
sudo lxc file push "$REPO_TAR" "$CONTAINER/opt/"
echo "📦 unpacking Bun + repo…"
sudo lxc exec "$CONTAINER" -- bash -ex <<'IN'
cd /opt
# Bun
unzip -oq bun-linux-x64.zip -d bun
BIN=$(find /opt/bun -type f -name bun -perm -111 | head -n1)
ln -sf "$BIN" /usr/local/bin/bun # bun
ln -sf "$BIN" /usr/local/bin/bunx # bunx shim
# Repo
rm -rf /opt/gitea-mirror
mkdir -p /opt/gitea-mirror
tar -xzf gitea-mirror-local.tar.gz --strip-components=1 -C /opt/gitea-mirror
IN
echo "🏗️ bun install / build…"
sudo lxc exec "$CONTAINER" -- bash -ex <<'IN'
cd /opt/gitea-mirror
bun install
bun run build
bun run manage-db init
IN
echo "📝 systemd unit…"
sudo lxc exec "$CONTAINER" -- bash -ex <<IN
cat >/etc/systemd/system/gitea-mirror.service <<SERVICE
[Unit]
Description=Gitea Mirror
After=network.target
[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/local/bin/bun dist/server/entry.mjs
Restart=on-failure
RestartSec=10
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
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable gitea-mirror
systemctl restart gitea-mirror
IN
echo -e "\n✅ finished; service status:"
sudo lxc exec "$CONTAINER" -- systemctl status gitea-mirror --no-pager

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# gitea-mirror-lxc-proxmox.sh
# Fully online installer for a Proxmox LXC guest running Gitea Mirror + Bun.
set -euo pipefail
# ────── adjustable defaults ──────────────────────────────────────────────
CTID=${CTID:-106} # container ID
HOSTNAME=${HOSTNAME:-gitea-mirror}
STORAGE=${STORAGE:-local-lvm} # where rootfs lives
DISK_SIZE=${DISK_SIZE:-8G}
CORES=${CORES:-2}
MEMORY=${MEMORY:-2048} # MiB
BRIDGE=${BRIDGE:-vmbr0}
IP_CONF=${IP_CONF:-dhcp} # or "192.168.1.240/24,gw=192.168.1.1"
PORT=4321
JWT_SECRET=$(openssl rand -hex 32)
REPO="https://github.com/arunavo4/gitea-mirror.git"
# ─────────────────────────────────────────────────────────────────────────
TEMPLATE='ubuntu-22.04-standard_22.04-1_amd64.tar.zst'
TEMPLATE_PATH="/var/lib/vz/template/cache/${TEMPLATE}"
echo "▶️ Ensuring template exists…"
if [[ ! -f $TEMPLATE_PATH ]]; then
pveam update >/dev/null
pveam download "$STORAGE" "$TEMPLATE"
fi
echo "▶️ Creating container $CTID (if missing)…"
if ! pct status "$CTID" &>/dev/null; then
pct create "$CTID" "$TEMPLATE_PATH" \
--rootfs "$STORAGE:$DISK_SIZE" \
--hostname "$HOSTNAME" \
--cores "$CORES" --memory "$MEMORY" \
--net0 "name=eth0,bridge=$BRIDGE,ip=$IP_CONF" \
--features nesting=1 \
--unprivileged 0
fi
pct start "$CTID"
echo "▶️ Installing base packages inside CT $CTID"
pct exec "$CTID" -- bash -c 'apt update && apt install -y curl git build-essential openssl sqlite3 unzip'
echo "▶️ Installing Bun runtime…"
pct exec "$CTID" -- bash -c '
export BUN_INSTALL=/opt/bun
curl -fsSL https://bun.sh/install | bash -s -- --yes
ln -sf /opt/bun/bin/bun /usr/local/bin/bun
ln -sf /opt/bun/bin/bun /usr/local/bin/bunx
bun --version
'
echo "▶️ Cloning & building Gitea Mirror…"
pct exec "$CTID" -- bash -c "
git clone --depth=1 '$REPO' /opt/gitea-mirror || (cd /opt/gitea-mirror && git pull)
cd /opt/gitea-mirror
bun install
bun run build
bun run manage-db init
"
echo "▶️ Creating systemd service…"
pct exec "$CTID" -- bash -c "
cat >/etc/systemd/system/gitea-mirror.service <<SERVICE
[Unit]
Description=Gitea Mirror
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/gitea-mirror
ExecStart=/usr/local/bin/bun dist/server/entry.mjs
Restart=on-failure
RestartSec=10
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
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable gitea-mirror
systemctl restart gitea-mirror
"
echo -e "\n🔍 Service status:"
pct exec "$CTID" -- systemctl status gitea-mirror --no-pager | head -n15
GUEST_IP=$(pct exec "$CTID" -- hostname -I | awk '{print $1}')
echo -e "\n🌐 Browse to: http://$GUEST_IP:$PORT\n"
echo "🗝️ JWT_SECRET = $JWT_SECRET"
echo -e "\n✅ Done Gitea Mirror is running in CT $CTID."

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bun
/**
* Script to make events appear older for testing cleanup
*/
import { db, events } from "../src/lib/db";
async function makeEventsOld() {
try {
console.log("Making events appear older...");
// Calculate a timestamp from 2 days ago
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 2);
// Update all events to have an older timestamp
const result = await db
.update(events)
.set({ createdAt: oldDate });
console.log(`Updated ${result.changes || 0} events to appear older`);
} catch (error) {
console.error("Error updating event timestamps:", error);
process.exit(1);
}
}
// Run the function
makeEventsOld();

View File

@@ -1,7 +1,6 @@
import fs from "fs";
import path from "path";
import { client, db } from "../src/lib/db";
import { configs } from "../src/lib/db";
import { Database } from "bun:sqlite";
import { v4 as uuidv4 } from "uuid";
// Command line arguments
@@ -21,61 +20,66 @@ 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 =
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
const dbPath = path.join(dataDir, "gitea-mirror.db");
/**
* Ensure all required tables exist
*/
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 {
await client.execute(`SELECT 1 FROM ${table} LIMIT 1`);
} catch (error) {
if (error instanceof Error && error.message.includes("SQLITE_ERROR")) {
// 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":
await client.execute(
`CREATE TABLE 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":
await client.execute(
`CREATE TABLE 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 '[]',
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":
await client.execute(
`CREATE TABLE repositories (
db.exec(`
CREATE TABLE repositories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
@@ -104,12 +108,12 @@ async function ensureTablesExist() {
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":
await client.execute(
`CREATE TABLE organizations (
db.exec(`
CREATE TABLE organizations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
@@ -125,12 +129,12 @@ async function ensureTablesExist() {
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":
await client.execute(
`CREATE TABLE mirror_jobs (
db.exec(`
CREATE TABLE mirror_jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
repository_id TEXT,
@@ -142,15 +146,33 @@ async function ensureTablesExist() {
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)`
);
)
`);
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.`);
} else {
console.error(`❌ Error checking table '${table}':`, error);
process.exit(1);
}
} catch (error) {
console.error(`❌ Error checking table '${table}':`, error);
process.exit(1);
}
}
}
@@ -168,7 +190,7 @@ async function checkDatabase() {
);
console.warn("This file should be in the data directory.");
console.warn(
'Run "pnpm manage-db fix" to fix this issue or "pnpm cleanup-db" to remove it.'
'Run "bun run manage-db fix" to fix this issue or "bun run cleanup-db" to remove it.'
);
}
@@ -180,10 +202,11 @@ async function checkDatabase() {
// Check for users
try {
const userCountResult = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
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.");
@@ -197,10 +220,8 @@ async function checkDatabase() {
}
// Check for configurations
const configCountResult = await client.execute(
`SELECT COUNT(*) as count FROM configs`
);
const configCount = configCountResult.rows[0].count;
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.");
@@ -215,12 +236,12 @@ async function checkDatabase() {
} catch (error) {
console.error("❌ Error connecting to the database:", error);
console.warn(
'The database file might be corrupted. Consider running "pnpm manage-db init" to recreate it.'
'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 "pnpm manage-db init" to create it.');
console.warn('Run "bun run manage-db init" to create it.');
}
}
@@ -235,15 +256,16 @@ async function initializeDatabase() {
if (fs.existsSync(dataDbFile)) {
console.log("⚠️ Database already exists at data/gitea-mirror.db");
console.log(
'If you want to recreate the database, run "pnpm cleanup-db" first.'
'If you want to recreate the database, run "bun run cleanup-db" first.'
);
console.log(
'Or use "pnpm manage-db reset-users" to just remove users without recreating tables.'
'Or use "bun run manage-db reset-users" to just remove users without recreating tables.'
);
// Check if we can connect to it
try {
await client.execute(`SELECT COUNT(*) as count FROM users`);
const db = new Database(dbPath);
db.query(`SELECT COUNT(*) as count FROM users`).get();
console.log("✅ Database is valid and accessible.");
return;
} catch (error) {
@@ -257,135 +279,136 @@ async function initializeDatabase() {
console.log(`Initializing database at ${dbPath}...`);
try {
const db = new Database(dbPath);
// Create tables if they don't exist
await client.execute(
`CREATE TABLE IF NOT EXISTS users (
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
)`
);
)
`);
// NOTE: We no longer create a default admin user - user will create one via signup page
await client.execute(
`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,
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 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)
)
`);
await client.execute(
`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 '',
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)
)
`);
is_private INTEGER NOT NULL DEFAULT 0,
is_fork INTEGER NOT NULL DEFAULT 0,
forked_from TEXT,
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)
)
`);
has_issues INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
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)
)
`);
size INTEGER NOT NULL DEFAULT 0,
has_lfs INTEGER NOT NULL DEFAULT 0,
has_submodules INTEGER NOT NULL DEFAULT 0,
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)
)
`);
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)
);
`
);
await client.execute(
`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)
);
`
);
await client.execute(
`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 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 = await client.execute(
`SELECT COUNT(*) as count FROM configs`
);
const configCount = configCountResult.rows[0].count;
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 = await client.execute(
`SELECT id FROM users LIMIT 1`
);
if (firstUserResult.rows.length > 0) {
const userId = firstUserResult.rows[0].id;
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 || "",
@@ -415,24 +438,23 @@ async function initializeDatabase() {
nextRun: null,
});
await client.execute(
`
const stmt = db.prepare(`
INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
configId,
userId,
"Default Configuration",
1,
githubConfig,
giteaConfig,
include,
exclude,
scheduleConfig,
Date.now(),
Date.now(),
]
`);
stmt.run(
configId,
userId,
"Default Configuration",
1,
githubConfig,
giteaConfig,
include,
exclude,
scheduleConfig,
Date.now(),
Date.now()
);
}
}
@@ -452,21 +474,20 @@ async function resetUsers() {
try {
// Check if the database exists
const dbFilePath = dbPath.replace("file:", "");
const doesDbExist = fs.existsSync(dbFilePath);
const doesDbExist = fs.existsSync(dbPath);
if (!doesDbExist) {
console.log(
"❌ Database file doesn't exist. Run 'pnpm manage-db init' first to create it."
"❌ 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 = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
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.");
@@ -474,63 +495,43 @@ async function resetUsers() {
}
// Delete all users
await client.execute(`DELETE FROM users`);
db.exec(`DELETE FROM users`);
console.log(`✅ Deleted ${userCount} users from the database.`);
// Check dependent configurations that need to be removed
const configCount = await client.execute(
`SELECT COUNT(*) as count FROM configs`
);
const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get();
const configCount = configCountResult?.count || 0;
if (
configCount.rows &&
configCount.rows[0] &&
Number(configCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM configs`);
console.log(`✅ Deleted ${configCount.rows[0].count} configurations.`);
if (configCount > 0) {
db.exec(`DELETE FROM configs`);
console.log(`✅ Deleted ${configCount} configurations.`);
}
// Check for dependent repositories
const repoCount = await client.execute(
`SELECT COUNT(*) as count FROM repositories`
);
const repoCountResult = db.query(`SELECT COUNT(*) as count FROM repositories`).get();
const repoCount = repoCountResult?.count || 0;
if (
repoCount.rows &&
repoCount.rows[0] &&
Number(repoCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM repositories`);
console.log(`✅ Deleted ${repoCount.rows[0].count} repositories.`);
if (repoCount > 0) {
db.exec(`DELETE FROM repositories`);
console.log(`✅ Deleted ${repoCount} repositories.`);
}
// Check for dependent organizations
const orgCount = await client.execute(
`SELECT COUNT(*) as count FROM organizations`
);
const orgCountResult = db.query(`SELECT COUNT(*) as count FROM organizations`).get();
const orgCount = orgCountResult?.count || 0;
if (
orgCount.rows &&
orgCount.rows[0] &&
Number(orgCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM organizations`);
console.log(`✅ Deleted ${orgCount.rows[0].count} organizations.`);
if (orgCount > 0) {
db.exec(`DELETE FROM organizations`);
console.log(`✅ Deleted ${orgCount} organizations.`);
}
// Check for dependent mirror jobs
const jobCount = await client.execute(
`SELECT COUNT(*) as count FROM mirror_jobs`
);
const jobCountResult = db.query(`SELECT COUNT(*) as count FROM mirror_jobs`).get();
const jobCount = jobCountResult?.count || 0;
if (
jobCount.rows &&
jobCount.rows[0] &&
Number(jobCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM mirror_jobs`);
console.log(`✅ Deleted ${jobCount.rows[0].count} mirror jobs.`);
if (jobCount > 0) {
db.exec(`DELETE FROM mirror_jobs`);
console.log(`✅ Deleted ${jobCount} mirror jobs.`);
}
console.log(
@@ -629,19 +630,20 @@ async function fixDatabaseIssues() {
console.warn(
"⚠️ WARNING: Production database file not found in data directory."
);
console.warn('Run "pnpm manage-db init" to create it.');
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
await db.select().from(configs).limit(1);
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 "pnpm manage-db init" to recreate it.'
'The database file might be corrupted. Consider running "bun run manage-db init" to recreate it.'
);
}
}
@@ -692,7 +694,7 @@ Available commands:
reset-users - Remove all users and their data
auto - Automatic mode: check, fix, and initialize if needed
Usage: pnpm manage-db [command]
Usage: bun run manage-db [command]
`);
}
}

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bun
/**
* Script to mark all events as read
*/
import { db, events } from "../src/lib/db";
import { eq } from "drizzle-orm";
async function markEventsAsRead() {
try {
console.log("Marking all events as read...");
// Update all events to mark them as read
const result = await db
.update(events)
.set({ read: true })
.where(eq(events.read, false));
console.log(`Marked ${result.changes || 0} events as read`);
} catch (error) {
console.error("Error marking events as read:", error);
process.exit(1);
}
}
// Run the function
markEventsAsRead();

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from "react";
import { useEffect, useState } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { GitHubConfigForm } from "./GitHubConfigForm";
import { GiteaConfigForm } from "./GiteaConfigForm";
import { ScheduleConfigForm } from "./ScheduleConfigForm";
} from '@/components/ui/card';
import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm';
import { ScheduleConfigForm } from './ScheduleConfigForm';
import type {
ConfigApiResponse,
GiteaConfig,
@@ -16,12 +16,13 @@ import type {
SaveConfigApiRequest,
SaveConfigApiResponse,
ScheduleConfig,
} from "@/types/config";
import { Button } from "../ui/button";
import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils";
import { Copy, CopyCheck, RefreshCw } from "lucide-react";
import { toast } from "sonner";
} from '@/types/config';
import { Button } from '../ui/button';
import { useAuth } from '@/hooks/useAuth';
import { apiRequest } from '@/lib/utils';
import { Copy, CopyCheck, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
type ConfigState = {
githubConfig: GitHubConfig;
@@ -32,8 +33,8 @@ type ConfigState = {
export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({
githubConfig: {
username: "",
token: "",
username: '',
token: '',
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
@@ -41,16 +42,14 @@ export function ConfigTabs() {
preserveOrgStructure: false,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
username: "",
token: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
url: '',
username: '',
token: '',
organization: 'github-mirrors',
visibility: 'public',
starredReposOrg: 'github',
},
scheduleConfig: {
enabled: false,
interval: 3600,
@@ -58,27 +57,21 @@ export function ConfigTabs() {
});
const { user, refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [dockerCode, setDockerCode] = useState<string>("");
const [dockerCode, setDockerCode] = useState<string>('');
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
// Check if all required fields are filled to enable the Save Configuration button
const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config;
// Check GitHub required fields
const isGitHubValid = !!(
githubConfig.username?.trim() && githubConfig.token?.trim()
githubConfig.username.trim() && githubConfig.token.trim()
);
// Check Gitea required fields
const isGiteaValid = !!(
giteaConfig.url?.trim() &&
giteaConfig.username?.trim() &&
giteaConfig.token?.trim()
giteaConfig.url.trim() &&
giteaConfig.username.trim() &&
giteaConfig.token.trim()
);
return isGitHubValid && isGiteaValid;
};
@@ -86,11 +79,12 @@ export function ConfigTabs() {
const updateLastAndNextRun = () => {
const lastRun = config.scheduleConfig.lastRun
? new Date(config.scheduleConfig.lastRun)
: new Date(); // fallback to now if lastRun is null
: new Date();
const intervalInSeconds = config.scheduleConfig.interval;
const nextRun = new Date(lastRun.getTime() + intervalInSeconds * 1000);
setConfig((prev) => ({
const nextRun = new Date(
lastRun.getTime() + intervalInSeconds * 1000,
);
setConfig(prev => ({
...prev,
scheduleConfig: {
...prev.scheduleConfig,
@@ -99,37 +93,31 @@ export function ConfigTabs() {
},
}));
};
updateLastAndNextRun();
}, [config.scheduleConfig.interval]);
const handleImportGitHubData = async () => {
if (!user?.id) return;
setIsSyncing(true);
try {
if (!user?.id) return;
setIsSyncing(true);
const result = await apiRequest<{ success: boolean; message?: string }>(
`/sync?userId=${user.id}`,
{
method: "POST",
}
{ method: 'POST' },
);
if (result.success) {
toast.success(
"GitHub data imported successfully! Head to the Dashboard to start mirroring repositories."
);
} else {
toast.error(
`Failed to import GitHub data: ${result.message || "Unknown error"}`
);
}
result.success
? toast.success(
'GitHub data imported successfully! Head to the Dashboard to start mirroring repositories.',
)
: toast.error(
`Failed to import GitHub data: ${
result.message || 'Unknown error'
}`,
);
} catch (error) {
toast.error(
`Error importing GitHub data: ${
error instanceof Error ? error.message : String(error)
}`
}`,
);
} finally {
setIsSyncing(false);
@@ -137,94 +125,76 @@ export function ConfigTabs() {
};
const handleSaveConfig = async () => {
if (!user?.id) return;
const reqPayload: SaveConfigApiRequest = {
userId: user.id,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
};
try {
if (!user || !user.id) {
return;
}
const reqPyload: SaveConfigApiRequest = {
userId: user.id,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
};
const response = await fetch("/api/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqPyload),
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
await refreshUser();
setIsConfigSaved(true);
toast.success(
"Configuration saved successfully! Now import your GitHub data to begin."
'Configuration saved successfully! Now import your GitHub data to begin.',
);
} else {
toast.error(
`Failed to save configuration: ${result.message || "Unknown error"}`
`Failed to save configuration: ${result.message || 'Unknown error'}`,
);
}
} catch (error) {
toast.error(
`An error occurred while saving the configuration: ${
error instanceof Error ? error.message : String(error)
}`
}`,
);
}
};
useEffect(() => {
if (!user) return;
const fetchConfig = async () => {
setIsLoading(true);
try {
if (!user) {
return;
}
setIsLoading(true);
const response = await apiRequest<ConfigApiResponse>(
`/config?userId=${user.id}`,
{
method: "GET",
}
{ method: 'GET' },
);
// Check if we have a valid config response
if (response && !response.error) {
setConfig({
githubConfig: response.githubConfig || config.githubConfig,
giteaConfig: response.giteaConfig || config.giteaConfig,
scheduleConfig: response.scheduleConfig || config.scheduleConfig,
githubConfig:
response.githubConfig || config.githubConfig,
giteaConfig:
response.giteaConfig || config.giteaConfig,
scheduleConfig:
response.scheduleConfig || config.scheduleConfig,
});
// If we got a valid config from the server, it means it was previously saved
if (response.id) {
setIsConfigSaved(true);
}
if (response.id) setIsConfigSaved(true);
}
// If there's an error, we'll just use the default config defined in state
setIsLoading(false);
} catch (error) {
// Don't show error for first-time users, just use the default config
console.warn("Could not fetch configuration, using defaults:", error);
} finally {
setIsLoading(false);
console.warn(
'Could not fetch configuration, using defaults:',
error,
);
}
setIsLoading(false);
};
fetchConfig();
}, [user]);
useEffect(() => {
const generateDockerCode = () => {
return `services:
const generateDockerCode = () => `
services:
gitea-mirror:
image: arunavo4/gitea-mirror:latest
restart: unless-stopped
@@ -243,27 +213,93 @@ export function ConfigTabs() {
- GITEA_ORGANIZATION=${config.giteaConfig.organization}
- GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility}
- DELAY=${config.scheduleConfig.interval}`;
};
const code = generateDockerCode();
setDockerCode(code);
setDockerCode(generateDockerCode());
}, [config]);
const handleCopyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(
() => {
setIsCopied(true);
toast.success("Docker configuration copied to clipboard!");
toast.success('Docker configuration copied to clipboard!');
setTimeout(() => setIsCopied(false), 2000);
},
(err) => {
toast.error("Could not copy text to clipboard.");
}
() => toast.error('Could not copy text to clipboard.'),
);
};
function ConfigCardSkeleton() {
return (
<Card>
<CardHeader className="flex-row justify-between">
<div className="flex flex-col gap-y-1.5 m-0">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="flex gap-x-4">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-10 w-36" />
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
<div className="border rounded-lg p-4">
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-32" />
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function DockerConfigSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="relative">
<Skeleton className="h-8 w-8 absolute top-4 right-10 rounded-md" />
<Skeleton className="h-48 w-full rounded-md" />
</CardContent>
</Card>
);
}
return isLoading ? (
<div>loading...</div>
<div className="flex flex-col gap-y-6">
<ConfigCardSkeleton />
<DockerConfigSkeleton />
</div>
) : (
<div className="flex flex-col gap-y-6">
<Card>
@@ -275,17 +311,16 @@ export function ConfigTabs() {
mirroring.
</CardDescription>
</div>
<div className="flex gap-x-4">
<Button
onClick={handleImportGitHubData}
disabled={isSyncing || !isConfigSaved}
title={
!isConfigSaved
? "Save configuration first"
? 'Save configuration first'
: isSyncing
? "Import in progress"
: "Import GitHub Data"
? 'Import in progress'
: 'Import GitHub Data'
}
>
{isSyncing ? (
@@ -305,66 +340,57 @@ export function ConfigTabs() {
disabled={!isConfigFormValid()}
title={
!isConfigFormValid()
? "Please fill all required fields"
: "Save Configuration"
? 'Please fill all required fields'
: 'Save Configuration'
}
>
Save Configuration
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<GitHubConfigForm
config={config.githubConfig}
setConfig={(update) =>
setConfig((prev) => ({
setConfig={update =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === "function"
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
/>
<GiteaConfigForm
config={config?.giteaConfig ?? ({} as GiteaConfig)}
setConfig={(update) =>
setConfig((prev) => ({
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === "function"
typeof update === 'function'
? update(prev.giteaConfig)
: update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
scheduleConfig:
prev?.scheduleConfig ?? ({} as ScheduleConfig),
}))
}
/>
</div>
<ScheduleConfigForm
config={config?.scheduleConfig ?? ({} as ScheduleConfig)}
setConfig={(update) =>
setConfig((prev) => ({
config={config.scheduleConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
scheduleConfig:
typeof update === "function"
typeof update === 'function'
? update(prev.scheduleConfig)
: update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
giteaConfig: prev?.giteaConfig ?? ({} as GiteaConfig),
}))
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
@@ -372,7 +398,6 @@ export function ConfigTabs() {
Equivalent Docker configuration for your current settings.
</CardDescription>
</CardHeader>
<CardContent className="relative">
<Button
variant="outline"
@@ -386,7 +411,6 @@ export function ConfigTabs() {
<Copy className="text-muted-foreground" />
)}
</Button>
<pre className="bg-muted p-4 rounded-md overflow-auto text-sm">
{dockerCode}
</pre>

View File

@@ -9,6 +9,8 @@ import { apiRequest } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { useSSE } from "@/hooks/useSEE";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function Dashboard() {
const { user } = useAuth();
@@ -59,8 +61,6 @@ export function Dashboard() {
return;
}
setIsLoading(false);
const response = await apiRequest<DashboardApiResponse>(
`/dashboard?userId=${user.id}`,
{
@@ -93,8 +93,61 @@ export function Dashboard() {
fetchDashboardData();
}, [user]);
// Status Card Skeleton component
function StatusCardSkeleton() {
return (
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">
<Skeleton className="h-4 w-24" />
</CardTitle>
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16 mb-1" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
);
}
return isLoading || !connected ? (
<div>loading...</div>
<div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatusCardSkeleton />
<StatusCardSkeleton />
<StatusCardSkeleton />
<StatusCardSkeleton />
</div>
<div className="flex gap-x-6 items-start">
{/* Repository List Skeleton */}
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
{/* Recent Activity Skeleton */}
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">

View File

@@ -4,9 +4,10 @@ import { SiGitea } from "react-icons/si";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
export function Header() {
const { user, logout } = useAuth();
const { user, logout, isLoading } = useAuth();
const handleLogout = async () => {
toast.success("Logged out successfully");
@@ -15,6 +16,16 @@ export function Header() {
logout();
};
// Auth buttons skeleton loader
function AuthButtonsSkeleton() {
return (
<>
<Skeleton className="h-10 w-10 rounded-full" /> {/* Avatar placeholder */}
<Skeleton className="h-10 w-24" /> {/* Button placeholder */}
</>
);
}
return (
<header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-6">
@@ -25,7 +36,10 @@ export function Header() {
<div className="flex items-center gap-4">
<ModeToggle />
{user ? (
{isLoading ? (
<AuthButtonsSkeleton />
) : user ? (
<>
<Avatar>
<AvatarImage src="" alt="@shadcn" />

View File

@@ -2,7 +2,7 @@
title: "Architecture"
description: "Comprehensive overview of the Gitea Mirror application architecture."
order: 1
updatedDate: 2023-10-15
updatedDate: 2025-05-22
---
<div class="mb-6">
@@ -21,17 +21,18 @@ The application is built using:
- <span class="font-semibold text-foreground">Astro</span>: Web framework for the frontend
- <span class="font-semibold text-foreground">React</span>: Component library for interactive UI elements
- <span class="font-semibold text-foreground">Shadcn UI</span>: UI component library built on Tailwind CSS
- <span class="font-semibold text-foreground">SQLite</span>: Database for storing configuration and state
- <span class="font-semibold text-foreground">Node.js</span>: Runtime environment for the backend
- <span class="font-semibold text-foreground">SQLite</span>: Database for storing configuration, state, and events
- <span class="font-semibold text-foreground">Bun</span>: Runtime environment for the backend
- <span class="font-semibold text-foreground">Drizzle ORM</span>: Type-safe ORM for database interactions
## Architecture Diagram
```mermaid
graph TD
subgraph "Gitea Mirror"
Frontend["Frontend<br/>(Astro)"]
Backend["Backend<br/>(Node.js)"]
Database["Database<br/>(SQLite)"]
Frontend["Frontend<br/>(Astro + React)"]
Backend["Backend<br/>(Bun)"]
Database["Database<br/>(SQLite + Drizzle)"]
Frontend <--> Backend
Backend <--> Database
@@ -60,9 +61,9 @@ Key frontend components:
- **Configuration**: Settings for GitHub and Gitea connections
- **Activity Log**: Detailed log of mirroring operations
### Backend (Node.js)
### Backend (Bun)
The backend is built with Node.js and provides API endpoints for the frontend to interact with. It handles:
The backend is built with Bun and provides API endpoints for the frontend to interact with. It handles:
- Authentication and user management
- GitHub API integration
@@ -70,14 +71,15 @@ The backend is built with Node.js and provides API endpoints for the frontend to
- Mirroring operations
- Database interactions
### Database (SQLite)
### Database (SQLite + Drizzle ORM)
SQLite is used for data persistence, storing:
SQLite with Bun's native SQLite driver is used for data persistence, with Drizzle ORM providing type-safe database interactions. The database stores:
- User accounts and authentication data
- GitHub and Gitea configuration
- Repository and organization information
- Mirroring job history and status
- Event notifications and their read status
## Data Flow
@@ -93,11 +95,30 @@ SQLite is used for data persistence, storing:
gitea-mirror/
├── src/ # Source code
│ ├── components/ # React components
│ ├── content/ # Documentation and content
│ ├── layouts/ # Astro layout components
│ ├── lib/ # Utility functions and database
│ ├── pages/ # Astro pages and API routes
│ └── styles/ # CSS and Tailwind styles
├── public/ # Static assets
├── data/ # Database and persistent data
── docker/ # Docker configuration
── docker/ # Docker configuration
└── scripts/ # Utility scripts for deployment and maintenance
├── gitea-mirror-lxc-proxmox.sh # Proxmox LXC deployment script
├── gitea-mirror-lxc-local.sh # Local LXC deployment script
└── manage-db.ts # Database management tool
```
## Deployment Options
Gitea Mirror supports multiple deployment options:
1. **Docker**: Run as a containerized application using Docker and docker-compose
2. **LXC Containers**: Deploy in Linux Containers (LXC) on Proxmox VE or local workstations
3. **Native**: Run directly on the host system using Bun runtime
Each deployment method has its own advantages:
- **Docker**: Isolation, easy updates, consistent environment
- **LXC**: Lightweight virtualization, better performance than Docker, system-level isolation
- **Native**: Best performance, direct access to system resources

View File

@@ -2,7 +2,7 @@
title: "Configuration"
description: "Guide to configuring Gitea Mirror for your environment."
order: 2
updatedDate: 2023-10-15
updatedDate: 2025-05-22
---
<div class="mb-6">
@@ -23,11 +23,11 @@ The following environment variables can be used to configure Gitea Mirror:
| Variable | Description | Default Value | Example |
|----------|-------------|---------------|---------|
| `NODE_ENV` | Node environment (development, production, test) | `development` | `production` |
| `DATABASE_URL` | SQLite database URL | `sqlite://data/gitea-mirror.db` | `sqlite://path/to/your/database.db` |
| `NODE_ENV` | Runtime environment (development, production, test) | `development` | `production` |
| `DATABASE_URL` | SQLite database URL | `file:data/gitea-mirror.db` | `file:path/to/your/database.db` |
| `JWT_SECRET` | Secret key for JWT authentication | `your-secret-key-change-this-in-production` | `your-secure-random-string` |
| `HOST` | Server host | `localhost` | `0.0.0.0` |
| `PORT` | Server port | `3000` | `8080` |
| `PORT` | Server port | `4321` | `8080` |
### Important Security Note
@@ -118,3 +118,58 @@ Example patterns:
- `*` - All repositories
- `org-name/*` - All repositories in a specific organization
- `username/repo-name` - A specific repository
### Database Management
Gitea Mirror includes several database management tools that can be run from the command line:
```bash
# Initialize the database (only if it doesn't exist)
bun run init-db
# Check database status
bun run check-db
# Fix database location issues
bun run fix-db
# Reset all users (for testing signup flow)
bun run reset-users
# Remove database files completely
bun run cleanup-db
```
### Event Management
Events in Gitea Mirror (such as repository mirroring operations) are stored in the SQLite database. You can manage these events using the following scripts:
```bash
# View all events in the database
bun scripts/check-events.ts
# Clean up old events (default: older than 7 days)
bun scripts/cleanup-events.ts
# Mark all events as read
bun scripts/mark-events-read.ts
```
### Health Check Endpoint
Gitea Mirror includes a built-in health check endpoint at `/api/health` that provides:
- System status and uptime
- Database connectivity check
- Memory usage statistics
- Environment information
You can use this endpoint for monitoring your deployment:
```bash
# Basic check (returns 200 OK if healthy)
curl -I http://your-server:port/api/health
# Detailed health information (JSON)
curl http://your-server:port/api/health
```

View File

@@ -2,7 +2,7 @@
title: "Quick Start Guide"
description: "Get started with Gitea Mirror quickly."
order: 3
updatedDate: 2023-10-15
updatedDate: 2025-05-22
---
<div class="mb-6">
@@ -16,13 +16,16 @@ Before you begin, make sure you have:
1. <span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
2. <span class="font-semibold text-foreground">A Gitea instance with an access token</span>
3. <span class="font-semibold text-foreground">Docker and docker-compose (recommended) or Node.js 18+ installed</span>
3. <span class="font-semibold text-foreground">One of the following:</span>
- Docker and docker-compose (for Docker deployment)
- Bun 1.2.9+ (for native deployment)
- Proxmox VE or LXD (for LXC container deployment)
## Installation Options
Choose the installation method that works best for your environment.
### Using Docker (Recommended)
### Using Docker (Recommended for most users)
Docker provides the easiest way to get started with minimal configuration.
@@ -39,7 +42,7 @@ Docker provides the easiest way to get started with minimal configuration.
3. Access the application at [http://localhost:4321](http://localhost:4321)
### Manual Installation
### Using Bun (Native Installation)
If you prefer to run the application directly on your system:
@@ -51,7 +54,7 @@ If you prefer to run the application directly on your system:
2. Run the quick setup script:
```bash
pnpm setup
bun run setup
```
This installs dependencies and initializes the database.
@@ -59,17 +62,60 @@ If you prefer to run the application directly on your system:
**Development Mode:**
```bash
pnpm dev
bun run dev
```
Note: For Bun-specific features, use:
```bash
bunx --bun astro dev
```
**Production Mode:**
```bash
pnpm build
pnpm start
bun run build
bun run start
```
4. Access the application at [http://localhost:4321](http://localhost:4321)
### Using LXC Containers (Recommended for server deployments)
#### Proxmox VE (Online Installation)
For deploying on a Proxmox VE host with internet access:
```bash
# Optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh)"
```
This script:
- Creates a privileged LXC container
- Installs Bun and dependencies
- Clones and builds the application
- Sets up a systemd service
#### Local LXD (Offline-friendly Installation)
For testing on a local workstation or in environments without internet access:
1. Clone the repository locally:
```bash
git clone https://github.com/arunavo4/gitea-mirror.git
```
2. Download the Bun installer once:
```bash
curl -L -o /tmp/bun-linux-x64.zip https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip
```
3. Run the local LXC installer:
```bash
sudo LOCAL_REPO_DIR=~/path/to/gitea-mirror ./gitea-mirror/scripts/gitea-mirror-lxc-local.sh
```
For more details on LXC deployment, see the [LXC Container Deployment Guide](https://github.com/arunavo4/gitea-mirror/blob/main/scripts/README-lxc.md).
## Initial Configuration
Follow these steps to configure Gitea Mirror for first use:
@@ -116,7 +162,12 @@ If you encounter any issues:
- Check the Activity Log for detailed error messages
- Verify your GitHub and Gitea tokens have the correct permissions
- Ensure your Gitea instance is accessible from the machine running Gitea Mirror
- For Docker installations, check container logs with `docker logs gitea-mirror`
- Check logs based on your deployment method:
- Docker: `docker logs gitea-mirror`
- Native: Check the terminal output or system logs
- LXC: `systemctl status gitea-mirror` or `journalctl -u gitea-mirror -f`
- Use the health check endpoint to verify system status: `curl http://your-server:4321/api/health`
- For database issues, try the database management tools: `bun run check-db` or `bun run fix-db`
## Next Steps
@@ -125,3 +176,7 @@ After your initial setup:
- Explore the dashboard for an overview of your mirroring status
- Set up automatic mirroring schedules for hands-off operation
- Configure organization mirroring for team repositories
- Check out the [Configuration Guide](/configuration) for advanced settings
- Review the [Architecture Documentation](/architecture) to understand the system
- For server deployments, set up monitoring using the health check endpoint
- Consider setting up a cron job to clean up old events: `bun scripts/cleanup-events.ts`

View File

@@ -1,34 +1,61 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useState, useRef, useCallback } from "react";
import type { MirrorJob } from "@/lib/db/schema";
interface UseSSEOptions {
userId?: string;
onMessage: (data: MirrorJob) => void;
maxReconnectAttempts?: number;
reconnectDelay?: number;
}
export const useSSE = ({ userId, onMessage }: UseSSEOptions) => {
export const useSSE = ({
userId,
onMessage,
maxReconnectAttempts = 5,
reconnectDelay = 3000
}: UseSSEOptions) => {
const [connected, setConnected] = useState<boolean>(false);
const [reconnectCount, setReconnectCount] = useState<number>(0);
const onMessageRef = useRef(onMessage);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
// Update the ref when onMessage changes
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
// Create a stable connect function that can be called for reconnection
const connect = useCallback(() => {
if (!userId) return;
// Clean up any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// Clear any pending reconnect timeout
if (reconnectTimeoutRef.current) {
window.clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Create new EventSource connection
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
eventSourceRef.current = eventSource;
const handleMessage = (event: MessageEvent) => {
try {
// Check if this is an error message from our server
if (event.data.startsWith('{"error":')) {
console.warn("SSE server error:", event.data);
return;
}
const parsedMessage: MirrorJob = JSON.parse(event.data);
// console.log("Received new log:", parsedMessage);
onMessageRef.current(parsedMessage); // Use ref instead of prop directly
onMessageRef.current(parsedMessage);
} catch (error) {
console.error("Error parsing message:", error);
console.error("Error parsing SSE message:", error);
}
};
@@ -36,19 +63,50 @@ export const useSSE = ({ userId, onMessage }: UseSSEOptions) => {
eventSource.onopen = () => {
setConnected(true);
setReconnectCount(0); // Reset reconnect counter on successful connection
console.log(`Connected to SSE for user: ${userId}`);
};
eventSource.onerror = () => {
console.error("SSE connection error");
eventSource.onerror = (error) => {
console.error("SSE connection error:", error);
setConnected(false);
eventSource.close();
};
eventSourceRef.current = null;
return () => {
eventSource.close();
// Attempt to reconnect if we haven't exceeded max attempts
if (reconnectCount < maxReconnectAttempts) {
const nextReconnectDelay = Math.min(reconnectDelay * Math.pow(1.5, reconnectCount), 30000);
console.log(`Attempting to reconnect in ${nextReconnectDelay}ms (attempt ${reconnectCount + 1}/${maxReconnectAttempts})`);
reconnectTimeoutRef.current = window.setTimeout(() => {
setReconnectCount(prev => prev + 1);
connect();
}, nextReconnectDelay);
} else {
console.error(`Failed to reconnect after ${maxReconnectAttempts} attempts`);
}
};
}, [userId]); // Only depends on userId now
}, [userId, maxReconnectAttempts, reconnectDelay, reconnectCount]);
// Set up the connection
useEffect(() => {
if (!userId) return;
connect();
// Cleanup function
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
window.clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
}, [userId, connect]);
return { connected };
};

View File

@@ -88,3 +88,80 @@ export const giteaApi = {
body: JSON.stringify({ url, token }),
}),
};
// Health API
export interface HealthResponse {
status: "ok" | "error";
timestamp: string;
version: string;
database: {
connected: boolean;
message: string;
};
system: {
uptime: {
startTime: string;
uptimeMs: number;
formatted: string;
};
memory: {
rss: string;
heapTotal: string;
heapUsed: string;
external: string;
systemTotal: string;
systemFree: string;
};
os: {
platform: string;
version: string;
arch: string;
};
env: string;
};
error?: string;
}
export const healthApi = {
check: async (): Promise<HealthResponse> => {
try {
const response = await fetch(`${API_BASE}/health`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({
status: "error",
error: "Failed to parse error response",
}));
return {
...errorData,
status: "error",
timestamp: new Date().toISOString(),
} as HealthResponse;
}
return await response.json();
} catch (error) {
return {
status: "error",
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : "Unknown error checking health",
version: "unknown",
database: { connected: false, message: "Failed to connect to API" },
system: {
uptime: { startTime: "", uptimeMs: 0, formatted: "N/A" },
memory: {
rss: "N/A",
heapTotal: "N/A",
heapUsed: "N/A",
external: "N/A",
systemTotal: "N/A",
systemFree: "N/A",
},
os: { platform: "", version: "", arch: "" },
env: "",
},
};
}
},
};

View File

@@ -4,7 +4,7 @@
// Environment variables
export const ENV = {
// Node environment (development, production, test)
// Runtime environment (development, production, test)
NODE_ENV: process.env.NODE_ENV || "development",
// Database URL - use SQLite by default

View File

@@ -1,21 +1,56 @@
import { z } from "zod";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
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";
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
const dbUrl =
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Create a client connection to the database
export const client = createClient({ url: dbUrl });
const dbPath = path.join(dataDir, "gitea-mirror.db");
// Create a drizzle instance
export const db = drizzle(client);
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
}
// 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");
} catch (error) {
console.error("Error opening database:", error);
throw error;
}
// 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", {
@@ -31,6 +66,18 @@ export const users = sqliteTable("users", {
.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;

View File

@@ -140,3 +140,15 @@ export const organizationSchema = z.object({
});
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),
payload: z.any(),
read: z.boolean().default(false),
createdAt: z.date().default(() => new Date()),
});
export type Event = z.infer<typeof eventSchema>;

161
src/lib/events.ts Normal file
View File

@@ -0,0 +1,161 @@
import { v4 as uuidv4 } from "uuid";
import { db, events } from "./db";
import { eq, and, gt, lt } from "drizzle-orm";
/**
* Publishes an event to a specific channel for a user
* This replaces Redis pub/sub with SQLite storage
*/
export async function publishEvent({
userId,
channel,
payload,
}: {
userId: string;
channel: string;
payload: any;
}): Promise<string> {
try {
const eventId = uuidv4();
console.log(`Publishing event to channel ${channel} for user ${userId}`);
// Insert the event into the SQLite database
await db.insert(events).values({
id: eventId,
userId,
channel,
payload: JSON.stringify(payload),
createdAt: new Date(),
});
console.log(`Event published successfully with ID ${eventId}`);
return eventId;
} catch (error) {
console.error("Error publishing event:", error);
throw new Error("Failed to publish event");
}
}
/**
* Gets new events for a specific user and channel
* This replaces Redis subscribe with SQLite polling
*/
export async function getNewEvents({
userId,
channel,
lastEventTime,
}: {
userId: string;
channel: string;
lastEventTime?: Date;
}): Promise<any[]> {
try {
console.log(`Getting new events for user ${userId} in channel ${channel}`);
if (lastEventTime) {
console.log(`Looking for events after ${lastEventTime.toISOString()}`);
}
// Build the query
let query = db
.select()
.from(events)
.where(
and(
eq(events.userId, userId),
eq(events.channel, channel),
eq(events.read, false)
)
)
.orderBy(events.createdAt);
// Add time filter if provided
if (lastEventTime) {
query = query.where(gt(events.createdAt, lastEventTime));
}
// Execute the query
const newEvents = await query;
console.log(`Found ${newEvents.length} new events`);
// Mark events as read
if (newEvents.length > 0) {
console.log(`Marking ${newEvents.length} events as read`);
await db
.update(events)
.set({ read: true })
.where(
and(
eq(events.userId, userId),
eq(events.channel, channel),
eq(events.read, false)
)
);
}
// Parse the payloads
return newEvents.map(event => ({
...event,
payload: JSON.parse(event.payload as string),
}));
} catch (error) {
console.error("Error getting new events:", error);
return [];
}
}
/**
* Cleans up old events to prevent the database from growing too large
* Should be called periodically (e.g., daily via a cron job)
*
* @param maxAgeInDays Number of days to keep events (default: 7)
* @param cleanupUnreadAfterDays Number of days after which to clean up unread events (default: 2x maxAgeInDays)
* @returns Object containing the number of read and unread events deleted
*/
export async function cleanupOldEvents(
maxAgeInDays: number = 7,
cleanupUnreadAfterDays?: number
): Promise<{ readEventsDeleted: number; unreadEventsDeleted: number }> {
try {
console.log(`Cleaning up events older than ${maxAgeInDays} days...`);
// Calculate the cutoff date for read events
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - maxAgeInDays);
// Delete read events older than the cutoff date
const readResult = await db
.delete(events)
.where(
and(
eq(events.read, true),
lt(events.createdAt, cutoffDate)
)
);
const readEventsDeleted = readResult.changes || 0;
console.log(`Deleted ${readEventsDeleted} read events`);
// Calculate the cutoff date for unread events (default to 2x the retention period)
const unreadCutoffDate = new Date();
const unreadMaxAge = cleanupUnreadAfterDays || (maxAgeInDays * 2);
unreadCutoffDate.setDate(unreadCutoffDate.getDate() - unreadMaxAge);
// Delete unread events that are significantly older
const unreadResult = await db
.delete(events)
.where(
and(
eq(events.read, false),
lt(events.createdAt, unreadCutoffDate)
)
);
const unreadEventsDeleted = unreadResult.changes || 0;
console.log(`Deleted ${unreadEventsDeleted} unread events`);
return { readEventsDeleted, unreadEventsDeleted };
} catch (error) {
console.error("Error cleaning up old events:", error);
return { readEventsDeleted: 0, unreadEventsDeleted: 0 };
}
}

View File

@@ -1,7 +1,7 @@
import type { RepoStatus } from "@/types/Repository";
import { db, mirrorJobs } from "./db";
import { v4 as uuidv4 } from "uuid";
import { redisPublisher } from "./redis";
import { publishEvent } from "./events";
export async function createMirrorJob({
userId,
@@ -40,10 +40,16 @@ export async function createMirrorJob({
};
try {
// Insert the job into the database
await db.insert(mirrorJobs).values(job);
// Publish the event using SQLite instead of Redis
const channel = `mirror-status:${userId}`;
await redisPublisher.publish(channel, JSON.stringify(job));
await publishEvent({
userId,
channel,
payload: job
});
return jobId;
} catch (error) {

View File

@@ -1,30 +0,0 @@
import Redis from "ioredis";
// Connect to Redis using REDIS_URL environment variable or default to redis://redis:6379
// This ensures we have a fallback URL when running with Docker Compose
const redisUrl = process.env.REDIS_URL ?? 'redis://redis:6379';
console.log(`Connecting to Redis at: ${redisUrl}`);
// Configure Redis client with connection options
const redisOptions = {
retryStrategy: (times: number) => {
// Retry with exponential backoff up to 30 seconds
const delay = Math.min(times * 100, 3000);
console.log(`Redis connection attempt ${times} failed. Retrying in ${delay}ms...`);
return delay;
},
maxRetriesPerRequest: 5,
enableReadyCheck: true,
connectTimeout: 10000,
};
export const redis = new Redis(redisUrl, redisOptions);
export const redisPublisher = new Redis(redisUrl, redisOptions); // For publishing
export const redisSubscriber = new Redis(redisUrl, redisOptions); // For subscribing
// Log connection events
redis.on('connect', () => console.log('Redis client connected'));
redis.on('error', (err) => console.error('Redis client error:', err));
redis.on('ready', () => console.log('Redis client ready'));
redis.on('reconnecting', () => console.log('Redis client reconnecting...'));

126
src/pages/api/health.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { APIRoute } from "astro";
import { jsonResponse } from "@/lib/utils";
import { db } from "@/lib/db";
import { ENV } from "@/lib/config";
import os from "os";
// Track when the server started
const serverStartTime = new Date();
export const GET: APIRoute = async () => {
try {
// Check database connection by running a simple query
const dbStatus = await checkDatabaseConnection();
// Get system information
const systemInfo = {
uptime: getUptime(),
memory: getMemoryUsage(),
os: {
platform: os.platform(),
version: os.version(),
arch: os.arch(),
},
env: ENV.NODE_ENV,
};
// Build response
const healthData = {
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "unknown",
database: dbStatus,
system: systemInfo,
};
return jsonResponse({
data: healthData,
status: 200,
});
} catch (error) {
console.error("Health check failed:", error);
return jsonResponse({
data: {
status: "error",
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : "Unknown error",
},
status: 503, // Service Unavailable
});
}
};
/**
* Check database connection by running a simple query
*/
async function checkDatabaseConnection() {
try {
// Run a simple query to check if the database is accessible
const result = await db.select({ test: sql`1` }).from(sql`sqlite_master`).limit(1);
return {
connected: true,
message: "Database connection successful",
};
} catch (error) {
console.error("Database connection check failed:", error);
return {
connected: false,
message: error instanceof Error ? error.message : "Database connection failed",
};
}
}
/**
* Get server uptime information
*/
function getUptime() {
const now = new Date();
const uptimeMs = now.getTime() - serverStartTime.getTime();
// Convert to human-readable format
const seconds = Math.floor(uptimeMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
return {
startTime: serverStartTime.toISOString(),
uptimeMs,
formatted: `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`,
};
}
/**
* Get memory usage information
*/
function getMemoryUsage() {
const memoryUsage = process.memoryUsage();
return {
rss: formatBytes(memoryUsage.rss),
heapTotal: formatBytes(memoryUsage.heapTotal),
heapUsed: formatBytes(memoryUsage.heapUsed),
external: formatBytes(memoryUsage.external),
systemTotal: formatBytes(os.totalmem()),
systemFree: formatBytes(os.freemem()),
};
}
/**
* Format bytes to human-readable format
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Import sql tag for raw SQL queries
import { sql } from "drizzle-orm";

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { redisSubscriber } from "@/lib/redis";
import { getNewEvents } from "@/lib/events";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
@@ -11,50 +11,89 @@ export const GET: APIRoute = async ({ request }) => {
const channel = `mirror-status:${userId}`;
let isClosed = false;
const POLL_INTERVAL = 5000; // Poll every 5 seconds (reduced from 2 seconds for low-traffic usage)
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let lastEventTime: Date | undefined = undefined;
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
const handleMessage = (ch: string, message: string) => {
if (isClosed || ch !== channel) return;
// Function to send a message to the client
const sendMessage = (message: string) => {
if (isClosed) return;
try {
controller.enqueue(encoder.encode(`data: ${message}\n\n`));
controller.enqueue(encoder.encode(message));
} catch (err) {
console.error("Stream enqueue error:", err);
}
};
redisSubscriber.subscribe(channel, (err) => {
if (err) {
isClosed = true;
controller.error(err);
// Function to poll for new events
const pollForEvents = async () => {
if (isClosed) return;
try {
console.log(`Polling for events for user ${userId} in channel ${channel}`);
// Get new events from SQLite
const events = await getNewEvents({
userId,
channel,
lastEventTime,
});
console.log(`Found ${events.length} new events`);
// Send events to client
if (events.length > 0) {
// Update last event time
lastEventTime = events[events.length - 1].createdAt;
// Send each event to the client
for (const event of events) {
console.log(`Sending event: ${JSON.stringify(event.payload)}`);
sendMessage(`data: ${JSON.stringify(event.payload)}\n\n`);
}
}
} catch (err) {
console.error("Error polling for events:", err);
sendMessage(`data: {"error": "Error polling for events"}\n\n`);
}
});
};
redisSubscriber.on("message", handleMessage);
// Send initial connection message
sendMessage(": connected\n\n");
try {
controller.enqueue(encoder.encode(": connected\n\n"));
} catch (err) {
console.error("Initial enqueue error:", err);
}
// Start polling for events
pollForEvents();
// Set up polling interval
pollIntervalId = setInterval(pollForEvents, POLL_INTERVAL);
// Send a heartbeat every 30 seconds to keep the connection alive
const heartbeatInterval = setInterval(() => {
if (!isClosed) {
sendMessage(": heartbeat\n\n");
} else {
clearInterval(heartbeatInterval);
}
}, 30000);
// Handle client disconnection
request.signal?.addEventListener("abort", () => {
if (!isClosed) {
isClosed = true;
redisSubscriber.off("message", handleMessage);
redisSubscriber.unsubscribe(channel);
if (pollIntervalId) {
clearInterval(pollIntervalId);
}
controller.close();
}
});
},
cancel() {
// extra safety in case cancel is triggered
if (!isClosed) {
isClosed = true;
redisSubscriber.unsubscribe(channel);
}
// Extra safety in case cancel is triggered
isClosed = true;
},
});

View File

@@ -0,0 +1,56 @@
import type { APIRoute } from "astro";
import { publishEvent } from "@/lib/events";
import { v4 as uuidv4 } from "uuid";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { userId, message, status } = body;
if (!userId || !message || !status) {
return new Response(
JSON.stringify({
error: "Missing required fields: userId, message, status",
}),
{ status: 400 }
);
}
// Create a test event
const eventData = {
id: uuidv4(),
userId,
repositoryId: uuidv4(),
repositoryName: "test-repo",
message,
status,
timestamp: new Date(),
};
// Publish the event
const channel = `mirror-status:${userId}`;
await publishEvent({
userId,
channel,
payload: eventData,
});
return new Response(
JSON.stringify({
success: true,
message: "Event published successfully",
event: eventData,
}),
{ status: 200 }
);
} catch (error) {
console.error("Error publishing test event:", error);
return new Response(
JSON.stringify({
error: "Failed to publish event",
details: error instanceof Error ? error.message : String(error),
}),
{ status: 500 }
);
}
};