From bbf420759b5ab1e3d0542cad582d71061535f7b4 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 5 Jul 2026 22:06:52 +0700 Subject: [PATCH] chore: update Dockerfile and docker-compose for improved deployment process; add deploy script --- Dockerfile | 10 +- deploy.sh | 213 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 7 +- src/env.ts | 6 +- src/routes/files.ts | 3 +- src/routes/swagger.ts | 3 +- src/routes/upload.ts | 5 +- src/utils/telegram.ts | 5 - src/utils/uploadBatcher.ts | 5 +- src/utils/zip.ts | 2 +- 10 files changed, 236 insertions(+), 23 deletions(-) create mode 100755 deploy.sh diff --git a/Dockerfile b/Dockerfile index 9222e23..9771c40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,16 +3,14 @@ FROM oven/bun:alpine AS builder WORKDIR /usr/src/app -# Install dependencies (including devDependencies for build and lint) -COPY package.json tsconfig.json biome.json ./ +# Install dependencies (devDependencies included for build) +COPY package.json tsconfig.json ./ RUN bun install -# Copy src and test directories +# Copy source COPY src ./src -COPY test ./test -# Run lint and build -RUN bun run lint +# Build (lint is run locally before deploy) RUN bun run build # Stage 2: Runner diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..835ee0b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,213 @@ +#!/bin/bash +# ─── TeleUploader Deploy Script ────────────────────────────────────────────── +# Builds the Bun app locally and deploys to the VPS via Docker. +# +# Strategy: build dist locally, ship dist + Docker context to VPS via tar pipe, +# then rebuild the Docker image and restart the container on the VPS. +# +# Prerequisites: +# - GitLab CLI (glab) with active session, OR the env vars below +# - SSH access to the VPS +# - Docker + docker compose on the VPS +# +# Usage: +# ./deploy.sh # build + deploy +# ./deploy.sh --no-build # skip build, just deploy dist +# ./deploy.sh --help # show this message +# ./deploy.sh --check # dry-run: show vars and exit +# +# Required env (or auto-fetched from GitLab CI vars via glab): +# VPS_HOST — VPS IP/hostname +# VPS_USER — SSH user (default: root) +# VPS_SSH_KEY — path/contents of SSH private key +# +# Optional: +# DEPLOY_DIR — deploy dir on VPS (default: /opt/teleuploader) +# ADMIN_PASSWORD — verify health after deploy (optional) +# ────────────────────────────────────────────────────────────────────────────── + +set -eu + +# ── Config ──────────────────────────────────────────────────────────────────── +APP_NAME="teleuploader" +GITLAB_PROJECT="superaseph%2FTeleUploader" +DEPLOY_DIR="${DEPLOY_DIR:-/opt/${APP_NAME}}" +COMPOSE_FILE="docker-compose.yml" +DOCKER_IMAGE="ghcr.io/mytheclipse/${APP_NAME}" + +# ── Parse args ──────────────────────────────────────────────────────────────── +DO_BUILD=true +DO_CHECK=false + +for arg in "$@"; do + case "$arg" in + --help|-h) + sed -n '2,/^$/ s/^# //p' "$0" + exit 0 + ;; + --no-build) DO_BUILD=false ;; + --check) DO_CHECK=true ;; + esac +done + +# ── Auto-fetch credentials from GitLab CI vars ───────────────────────────── +fetch_ci_var() { + glab api "projects/${GITLAB_PROJECT}/variables/$1" 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('value',''))" 2>/dev/null || true +} + +# ── Default credentials ───────────────────────────────────────────────────── +# Hardcoded defaults for this project. Env vars take precedence. +: "${VPS_HOST:=45.127.35.244}" +: "${VPS_USER:=root}" +: "${VPS_SSH_KEY:=${HOME}/.ssh/id_ed25519}" + +# Fallback: fetch from GitLab CI vars if defaults are empty (for CI runs) +if [ -z "${VPS_HOST:-}" ]; then VPS_HOST=$(fetch_ci_var VPS_HOST); fi +if [ -z "${VPS_USER:-}" ]; then VPS_USER=$(fetch_ci_var VPS_USERNAME); fi +if [ -z "${VPS_SSH_KEY:-}" ]; then + KEY=$(fetch_ci_var VPS_SSH_KEY) + if [ -n "$KEY" ]; then + VPS_SSH_KEY=$(mktemp) + echo "$KEY" > "$VPS_SSH_KEY" + chmod 600 "$VPS_SSH_KEY" + fi +fi + +# ── Check mode ──────────────────────────────────────────────────────────────── +if $DO_CHECK; then + echo "=== Config ===" + echo "App name: $APP_NAME" + echo "Deploy dir: $DEPLOY_DIR" + echo "Image: $DOCKER_IMAGE" + echo "" + echo "=== Credentials ===" + echo "VPS_HOST: ${VPS_HOST:-}" + echo "VPS_USER: ${VPS_USER:-}" + echo "VPS_SSH_KEY: ${VPS_SSH_KEY:+}" + echo "" + echo "=== Files to deploy ===" + for f in .env package.json bun.lock schema.sql Dockerfile docker-compose.yml dist/index.js dist/migrate.js; do + [ -e "$f" ] && echo " ✓ $f" || echo " ✗ $f (missing)" + done + exit 0 +fi + +# ── Validate ────────────────────────────────────────────────────────────────── +# (Defaults are set above — this fails only if something went wrong) +: "${VPS_HOST:?VPS_HOST resolved to empty}" +: "${VPS_USER:?VPS_USER resolved to empty}" +: "${VPS_SSH_KEY:?VPS_SSH_KEY resolved to empty}" +[ -f "$VPS_SSH_KEY" ] || die "SSH key not found at $VPS_SSH_KEY" + +SSH_DEST="${VPS_USER}@${VPS_HOST}" +SSH_OPTS="-i $VPS_SSH_KEY -o StrictHostKeyChecking=accept-new" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +vps() { ssh $SSH_OPTS "$SSH_DEST" "$@"; } +log() { echo "→ $*"; } +ok() { echo "✓ $*"; } +die() { echo "✗ $*"; exit 1; } + +# ── 1. Test SSH connection ──────────────────────────────────────────────────── +log "Testing SSH connection to ${VPS_USER}@${VPS_HOST}..." +vps "echo connected" > /dev/null 2>&1 || die "SSH connection failed" +ok "SSH connection established" + +# ── 2. Build ────────────────────────────────────────────────────────────────── +REPO_ROOT=$(cd "$(dirname "$0")" && pwd) +cd "$REPO_ROOT" + +if $DO_BUILD; then + log "Installing dependencies..." + bun install 2>&1 | tail -1 || die "bun install failed" + + log "Formatting code..." + bun run format 2>&1 | tail -3 || log "Format skipped (may be clean)" + + log "Linting..." + bun run lint 2>&1 | tail -5 || die "Lint failed" + + log "Building dist..." + bun run build 2>&1 || die "Build failed" + + # Verify dist output exists + [ -f dist/index.js ] || die "dist/index.js not found after build" + [ -f dist/migrate.js ] || die "dist/migrate.js not found after build" + ok "Build complete (dist/index.js: $(wc -c < dist/index.js | numfmt --to=iec) — dist/migrate.js: $(wc -c < dist/migrate.js | numfmt --to=iec))" +else + log "Skipping build (--no-build)" +fi + +# ── 3. Ensure remote deploy directory exists ───────────────────────────────── +log "Ensuring remote directory ${DEPLOY_DIR} exists..." +vps "mkdir -p '${DEPLOY_DIR}'" +ok "Remote directory ready" + +# ── 4. Deploy to VPS ───────────────────────────────────────────────────────── +log "Creating deploy archive..." +# Build context: everything needed for `docker compose build` on the VPS +DEPLOY_FILES=( + .env + package.json + bun.lock + schema.sql + Dockerfile + docker-compose.yml + biome.json + tsconfig.json + src + dist +) + +log "Shipping files to VPS..." +# Atomic deploy: extract into temp dir, then rename — avoids partial state +vps "rm -rf '${DEPLOY_DIR}.new' && mkdir -p '${DEPLOY_DIR}.new'" +tar czf - "${DEPLOY_FILES[@]}" | vps "tar xzf - -C '${DEPLOY_DIR}.new'" +vps "rm -rf '${DEPLOY_DIR}.old' && mv '${DEPLOY_DIR}' '${DEPLOY_DIR}.old' 2>/dev/null; mv '${DEPLOY_DIR}.new' '${DEPLOY_DIR}' && rm -rf '${DEPLOY_DIR}.old'" + +ok "Files shipped to ${DEPLOY_DIR}" + +# ── 5. Build Docker image & restart on VPS ─────────────────────────────────── +log "Building Docker image on VPS..." +vps "cd '${DEPLOY_DIR}' && docker compose build --pull 2>&1" | tail -5 || die "Docker build failed on VPS" + +log "Restarting container..." +vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate 2>&1" || die "Container restart failed" + +# ── 6. Verify container is running ──────────────────────────────────────────── +log "Waiting for container to be healthy..." +sleep 5 +CONTAINER_ID=$(vps "docker ps --filter 'name=${APP_NAME}' --format '{{.ID}}' 2>/dev/null" || true) + +if [ -n "$CONTAINER_ID" ]; then + HEALTH=$(vps "docker inspect --format='{{.State.Health.Status}}' '${CONTAINER_ID}'" 2>/dev/null || echo "no-healthcheck") + STATUS=$(vps "docker inspect --format='{{.State.Status}}' '${CONTAINER_ID}'" 2>/dev/null || echo "unknown") + log "Container status: ${STATUS} | health: ${HEALTH}" + + # Tail recent logs + vps "docker logs --tail 10 '${CONTAINER_ID}' 2>&1" || true +else + log "No container found with name '${APP_NAME}' — checking all recent..." + vps "docker ps -a --filter 'name=${APP_NAME}' 2>/dev/null" || true +fi + +# ── 7. Health check ─────────────────────────────────────────────────────────── +if [ -n "${ADMIN_PASSWORD:-}" ]; then + log "Running health check via HTTP..." + sleep 3 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${VPS_HOST}/health" 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + ok "Health check passed (HTTP ${HTTP_CODE})" + else + log "Health check returned HTTP ${HTTP_CODE} (may need a moment or TLS not set up)" + fi +fi + +# ── Cleanup temp SSH key ───────────────────────────────────────────────────── +if [[ "${VPS_SSH_KEY:-}" == /tmp/* ]]; then + rm -f "$VPS_SSH_KEY" +fi + +echo "" +echo "✓ Deploy complete — ${APP_NAME} is running on ${VPS_HOST}" diff --git a/docker-compose.yml b/docker-compose.yml index e6ff42b..9959e01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ -version: '3.8' - services: app: - image: ghcr.io/mytheclipse/teleuploader:latest + build: . container_name: teleuploader-app restart: always environment: @@ -26,6 +24,7 @@ services: read_only: true tmpfs: - /tmp:size=4g,mode=1777 + - /usr/src/app/logs:size=64m,mode=1777 deploy: resources: limits: @@ -37,7 +36,7 @@ services: healthcheck: test: - CMD-SHELL - - curl -sf http://localhost:3000/health || exit 1 + - "bun -e \"fetch('http://localhost:3000/health').then(r => r.status === 200 ? process.exit(0) : process.exit(1))\"" interval: 30s timeout: 10s retries: 3 diff --git a/src/env.ts b/src/env.ts index 7c37702..5fb686d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -52,11 +52,13 @@ const maskSecret = (value: string): string => { return `${value.slice(0, 6)}...${value.slice(-4)}`; }; -const maskDatabaseUrl = (value: string): string => value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@'); +const maskDatabaseUrl = (value: string): string => + value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@'); export const config: AppConfig = { botToken: process.env.BOT_TOKEN!, - additionalBotTokens: process.env.NODE_ENV === 'test' ? [] : parseTokens(process.env.ADDITIONAL_BOT_TOKENS), + additionalBotTokens: + process.env.NODE_ENV === 'test' ? [] : parseTokens(process.env.ADDITIONAL_BOT_TOKENS), storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10), baseUrl: process.env.BASE_URL!, databaseUrl: process.env.DATABASE_URL!, diff --git a/src/routes/files.ts b/src/routes/files.ts index d7144ec..71e0556 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -43,8 +43,7 @@ const cleanupTempFile = async (tempPath: string): Promise => { const sanitizeFilenameHeader = (fileName: string): string => fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, ''); -const fail = (status: number, error: string): Response => - Response.json({ error }, { status }); +const fail = (status: number, error: string): Response => Response.json({ error }, { status }); export const handleFileRedirect = async (req: RequestWithParams): Promise => { const public_id = req.params?.public_id; diff --git a/src/routes/swagger.ts b/src/routes/swagger.ts index 5c5c08d..9195de1 100644 --- a/src/routes/swagger.ts +++ b/src/routes/swagger.ts @@ -90,7 +90,8 @@ export const handleSwaggerJson = async (): Promise => { '/api/upload': { post: { summary: 'Upload File', - description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64. Rate-limited by IP.', + description: + 'Uploads a file to Telegram storage via multipart/form-data or JSON base64. Rate-limited by IP.', requestBody: { required: true, content: { diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 0c3f960..aefd8e9 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -231,7 +231,10 @@ const handleJSONUpload = async (req: Request): Promise => { const { base64Data, mimeType: rawMimeType } = parseBase64File(file); const estimatedSizeBytes = Math.floor((base64Data.length * 3) / 4); - if (estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES || estimatedSizeBytes > config.maxRequestBodyBytes) { + if ( + estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES || + estimatedSizeBytes > config.maxRequestBodyBytes + ) { return Response.json( { error: diff --git a/src/utils/telegram.ts b/src/utils/telegram.ts index bea4d52..e0e508f 100644 --- a/src/utils/telegram.ts +++ b/src/utils/telegram.ts @@ -5,11 +5,6 @@ import { enqueueUpload } from './telegramQueue'; const botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens])); -type FileInfoResult = { - result: unknown; - botToken: string; -}; - const bots = botTokens.map((token) => new Telegraf(token)); let nextBotIndex = 0; diff --git a/src/utils/uploadBatcher.ts b/src/utils/uploadBatcher.ts index b14ba41..b5aa18e 100644 --- a/src/utils/uploadBatcher.ts +++ b/src/utils/uploadBatcher.ts @@ -140,7 +140,10 @@ export const enqueuePreparedUpload = (item: BatchUploadItem): Promise= config.batchMaxItems || getPendingSize() >= config.batchMaxSizeBytes) { + if ( + pendingUploads.length >= config.batchMaxItems || + getPendingSize() >= config.batchMaxSizeBytes + ) { void flushUploads(); } }); diff --git a/src/utils/zip.ts b/src/utils/zip.ts index 6791972..d288976 100644 --- a/src/utils/zip.ts +++ b/src/utils/zip.ts @@ -1,8 +1,8 @@ import { once } from 'node:events'; import { createReadStream, createWriteStream } from 'node:fs'; -import { finished } from 'node:stream/promises'; import { open, stat } from 'node:fs/promises'; import { basename } from 'node:path'; +import { finished } from 'node:stream/promises'; import { nanoid } from 'nanoid'; export type ZipInputFile = {