Compare commits
140
Commits
44e887c7cd
..
v1.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
083fbe1a6f | ||
|
|
b438981231 | ||
|
|
890444fed6 | ||
|
|
3a9052d551 | ||
|
|
e610f1ca21 | ||
|
|
da7fca9fdc | ||
|
|
2d20365d56 | ||
|
|
bd4cf8b285 | ||
|
|
7d5b8154a4 | ||
|
|
cba2160acb | ||
|
|
b164b8826f | ||
|
|
d323260ed5 | ||
|
|
5d01a9405f | ||
|
|
f4b30cef0c | ||
|
|
a4bb860526 | ||
|
|
6797dbada2 | ||
|
|
2ca1e67435 | ||
|
|
ab955afad4 | ||
|
|
f442797a86 | ||
|
|
6ca92fcc21 | ||
|
|
8043247057 | ||
|
|
0782d0f993 | ||
|
|
bdda2d9884 | ||
|
|
357a1ae41b | ||
|
|
f16161d38a | ||
|
|
9743fbf4b7 | ||
|
|
d296e0ebcf | ||
|
|
765b3588a3 | ||
|
|
94c782ef23 | ||
|
|
0deb6072dd | ||
|
|
77340f63ae | ||
|
|
43e36b7629 | ||
|
|
5d2f3331de | ||
|
|
f2cb0007f4 | ||
|
|
d2a306677c | ||
|
|
fbe09ec08a | ||
|
|
9bba0d3c6c | ||
|
|
643c5add70 | ||
|
|
aa7f7058f2 | ||
|
|
55bf04cb65 | ||
|
|
8abd334746 | ||
|
|
aff7e53a2f | ||
|
|
51107e2ccf | ||
|
|
5417cde649 | ||
|
|
2fee3861f0 | ||
|
|
88e2a3faad | ||
|
|
75b6d0a527 | ||
|
|
0c2ee3b3cf | ||
|
|
12b6a133a7 | ||
|
|
d2e2b450f6 | ||
|
|
17d877e2c6 | ||
|
|
62021397e0 | ||
|
|
ad7747bfd4 | ||
|
|
66fb92e5af | ||
|
|
673c934f37 | ||
|
|
90d6c7dd6f | ||
|
|
2653e23283 | ||
|
|
91ec588a88 | ||
|
|
864d41d8fc | ||
|
|
c14be68ff8 | ||
|
|
811a68821d | ||
|
|
bf616f6790 | ||
|
|
24dfb1c6b1 | ||
|
|
b9a3fcd828 | ||
|
|
f00b944e56 | ||
|
|
73b0630cbd | ||
|
|
d1771720bd | ||
|
|
7dd2a4f0c7 | ||
|
|
fbc1f1822a | ||
|
|
c768b29e9d | ||
|
|
59535d66b8 | ||
|
|
319d2980c5 | ||
|
|
4acfa3d2e9 | ||
|
|
5bd3d286ce | ||
|
|
bfafcc0a20 | ||
|
|
c26ca806d0 | ||
|
|
bc3d1f2e3f | ||
|
|
f95022142d | ||
|
|
bd766792b1 | ||
|
|
d22f788e3e | ||
|
|
1ecab987a6 | ||
|
|
9a4853a484 | ||
|
|
ad917f6675 | ||
|
|
fab91ad69c | ||
|
|
3501d547c0 | ||
|
|
332853f398 | ||
|
|
4c216b1d9f | ||
|
|
3580001b8a | ||
|
|
6d3696d261 | ||
|
|
1484d5265d | ||
|
|
245ea169ad | ||
|
|
2cf6aa3275 | ||
|
|
d0de4de2d5 | ||
|
|
adbf9b5efa | ||
|
|
a986ce1e08 | ||
|
|
5617d0ff35 | ||
|
|
d7d6ae0f0d | ||
|
|
ea31c4c591 | ||
|
|
996b7d06ef | ||
|
|
3f541121f5 | ||
|
|
1a38b32fbc | ||
|
|
b034c3131d | ||
|
|
d8b8a1381d | ||
|
|
b0c5327bb3 | ||
|
|
e3e7430ce0 | ||
|
|
d8b1878a75 | ||
|
|
e50e297e79 | ||
|
|
888af45a7f | ||
|
|
cd9852a5ec | ||
|
|
c18b9d265b | ||
|
|
66c4247e64 | ||
|
|
9bc2f22589 | ||
|
|
f9484738db | ||
|
|
2882247ad3 | ||
|
|
d8da2044b2 | ||
|
|
de7d276245 | ||
|
|
e1e228430f | ||
|
|
af160e0f33 | ||
|
|
f5d56f52d4 | ||
|
|
ea87397801 | ||
|
|
73adb5f58e | ||
|
|
1422318f0a | ||
|
|
667921b100 | ||
|
|
002492626b | ||
|
|
82c7f81ffa | ||
|
|
da7d7c2396 | ||
|
|
a9a1938ffe | ||
|
|
ee10cb494e | ||
|
|
4568644922 | ||
|
|
234ca7b14c | ||
|
|
e40dfd8084 | ||
|
|
ea48e8fc03 | ||
|
|
e2de8c4245 | ||
|
|
5e29589f1a | ||
|
|
b15219335d | ||
|
|
ee3167fbfb | ||
|
|
e7657453d5 | ||
|
|
5b5d4b0ba8 | ||
|
|
af8949de02 | ||
|
|
d8f02b79b1 |
No files matched your search
@@ -0,0 +1,179 @@
|
|||||||
|
{
|
||||||
|
"sessionStartHook": "echo '📁 TeleUploader — Telegram file uploader backend' && echo ' Bun project — use bun, not node/npm/yarn' && echo ' Tests: bun test --preload ./test/helpers/setup-env.ts <file>'",
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bun(bun test *)",
|
||||||
|
"Bun(bunx *)",
|
||||||
|
"Bun(bun run *)",
|
||||||
|
"Bun(bun build *)",
|
||||||
|
"Bun(bun install)",
|
||||||
|
"Bash(npx @claude-flow*)",
|
||||||
|
"Bash(npx claude-flow*)",
|
||||||
|
"Bash(node .claude/*)",
|
||||||
|
"mcp__claude-flow__*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" pre-bash'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" pre-edit'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" post-edit'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" post-bash'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" route'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" session-restore'",
|
||||||
|
"timeout": 15000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/auto-memory-hook.mjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/auto-memory-hook.mjs\" import'",
|
||||||
|
"timeout": 8000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/auto-memory-hook.mjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/auto-memory-hook.mjs\" sync'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": "manual",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" compact-manual'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "auto",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" compact-auto'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||||
|
"timeout": 6000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" status'",
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" post-task'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'D=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -f \"$D/.claude/helpers/hook-handler.cjs\" ] || D=\"${HOME}\"; exec node \"$D/.claude/helpers/hook-handler.cjs\" notify'",
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
|
||||||
|
"CLAUDE_FLOW_V3_ENABLED": "true",
|
||||||
|
"CLAUDE_FLOW_HOOKS_ENABLED": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -1,14 +1,13 @@
|
|||||||
BOT_TOKEN=isi_token_bot_telegram
|
BOT_TOKENS=isi_token_bot_1,isi_token_bot_2,isi_token_bot_3
|
||||||
ADDITIONAL_BOT_TOKENS=token_cadangan_1,token_cadangan_2,token_cadangan_3
|
|
||||||
STORAGE_CHANNEL_ID=-1001234567890
|
STORAGE_CHANNEL_ID=-1001234567890
|
||||||
BASE_URL=https://tele.asepharyana.my.id
|
BASE_URL=https://upload.asepharyana.my.id
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
|
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/uploader
|
||||||
PORT=3000
|
PORT=4000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
RATE_LIMIT_MAX_REQUESTS=30
|
RATE_LIMIT_MAX_REQUESTS=30
|
||||||
# TRUST_PROXY=true # Uncomment when behind reverse proxy (Traefik, Nginx) for correct client IP detection
|
# TRUST_PROXY=true # Uncomment when behind reverse proxy (Caddy, Nginx) for correct client IP detection
|
||||||
|
|
||||||
# S3-compatible API credentials
|
# S3-compatible API credentials
|
||||||
# S3_ACCESS_KEY=filedrop-admin
|
# S3_ACCESS_KEY=filedrop-admin
|
||||||
@@ -17,7 +16,8 @@ RATE_LIMIT_MAX_REQUESTS=30
|
|||||||
# S3_VHOST_DOMAINS=upload.asepharyana.my.id,upload.asepharyana.web.id
|
# S3_VHOST_DOMAINS=upload.asepharyana.my.id,upload.asepharyana.web.id
|
||||||
|
|
||||||
# Telegram-safe internal chunking for large stored files
|
# Telegram-safe internal chunking for large stored files
|
||||||
# TELEGRAM_CHUNK_SIZE_BYTES=20971520
|
# Telegram getFile download limit is 20 MB; guard rejects > 19922944 (19 MB)
|
||||||
|
# TELEGRAM_CHUNK_SIZE_BYTES=19922944
|
||||||
# COMPRESS_CHUNKED_UPLOADS=true
|
# COMPRESS_CHUNKED_UPLOADS=true
|
||||||
# CHUNK_COMPRESSION_MIN_SIZE_BYTES=4096
|
# CHUNK_COMPRESSION_MIN_SIZE_BYTES=4096
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
name: Deploy FileDrop
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: bun run lint
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Deploy to VPS
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
|
||||||
VPS_SSH_KEY_VALUE: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
|
|
||||||
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
key_file="${RUNNER_TEMP:-/tmp}/filedrop_deploy_key"
|
|
||||||
printf '%s\n' "$VPS_SSH_KEY_VALUE" > "$key_file"
|
|
||||||
chmod 600 "$key_file"
|
|
||||||
|
|
||||||
printf '%s\n' "$PRODUCTION_ENV" > .env
|
|
||||||
chmod 600 .env
|
|
||||||
|
|
||||||
VPS_SSH_KEY="$key_file" ./deploy.sh --no-build
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Dependabot Auto-Merge
|
||||||
|
on: pull_request
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
steps:
|
||||||
|
- name: Enable auto-merge for Dependabot PR
|
||||||
|
run: gh pr merge --auto --merge "$PR_URL"
|
||||||
|
env:
|
||||||
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
development:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: Build & Deploy (Nix)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint (biome)
|
||||||
|
run: bunx biome check src test
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@v22
|
||||||
|
with:
|
||||||
|
determinate: false
|
||||||
|
extra-conf: |
|
||||||
|
sandbox = false
|
||||||
|
accept-flake-config = true
|
||||||
|
|
||||||
|
- name: Cache Nix
|
||||||
|
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||||
|
with:
|
||||||
|
use-flakehub: false
|
||||||
|
|
||||||
|
- name: Build teleuploader
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
nix build .#teleuploader --impure --option sandbox false --print-build-logs
|
||||||
|
STORE_PATH=$(readlink result)
|
||||||
|
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Build OK: $STORE_PATH"
|
||||||
|
|
||||||
|
- name: Setup SSH key
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||||
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Deploy teleuploader to VPS
|
||||||
|
run: |
|
||||||
|
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||||
|
echo "=== Copying teleuploader: $STORE_PATH ==="
|
||||||
|
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||||
|
|
||||||
|
echo "=== Updating profile ==="
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/teleuploader --set '$STORE_PATH'"
|
||||||
|
|
||||||
|
echo "=== Restarting service ==="
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart teleuploader && sleep 3 && sudo systemctl is-active teleuploader"
|
||||||
|
echo "✅ teleuploader deployed"
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
||||||
|
# + nix store gc. Profil yang sedang dipakai tidak disentuh.
|
||||||
|
needs: build-and-deploy
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Nix GC on VPS
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Publish to FlakeHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flakehub-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: DeterminateSystems/determinate-nix-action@main
|
||||||
|
- uses: DeterminateSystems/flakehub-push@main
|
||||||
|
with:
|
||||||
|
visibility: public
|
||||||
|
rolling: true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Mirror to Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Mirror to Gitea
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/TeleUploader.git"
|
||||||
|
git push --mirror gitea
|
||||||
|
echo "✅ Mirrored to Gitea (MythEclipse/TeleUploader)"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Semantic Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: github.actor != 'dependabot[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# semantic-release needs the full history + tags to analyse commits
|
||||||
|
persist-credentials: true
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: Install semantic-release (isolated, no package.json pollution)
|
||||||
|
run: |
|
||||||
|
npm install --prefix /tmp/sr-tools \
|
||||||
|
semantic-release@^24 \
|
||||||
|
@semantic-release/changelog@^6 \
|
||||||
|
@semantic-release/exec@^6 \
|
||||||
|
@semantic-release/git@^10
|
||||||
|
echo "/tmp/sr-tools/node_modules/.bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run semantic-release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: semantic-release
|
||||||
+34
@@ -1,5 +1,6 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
@@ -36,3 +37,36 @@ S3_GUIDE.md
|
|||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
result
|
||||||
|
|
||||||
|
# Ruflo / Claude Flow local state (scaffolding kept on disk, never committed)
|
||||||
|
# Secrets & configs with credentials
|
||||||
|
claude-flow.config.json
|
||||||
|
claude-flow.config.json.bak
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Runtime data & databases
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
ruvector.db
|
||||||
|
data/
|
||||||
|
.swarm/
|
||||||
|
.claude-flow/
|
||||||
|
.claude/*.db
|
||||||
|
.claude/.proven-config-version
|
||||||
|
.claude/proven-config.json
|
||||||
|
|
||||||
|
# Generated scaffolding (re-creatable via ruflo init; force-add if you want it tracked)
|
||||||
|
.agents/
|
||||||
|
AGENTS.md
|
||||||
|
.claude/agents/
|
||||||
|
.claude/commands/
|
||||||
|
.claude/helpers/
|
||||||
|
.claude/skills/
|
||||||
|
|
||||||
|
# Codex local configuration
|
||||||
|
.codex/
|
||||||
|
|
||||||
|
# Planning files (per-repo)
|
||||||
|
.planning/
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# TeleUploader — Audit & Optimisasi Stabilitas + S3 (2026-08-27)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
Audit dan optimasikan backend TeleUploader agar stabil di segala situasi dan
|
||||||
|
S3 protocol stabil + secepat mungkin. Fokus pada bottleneck nyata yang
|
||||||
|
ditemukan saat audit kode + log.
|
||||||
|
|
||||||
|
## Temuan Audit (root cause → prioritas)
|
||||||
|
|
||||||
|
### A. S3 GET lambat (bottleneck terbesar download)
|
||||||
|
1. **`concatPartStreams` (object-stream.ts) fetch part SEQUENTIAL** — tiap part
|
||||||
|
di-`fetch` satu per satu (for..of + await penuh). Untuk objek chunked/multipart
|
||||||
|
dengan N part, byte pertama baru terkirim SETELAH semua fetch part selesai.
|
||||||
|
→ **PARALELISASI** dengan bounded concurrency, urut tetap dipertahankan.
|
||||||
|
2. **`buildChunkedObjectSources` & `handleGetMultipartObject` resolve
|
||||||
|
`getFileInfo` SEQUENTIAL** (for..of + await). N call Telegram API berurutan
|
||||||
|
sebelum streaming mulai.
|
||||||
|
→ **`Promise.all`** resolve paralel.
|
||||||
|
3. **`botPool.getFileInfo` TIDAK pakai `fileInfoCache`** yang sudah ada — setiap
|
||||||
|
S3 GET objek reguler memanggil API Telegram `getFile` (network round-trip).
|
||||||
|
file-controller punya wrapper cache sendiri, tapi path S3 tidak.
|
||||||
|
→ **pindahkan caching ke dalam `botPool.getFileInfo`** agar semua caller dapat.
|
||||||
|
|
||||||
|
### B. Stabilitas / lingkungan produksi
|
||||||
|
4. **`nodeEnv: "development"` di produksi** (dari log BWS). Winston format json
|
||||||
|
sudah benar di production, tapi flag salah.
|
||||||
|
5. **`trustProxy: false` di produksi padahal di belakang Caddy** → rate limiter
|
||||||
|
meng-key semua user ke `127.0.0.1` (satu bucket global). Ini bug kecil untuk
|
||||||
|
non-S3 route. Fix: default trustProxy menyesuaikan, tapi utama via env.
|
||||||
|
6. **MemoryMax=512M di systemd** padahal config mendukung blob 2GB. Streaming
|
||||||
|
PUT O(1) memory, tapi GET gzip part membuffer part utuh. Naikkan ke 1G.
|
||||||
|
|
||||||
|
### C. Robustness S3
|
||||||
|
7. **`handleUploadPart` tidak `await writer.end()`** sebelum `createReadStream`
|
||||||
|
→ potensi race baca file belum flush. Await end().
|
||||||
|
8. Batas kecepatan unggah: `telegramBotConcurrency:1` × 6 bot = 6 unggah paralel.
|
||||||
|
Karena Telegram ~1 rps/bot untuk sendDocument, konkuransi per-bot 1 masuk
|
||||||
|
akal. Biarkan default, tapi pastikan pool retry terhadap 429 sudah bagus
|
||||||
|
(sudah, ada retry-after handling).
|
||||||
|
|
||||||
|
## File yang disentuh (semua di bawah src/)
|
||||||
|
- `src/interfaces/s3/object-stream.ts` — paralelisasi fetch part (concatPartStreams)
|
||||||
|
- `src/infrastructure/telegram/bot-pool.ts` — getFileInfo + cache, resolve paralel
|
||||||
|
- `src/infrastructure/telegram/chunked-storage.ts` — buildChunkedObjectSources paralel
|
||||||
|
- `src/interfaces/http/controllers/s3-controller.ts` — handleGetMultipartObject paralel,
|
||||||
|
await writer.end() di handleUploadPart
|
||||||
|
- `src/infrastructure/cache/index.ts` — (opsional) tambah helper
|
||||||
|
|
||||||
|
## Verifikasi
|
||||||
|
- `bunx biome check src test` (lint)
|
||||||
|
- `bun test test/s3-auth.test.ts` & `test/s3-operations.test.ts` (S3 regresi)
|
||||||
|
- `bun test test/telegram.test.ts` (pool) kalau ada
|
||||||
|
- Test unit baru untuk parallel part streaming (tanpa network) kalau layak.
|
||||||
|
- Deploy via push main → CI `deploy.yml` (Nix build → nix copy → systemctl restart).
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
Push ke `main` → GitHub Actions `deploy.yml` build + deploy Nix ke orangevps.
|
||||||
|
Setelah deploy, verifikasi `/health` 200 dan S3 GET masih berfungsi.
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
bun run lint
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"branches": ["main"],
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
{
|
||||||
|
"releaseRules": [
|
||||||
|
{ "type": "refactor", "release": "patch" },
|
||||||
|
{ "type": "test", "release": "patch" },
|
||||||
|
{ "type": "build", "release": "patch" },
|
||||||
|
{ "type": "ci", "release": "patch" },
|
||||||
|
{ "type": "docs", "release": "patch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
[
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
{
|
||||||
|
"changelogFile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"prepareCmd": "node ./.semrel/prepare.mjs '${nextRelease.version}' && bunx biome check package.json flake.nix CHANGELOG.md 2>/dev/null || true",
|
||||||
|
"successCmd": "node ./.semrel/dispatch.mjs '${nextRelease.version}'"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": ["package.json", "flake.nix", "CHANGELOG.md"],
|
||||||
|
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/github"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* successCmd hook for semantic-release: dispatch the deploy workflow so the
|
||||||
|
* freshly released version is built and deployed to the VPS.
|
||||||
|
*
|
||||||
|
* GitHub disables workflow re-triggering for pushes made by GITHUB_TOKEN
|
||||||
|
* (which is what @semantic-release/git uses to commit the release), so the
|
||||||
|
* plain `push: branches: [main]` trigger in deploy.yml never fires for the
|
||||||
|
* release commit. We dispatch deploy.yml explicitly instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const version = (process.argv[2] || '').trim();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
console.error('dispatch.mjs: GITHUB_TOKEN not set, skipping deploy dispatch');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (!version) {
|
||||||
|
console.error('dispatch.mjs: no version argument, skipping');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = process.env.GITHUB_REPOSITORY || 'asepharyana/TeleUploader';
|
||||||
|
const url = `https://api.github.com/repos/${repo}/actions/workflows/deploy.yml/dispatches`;
|
||||||
|
|
||||||
|
// dispatch with branch ref main; deploy builds #teleuploader from the
|
||||||
|
// release commit whose package.json/flake.nix already carry `version`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'semantic-release-dispatch',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ref: 'main' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
console.log(`dispatch.mjs: dispatched deploy for v${version} to ${repo} main`);
|
||||||
|
} else {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
console.error(`dispatch.mjs: deploy dispatch failed (HTTP ${res.status}): ${body}`);
|
||||||
|
// Non-fatal: a user can still run the deploy workflow manually.
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('dispatch.mjs: unexpected error:', err);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Prepare script for semantic-release: synchronises the package version
|
||||||
|
* across package.json and flake.nix before the release commit.
|
||||||
|
*
|
||||||
|
* getVersion() keeps package.json as the source of truth when semver has
|
||||||
|
* not run yet (e.g. first bootstrap) — after that, nextRelease.version
|
||||||
|
* is passed as argv[2] and everything is aligned to it.
|
||||||
|
*
|
||||||
|
* Fixes the TeleUploader Nix deploy trap: a source change without a
|
||||||
|
* version bump made Nix reuse the same store path, so CI reported success
|
||||||
|
* while the VPS profile stayed on the old build. By bumping flake.nix's
|
||||||
|
* `version` on every release, the Nix derivation always changes and the
|
||||||
|
* store path is always fresh.
|
||||||
|
*
|
||||||
|
* IMPORTANT: only bump the TeleUploader package version (the block whose
|
||||||
|
* `pname = "teleuploader"` precedes it), never the Bun overlay version that
|
||||||
|
* appears earlier in the file. Store path is derived from the
|
||||||
|
* teleuploader package's `version`.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const nextVersion = process.argv[2];
|
||||||
|
|
||||||
|
// --- package.json ---
|
||||||
|
const pkgPath = 'package.json';
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||||
|
if (nextVersion) pkg.version = nextVersion;
|
||||||
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||||
|
|
||||||
|
// --- flake.nix ---
|
||||||
|
const flakePath = 'flake.nix';
|
||||||
|
let flake = readFileSync(flakePath, 'utf8');
|
||||||
|
const target = nextVersion ?? pkg.version;
|
||||||
|
|
||||||
|
const anchor = 'pname = "teleuploader";';
|
||||||
|
const anchorIdx = flake.indexOf(anchor);
|
||||||
|
if (anchorIdx === -1) {
|
||||||
|
throw new Error(`prepare.mjs: TeleUploader pname anchor not found in ${flakePath}`);
|
||||||
|
}
|
||||||
|
const rest = flake.slice(anchorIdx);
|
||||||
|
const verMatch = rest.match(/(\s*)version = "\d+\.\d+\.\d+";/);
|
||||||
|
if (!verMatch) {
|
||||||
|
throw new Error(`prepare.mjs: could not locate TeleUploader version block in ${flakePath}`);
|
||||||
|
}
|
||||||
|
const replaced =
|
||||||
|
flake.slice(0, anchorIdx) +
|
||||||
|
rest.replace(/(\s*)version = "\d+\.\d+\.\d+";/, `$1version = "${target}";`);
|
||||||
|
if (!replaced.includes(`version = "${target}";`)) {
|
||||||
|
throw new Error(`prepare.mjs: replacement did not land in ${flakePath}`);
|
||||||
|
}
|
||||||
|
writeFileSync(flakePath, replaced);
|
||||||
|
|
||||||
|
console.log(`prepare.mjs: versions synced to ${target}`);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## [1.2.3](https://github.com/asepharyana/TeleUploader/compare/v1.2.2...v1.2.3) (2026-09-18)
|
||||||
|
|
||||||
|
## [1.2.2](https://github.com/asepharyana/TeleUploader/compare/v1.2.1...v1.2.2) (2026-09-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **health:** resolve app version in Nix bundle at runtime ([6797dba](https://github.com/asepharyana/TeleUploader/commit/6797dbada27873b14c064e238589e61ad3ecb67c))
|
||||||
|
|
||||||
|
## [1.2.1](https://github.com/asepharyana/TeleUploader/compare/v1.2.0...v1.2.1) (2026-09-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** bump only TeleUploader flake version, not Bun overlay ([f442797](https://github.com/asepharyana/TeleUploader/commit/f442797a86e845853c28dc22432a530560434488))
|
||||||
|
|
||||||
|
# [1.2.0](https://github.com/asepharyana/TeleUploader/compare/v1.1.1...v1.2.0) (2026-09-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **health:** expose app version in /health response ([0782d0f](https://github.com/asepharyana/TeleUploader/commit/0782d0f993a033bb9d84fa1f876e29486751889d))
|
||||||
@@ -20,11 +20,17 @@ Default to using Bun instead of Node.js.
|
|||||||
- Bun.$`ls` instead of execa.
|
- Bun.$`ls` instead of execa.
|
||||||
- Rate limiter lokal dinonaktifkan (`checkRateLimit` di `src/utils/rateLimit.ts` selalu mengembalikan `true`).
|
- Rate limiter lokal dinonaktifkan (`checkRateLimit` di `src/utils/rateLimit.ts` selalu mengembalikan `true`).
|
||||||
- Telegram API memiliki auto-retry otomatis jika mengembalikan error 429 (Too Many Requests) menggunakan pool Telegraf multi-bot di `src/utils/telegram.ts`.
|
- Telegram API memiliki auto-retry otomatis jika mengembalikan error 429 (Too Many Requests) menggunakan pool Telegraf multi-bot di `src/utils/telegram.ts`.
|
||||||
- Multi-bot dikonfigurasi melalui `ADDITIONAL_BOT_TOKENS` (koma terpisah) di `.env` yang digabung dengan `BOT_TOKEN` utama (total 4 bot).
|
|- Multi-bot dikonfigurasi melalui `BOT_TOKENS` (koma terpisah) di `.env` — semua token bot digabung dalam satu variabel.
|
||||||
- Menggunakan mekanisme rotasi instan jika ada bot yang terkena rate limit 429 sebelum memutuskan untuk sleep.
|
- Menggunakan mekanisme rotasi instan jika ada bot yang terkena rate limit 429 sebelum memutuskan untuk sleep.
|
||||||
- Pengiriman berkas ke Telegram dieksekusi secara responsif dan paralel penuh tanpa batas konkurensi/antrian.
|
- Pengiriman berkas ke Telegram dieksekusi secara responsif dan paralel penuh tanpa batas konkurensi/antrian.
|
||||||
- Berkas API upload ditulis secara sementara ke disk `/tmp/teleuploader-*` dan di-stream ke Telegram menggunakan `fs.createReadStream` (RAM-optimized) lalu dihapus otomatis setelah 50ms (timeout aman).
|
- Berkas API upload ditulis secara sementara ke disk `/tmp/teleuploader-*` dan di-stream ke Telegram menggunakan `fs.createReadStream` (RAM-optimized) lalu dihapus otomatis setelah 50ms (timeout aman).
|
||||||
|
|
||||||
|
## Chunk size (TELEGRAM_CHUNK_SIZE_BYTES)
|
||||||
|
|
||||||
|
- Batas keras: Telegram Bot API `getFile` hanya bisa resolve file ≤ 20 MB — di atas itu error `Bad Request: file is too big` dan part tidak bisa di-download.
|
||||||
|
- Guard fail-fast di `src/env.ts`: service MENOLAK start (exit non-zero) jika `TELEGRAM_CHUNK_SIZE_BYTES` > 19922944 (19 MB, margin aman dari limit 20 MB). Konstanta: `TELEGRAM_CHUNK_SIZE_MAX_BYTES` di `src/shared/utils/validation.ts`, juga dipakai `asSafeChunkSize()` di runtime.
|
||||||
|
- Default 19 MB; berlaku untuk chunked storage DAN S3 multipart parts (sama-sama disimpan ke Telegram lalu di-resolve via getFile).
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Use `bun test` to run tests. Jalankan tes secara spesifik (misal `bun test test/rateLimit.test.ts`) untuk menghindari polusi mock antar berkas tes ketika dijalankan bersamaan.
|
Use `bun test` to run tests. Jalankan tes secara spesifik (misal `bun test test/rateLimit.test.ts`) untuk menghindari polusi mock antar berkas tes ketika dijalankan bersamaan.
|
||||||
|
|||||||
+7
-3
@@ -1,3 +1,7 @@
|
|||||||
|
# ⚠️ LEGACY — pembangunan & deploy sekarang DISARANKAN memakai Nix + systemd
|
||||||
|
# (lihat flake.nix + .github/workflows/deploy.yml + Caddy reverse proxy di orangevps).
|
||||||
|
# Dockerfile ini hanya dipertahankan untuk konteks historis / fallback, bukan deploy produksi.
|
||||||
|
|
||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM oven/bun:alpine AS builder
|
FROM oven/bun:alpine AS builder
|
||||||
|
|
||||||
@@ -21,12 +25,12 @@ WORKDIR /usr/src/app
|
|||||||
# Copy built files, schema, and package.json
|
# Copy built files, schema, and package.json
|
||||||
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
|
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
|
||||||
COPY --from=builder /usr/src/app/dist/migrate.js ./dist/migrate.js
|
COPY --from=builder /usr/src/app/dist/migrate.js ./dist/migrate.js
|
||||||
COPY --from=builder /usr/src/app/src/home.html ./home.html
|
COPY --from=builder /usr/src/app/src/home.html ./dist/home.html
|
||||||
COPY schema.sql ./
|
COPY schema.sql ./
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 4000
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
CMD ["bun", "dist/index.js"]
|
CMD ["bun", "dist/index.js"]
|
||||||
@@ -4,12 +4,11 @@ Backend production-ready untuk upload file ke Telegram yang tersimpan di private
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Install PostgreSQL database
|
1. Siapkan PostgreSQL database (produksi: database `uploader` via PgBouncer pool di `100.121.180.82:6432`)
|
||||||
2. Buat database: `createdb telegram_uploader`
|
2. Setup environment: `cp .env.example .env`
|
||||||
3. Setup environment: `cp .env.example .env`
|
3. Edit `.env` dengan nilai yang sesuai (lihat `DATABASE_URL`, `PORT=4000`)
|
||||||
4. Edit `.env` dengan nilai yang sesuai
|
4. Create table: `bun run db:migrate`
|
||||||
5. Create table: `bun run db:migrate`
|
5. Install dependencies: `bun install`
|
||||||
6. Install dependencies: `bun install`
|
|
||||||
|
|
||||||
## Telegram Private Channel Setup
|
## Telegram Private Channel Setup
|
||||||
|
|
||||||
@@ -24,6 +23,20 @@ bun run dev # Development mode
|
|||||||
bun run start # Production mode
|
bun run start # Production mode
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Deployment (Produksi — Nix + systemd)
|
||||||
|
|
||||||
|
> Infra lama berbasis Docker + Traefik sudah dihapus dari orangevps (2026-08-02).
|
||||||
|
|
||||||
|
- **Host**: orangevps
|
||||||
|
- **Service**: systemd unit `teleuploader` (env via `/etc/teleuploader/env` / BWS secrets)
|
||||||
|
- **Build**: Nix flake (`flake.nix`) — `nix build .#teleuploader` → `nix copy` → `systemctl restart teleuploader`
|
||||||
|
- **CI**: `.github/workflows/deploy.yml` (Gitea Actions / GitHub Actions)
|
||||||
|
- **Port**: `4000` (`PORT` env)
|
||||||
|
- **Domain**: `https://upload.asepharyana.my.id`
|
||||||
|
- **Reverse proxy**: Caddy (bukan Traefik/Docker)
|
||||||
|
- **Database**: `postgresql://asephs:***@100.121.180.82:6432/uploader` (PgBouncer pool di imrnes, **bukan** 5432/localhost)
|
||||||
|
- `deploy.sh` & `Dockerfile` & `docker-compose.yml` bersifat **legacy** — jangan dipakai untuk deploy produksi.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
- `POST /api/upload` - Upload file
|
- `POST /api/upload` - Upload file
|
||||||
@@ -34,9 +47,22 @@ bun run start # Production mode
|
|||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**URL permanen maksudnya apa?**
|
**URL permanen maksudnya apa?**
|
||||||
URL backend tetap permanen: `https://tele.asepharyana.my.id/f/{public_id}`
|
URL backend tetap permanen: `https://upload.asepharyana.my.id/f/{public_id}`
|
||||||
Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.
|
Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Gunakan bot Telegram untuk upload, atau upload API langsung via HTTP.
|
Gunakan bot Telegram untuk upload, atau upload API langsung via HTTP.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
This repository uses auto semantic versioning via [semantic-release](https://github.com/semantic-release/semantic-release):
|
||||||
|
|
||||||
|
- `fix(...)` commits → patch bump (v1.1.1 → v1.1.2)
|
||||||
|
- `feat(...)` commits → minor bump (v1.2.0)
|
||||||
|
- breaking changes → major bump (v2.0.0)
|
||||||
|
- `chore/ci/docs/refactor/test` commits → no release
|
||||||
|
|
||||||
|
On every release, `prepare.mjs` syncs the version across `package.json` and
|
||||||
|
`flake.nix`, guaranteeing a fresh Nix store path and true auto-deploy via
|
||||||
|
the `Build & Deploy (Nix)` workflow.
|
||||||
@@ -7,19 +7,21 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "^7.8.0",
|
"@prisma/engines": "^7.8.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^6.0.1",
|
||||||
"p-queue": "^9.3.0",
|
"p-queue": "^9.3.0",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"telegraf": "^4.15.0",
|
"telegraf": "^4.15.0",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^4.6.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1079.0",
|
"@aws-sdk/client-s3": "^3.1079.0",
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^26.2.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"typescript": "^6.0.3",
|
"husky": "^9.1.7",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -166,10 +168,50 @@
|
|||||||
|
|
||||||
"@telegraf/types": ["@telegraf/types@7.1.0", "", {}, "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw=="],
|
"@telegraf/types": ["@telegraf/types@7.1.0", "", {}, "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
"@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="],
|
||||||
|
|
||||||
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
|
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||||
|
|
||||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
@@ -216,6 +258,8 @@
|
|||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
|
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||||
@@ -228,7 +272,7 @@
|
|||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="],
|
"nanoid": ["nanoid@6.0.1", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw=="],
|
||||||
|
|
||||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
@@ -298,9 +342,9 @@
|
|||||||
|
|
||||||
"tsx": ["tsx@4.22.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg=="],
|
"tsx": ["tsx@4.22.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@Line truncated
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
@@ -314,8 +358,12 @@
|
|||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mipsLine truncated
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mipsLine truncated
|
||||||
|
|
||||||
|
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
||||||
|
|
||||||
"telegraf/p-timeout": ["p-timeout@4.1.0", "", {}, "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw=="],
|
"telegraf/p-timeout": ["p-timeout@4.1.0", "", {}, "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw=="],
|
||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mipsLine truncated
|
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mipsLine truncated
|
||||||
@@ -364,6 +412,8 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# ⚠️ LEGACY — Script deploy lama berbasis Docker. Sejak 2026-08-02 Docker dihapus
|
||||||
|
# dari orangevps; produksi kini memakai Nix + systemd (lihat flake.nix, dan CI
|
||||||
|
# .github/workflows/deploy.yml yang menjalankan `nix build` → `nix copy` →
|
||||||
|
# `systemctl restart teleuploader`). Berkas ini hanya dipertahankan sebagai
|
||||||
|
# referensi historis — JANGAN dipakai untuk deploy produksi.
|
||||||
# ─── FileDrop Deploy Script ──────────────────────────────────────────────────
|
# ─── FileDrop Deploy Script ──────────────────────────────────────────────────
|
||||||
# Builds the Bun app locally and deploys to the VPS via Docker.
|
# Builds the Bun app locally and deploys to the VPS via Docker.
|
||||||
#
|
#
|
||||||
@@ -155,12 +160,15 @@ vps "rm -rf '${DEPLOY_DIR}.old' && mv '${DEPLOY_DIR}' '${DEPLOY_DIR}.old' 2>/dev
|
|||||||
|
|
||||||
ok "Files shipped to ${DEPLOY_DIR}"
|
ok "Files shipped to ${DEPLOY_DIR}"
|
||||||
|
|
||||||
# ── 5. Build Docker image & restart on VPS ───────────────────────────────────
|
# ── 5. Build Docker image & restart ──────────────────────────────────────
|
||||||
log "Building Docker image 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"
|
vps "cd '${DEPLOY_DIR}' && docker compose build --pull 2>&1" | tail -5 || die "Docker build failed on VPS"
|
||||||
|
|
||||||
log "Restarting container..."
|
log "Restarting container (zero-downtime via healthcheck)..."
|
||||||
vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate 2>&1" || die "Container restart failed"
|
vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate --wait --wait-timeout 60 2>&1" || {
|
||||||
|
log "Warn: --wait not supported on this docker-compose version, falling back to basic restart"
|
||||||
|
vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate 2>&1" || die "Container restart failed"
|
||||||
|
}
|
||||||
|
|
||||||
# ── 6. Verify container is running ────────────────────────────────────────────
|
# ── 6. Verify container is running ────────────────────────────────────────────
|
||||||
log "Waiting for container to be healthy..."
|
log "Waiting for container to be healthy..."
|
||||||
|
|||||||
+7
-4
@@ -1,19 +1,22 @@
|
|||||||
|
# ⚠️ LEGACY — Docker/Traefik sudah DIHAPUS dari VPS produksi (orangevps).
|
||||||
|
# Deploy sekarang Nix + systemd (flake.nix + .github/workflows/deploy.yml) dengan
|
||||||
|
# Caddy reverse proxy. Berkas ini hanya dipertahankan sebagai referensi historis.
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
container_name: filedrop-app
|
container_name: filedrop-app
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
|
- BOT_TOKENS=${BOT_TOKENS}
|
||||||
- BOT_TOKEN=${BOT_TOKEN}
|
- BOT_TOKEN=${BOT_TOKEN}
|
||||||
- ADDITIONAL_BOT_TOKENS=${ADDITIONAL_BOT_TOKENS:-}
|
- ADDITIONAL_BOT_TOKENS=${ADDITIONAL_BOT_TOKENS:-}
|
||||||
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
||||||
- BASE_URL=${BASE_URL}
|
- BASE_URL=${BASE_URL}
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- PORT=3000
|
- PORT=4000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- TRUST_PROXY=true
|
- TRUST_PROXY=true
|
||||||
- UPLOAD_CONCURRENCY=${UPLOAD_CONCURRENCY:-8}
|
|
||||||
- BATCH_MAX_ITEMS=${BATCH_MAX_ITEMS:-20}
|
- BATCH_MAX_ITEMS=${BATCH_MAX_ITEMS:-20}
|
||||||
- BATCH_MAX_SIZE_BYTES=${BATCH_MAX_SIZE_BYTES:-524288000}
|
- BATCH_MAX_SIZE_BYTES=${BATCH_MAX_SIZE_BYTES:-524288000}
|
||||||
- MAX_REQUEST_BODY_BYTES=${MAX_REQUEST_BODY_BYTES:-2147483648}
|
- MAX_REQUEST_BODY_BYTES=${MAX_REQUEST_BODY_BYTES:-2147483648}
|
||||||
@@ -46,7 +49,7 @@ services:
|
|||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
- "bun -e \"fetch('http://localhost:3000/health').then(r => r.status === 200 ? process.exit(0) : process.exit(1))\""
|
- "bun -e \"fetch('http://localhost:4000/health').then(r => r.status === 200 ? process.exit(0) : process.exit(1))\""
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -59,7 +62,7 @@ services:
|
|||||||
- "traefik.http.routers.filedrop.entrypoints=websecure"
|
- "traefik.http.routers.filedrop.entrypoints=websecure"
|
||||||
- "traefik.http.routers.filedrop.tls=true"
|
- "traefik.http.routers.filedrop.tls=true"
|
||||||
- "traefik.http.routers.filedrop.tls.certresolver=cloudflare"
|
- "traefik.http.routers.filedrop.tls.certresolver=cloudflare"
|
||||||
- "traefik.http.services.filedrop.loadbalancer.server.port=3000"
|
- "traefik.http.services.filedrop.loadbalancer.server.port=4000"
|
||||||
- "traefik.http.middlewares.filedrop-rl.ratelimit.average=300"
|
- "traefik.http.middlewares.filedrop-rl.ratelimit.average=300"
|
||||||
- "traefik.http.middlewares.filedrop-rl.ratelimit.burst=100"
|
- "traefik.http.middlewares.filedrop-rl.ratelimit.burst=100"
|
||||||
- "traefik.http.middlewares.filedrop-rl.ratelimit.period=1m"
|
- "traefik.http.middlewares.filedrop-rl.ratelimit.period=1m"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Telegram Bot Uploader Backend Implementation Plan
|
# Telegram Bot Uploader Backend Implementation Plan
|
||||||
|
|
||||||
|
> ⚠️ **LEGACY** — Dokumen historis (2026-05-17). Port & infrastruktur sudah berubah:
|
||||||
|
> produksi kini berjalan di port `4000` (Nix + systemd + Caddy, domain `upload.asepharyana.my.id`)
|
||||||
|
> dan database via PgBouncer pool `100.121.180.82:6432` (bukan port 5432, bukan localhost).
|
||||||
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Production-ready backend for Telegram file uploader with dual upload methods (bot + HTTP API), PostgreSQL storage, and redirect-based downloads.
|
**Goal:** Production-ready backend for Telegram file uploader with dual upload methods (bot + HTTP API), PostgreSQL storage, and redirect-based downloads.
|
||||||
@@ -75,9 +80,9 @@ schema.sql
|
|||||||
```bash
|
```bash
|
||||||
BOT_TOKEN=isi_token_bot_telegram
|
BOT_TOKEN=isi_token_bot_telegram
|
||||||
STORAGE_CHANNEL_ID=-1001234567890
|
STORAGE_CHANNEL_ID=-1001234567890
|
||||||
BASE_URL=https://tele.asepharyana.my.id
|
BASE_URL=https://upload.asepharyana.my.id
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
|
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/uploader
|
||||||
PORT=3000
|
PORT=4000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
@@ -148,7 +153,7 @@ bun run start # Production mode
|
|||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**URL permanen maksudnya apa?**
|
**URL permanen maksudnya apa?**
|
||||||
URL backend tetap permanen: `https://tele.asepharyana.my.id/f/{public_id}`
|
URL backend tetap permanen: `https://upload.asepharyana.my.id/f/{public_id}`
|
||||||
Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.
|
Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
@@ -250,7 +255,7 @@ export const config = {
|
|||||||
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID, 10),
|
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID, 10),
|
||||||
baseUrl: process.env.BASE_URL,
|
baseUrl: process.env.BASE_URL,
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
port: parseInt(process.env.PORT, 10) || 3000,
|
port: parseInt(process.env.PORT, 10) || 4000,
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
|
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
|
||||||
@@ -1009,14 +1014,14 @@ bun run dev
|
|||||||
- [ ] **Step 2: Test health endpoint**
|
- [ ] **Step 2: Test health endpoint**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/health
|
curl http://localhost:4000/health
|
||||||
# Expected: {"status":"ok"}
|
# Expected: {"status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Upload test file via HTTP API (multipart)**
|
- [ ] **Step 3: Upload test file via HTTP API (multipart)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/upload \
|
curl -X POST http://localhost:4000/api/upload \
|
||||||
-F "file=@/path/to/testfile.txt" \
|
-F "file=@/path/to/testfile.txt" \
|
||||||
-F "fileName=test.txt"
|
-F "fileName=test.txt"
|
||||||
```
|
```
|
||||||
@@ -1024,14 +1029,14 @@ curl -X POST http://localhost:3000/api/upload \
|
|||||||
- [ ] **Step 4: Check file info endpoint**
|
- [ ] **Step 4: Check file info endpoint**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/file/{public_id}/info
|
curl http://localhost:4000/file/{public_id}/info
|
||||||
# Expected: JSON with file metadata
|
# Expected: JSON with file metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Download redirect**
|
- [ ] **Step 5: Download redirect**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -I http://localhost:3000/f/{public_id}
|
curl -I http://localhost:4000/f/{public_id}
|
||||||
# Expected: HTTP 302 with Location header to Telegram CDN
|
# Expected: HTTP 302 with Location header to Telegram CDN
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1047,7 +1052,7 @@ curl -I http://localhost:3000/f/{public_id}
|
|||||||
- [ ] **Step 7: Test error handling (file too large)**
|
- [ ] **Step 7: Test error handling (file too large)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/upload \
|
curl -X POST http://localhost:4000/api/upload \
|
||||||
-F "file=@/dev/null" \
|
-F "file=@/dev/null" \
|
||||||
-H "Content-Length: 10000000000"
|
-H "Content-Length: 10000000000"
|
||||||
# Expected: HTTP 400 with error message
|
# Expected: HTTP 400 with error message
|
||||||
@@ -1057,7 +1062,7 @@ curl -X POST http://localhost:3000/api/upload \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Send 31 requests within 1 minute
|
# Send 31 requests within 1 minute
|
||||||
for i in {1..31}; do curl http://localhost:3000/f/{public_id} & done
|
for i in {1..31}; do curl http://localhost:4000/f/{public_id} & done
|
||||||
wait
|
wait
|
||||||
# Expected: First 30 succeed, last one returns 429
|
# Expected: First 30 succeed, last one returns 429
|
||||||
```
|
```
|
||||||
@@ -1066,7 +1071,7 @@ wait
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# In terminal 1: bun run dev
|
# In terminal 1: bun run dev
|
||||||
# In terminal 2: curl http://localhost:3000/health && sleep 0.1 && curl http://localhost:3000/health
|
# In terminal 2: curl http://localhost:4000/health && sleep 0.1 && curl http://localhost:4000/health
|
||||||
# Send SIGINT to server (Ctrl+C in terminal 1)
|
# Send SIGINT to server (Ctrl+C in terminal 1)
|
||||||
# Check if server stops cleanly, logs show shutdown sequence
|
# Check if server stops cleanly, logs show shutdown sequence
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Docker, Traefik, and GitHub Actions Deployment Plan
|
# Docker, Traefik, and GitHub Actions Deployment Plan
|
||||||
|
|
||||||
|
> ⚠️ **LEGACY** — Dokumen historis (2026-05-18). Arsitektur Docker + Traefik +
|
||||||
|
> GitHub Actions sudah digantikan (2026-08-02) oleh Nix + systemd + Caddy di
|
||||||
|
> orangevps: port `4000`, domain `upload.asepharyana.my.id`, database via
|
||||||
|
> PgBouncer pool `100.121.180.82:6432` (bukan 5432/localhost).
|
||||||
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Containerize TeleUploader using Bun, configure Traefik labels for routing `upload.asepharyana.my.id`, and set up full GitHub Actions CI/CD to VPS `45.127.35.244`.
|
**Goal:** Containerize TeleUploader using Bun, configure Traefik labels for routing `upload.asepharyana.my.id`, and set up full GitHub Actions CI/CD to VPS `45.127.35.244`.
|
||||||
@@ -57,7 +63,7 @@ WORKDIR /usr/src/app
|
|||||||
|
|
||||||
# Set production environment variables
|
# Set production environment variables
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=4000
|
||||||
|
|
||||||
# Copy necessary files from builder and repo
|
# Copy necessary files from builder and repo
|
||||||
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
|
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
|
||||||
@@ -65,7 +71,7 @@ COPY --from=builder /usr/src/app/package.json ./package.json
|
|||||||
COPY schema.sql ./schema.sql
|
COPY schema.sql ./schema.sql
|
||||||
|
|
||||||
# Expose server port
|
# Expose server port
|
||||||
EXPOSE 3000
|
EXPOSE 4000
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
CMD ["bun", "dist/index.js"]
|
CMD ["bun", "dist/index.js"]
|
||||||
@@ -104,7 +110,7 @@ services:
|
|||||||
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
||||||
- BASE_URL=${BASE_URL}
|
- BASE_URL=${BASE_URL}
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- PORT=3000
|
- PORT=4000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
networks:
|
networks:
|
||||||
@@ -115,7 +121,7 @@ services:
|
|||||||
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
||||||
- "traefik.http.routers.teleuploader.tls=true"
|
- "traefik.http.routers.teleuploader.tls=true"
|
||||||
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
|
- "traefik.http.services.teleuploader.loadbalancer.server.port=4000"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-shared-net:
|
app-shared-net:
|
||||||
@@ -166,9 +172,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BOT_TOKEN: "mock_token"
|
BOT_TOKEN: "mock_token"
|
||||||
STORAGE_CHANNEL_ID: "123456"
|
STORAGE_CHANNEL_ID: "123456"
|
||||||
BASE_URL: "http://localhost:3000"
|
BASE_URL: "http://localhost:4000"
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres"
|
DATABASE_URL: "postgresql://asephs:***@100.121.180.82:6432/postgres"
|
||||||
PORT: "3000"
|
PORT: "4000"
|
||||||
run: bun run test
|
run: bun run test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -223,7 +229,7 @@ jobs:
|
|||||||
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
||||||
- BASE_URL=${BASE_URL}
|
- BASE_URL=${BASE_URL}
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- PORT=3000
|
- PORT=4000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
networks:
|
networks:
|
||||||
@@ -234,7 +240,7 @@ jobs:
|
|||||||
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
||||||
- "traefik.http.routers.teleuploader.tls=true"
|
- "traefik.http.routers.teleuploader.tls=true"
|
||||||
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
|
- "traefik.http.services.teleuploader.loadbalancer.server.port=4000"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-shared-net:
|
app-shared-net:
|
||||||
@@ -248,7 +254,7 @@ jobs:
|
|||||||
STORAGE_CHANNEL_ID=${{ secrets.STORAGE_CHANNEL_ID }}
|
STORAGE_CHANNEL_ID=${{ secrets.STORAGE_CHANNEL_ID }}
|
||||||
BASE_URL=${{ secrets.BASE_URL }}
|
BASE_URL=${{ secrets.BASE_URL }}
|
||||||
DATABASE_URL=${{ secrets.DATABASE_URL }}
|
DATABASE_URL=${{ secrets.DATABASE_URL }}
|
||||||
PORT=3000
|
PORT=4000
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Pull latest docker image
|
# Pull latest docker image
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# S3-Compatible TeleUploader Implementation Plan
|
# S3-Compatible TeleUploader Implementation Plan
|
||||||
|
|
||||||
|
> ⚠️ **LEGACY** — Dokumen historis (2026-07-06). Port & infrastruktur sudah berubah:
|
||||||
|
> produksi kini berjalan di port `4000` (Nix + systemd + Caddy, domain
|
||||||
|
> `upload.asepharyana.my.id`), database via PgBouncer pool `100.121.180.82:6432`
|
||||||
|
> (bukan 5432/localhost). Contoh kode di bawah memakai `localhost:4000` untuk dev.
|
||||||
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Transform TeleUploader into an S3-compatible storage server (Telegram-backed) with a web file manager UI.
|
**Goal:** Transform TeleUploader into an S3-compatible storage server (Telegram-backed) with a web file manager UI.
|
||||||
@@ -2675,7 +2681,7 @@ describe('S3 Bucket Operations', () => {
|
|||||||
process.env.S3_DEFAULT_REGION = 'us-east-1';
|
process.env.S3_DEFAULT_REGION = 'us-east-1';
|
||||||
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
||||||
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
||||||
process.env.BASE_URL = 'http://localhost:3000';
|
process.env.BASE_URL = 'http://localhost:4000';
|
||||||
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2684,7 +2690,7 @@ describe('S3 Bucket Operations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 for unauthorized requests', async () => {
|
it('should return 403 for unauthorized requests', async () => {
|
||||||
const req = new Request('http://localhost:3000/', {
|
const req = new Request('http://localhost:4000/', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { authorization: 'Invalid' },
|
headers: { authorization: 'Invalid' },
|
||||||
});
|
});
|
||||||
@@ -2849,7 +2855,7 @@ describe('Web API v1', () => {
|
|||||||
mockDbExecute.mockClear();
|
mockDbExecute.mockClear();
|
||||||
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
||||||
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
||||||
process.env.BASE_URL = 'http://localhost:3000';
|
process.env.BASE_URL = 'http://localhost:4000';
|
||||||
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2858,7 +2864,7 @@ describe('Web API v1', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should list buckets via GET /api/v1/buckets', async () => {
|
it('should list buckets via GET /api/v1/buckets', async () => {
|
||||||
const req = new Request('http://localhost:3000/api/v1/buckets');
|
const req = new Request('http://localhost:4000/api/v1/buckets');
|
||||||
const res = await handleWebApiV1(req);
|
const res = await handleWebApiV1(req);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -2867,7 +2873,7 @@ describe('Web API v1', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for unknown API path', async () => {
|
it('should return 404 for unknown API path', async () => {
|
||||||
const req = new Request('http://localhost:3000/api/v1/unknown');
|
const req = new Request('http://localhost:4000/api/v1/unknown');
|
||||||
const res = await handleWebApiV1(req);
|
const res = await handleWebApiV1(req);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -2875,7 +2881,7 @@ describe('Web API v1', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return bucket object listing', async () => {
|
it('should return bucket object listing', async () => {
|
||||||
const req = new Request('http://localhost:3000/api/v1/buckets/test-bucket/objects?prefix=');
|
const req = new Request('http://localhost:4000/api/v1/buckets/test-bucket/objects?prefix=');
|
||||||
const res = await handleWebApiV1(req);
|
const res = await handleWebApiV1(req);
|
||||||
// Should return 200 even with empty results
|
// Should return 200 even with empty results
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -2885,7 +2891,7 @@ describe('Web API v1', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid bucket name on create', async () => {
|
it('should reject invalid bucket name on create', async () => {
|
||||||
const req = new Request('http://localhost:3000/api/v1/buckets', {
|
const req = new Request('http://localhost:4000/api/v1/buckets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ name: 'INVALID_NAME!' }),
|
body: JSON.stringify({ name: 'INVALID_NAME!' }),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Gitea CI/CD Migration Implementation Plan
|
# Gitea CI/CD Migration Implementation Plan
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Move `origin` from GitLab to `git.imrnes.team:MythEclipse/TeleUploader` and add Gitea Actions deployment on push to `main` using the existing VPS deploy path.
|
**Goal:** Move `origin` from GitLab to `git.imrnes.team:MythEclipse/TeleUploader` and add Gitea Actions deployment on push to `main` using the existing VPS deploy path.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# S3 Compatibility Completion Implementation Plan
|
# S3 Compatibility Completion Implementation Plan
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Finish TeleUploader S3 compatibility gaps: strict presigned GET, byte ranges, complete multipart GetObject streaming, strict AWS SDK multipart investigation/fix, and warning-free lint.
|
**Goal:** Finish TeleUploader S3 compatibility gaps: strict presigned GET, byte ranges, complete multipart GetObject streaming, strict AWS SDK multipart investigation/fix, and warning-free lint.
|
||||||
|
|||||||
@@ -0,0 +1,950 @@
|
|||||||
|
# DDD & Clean Architecture Restructure Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Restructure TeleUploader from flat architecture to DDD/Clean Architecture with repository abstraction, controller/use-case separation, and full JSDoc documentation.
|
||||||
|
|
||||||
|
**Architecture:** 5-layer DDD — domain (entities + ports), application (use cases + DTOs), infrastructure (persistence + telegram + cache), interfaces (HTTP controllers + routes + middleware + bot + S3 protocol), and shared/config foundation.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun + TypeScript + Drizzle ORM + Telegraf
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Zero behavioral changes — only move/restructure code, never alter logic
|
||||||
|
- Old source files remain until Phase 8 (cleanup), so existing tests keep passing
|
||||||
|
- JSDoc on every exported function, interface, type, and class (English)
|
||||||
|
- All new imports use relative paths within new structure
|
||||||
|
- `config/` and `shared/` have zero dependencies on other new layers
|
||||||
|
- Domain `ports/` interfaces are implemented by `infrastructure/` repositories
|
||||||
|
- Use cases constructed via factory functions accepting repository interfaces (no DI framework)
|
||||||
|
- Controller = HTTP parsing + use case call + response formatting only; no business logic
|
||||||
|
- Domain entities reuse Drizzle types (no pure domain models)
|
||||||
|
- `bun test test/<file>` must pass at every phase (old files remain)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: Foundation Layer — Config + Shared Utilities
|
||||||
|
|
||||||
|
#### Task 1: Create config/index.ts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/config/index.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `AppConfig` type, `config: AppConfig` singleton
|
||||||
|
|
||||||
|
- [ ] **Create `src/config/index.ts`** — Move content from `src/env.ts`. Same `AppConfig` interface, same `config` export. Add JSDoc to `AppConfig` interface (document every property), the `config` export, `parseNumber`, `parseTokens`, `parseDomains`, `maskSecret`, `maskDatabaseUrl`.
|
||||||
|
|
||||||
|
- [ ] **Run tests to verify nothing broken**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test test/env.test.ts
|
||||||
|
```
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Create `src/config/__tests__/env.test.ts`** — Mirror of `test/env.test.ts` but imports from `../../config/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Run both env tests to verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test test/env.test.ts
|
||||||
|
bun test src/config/__tests__/env.test.ts
|
||||||
|
```
|
||||||
|
Expected: Both PASS
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config/
|
||||||
|
git commit -m "feat: create config layer with JSDoc"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 2: Create shared/ errors and logger
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/shared/errors/index.ts`
|
||||||
|
- Create: `src/shared/logger/index.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `DomainError`, `FileNotFoundError`, `BucketNotFoundError`, `FileTooLargeError`, `DuplicateFileError`, `AuthenticationError`, `ValidationError`, `logger` singleton, type `Logger`
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/errors/index.ts`** — Export all error classes from spec Section 11. Add JSDoc for each class.
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/logger/index.ts`** — Move content from `src/utils/logger.ts`. Same implementation. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Quick compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/config/index.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
bun build src/shared/errors/index.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/shared/errors/ src/shared/logger/
|
||||||
|
git commit -m "feat: create shared errors and logger layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 3: Create shared/ metrics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/shared/metrics/index.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `metricsCollector`, `MetricsCollector`, `getSnapshot()`
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/metrics/index.ts`** — Move content from `src/utils/metrics.ts`. Same implementation. Add JSDoc to class, all methods, and `getSnapshot()` return type.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/shared/metrics/index.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/shared/metrics/
|
||||||
|
git commit -m "feat: create shared metrics layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 4: Create shared/utils (file, ip, retry, zip)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/shared/utils/file.ts`
|
||||||
|
- Create: `src/shared/utils/ip.ts`
|
||||||
|
- Create: `src/shared/utils/retry.ts`
|
||||||
|
- Create: `src/shared/utils/zip.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `getErrorMessage`, `cleanupTempFile`, `getFileType`, `checkFileSize`, `ensureExtension`, `extractFileName`, `extractMimeType`, `computeHash`, `extractFileFromMessage`, `detectFileType`, `getFileSizeLimit`, `formatCreatedAt`, `buildUploadResponse`, `extractClientIp`, `withRetry`, `withTimeout`, `withFallback`, `createZip`, `extractZipEntry`, `locateZipEntry`
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/utils/file.ts`** — Move all exports from `src/utils/file.ts`. Add JSDoc to every export. Keep all logic identical.
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/utils/ip.ts`** — Move `extractClientIp` from `src/utils/ip.ts`. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/utils/retry.ts`** — Move all exports from `src/utils/retry.ts`. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/shared/utils/zip.ts`** — Move all exports from `src/utils/zip.ts`. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Run existing tests to verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test test/rateLimit.test.ts
|
||||||
|
bun test test/file.test.ts
|
||||||
|
bun test test/zip.test.ts
|
||||||
|
```
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/shared/utils/
|
||||||
|
git commit -m "feat: create shared utilities layer with JSDoc"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Domain Layer — Entities + Ports
|
||||||
|
|
||||||
|
#### Task 5: Create domain/entities (File, Bucket, FilePart, Multipart)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/domain/entities/file.ts`
|
||||||
|
- Create: `src/domain/entities/bucket.ts`
|
||||||
|
- Create: `src/domain/entities/file-part.ts`
|
||||||
|
- Create: `src/domain/entities/multipart.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `File`, `NewFile`, `FilePart`, `NewFilePart`, `Bucket`, `MultipartUpload`, `MultipartPart` types
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/entities/file.ts`** — Re-export `File` and `NewFile` types from Drizzle schema. Add domain JSDoc.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Re-export Drizzle types as domain entities
|
||||||
|
export type { File, NewFile } from '../../infrastructure/persistence/drizzle/schema';
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — this creates a circular dependency! Domain shouldn't import from infrastructure. Instead, define standalone interfaces:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Core domain entity representing a file stored in Telegram.
|
||||||
|
* Contains both Telegram metadata and optional S3-compatible fields.
|
||||||
|
*/
|
||||||
|
export interface File {
|
||||||
|
id: string;
|
||||||
|
publicId: string;
|
||||||
|
telegramFileId: string;
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
storageChatId: number;
|
||||||
|
storageMessageId: number;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
fileType: string;
|
||||||
|
uploaderId: number;
|
||||||
|
fileHash: string | null;
|
||||||
|
archiveTelegramFileId: string | null;
|
||||||
|
archiveStorageMessageId: number | null;
|
||||||
|
archiveFileName: string | null;
|
||||||
|
archiveEntryName: string | null;
|
||||||
|
archiveMimeType: string | null;
|
||||||
|
archiveSizeBytes: number | null;
|
||||||
|
bucketId: string | null;
|
||||||
|
s3Key: string | null;
|
||||||
|
storageBackend: string | null;
|
||||||
|
isDeleted: boolean | null;
|
||||||
|
multipartUploadId: string | null;
|
||||||
|
partCount: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewFile = Omit<File, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/entities/bucket.ts`** — Standalone Bucket interface.
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/entities/file-part.ts`** — Standalone FilePart + NewFilePart types with `CompressionAlgorithm`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/entities/multipart.ts`** — Standalone MultipartUpload, MultipartPart types.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/domain/entities/file.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/domain/entities/
|
||||||
|
git commit -m "feat: create domain entities with JSDoc"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 6: Create domain/ports (repository interfaces)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/domain/ports/file-repository.ts`
|
||||||
|
- Create: `src/domain/ports/bucket-repository.ts`
|
||||||
|
- Create: `src/domain/ports/file-part-repository.ts`
|
||||||
|
- Create: `src/domain/ports/multipart-repository.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `IFileRepository`, `IBucketRepository`, `IFilePartRepository`, `IMultipartRepository`, `ITelegramService`, `S3FileRecord`, `CompressionAlgorithm`
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/ports/file-repository.ts`** — Define interface for all file operations currently in `db/files.ts` and `db/files-ext.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { File, NewFile } from '../entities/file';
|
||||||
|
|
||||||
|
export interface S3FileRecord extends File {
|
||||||
|
bucketId: string;
|
||||||
|
s3Key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFileRepository {
|
||||||
|
findByHash(hash: string): Promise<File | null>;
|
||||||
|
findByPublicId(publicId: string): Promise<File | null>;
|
||||||
|
findByUniqueId(telegramFileUniqueId: string): Promise<File | null>;
|
||||||
|
findByBucketAndKey(bucketId: string, s3Key: string): Promise<File | null>;
|
||||||
|
create(file: NewFile): Promise<File>;
|
||||||
|
listByPrefix(
|
||||||
|
bucketId: string,
|
||||||
|
prefix: string,
|
||||||
|
delimiter: string | null,
|
||||||
|
maxKeys: number,
|
||||||
|
startAfter: string | null,
|
||||||
|
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }>;
|
||||||
|
softDelete(bucketId: string, s3Key: string): Promise<boolean>;
|
||||||
|
softDeleteBatch(bucketId: string, keys: string[]): Promise<number>;
|
||||||
|
countByBucket(bucketId: string): Promise<number>;
|
||||||
|
findOrphansByBucket(bucketId: string): Promise<File[]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/ports/bucket-repository.ts`** — Interface for bucket CRUD.
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/ports/file-part-repository.ts`** — Interface for file parts operations.
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/ports/multipart-repository.ts`** — Interface for multipart upload operations.
|
||||||
|
|
||||||
|
- [ ] **Create `src/domain/ports/telegram-service.ts`** — Interface for Telegram operations.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Result of forwarding a file to Telegram storage */
|
||||||
|
export interface ForwardResult {
|
||||||
|
telegramFileId: string;
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
storageMessageId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File information returned by Telegram's getFile API */
|
||||||
|
export interface TelegramFileInfo {
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
file_path: string;
|
||||||
|
bot_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstraction over Telegram bot API operations */
|
||||||
|
export interface ITelegramService {
|
||||||
|
forwardToStorage(fileChunk: unknown, fileName: string, fileType: string): Promise<ForwardResult>;
|
||||||
|
getFileInfo(telegramFileId: string): Promise<TelegramFileInfo>;
|
||||||
|
enqueueUpload<T>(task: () => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/domain/ports/file-repository.ts --target=bun --outfile=/dev/null 2>&1
|
||||||
|
bun build src/domain/ports/telegram-service.ts --target=bun --outfile=/dev/null 2>&1
|
||||||
|
```
|
||||||
|
Expected: No errors (domain has no infrastructure dependencies)
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/domain/ports/
|
||||||
|
git commit -m "feat: create domain port interfaces with JSDoc"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Infrastructure Layer — Persistence, Telegram, Cache
|
||||||
|
|
||||||
|
#### Task 7: Create infrastructure/persistence/drizzle (DB init, schema, migrate)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/infrastructure/persistence/drizzle/index.ts`
|
||||||
|
- Create: `src/infrastructure/persistence/drizzle/schema.ts`
|
||||||
|
- Create: `src/infrastructure/persistence/drizzle/migrate.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: domain entities (for type alignment)
|
||||||
|
- Produces: `db` client, `files`, `fileParts` table definitions, `runMigration()`, Drizzle schema types
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/drizzle/index.ts`** — Move content from `src/db/index.ts`. Same implementation. Add JSDoc to exports.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/drizzle/schema.ts`** — Move content from `src/db/schema.ts`. Same implementation. Add JSDoc to table definitions and exported types.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/drizzle/migrate.ts`** — Move content from `src/db/migrate.ts`. Same implementation. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/infrastructure/persistence/drizzle/index.ts --target=bun --outfile=/dev/null 2>&1 | head -10
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/infrastructure/persistence/drizzle/
|
||||||
|
git commit -m "feat: create persistence drizzle layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 8: Create infrastructure/persistence/repositories (implement ports)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/infrastructure/persistence/repositories/file-repository.ts`
|
||||||
|
- Create: `src/infrastructure/persistence/repositories/bucket-repository.ts`
|
||||||
|
- Create: `src/infrastructure/persistence/repositories/file-part-repository.ts`
|
||||||
|
- Create: `src/infrastructure/persistence/repositories/multipart-repository.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `IFileRepository`, `IBucketRepository`, `IFilePartRepository`, `IMultipartRepository` from domain/ports
|
||||||
|
- Produces: Concrete repository classes implementing each interface
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/repositories/file-repository.ts`** — Implement `IFileRepository` using Drizzle. Extract logic from `src/db/files.ts` and `src/db/files-ext.ts`. Methods delegate to Drizzle queries (same SQL as original).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
import { db, files as fileSchema } from '../drizzle/index';
|
||||||
|
import type { File, NewFile } from '../../../domain/entities/file';
|
||||||
|
import type { IFileRepository, S3FileRecord } from '../../../domain/ports/file-repository';
|
||||||
|
|
||||||
|
export class DrizzleFileRepository implements IFileRepository {
|
||||||
|
async findByHash(hash: string): Promise<File | null> {
|
||||||
|
const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
// ... all methods from original files.ts + files-ext.ts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/repositories/bucket-repository.ts`** — Implement `IBucketRepository` from `src/db/buckets.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/repositories/file-part-repository.ts`** — Implement `IFilePartRepository` from `src/db/file-parts.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/persistence/repositories/multipart-repository.ts`** — Implement `IMultipartRepository` from `src/db/multipart.ts`.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/infrastructure/persistence/repositories/file-repository.ts --target=bun --outfile=/dev/null 2>&1 | head -10
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/infrastructure/persistence/repositories/
|
||||||
|
git commit -m "feat: create Drizzle repository implementations"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 9: Create infrastructure/telegram (bot-pool, queue, batcher, chunked-storage)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/infrastructure/telegram/types.ts`
|
||||||
|
- Create: `src/infrastructure/telegram/bot-pool.ts`
|
||||||
|
- Create: `src/infrastructure/telegram/upload-queue.ts`
|
||||||
|
- Create: `src/infrastructure/telegram/upload-batcher.ts`
|
||||||
|
- Create: `src/infrastructure/telegram/chunked-storage.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ITelegramService`, `IFilePartRepository`, `IFileRepository` from domain/ports
|
||||||
|
- Produces: `DrizzleFileRepository`, `BotPool`, etc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/telegram/types.ts`** — Extract Telegram-specific types from `src/utils/telegram.ts` (no longer inline).
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/telegram/bot-pool.ts`** — Move multi-bot pool + retry logic from `src/utils/telegram.ts`. Implement `ITelegramService` interface. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/telegram/upload-queue.ts`** — Move P-Queue wrapper from `src/utils/telegramQueue.ts`. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/telegram/upload-batcher.ts`** — Move batch upload logic from `src/utils/uploadBatcher.ts`. Add JSDoc. Accept repository interface instead of importing `db` directly.
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/telegram/chunked-storage.ts`** — Move chunked storage logic from `src/utils/chunked-storage.ts`. Accept repository interfaces. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/infrastructure/telegram/bot-pool.ts --target=bun --outfile=/dev/null 2>&1 | head -10
|
||||||
|
bun build src/infrastructure/telegram/upload-batcher.ts --target=bun --outfile=/dev/null 2>&1 | head -10
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/infrastructure/telegram/
|
||||||
|
git commit -m "feat: create infrastructure telegram layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 10: Create infrastructure/cache
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/infrastructure/cache/index.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Cache<T>`, `fileInfoCache` singleton
|
||||||
|
|
||||||
|
- [ ] **Create `src/infrastructure/cache/index.ts`** — Move generic TTL cache from `src/utils/cache.ts`. Add JSDoc. Same implementation.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/infrastructure/cache/index.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/infrastructure/cache/
|
||||||
|
git commit -m "feat: create infrastructure cache layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Application Layer — DTOs + Use Cases
|
||||||
|
|
||||||
|
#### Task 11: Create application/dto
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/application/dto/upload.ts`
|
||||||
|
- Create: `src/application/dto/file.ts`
|
||||||
|
- Create: `src/application/dto/bucket.ts`
|
||||||
|
- Create: `src/application/dto/s3.ts`
|
||||||
|
- Create: `src/application/dto/auth.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `UploadInput`, `UploadOutput`, `FileInfoResponse`, `BucketResponse`, `S3ObjectResponse`, `AuthSession`, etc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/dto/upload.ts`** — Define upload request/response DTOs.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Input for the upload file use case */
|
||||||
|
export interface UploadInput {
|
||||||
|
tempPath: string;
|
||||||
|
fileHash: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
uploaderId?: number;
|
||||||
|
bucketId?: string | null;
|
||||||
|
s3Key?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output from the upload file use case */
|
||||||
|
export interface UploadOutput {
|
||||||
|
publicId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
fileType: string;
|
||||||
|
createdAt: Date;
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/dto/file.ts`** — File info response DTOs.
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/dto/bucket.ts`** — Bucket CRUD DTOs.
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/dto/s3.ts`** — S3 operation DTOs (list, copy, multipart).
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/dto/auth.ts`** — Auth request/response DTOs (login input, session info).
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/application/dto/upload.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/application/dto/
|
||||||
|
git commit -m "feat: create application DTOs"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 12: Create application/use-cases (upload, get-file, auth)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/application/use-cases/upload-file.ts`
|
||||||
|
- Create: `src/application/use-cases/get-file.ts`
|
||||||
|
- Create: `src/application/use-cases/authenticate.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Repository interfaces from domain/ports, DTOs from application/dto
|
||||||
|
- Produces: Factory functions returning use case closures
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/use-cases/upload-file.ts`** — Extract upload business logic from `src/routes/upload.ts` + `src/utils/chunked-storage.ts`. Factory function pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import type { AppConfig } from '../../config/index';
|
||||||
|
import type { UploadInput, UploadOutput } from '../dto/upload';
|
||||||
|
|
||||||
|
export interface UploadFileUseCaseDeps {
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
filePartRepo: IFilePartRepository;
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
config: AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
|
||||||
|
return async (input: UploadInput): Promise<UploadOutput> => {
|
||||||
|
// 1. Check dedup (fileRepo.findByHash)
|
||||||
|
// 2. Check file size limits
|
||||||
|
// 3. Determine storage strategy: chunked vs single vs batch
|
||||||
|
// 4. Store via telegramService
|
||||||
|
// 5. Insert DB record via fileRepo
|
||||||
|
// 6. Build and return UploadOutput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/use-cases/get-file.ts`** — Extract file redirect/stream logic from `src/routes/files.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/use-cases/authenticate.ts`** — Extract login/logout/session logic from `src/routes/auth.ts` + `src/utils/auth.ts`.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/application/use-cases/upload-file.ts --target=bun --outfile=/dev/null 2>&1 | head -15
|
||||||
|
```
|
||||||
|
Expected: No errors (may have non-functional code until controllers wired up — skip if not compile-ready)
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/application/use-cases/authenticate.ts src/application/use-cases/upload-file.ts src/application/use-cases/get-file.ts
|
||||||
|
git commit -m "feat: create application use cases (upload, get-file, auth)"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 13: Create application/use-cases (bucket, s3-object, multipart)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/application/use-cases/manage-bucket.ts`
|
||||||
|
- Create: `src/application/use-cases/s3-object.ts`
|
||||||
|
- Create: `src/application/use-cases/multipart-upload.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Repository interfaces from domain/ports, DTOs from application/dto
|
||||||
|
- Produces: Factory functions for bucket CRUD, S3 object ops, multipart upload
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/use-cases/manage-bucket.ts`** — Extract bucket CRUD logic from `src/routes/web-api.ts` + `src/routes/s3.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/use-cases/s3-object.ts`** — Extract S3 get/put/delete/copy logic from `src/routes/s3.ts`. This is the biggest extraction.
|
||||||
|
|
||||||
|
- [ ] **Create `src/application/use-cases/multipart-upload.ts`** — Extract S3 multipart upload logic from `src/routes/s3.ts`.
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/application/use-cases/manage-bucket.ts src/application/use-cases/s3-object.ts src/application/use-cases/multipart-upload.ts
|
||||||
|
git commit -m "feat: create application use cases (bucket, s3-object, multipart)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Interfaces Layer — Controllers, Middleware, Routes, S3, Bot
|
||||||
|
|
||||||
|
#### Task 14: Create interfaces/s3 protocol files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/interfaces/s3/auth.ts`
|
||||||
|
- Create: `src/interfaces/s3/headers.ts`
|
||||||
|
- Create: `src/interfaces/s3/object-stream.ts`
|
||||||
|
- Create: `src/interfaces/s3/range.ts`
|
||||||
|
- Create: `src/interfaces/s3/virtual-host.ts`
|
||||||
|
- Create: `src/interfaces/s3/xml.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: SigV4 auth, S3 headers, object streaming, range parsing, virtual-host extraction, XML builders — all same as `src/utils/s3/*`
|
||||||
|
|
||||||
|
- [ ] **Create each file** — Move content from `src/utils/s3/auth.ts`, `src/utils/s3/headers.ts`, etc. Add JSDoc to each exported function. Same implementations.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/interfaces/s3/auth.ts --target=bun --outfile=/dev/null 2>&1 | head -5
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/interfaces/s3/
|
||||||
|
git commit -m "feat: create interfaces/s3 protocol layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 15: Create HTTP middleware (auth, rate-limit)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/interfaces/http/middleware/auth.ts`
|
||||||
|
- Create: `src/interfaces/http/middleware/rate-limit.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `requireAuth`, `getAuthSession`, `withRateLimit`, `checkRateLimit`, `cleanupRateLimitCache`
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/middleware/auth.ts`** — Move auth middleware from `src/utils/auth.ts`. Update imports to use `config` from `../../config/index`. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/middleware/rate-limit.ts`** — Move rate limiter from `src/utils/rateLimit.ts`. Add JSDoc.
|
||||||
|
|
||||||
|
- [ ] **Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun build src/interfaces/http/middleware/auth.ts --target=bun --outfile=/dev/null 2>&1 | head -10
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/interfaces/http/middleware/
|
||||||
|
git commit -m "feat: create HTTP middleware layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 16: Create HTTP controllers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/interfaces/http/controllers/upload-controller.ts`
|
||||||
|
- Create: `src/interfaces/http/controllers/file-controller.ts`
|
||||||
|
- Create: `src/interfaces/http/controllers/auth-controller.ts`
|
||||||
|
- Create: `src/interfaces/http/controllers/home-controller.ts`
|
||||||
|
- Create: `src/interfaces/http/controllers/health-controller.ts`
|
||||||
|
- Create: `src/interfaces/http/controllers/s3-controller.ts`
|
||||||
|
- Create: `src/interfaces/http/controllers/web-api-controller.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Use case factory functions from application layer
|
||||||
|
- Produces: HTTP handler functions compatible with `Bun.serve()` routes
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/upload-controller.ts`** — Extract upload HTTP handling from `src/routes/upload.ts`. Controller parses request, calls use case, formats response:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createUploadFileUseCase } from '../../../application/use-cases/upload-file';
|
||||||
|
// ... setup use case with dependencies
|
||||||
|
// ... handler functions for multipart and JSON upload paths
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/file-controller.ts`** — Extract from `src/routes/files.ts`. Controller handles params, calls getFile use case, handles redirect/stream.
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/auth-controller.ts`** — Extract from `src/routes/auth.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/home-controller.ts`** — Move from `src/routes/home.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/health-controller.ts`** — Move from `src/routes/health.ts`.
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/s3-controller.ts`** — Extract S3 dispatching from `src/routes/s3.ts`. This is the largest extraction — split into clear sections (bucket ops, object ops, multipart ops). The 1179-line file becomes a focused controller that delegates to use cases.
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/controllers/web-api-controller.ts`** — Extract from `src/routes/web-api.ts`.
|
||||||
|
|
||||||
|
- [ ] **Move `src/home.html` to `src/interfaces/http/controllers/home.html`** — Update reference in home-controller.
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/interfaces/http/controllers/
|
||||||
|
git commit -m "feat: create HTTP controller layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 17: Create HTTP route definitions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/interfaces/http/routes/index.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: All controller handler functions
|
||||||
|
- Produces: Route table for `Bun.serve()`
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/http/routes/index.ts`** — Define route table matching current `src/index.ts` routes. Each route maps to its controller handler. Add JSDoc.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Server } from 'bun';
|
||||||
|
import { handleUpload } from '../controllers/upload-controller';
|
||||||
|
import { handleFileRedirect, handleFileInfo } from '../controllers/file-controller';
|
||||||
|
// ... other imports
|
||||||
|
|
||||||
|
/** Defines all HTTP routes for the application */
|
||||||
|
export const routes: Server['routes'] = {
|
||||||
|
'/api/upload': { POST: handleUpload },
|
||||||
|
'/f/:public_id': { GET: handleFileRedirect },
|
||||||
|
'/file/:public_id/info': { GET: handleFileInfo },
|
||||||
|
'/health': { GET: handleHealth },
|
||||||
|
'/docs': { GET: handleSwaggerHtml },
|
||||||
|
'/swagger.json': { GET: handleSwaggerJson },
|
||||||
|
// ... etc
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/interfaces/http/routes/
|
||||||
|
git commit -m "feat: create HTTP route definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 18: Create interfaces/bot
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/interfaces/bot/handler.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ITelegramService`, use cases
|
||||||
|
- Produces: `startBot()` function for Telegram bot lifecycle
|
||||||
|
|
||||||
|
- [ ] **Create `src/interfaces/bot/handler.ts`** — Move bot handler logic from `src/bot.ts`. Add JSDoc. Update imports to use new structure.
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/interfaces/bot/
|
||||||
|
git commit -m "feat: create bot interface layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Entry Point Rewire
|
||||||
|
|
||||||
|
#### Task 19: Rewrite src/index.ts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/index.ts`
|
||||||
|
- Modify (delete): `src/bot.ts`
|
||||||
|
|
||||||
|
- [ ] **Rewrite `src/index.ts`** — Update to import from new structure. Server bootstrap, S3 detection, route table from `interfaces/http/routes/index`, lifecycle management.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serve } from 'bun';
|
||||||
|
import { config } from './config/index';
|
||||||
|
import { startBot } from './interfaces/bot/handler';
|
||||||
|
import { routes } from './interfaces/http/routes/index';
|
||||||
|
import { isS3Request } from './interfaces/s3/auth';
|
||||||
|
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
||||||
|
import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
|
||||||
|
import { fileInfoCache } from './infrastructure/cache/index';
|
||||||
|
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
|
||||||
|
import { logger } from './shared/logger/index';
|
||||||
|
import { metricsCollector } from './shared/metrics/index';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Remove `src/bot.ts`** (replaced by `src/interfaces/bot/handler.ts`)
|
||||||
|
|
||||||
|
- [ ] **Run full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
```
|
||||||
|
Expected: All tests PASS (old src files still exist for backward compat)
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/index.ts src/interfaces/bot/handler.ts
|
||||||
|
git rm src/bot.ts
|
||||||
|
git commit -m "feat: rewire entry point to new architecture"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Test Restructure
|
||||||
|
|
||||||
|
#### Task 20: Create mirror test structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/unit/domain/entities/` (entity type tests)
|
||||||
|
- Create: `test/unit/application/use-cases/` (use case tests)
|
||||||
|
- Create: `test/unit/shared/` (util tests)
|
||||||
|
- Create: `test/integration/interfaces/http/` (route tests)
|
||||||
|
- Create: `test/integration/infrastructure/` (repo tests)
|
||||||
|
|
||||||
|
- [ ] **Create test folder structure mirroring src/**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p test/unit/domain/entities \
|
||||||
|
test/unit/application/use-cases \
|
||||||
|
test/unit/shared \
|
||||||
|
test/integration/interfaces/http \
|
||||||
|
test/integration/infrastructure/persistence \
|
||||||
|
test/integration/infrastructure/telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Move util tests** — Copy `test/env.test.ts` → `test/unit/config/env.test.ts`, update import path to `src/config/index`. Copy `test/file.test.ts` → `test/unit/shared/file.test.ts`, update imports. Copy `test/zip.test.ts` → `test/unit/shared/zip.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Move route tests** — Copy `test/upload.test.ts` → `test/integration/interfaces/http/upload.test.ts`, update imports. Copy `test/files.test.ts` → `test/integration/interfaces/http/files.test.ts`. Copy `test/auth-routes.test.ts`, `test/health.test.ts`, `test/swagger.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Move S3 tests** — Copy `test/s3-auth.test.ts` → `test/unit/interfaces/s3/auth.test.ts`. Copy `test/s3-range.test.ts`, `test/s3-operations.test.ts`, `test/s3-bucket-config.test.ts`, `test/s3-object-stream.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Move infrastructure tests** — Copy `test/db.test.ts` → `test/unit/infrastructure/persistence/drizzle/schema.test.ts`. Copy `test/chunked-storage.test.ts` → `test/unit/infrastructure/telegram/chunked-storage.test.ts`. Copy `test/telegram.test.ts`, `test/telegramQueue.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Keep original test files** — Do NOT delete them yet. Original tests still reference old source paths and need to keep passing for now.
|
||||||
|
|
||||||
|
- [ ] **Update test runner scripts in package.json** — Add test patterns for new test locations:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test:new": "bun test test/unit/ test/integration/",
|
||||||
|
"test:all": "bun test && bun test test/unit/ test/integration/"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run both old and new tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test test/env.test.ts
|
||||||
|
bun test test/unit/config/env.test.ts
|
||||||
|
```
|
||||||
|
Expected: Both PASS
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add test/unit/ test/integration/ package.json
|
||||||
|
git commit -m "test: restructure tests to mirror new architecture"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: Cleanup — Delete Old Files
|
||||||
|
|
||||||
|
#### Task 21: Remove old source directories
|
||||||
|
|
||||||
|
**Files:** Delete old source directories once new structure is verified.
|
||||||
|
|
||||||
|
- [ ] **Delete old directories** (only after all tests pass with new imports)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf src/db src/routes src/utils src/env.ts src/bot.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run full test suite to confirm nothing is broken**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
bun test test/unit/ test/integration/
|
||||||
|
```
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm -r src/db src/routes src/utils src/env.ts src/bot.ts
|
||||||
|
git commit -m "chore: remove legacy source directories after migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task 22: Final verification
|
||||||
|
|
||||||
|
- [ ] **Full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test:all
|
||||||
|
```
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
- [ ] **Build verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
Expected: dist/index.js and dist/migrate.js compile without errors
|
||||||
|
|
||||||
|
- [ ] **Lint check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx biome check src test/unit test/integration
|
||||||
|
```
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Final commit** if any fixes were needed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: final cleanup after architecture migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Coverage Check
|
||||||
|
|
||||||
|
| Spec Requirement | Covered In |
|
||||||
|
|-----------------|------------|
|
||||||
|
| New folder structure | Task 5-19 (all phases) |
|
||||||
|
| DDD layers | Phase 2 (domain), Phase 4 (application), Phase 3 (infrastructure), Phase 5 (interfaces) |
|
||||||
|
| Repository interfaces | Task 6 |
|
||||||
|
| Repository implementations | Task 8 |
|
||||||
|
| Controller/use-case separation | Task 12-13 (use cases), Task 16 (controllers) |
|
||||||
|
| Dependency injection (factory functions) | Task 12 |
|
||||||
|
| Telegram service interface | Task 6 (ITelegramService port) |
|
||||||
|
| Bot moved to interfaces/bot | Task 18 |
|
||||||
|
| S3 protocol moved to interfaces/s3 | Task 14 |
|
||||||
|
| Config moved to config/ | Task 1 |
|
||||||
|
| Shared utilities moved to shared/ | Task 2-4 |
|
||||||
|
| Error classes | Task 2 |
|
||||||
|
| JSDoc on all exports | All creation tasks |
|
||||||
|
| Test restructure | Task 20 |
|
||||||
|
| Zero behavior changes | No logic altered — only moved/extracted |
|
||||||
|
| Delete old files | Task 21 |
|
||||||
|
| Final verification | Task 22 |
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
# Per-Bot Queue Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace global PQueue with per-bot queues (concurrency=1 per bot) to eliminate 429 collisions and improve rate-limit safety.
|
||||||
|
|
||||||
|
**Architecture:** Each bot token gets its own PQueue with concurrency=1. Uploads are assigned to the least-loaded available bot via `selectBot()`. On 429, the bot is marked rate-limited and the upload retries on the next available bot. The global `upload-queue.ts` is removed; `uploadConcurrency` config is replaced by `botCount * perBotConcurrency`.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, PQueue, Telegraf
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Use `Bun` runtime, not Node.js
|
||||||
|
- Follow existing code style (Biome lint)
|
||||||
|
- Each bot queue has concurrency=1 (no two uploads hit same bot simultaneously)
|
||||||
|
- `selectBot()` picks bot with lowest pending queue count, skipping rate-limited bots
|
||||||
|
- Remove `uploadConcurrency` from config; derive effective concurrency from bot count
|
||||||
|
- Remove `upload-queue.ts` entirely
|
||||||
|
- Remove `enqueueUpload` from `ITelegramService` interface
|
||||||
|
|
||||||
|
---
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/infrastructure/telegram/bot-pool.ts` | BotEntry array, selectBot(), per-bot queues, retry logic |
|
||||||
|
| `src/domain/ports/telegram-service.ts` | Remove `enqueueUpload` from interface |
|
||||||
|
| `src/env.ts` | Remove `uploadConcurrency` field |
|
||||||
|
| `src/index.ts` | Remove upload-queue import and usage |
|
||||||
|
| `src/utils/chunked-storage.ts` | Replace `config.uploadConcurrency` with bot count |
|
||||||
|
|
||||||
|
### Files to Delete
|
||||||
|
| File | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `src/infrastructure/telegram/upload-queue.ts` | Global queue replaced by per-bot queues |
|
||||||
|
| `test/telegramQueue.test.ts` | Tests for deleted module |
|
||||||
|
|
||||||
|
### Files Not Changed
|
||||||
|
| File | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `test/bot.test.ts` | Only uses ITelegramService interface (via `forwardToStorage`) |
|
||||||
|
| `src/infrastructure/telegram/chunked-storage.ts` | Uses ITelegramService interface, not BotPool directly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Refresh the spec & plan files after compaction
|
||||||
|
|
||||||
|
Due to context compaction, re-read the current spec and plan files to ensure accuracy before implementing.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Re-read the spec**
|
||||||
|
|
||||||
|
Read: `docs/superpowers/specs/2026-07-29-per-bot-queue-design.md`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Re-read key implementation files**
|
||||||
|
|
||||||
|
Read: `src/infrastructure/telegram/bot-pool.ts`, `src/env.ts`, `src/utils/chunked-storage.ts`
|
||||||
|
|
||||||
|
### Task 2: Refactor ITelegramService interface
|
||||||
|
|
||||||
|
Remove `enqueueUpload` from the interface — BotPool handles queueing internally now.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/domain/ports/telegram-service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove `enqueueUpload` from interface**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/ports/telegram-service.ts — remove entire section:
|
||||||
|
/**
|
||||||
|
* Enqueue a task for sequential upload execution.
|
||||||
|
*
|
||||||
|
* Ensures only one Telegram upload runs at a time to avoid
|
||||||
|
* rate limits and resource contention.
|
||||||
|
*
|
||||||
|
* @param task - An async function performing the upload.
|
||||||
|
* @returns The result of the task.
|
||||||
|
*/
|
||||||
|
enqueueUpload<T>(task: () => Promise<T>): Promise<T>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint to verify**
|
||||||
|
|
||||||
|
Run: `bunx biome check src/domain/ports/telegram-service.ts`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/domain/ports/telegram-service.ts
|
||||||
|
git commit -m "refactor: remove enqueueUpload from ITelegramService
|
||||||
|
|
||||||
|
Per-bot queue handles queueing internally.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Refactor BotPool with per-bot queues
|
||||||
|
|
||||||
|
The core of the redesign. Replace `claimBotIndex()` round-robin with per-bot PQueue instances and `selectBot()` for least-loaded assignment.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/infrastructure/telegram/bot-pool.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ITelegramService` (no `enqueueUpload` method)
|
||||||
|
- Produces: `botPool` singleton with per-bot queues, `selectBot()`, per-bot rate-limit tracking
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write test file for per-bot queue behavior**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// test/bot-pool.test.ts
|
||||||
|
import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
|
||||||
|
// We'll test the BotEntry queue behavior and selectBot logic
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement BotEntry type and BotPool refactor**
|
||||||
|
|
||||||
|
Replace the class body:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import PQueue from 'p-queue';
|
||||||
|
import { Telegraf } from 'telegraf';
|
||||||
|
import type {
|
||||||
|
ForwardResult,
|
||||||
|
ITelegramService,
|
||||||
|
TelegramFileInfo,
|
||||||
|
} from '../../domain/ports/telegram-service';
|
||||||
|
import { config } from '../../env';
|
||||||
|
import logger from '../../shared/logger/index';
|
||||||
|
import {
|
||||||
|
buildSendPayload,
|
||||||
|
extractUploadedFile,
|
||||||
|
type SendMethod,
|
||||||
|
sendMethodMap,
|
||||||
|
type TelegramMessageResult,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const sleep = (ms: number): Promise<void> =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const isTransientError = (error: unknown): boolean => {
|
||||||
|
const str = error instanceof Error ? error.message : String(error);
|
||||||
|
const transientPatterns = [
|
||||||
|
'timeout', 'Timed out', 'etimedout', 'econnrefused', 'econnreset',
|
||||||
|
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', '5xx', '502', '503', '504',
|
||||||
|
'Bad Gateway', 'Service Unavailable', 'Gateway Timeout', 'socket hang up',
|
||||||
|
'socket closed', 'fetch failed', 'network error', 'network timeout',
|
||||||
|
'API closed', 'read ECONNRESET', 'write EPIPE',
|
||||||
|
];
|
||||||
|
return transientPatterns.some((p) => str.toLowerCase().includes(p.toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_TRANSIENT_RETRIES = 3;
|
||||||
|
const TELEGRAM_API_TIMEOUT_MS = 120_000;
|
||||||
|
const PER_BOT_CONCURRENCY = 1;
|
||||||
|
|
||||||
|
interface BotEntry {
|
||||||
|
index: number;
|
||||||
|
token: string;
|
||||||
|
instance: Telegraf;
|
||||||
|
queue: PQueue;
|
||||||
|
rateLimitedUntil: number; // 0 = not rate-limited
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BotPool implements ITelegramService {
|
||||||
|
private readonly bots: BotEntry[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const tokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
|
||||||
|
this.bots = tokens.map((token, index) => ({
|
||||||
|
index,
|
||||||
|
token,
|
||||||
|
instance: new Telegraf(token),
|
||||||
|
queue: new PQueue({ concurrency: PER_BOT_CONCURRENCY }),
|
||||||
|
rateLimitedUntil: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of bots in the pool */
|
||||||
|
get size(): number {
|
||||||
|
return this.bots.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the bot with the fewest pending tasks that isn't rate-limited
|
||||||
|
* or in the skip set.
|
||||||
|
*/
|
||||||
|
private selectBot(skipIndexes?: Set<number>): BotEntry | null {
|
||||||
|
let best: BotEntry | null = null;
|
||||||
|
let bestPending = Infinity;
|
||||||
|
|
||||||
|
for (const bot of this.bots) {
|
||||||
|
if (skipIndexes?.has(bot.index)) continue;
|
||||||
|
if (bot.rateLimitedUntil > Date.now()) continue;
|
||||||
|
|
||||||
|
const pending = bot.queue.pending + bot.queue.size;
|
||||||
|
if (pending < bestPending) {
|
||||||
|
bestPending = pending;
|
||||||
|
best = bot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Telegram API action on a specific bot entry.
|
||||||
|
* Wraps with timeout.
|
||||||
|
*/
|
||||||
|
private async executeBotAction<T>(
|
||||||
|
bot: BotEntry,
|
||||||
|
action: (instance: Telegraf, token: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
action(bot.instance, bot.token),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`Telegram API timeout after ${TELEGRAM_API_TIMEOUT_MS}ms`)),
|
||||||
|
TELEGRAM_API_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward a file chunk to the configured Telegram storage chat.
|
||||||
|
*
|
||||||
|
* The upload is submitted to the least-loaded bot's queue. If the bot
|
||||||
|
* returns 429, it is marked rate-limited and the upload retries on the
|
||||||
|
* next available bot. If all bots are rate-limited, sleeps before retrying.
|
||||||
|
*/
|
||||||
|
async forwardToStorage(
|
||||||
|
fileChunk: unknown,
|
||||||
|
fileName: string,
|
||||||
|
fileType: string,
|
||||||
|
): Promise<ForwardResult> {
|
||||||
|
let lastError: unknown;
|
||||||
|
const attemptedIndexes = new Set<number>();
|
||||||
|
let transientAttempts = 0;
|
||||||
|
|
||||||
|
// Outer retry loop — up to 10 attempts across all bots
|
||||||
|
for (let attempt = 0; attempt < 10; attempt++) {
|
||||||
|
const bot = this.selectBot(attemptedIndexes);
|
||||||
|
|
||||||
|
if (!bot) {
|
||||||
|
// No available bots — either all rate-limited or all attempted
|
||||||
|
if (attemptedIndexes.size > 0) {
|
||||||
|
// All non-rate-limited bots were tried and failed — wait & reset
|
||||||
|
logger.warn('All available bots exhausted, sleeping 5s before retry');
|
||||||
|
await sleep(5000 + Math.random() * 1000);
|
||||||
|
attemptedIndexes.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// All bots rate-limited — wait for the shortest cooldown
|
||||||
|
const earliestCooldown = Math.min(
|
||||||
|
...this.bots.map((b) => b.rateLimitedUntil || Infinity),
|
||||||
|
);
|
||||||
|
const waitMs = Math.max(1000, earliestCooldown - Date.now() + 500);
|
||||||
|
logger.warn('All bots rate-limited, waiting', { waitMs });
|
||||||
|
await sleep(waitMs);
|
||||||
|
attemptedIndexes.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptedIndexes.add(bot.index);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await bot.queue.add(async () => {
|
||||||
|
// Inner transient retry loop inside the queue
|
||||||
|
for (let innerRetry = 0; innerRetry <= MAX_TRANSIENT_RETRIES; innerRetry++) {
|
||||||
|
try {
|
||||||
|
const filePayload = { source: fileChunk, filename: fileName };
|
||||||
|
const sendMethodName = sendMethodMap[fileType] || 'sendDocument';
|
||||||
|
const payload = buildSendPayload(fileType, fileName);
|
||||||
|
|
||||||
|
const tgResult = await this.executeBotAction<TelegramMessageResult>(
|
||||||
|
bot,
|
||||||
|
(activeBot) => {
|
||||||
|
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
|
||||||
|
return telegram[sendMethodName](config.storageChatId, filePayload, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedFile = extractUploadedFile(tgResult, fileType);
|
||||||
|
return {
|
||||||
|
telegramFileId: uploadedFile?.file_id || '',
|
||||||
|
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
|
||||||
|
storageMessageId: tgResult.message_id,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
const retryAfterMatch = errorStr.match(/retry after (\d+)/i);
|
||||||
|
|
||||||
|
if (retryAfterMatch) {
|
||||||
|
// 429 — mark bot rate-limited, throw to outer loop for retry on different bot
|
||||||
|
const seconds = parseInt(retryAfterMatch[1], 10);
|
||||||
|
bot.rateLimitedUntil = Date.now() + seconds * 1000;
|
||||||
|
logger.info(`Bot #${bot.index} rate-limited for ${seconds}s`, { fileName, attempt });
|
||||||
|
throw error; // caught by outer retry loop
|
||||||
|
}
|
||||||
|
|
||||||
|
if (innerRetry < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
|
||||||
|
const backoffMs = Math.min(1000 * 2 ** innerRetry, 10_000);
|
||||||
|
logger.warn(
|
||||||
|
`Transient error on bot #${bot.index}, retrying (${innerRetry + 1}/${MAX_TRANSIENT_RETRIES})`,
|
||||||
|
{ fileName, error: errorStr, backoffMs },
|
||||||
|
);
|
||||||
|
await sleep(backoffMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error; // non-transient — propagate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Exhausted transient retries on bot #${bot.index}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('File forwarded to storage', { fileName, message: result.storageMessageId });
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
lastError = error;
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
const retryAfterMatch = errorStr.match(/retry after (\d+)/i);
|
||||||
|
|
||||||
|
if (retryAfterMatch) {
|
||||||
|
// Bot was rate-limited — already marked, try next bot
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient error at the queue level (timeout, 5xx)
|
||||||
|
if (transientAttempts < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
|
||||||
|
transientAttempts++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-transient — give up
|
||||||
|
logger.error('Failed to forward file to storage', {
|
||||||
|
fileName,
|
||||||
|
error: errorStr,
|
||||||
|
attempt,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Failed to forward file after all retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get total effective concurrency across all bots */
|
||||||
|
getEffectiveConcurrency(): number {
|
||||||
|
return this.bots.length * PER_BOT_CONCURRENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const bot of this.bots) {
|
||||||
|
for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry++) {
|
||||||
|
try {
|
||||||
|
const result = await bot.instance.telegram.getFile(telegramFileId);
|
||||||
|
const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
|
||||||
|
return {
|
||||||
|
file_size: fileData.file_size || 0,
|
||||||
|
mime_type: fileData.mime_type || 'application/octet-stream',
|
||||||
|
file_path: fileData.file_path || '',
|
||||||
|
bot_token: bot.token,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
lastError = error;
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
if (
|
||||||
|
errorStr.includes('wrong file_id') ||
|
||||||
|
errorStr.includes('file is temporarily unavailable')
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (retry < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
|
||||||
|
const backoffMs = Math.min(1000 * 2 ** (retry + 1), 5_000);
|
||||||
|
await sleep(backoffMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.error('Failed to get file info from any bot', {
|
||||||
|
error: lastError instanceof Error ? lastError.message : String(lastError),
|
||||||
|
});
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const botPool = new BotPool();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint**
|
||||||
|
|
||||||
|
Run: `bunx biome check src/infrastructure/telegram/bot-pool.ts`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run existing test suite**
|
||||||
|
|
||||||
|
Run: `bun test test/bot.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/infrastructure/telegram/bot-pool.ts
|
||||||
|
git commit -m "refactor: per-bot queue with selectBot() and rate-limit tracking
|
||||||
|
|
||||||
|
Each bot has its own PQueue (concurrency=1). Uploads are assigned to
|
||||||
|
the least-loaded available bot. On 429, the bot is marked rate-limited
|
||||||
|
and the upload retries on the next available bot.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Remove global upload queue
|
||||||
|
|
||||||
|
Delete the global queue, its test, and all references to it from index.ts.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `src/infrastructure/telegram/upload-queue.ts`
|
||||||
|
- Modify: `src/index.ts` (lines 4, 71-81)
|
||||||
|
- Delete: `test/telegramQueue.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Delete upload-queue.ts**
|
||||||
|
|
||||||
|
Run: `rm src/infrastructure/telegram/upload-queue.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delete the test file**
|
||||||
|
|
||||||
|
Run: `rm test/telegramQueue.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update index.ts — remove upload-queue import and shutdown drain logic**
|
||||||
|
|
||||||
|
Remove line:
|
||||||
|
```typescript
|
||||||
|
import { clearQueue, getQueueStats, waitForQueue } from './infrastructure/telegram/upload-queue';
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the drain block (lines 70-81):
|
||||||
|
```typescript
|
||||||
|
// Drain pending upload queue with a timeout
|
||||||
|
const { pending, size } = getQueueStats();
|
||||||
|
if (pending > 0 || size > 0) {
|
||||||
|
logger.info('Draining upload queue', { pending, size });
|
||||||
|
const drainTimeout = setTimeout(() => {
|
||||||
|
logger.warn('Upload queue drain timeout — clearing remaining tasks');
|
||||||
|
clearQueue();
|
||||||
|
}, 30_000);
|
||||||
|
await waitForQueue();
|
||||||
|
clearTimeout(drainTimeout);
|
||||||
|
logger.info('Upload queue drained');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run lint**
|
||||||
|
|
||||||
|
Run: `bunx biome check src/index.ts`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass (some may be skipped due to missing queue test).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/infrastructure/telegram/upload-queue.ts test/telegramQueue.test.ts src/index.ts
|
||||||
|
git commit -m "refactor: remove global upload queue
|
||||||
|
|
||||||
|
Per-bot queues now handle concontrol internally.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Update env.ts and chunked-storage backpressure
|
||||||
|
|
||||||
|
Remove `uploadConcurrency` from config and update chunked-storage to derive effective concurrency from bot pool.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/env.ts`
|
||||||
|
- Modify: `src/utils/chunked-storage.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove `uploadConcurrency` from env.ts**
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
```typescript
|
||||||
|
uploadConcurrency: number;
|
||||||
|
```
|
||||||
|
and:
|
||||||
|
```typescript
|
||||||
|
uploadConcurrency: parseNumber(process.env.UPLOAD_CONCURRENCY, 8),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update chunked-storage.ts backpressure**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```typescript
|
||||||
|
import { config } from '../env';
|
||||||
|
// ...
|
||||||
|
if (inFlight.size >= config.uploadConcurrency * 2) {
|
||||||
|
```
|
||||||
|
With:
|
||||||
|
```typescript
|
||||||
|
import { botPool } from '../infrastructure/telegram/bot-pool';
|
||||||
|
// ...
|
||||||
|
if (inFlight.size >= botPool.getEffectiveConcurrency()) {
|
||||||
|
```
|
||||||
|
(Use effective concurrency * 2 for backpressure, or just use effective concurrency as the limit.)
|
||||||
|
|
||||||
|
Actually let me think about this more carefully. The backpressure in chunked-storage:
|
||||||
|
```
|
||||||
|
if (inFlight.size >= config.uploadConcurrency * 2) {
|
||||||
|
await Promise.race(inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This limits the number of in-flight chunks per file. With `uploadConcurrency: 16`, it was 32. Now with effective concurrency of 6 (6 bots), it would be 12. That's fine as backpressure — it prevents too many chunks from being in memory at once.
|
||||||
|
|
||||||
|
Let me use `botPool.getEffectiveConcurrency() * 2` to keep the same multiplier.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint and tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx biome check src/env.ts src/utils/chunked-storage.ts
|
||||||
|
bun test test/chunked-storage.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All checks pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/env.ts src/utils/chunked-storage.ts
|
||||||
|
git commit -m "refactor: remove uploadConcurrency from config
|
||||||
|
|
||||||
|
Effective concurrency derived from bot pool size. Chunked-storage
|
||||||
|
backpressure now uses botPool.getEffectiveConcurrency().
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Full integration test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run: `bunx biome check src test`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create summary commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "refactor: implement per-bot queue architecture
|
||||||
|
|
||||||
|
- Each bot has its own PQueue with concurrency=1
|
||||||
|
- selectBot() assigns uploads to least-loaded available bot
|
||||||
|
- 429 rate limits are tracked per-bot with cooldown timers
|
||||||
|
- Failed uploads retry on next available bot
|
||||||
|
- Removed global upload-queue.ts and uploadConcurrency config
|
||||||
|
- Updated ITelegramService interface
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Telegram Bot Uploader Backend Design
|
# Telegram Bot Uploader Backend Design
|
||||||
|
|
||||||
|
> ⚠️ **LEGACY** — Dokumen historis (2026-05-17). Port & infrastruktur sudah berubah:
|
||||||
|
> produksi kini berjalan di port `4000` (Nix + systemd + Caddy, domain `upload.asepharyana.my.id`)
|
||||||
|
> dan database via PgBouncer pool `100.121.180.82:6432` (bukan port 5432, bukan localhost).
|
||||||
|
|
||||||
|
|
||||||
**Date:** 2026-05-17
|
**Date:** 2026-05-17
|
||||||
**Status:** Approved
|
**Status:** Approved
|
||||||
**Stack:** Bun, Telegraf, PostgreSQL, Drizzle ORM, Winston, nanoid
|
**Stack:** Bun, Telegraf, PostgreSQL, Drizzle ORM, Winston, nanoid
|
||||||
@@ -35,7 +40,7 @@ Production-ready backend untuk Telegram file uploader dengan dual upload methods
|
|||||||
4. Bot extracts `telegram_file_id`, `telegram_file_unique_id`, `storage_message_id`
|
4. Bot extracts `telegram_file_id`, `telegram_file_unique_id`, `storage_message_id`
|
||||||
5. Bot generates `public_id` using nanoid
|
5. Bot generates `public_id` using nanoid
|
||||||
6. Bot saves metadata to PostgreSQL
|
6. Bot saves metadata to PostgreSQL
|
||||||
7. Bot replies with download link: `https://tele.asepharyana.my.id/f/{public_id}`
|
7. Bot replies with download link: `https://upload.asepharyana.my.id/f/{public_id}`
|
||||||
|
|
||||||
#### Upload via HTTP API
|
#### Upload via HTTP API
|
||||||
1. Client POSTs to `/api/upload` with file (multipart or base64)
|
1. Client POSTs to `/api/upload` with file (multipart or base64)
|
||||||
@@ -115,7 +120,7 @@ fileName: optional_filename.ext
|
|||||||
"size_bytes": 1024000,
|
"size_bytes": 1024000,
|
||||||
"file_type": "document",
|
"file_type": "document",
|
||||||
"created_at": "2026-05-17T23:42:19Z",
|
"created_at": "2026-05-17T23:42:19Z",
|
||||||
"download_url": "https://tele.asepharyana.my.id/f/abc123xyz"
|
"download_url": "https://upload.asepharyana.my.id/f/abc123xyz"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,9 +217,9 @@ schema.sql
|
|||||||
```
|
```
|
||||||
BOT_TOKEN=<telegram_bot_token>
|
BOT_TOKEN=<telegram_bot_token>
|
||||||
STORAGE_CHANNEL_ID=<private_channel_id>
|
STORAGE_CHANNEL_ID=<private_channel_id>
|
||||||
BASE_URL=https://tele.asepharyana.my.id
|
BASE_URL=https://upload.asepharyana.my.id
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
|
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/uploader
|
||||||
PORT=3000
|
PORT=4000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ metadata:
|
|||||||
|
|
||||||
# Design: TeleUploader Deployment & CI/CD Setup
|
# Design: TeleUploader Deployment & CI/CD Setup
|
||||||
|
|
||||||
|
> ⚠️ **LEGACY** — Dokumen historis (2026-05-18) untuk arsitektur Docker + Traefik +
|
||||||
|
> GitHub Actions. Docker & Traefik sudah dihapus dari VPS produksi (2026-08-02):
|
||||||
|
> deploy sekarang Nix + systemd + Caddy di orangevps, port `4000`, domain
|
||||||
|
> `upload.asepharyana.my.id`, database via PgBouncer pool `100.121.180.82:6432`.
|
||||||
|
|
||||||
|
|
||||||
We are setting up production deployment for TeleUploader on VPS `45.127.35.244` behind Traefik utilizing GitHub Actions.
|
We are setting up production deployment for TeleUploader on VPS `45.127.35.244` behind Traefik utilizing GitHub Actions.
|
||||||
|
|
||||||
## 1. System Architecture
|
## 1. System Architecture
|
||||||
@@ -22,7 +28,7 @@ TeleUploader is a Bun-based service.
|
|||||||
### `Dockerfile`
|
### `Dockerfile`
|
||||||
- Multi-stage build.
|
- Multi-stage build.
|
||||||
- **Stage 1 (Build)**: Install dependencies, copy source files, run Biome lint/format checks, compile TS build to `dist/index.js` using `bun build`.
|
- **Stage 1 (Build)**: Install dependencies, copy source files, run Biome lint/format checks, compile TS build to `dist/index.js` using `bun build`.
|
||||||
- **Stage 2 (Run)**: Use minimal `oven/bun:1.1-slim` runtime. Copy `dist/index.js`, `schema.sql`, and `package.json`. Expose port `3000`.
|
- **Stage 2 (Run)**: Use minimal `oven/bun:1.1-slim` runtime. Copy `dist/index.js`, `schema.sql`, and `package.json`. Expose port `4000`.
|
||||||
|
|
||||||
### `docker-compose.yml`
|
### `docker-compose.yml`
|
||||||
```yaml
|
```yaml
|
||||||
@@ -38,7 +44,7 @@ services:
|
|||||||
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
|
||||||
- BASE_URL=${BASE_URL}
|
- BASE_URL=${BASE_URL}
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- PORT=3000
|
- PORT=4000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
networks:
|
networks:
|
||||||
@@ -49,7 +55,7 @@ services:
|
|||||||
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
||||||
- "traefik.http.routers.teleuploader.tls=true"
|
- "traefik.http.routers.teleuploader.tls=true"
|
||||||
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
|
- "traefik.http.services.teleuploader.loadbalancer.server.port=4000"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-shared-net:
|
app-shared-net:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Gitea Remote and CI/CD Migration Design
|
# Gitea Remote and CI/CD Migration Design
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Move the repository origin from GitLab to a new Gitea repository and add a Gitea Actions deployment flow that behaves like a GitHub Actions CI/CD pipeline.
|
Move the repository origin from GitLab to a new Gitea repository and add a Gitea Actions deployment flow that behaves like a GitHub Actions CI/CD pipeline.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# S3 Compatibility Completion Design
|
# S3 Compatibility Completion Design
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
|
||||||
|
|
||||||
Date: 2026-07-07
|
Date: 2026-07-07
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# DDD & Clean Architecture Restructure — TeleUploader (filedrop)
|
||||||
|
|
||||||
|
**Date:** 2026-07-28
|
||||||
|
**Author:** Claude
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
The current codebase uses a flat structure (`src/routes/`, `src/utils/`, `src/db/`) that mixes concerns:
|
||||||
|
|
||||||
|
- Route handlers contain inline DB queries, Telegram API calls, and business logic
|
||||||
|
- Domain entity types (from Drizzle ORM) leak into all layers
|
||||||
|
- No clear separation between HTTP concerns, application logic, and infrastructure
|
||||||
|
- Large files (`routes/s3.ts` at 1179 lines) are hard to maintain, test, and reason about
|
||||||
|
|
||||||
|
The goal is to restructure into **Domain-Driven Design (DDD)** with **Clean Architecture** principles, adding **JSDoc documentation** throughout, without changing behaviour or breaking existing tests.
|
||||||
|
|
||||||
|
## 2. Approach: Hybrid C
|
||||||
|
|
||||||
|
| Aspect | Decision |
|
||||||
|
|--------|----------|
|
||||||
|
| **Depth** | Structural DDD + repository abstraction + controller/use-case separation |
|
||||||
|
| **Domain entities** | Re-use Drizzle-generated types (pragmatic, not creating new pure domain models) |
|
||||||
|
| **Dependency inversion** | Repository interfaces defined in `domain/ports/`, implementations in `infrastructure/` |
|
||||||
|
| **Dependency injection** | Simple factory functions (no DI framework) |
|
||||||
|
| **Testing** | Test files restructured alongside source files |
|
||||||
|
| **Documentation** | JSDoc on all public functions, classes, interfaces, and types |
|
||||||
|
| **Language** | English for code and JSDoc |
|
||||||
|
|
||||||
|
## 3. Layer Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ interfaces/ (Delivery) │
|
||||||
|
│ HTTP controllers, routes, middleware │
|
||||||
|
│ Bot handlers │
|
||||||
|
│ S3 protocol (SigV4, XML, range, stream) │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ application/ (Use Cases) │
|
||||||
|
│ Upload file, get file, auth, bucket ops, │
|
||||||
|
│ S3 object ops, multipart upload │
|
||||||
|
├────────────────────┬────────────────────────┤
|
||||||
|
│ domain/ │ infrastructure/ │
|
||||||
|
│ entities/ │ persistence/ │
|
||||||
|
│ ports/ (interfaces)│ telegram/ │
|
||||||
|
│ │ cache/ │
|
||||||
|
├────────────────────┴────────────────────────┤
|
||||||
|
│ config/ + shared/ │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency Rule:** Outer layers depend on inner layers, never the reverse. Domain knows nothing about HTTP, Drizzle, or Telegram.
|
||||||
|
|
||||||
|
## 4. Full Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # Entry point: server bootstrap, lifecycle
|
||||||
|
├── domain/
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ ├── file.ts # File entity type (re-export from drizzle)
|
||||||
|
│ │ ├── bucket.ts # Bucket entity type
|
||||||
|
│ │ ├── file-part.ts # FilePart entity type
|
||||||
|
│ │ └── multipart.ts # MultipartUpload, MultipartPart types
|
||||||
|
│ └── ports/
|
||||||
|
│ ├── file-repository.ts # IFileRepository interface
|
||||||
|
│ ├── bucket-repository.ts # IBucketRepository interface
|
||||||
|
│ ├── file-part-repository.ts # IFilePartRepository interface
|
||||||
|
│ └── multipart-repository.ts # IMultipartRepository interface
|
||||||
|
├── application/
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── upload.ts # Upload request/response DTOs
|
||||||
|
│ │ ├── file.ts # File response DTOs
|
||||||
|
│ │ ├── bucket.ts # Bucket DTOs
|
||||||
|
│ │ ├── s3.ts # S3 operation DTOs
|
||||||
|
│ │ └── auth.ts # Auth DTOs
|
||||||
|
│ └── use-cases/
|
||||||
|
│ ├── upload-file.ts # Upload logic (multipart, JSON base64)
|
||||||
|
│ ├── get-file.ts # File redirect/stream logic
|
||||||
|
│ ├── manage-bucket.ts # Bucket CRUD logic
|
||||||
|
│ ├── s3-object.ts # S3 get/put/delete/copy logic
|
||||||
|
│ ├── multipart-upload.ts # S3 multipart upload logic
|
||||||
|
│ └── authenticate.ts # Login/logout/session logic
|
||||||
|
├── infrastructure/
|
||||||
|
│ ├── persistence/
|
||||||
|
│ │ ├── drizzle/
|
||||||
|
│ │ │ ├── index.ts # DB client + Drizzle init (from db/index.ts)
|
||||||
|
│ │ │ └── schema.ts # Drizzle table definitions (from db/schema.ts)
|
||||||
|
│ │ │ └── migrate.ts # Migration runner (from db/migrate.ts)
|
||||||
|
│ │ └── repositories/
|
||||||
|
│ │ ├── file-repository.ts # IFileRepository implementation (Drizzle)
|
||||||
|
│ │ ├── bucket-repository.ts # IBucketRepository implementation
|
||||||
|
│ │ ├── file-part-repository.ts# IFilePartRepository implementation
|
||||||
|
│ │ └── multipart-repository.ts# IMultipartRepository implementation
|
||||||
|
│ ├── telegram/
|
||||||
|
│ │ ├── bot-pool.ts # Multi-bot pool + retry (from utils/telegram.ts)
|
||||||
|
│ │ ├── upload-queue.ts # P-Queue wrapper (from utils/telegramQueue.ts)
|
||||||
|
│ │ ├── upload-batcher.ts # Batch upload (from utils/uploadBatcher.ts)
|
||||||
|
│ │ ├── chunked-storage.ts # Chunked file storage (from utils/chunked-storage.ts)
|
||||||
|
│ │ └── types.ts # Telegram-related types
|
||||||
|
│ └── cache/
|
||||||
|
│ └── index.ts # Generic TTL cache (from utils/cache.ts)
|
||||||
|
├── interfaces/
|
||||||
|
│ ├── http/
|
||||||
|
│ │ ├── controllers/
|
||||||
|
│ │ │ ├── upload-controller.ts # Upload HTTP handler
|
||||||
|
│ │ │ ├── file-controller.ts # File redirect + info handler
|
||||||
|
│ │ │ ├── auth-controller.ts # Login/logout/me handler
|
||||||
|
│ │ │ ├── home-controller.ts # Home dashboard handler
|
||||||
|
│ │ │ ├── health-controller.ts # Health check handler
|
||||||
|
│ │ │ ├── s3-controller.ts # S3-compatible API handler
|
||||||
|
│ │ │ └── web-api-controller.ts # JSON REST API handler
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ │ ├── auth.ts # requireAuth, getAuthSession
|
||||||
|
│ │ │ └── rate-limit.ts # Rate limiter
|
||||||
|
│ │ └── routes/
|
||||||
|
│ │ └── index.ts # All route definitions
|
||||||
|
│ ├── bot/
|
||||||
|
│ │ └── handler.ts # Telegram bot event handlers (from bot.ts)
|
||||||
|
│ └── s3/
|
||||||
|
│ ├── auth.ts # SigV4 verification (from utils/s3/auth.ts)
|
||||||
|
│ ├── headers.ts # CORS/response headers
|
||||||
|
│ ├── object-stream.ts # Part streaming for GetObject
|
||||||
|
│ ├── range.ts # HTTP Range header parser
|
||||||
|
│ ├── virtual-host.ts # Virtual-hosted bucket extraction
|
||||||
|
│ └── xml.ts # S3 XML response builders/parsers
|
||||||
|
├── config/
|
||||||
|
│ └── index.ts # Environment config (from env.ts)
|
||||||
|
├── shared/
|
||||||
|
│ ├── errors/
|
||||||
|
│ │ └── index.ts # Domain + application error classes
|
||||||
|
│ ├── logger/
|
||||||
|
│ │ └── index.ts # Winston logger (from utils/logger.ts)
|
||||||
|
│ ├── metrics/
|
||||||
|
│ │ └── index.ts # Metrics collector (from utils/metrics.ts)
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── file.ts # File type, MIME, hash, extension (from utils/file.ts)
|
||||||
|
│ ├── ip.ts # Client IP extraction (from utils/ip.ts)
|
||||||
|
│ ├── retry.ts # Retry/timeout/fallback (from utils/retry.ts)
|
||||||
|
│ └── zip.ts # ZIP create/extract (from utils/zip.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Dependency Injection Pattern
|
||||||
|
|
||||||
|
No DI framework. Use-case factories accept repository interfaces:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// domain/ports/file-repository.ts
|
||||||
|
export interface IFileRepository {
|
||||||
|
findByHash(hash: string): Promise<File | null>;
|
||||||
|
findByPublicId(publicId: string): Promise<File | null>;
|
||||||
|
findByUniqueId(uniqueId: string): Promise<File | null>;
|
||||||
|
findByBucketAndKey(bucketId: string, key: string): Promise<File | null>;
|
||||||
|
create(file: NewFile): Promise<File>;
|
||||||
|
softDelete(bucketId: string, key: string): Promise<boolean>;
|
||||||
|
listByPrefix(...): Promise<{ objects: File[]; prefixes: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// application/use-cases/upload-file.ts
|
||||||
|
export function createUploadFileUseCase(repos: {
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
filePartRepo: IFilePartRepository;
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
config: AppConfig;
|
||||||
|
}) {
|
||||||
|
return async (input: UploadInput): Promise<UploadOutput> => {
|
||||||
|
// business logic here, using repos.xxx() not db directly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps use cases testable — inject mock repositories in tests.
|
||||||
|
|
||||||
|
## 6. Controller / Use-Case Boundary
|
||||||
|
|
||||||
|
**Controller responsibilities:**
|
||||||
|
- Parse HTTP request (body, params, headers)
|
||||||
|
- Call use case
|
||||||
|
- Format HTTP response
|
||||||
|
|
||||||
|
**Use case responsibilities:**
|
||||||
|
- Business logic (file size checks, dedup, chunking decisions)
|
||||||
|
- Orchestrate infrastructure calls (Telegram, DB, cache)
|
||||||
|
- Return DTOs
|
||||||
|
|
||||||
|
**Example flow for upload:**
|
||||||
|
```
|
||||||
|
UploadController.handleUpload(req)
|
||||||
|
→ parse multipart form data
|
||||||
|
→ write to temp file
|
||||||
|
→ call uploadFileUseCase({ tempPath, fileName, mimeType, ... })
|
||||||
|
→ check dedup (fileRepo.findByHash)
|
||||||
|
→ check file size limits
|
||||||
|
→ if chunked: telegramService.uploadChunks(...), filePartRepo.create(...)
|
||||||
|
→ if batch: telegramService.enqueueBatch(...)
|
||||||
|
→ fileRepo.create(...)
|
||||||
|
→ return UploadOutput
|
||||||
|
→ format response (buildUploadResponse)
|
||||||
|
→ return Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Migration Strategy
|
||||||
|
|
||||||
|
The restructure will be done in one pass per layer, with tests updated in lockstep:
|
||||||
|
|
||||||
|
1. Create new folder structure
|
||||||
|
2. Move `shared/` utilities first (no dependencies on other layers)
|
||||||
|
3. Move `config/`
|
||||||
|
4. Move `domain/` entities + ports
|
||||||
|
5. Move `infrastructure/` persistence + telegram + cache
|
||||||
|
6. Move `application/` use cases + DTOs
|
||||||
|
7. Move `interfaces/` controllers + middleware + routes
|
||||||
|
8. Update `src/index.ts` entry point
|
||||||
|
9. Restructure test files matching new layout
|
||||||
|
10. Run all tests, fix import paths
|
||||||
|
11. Delete old folders
|
||||||
|
|
||||||
|
## 8. Entity Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ Domain Layer │
|
||||||
|
│ Entities: File, Bucket, FilePart, │
|
||||||
|
│ MultipartUpload, MultipartPart │
|
||||||
|
│ Ports: IFileRepository, IBucketRepository, │
|
||||||
|
│ IFilePartRepository, │
|
||||||
|
│ IMultipartRepository │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Application Layer │
|
||||||
|
│ UploadFileUseCase │
|
||||||
|
│ ├── depends on IFileRepository │
|
||||||
|
│ ├── depends on IFilePartRepository │
|
||||||
|
│ └── depends on ITelegramService │
|
||||||
|
│ │
|
||||||
|
│ S3ObjectUseCase │
|
||||||
|
│ ├── depends on IFileRepository │
|
||||||
|
│ ├── depends on IBucketRepository │
|
||||||
|
│ ├── depends on IMultipartRepository │
|
||||||
|
│ └── depends on ITelegramService │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Telegram Service Interface
|
||||||
|
|
||||||
|
A new `ITelegramService` interface in `domain/ports/` to abstract Telegram operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ITelegramService {
|
||||||
|
forwardToStorage(fileChunk: unknown, fileName: string, fileType: string): Promise<ForwardResult>;
|
||||||
|
getFileInfo(telegramFileId: string): Promise<TelegramFileInfo>;
|
||||||
|
enqueueUpload<T>(task: () => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation stays in `infrastructure/telegram/`.
|
||||||
|
|
||||||
|
## 10. JSDoc Standards
|
||||||
|
|
||||||
|
Every exported function, interface, type, and class gets JSDoc:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Uploads a file to Telegram storage, handling chunking for large files
|
||||||
|
* and batch deduplication for small files.
|
||||||
|
*
|
||||||
|
* @param input - The prepared upload data and metadata
|
||||||
|
* @param input.tempPath - Path to the temporary file on disk
|
||||||
|
* @param input.fileName - Display name for the file
|
||||||
|
* @param input.mimeType - MIME type of the file
|
||||||
|
* @param input.fileType - Telegram file category (document, photo, etc.)
|
||||||
|
* @param input.sizeBytes - File size in bytes
|
||||||
|
* @returns The stored file entity with Telegram metadata
|
||||||
|
* @throws {FileTooLargeError} If file exceeds Telegram size limits
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Error Handling
|
||||||
|
|
||||||
|
Custom error classes replacing generic `Error` throws:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/errors/index.ts
|
||||||
|
export class DomainError extends Error { constructor(msg: string) { super(msg); this.name = 'DomainError'; } }
|
||||||
|
export class FileNotFoundError extends DomainError {}
|
||||||
|
export class BucketNotFoundError extends DomainError {}
|
||||||
|
export class FileTooLargeError extends DomainError {}
|
||||||
|
export class DuplicateFileError extends DomainError {}
|
||||||
|
export class AuthenticationError extends DomainError {}
|
||||||
|
export class ValidationError extends DomainError {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use cases throw domain errors. Controllers catch and format HTTP responses.
|
||||||
|
|
||||||
|
## 12. Existing Files That Remain Unchanged
|
||||||
|
|
||||||
|
Files that are purely structural (no business logic restructuring needed):
|
||||||
|
|
||||||
|
| Old Path | New Path |
|
||||||
|
|----------|----------|
|
||||||
|
| `src/home.html` | `src/interfaces/http/controllers/home.html` (or alongside home controller) |
|
||||||
|
| `src/utils/s3/*` | `src/interfaces/s3/*` (moved as-is) |
|
||||||
|
| `src/db/migrate.ts` | `src/infrastructure/persistence/drizzle/migrate.ts` |
|
||||||
|
|
||||||
|
## 13. State After Migration
|
||||||
|
|
||||||
|
- **Total source files:** ~60 (was ~30) — more focused files, each with clear responsibility
|
||||||
|
- **JSDoc coverage:** 100% of exported APIs
|
||||||
|
- **Test files:** ~24, restructured to mirror source layout
|
||||||
|
- **Behaviour changes:** Zero. No logic is altered, only moved
|
||||||
|
- **External interfaces:** All API endpoints, S3 XML formats, and response shapes unchanged
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Per-Bot Queue: Rate-Limit Safe Telegram Upload
|
||||||
|
|
||||||
|
**Date:** 2026-07-29
|
||||||
|
**Status:** Approved Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Telegram Bot API rate-limits each bot to approximately 1-2 concurrent uploads. When multiple upload chunks hit the same bot simultaneously, Telegram returns HTTP 429 (Too Many Requests), causing delays of 30-60 seconds per retry. Under Docker push load, these cumulative delays trigger Gitea client timeouts and `500 Internal Server Error`.
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
|
||||||
|
|
||||||
|
The current architecture uses a **global PQueue** with `concurrency=N` where each task picks a bot via round-robin (`claimBotIndex()`). This means two concurrent tasks can both land on the same bot index (after wrap-around), causing 429 collisions.
|
||||||
|
|
||||||
|
## Solution: Per-Bot Queue
|
||||||
|
|
||||||
|
Each bot has its own PQueue with `concurrency=1`. Uploads are assigned to the bot with the fewest pending tasks. If a bot rate-limits, the upload moves to the next available bot.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ BotPool │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────┐ │
|
||||||
|
│ │ BotEntry[0] token=b1 queue=PQueue(conc=1) │ │
|
||||||
|
│ │ rateLimitedUntil=0 │ │
|
||||||
|
│ ├──────────────────────────────────────────────────┤ │
|
||||||
|
│ │ BotEntry[1] token=b2 queue=PQueue(conc=1) │ │
|
||||||
|
│ │ rateLimitedUntil=0 │ │
|
||||||
|
│ ├──────────────────────────────────────────────────┤ │
|
||||||
|
│ │ ... up to N bots │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ selectBot(skip?): number │
|
||||||
|
│ └─ bot dengan pending queue paling sedikit │
|
||||||
|
│ dan tidak sedang rate-limited │
|
||||||
|
│ │
|
||||||
|
│ forwardToStorage(file): ForwardResult │
|
||||||
|
│ └─ retry loop: selectBot → queue.add → handle 429 │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### BotEntry Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BotEntry {
|
||||||
|
index: number;
|
||||||
|
token: string;
|
||||||
|
instance: Telegraf;
|
||||||
|
queue: PQueue; // concurrency: 1
|
||||||
|
rateLimitedUntil: number; // epoch ms, 0 = not limited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow: Upload
|
||||||
|
|
||||||
|
```
|
||||||
|
forwardToStorage(fileChunk, fileName, fileType)
|
||||||
|
│
|
||||||
|
├─ MAX_RETRIES loop (attempt up to all bots)
|
||||||
|
│ │
|
||||||
|
│ ├─ selectBot(attemptedIndexes)
|
||||||
|
│ │ ├─ Filter out rate-limited bots (rateLimitedUntil > Date.now())
|
||||||
|
│ │ ├─ Filter out already-attempted bots
|
||||||
|
│ │ ├─ If none available:
|
||||||
|
│ │ │ ├─ Wait MIN_SLEEP_MS (5000ms)
|
||||||
|
│ │ │ ├─ Reset rate-limited timers (clear attemptedIndexes)
|
||||||
|
│ │ │ └─ Retry selectBot
|
||||||
|
│ │ └─ Return bot with smallest queue.pending count
|
||||||
|
│ │
|
||||||
|
│ ├─ attemptedIndexes.add(selectedBot)
|
||||||
|
│ │
|
||||||
|
│ ├─ result = await bots[selectedBot].queue.add(() =>
|
||||||
|
│ │ executeTelegramCall(bot, fileChunk, fileName)
|
||||||
|
│ │ )
|
||||||
|
│ │ │
|
||||||
|
│ │ ├─ ✅ Success → return ForwardResult
|
||||||
|
│ │ │
|
||||||
|
│ │ └─ ❌ Error
|
||||||
|
│ │ ├─ 429 → markRateLimited(bot, retryAfter)
|
||||||
|
│ │ │ → continue to next bot in retry loop
|
||||||
|
│ │ ├─ Transient (timeout, 5xx) → continue
|
||||||
|
│ │ └─ Non-transient → throw (propagate up)
|
||||||
|
│ │
|
||||||
|
│ └─ Attempt counter exhausted → throw lastError
|
||||||
|
│
|
||||||
|
└─ Sorted part tracking (for chunked uploads)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **Concurrency=1 per bot**: Guarantees no two Telegram API calls compete for the same bot token. With 6 bots, effective concurrency = 6.
|
||||||
|
|
||||||
|
2. **Least-loaded assignment**: `selectBot()` picks the bot with the fewest queued + pending tasks. This naturally load-balances even when some bots are slower.
|
||||||
|
|
||||||
|
3. **Rate-limit isolation**: When bot A hits 429, only bot A's queue is paused. Other 5 bots continue serving uploads uninterrupted.
|
||||||
|
|
||||||
|
4. **Per-bot rate-limit timer**: `rateLimitedUntil` prevents re-selecting a recently-429'd bot until its cooldown expires.
|
||||||
|
|
||||||
|
5. **No global PQueue**: The old `upload-queue.ts` is removed. Each bot owns its queue, eliminating the global backpressure problem.
|
||||||
|
|
||||||
|
### Changes by File
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `src/infrastructure/telegram/bot-pool.ts` | **Major refactor**: BotEntry array, selectBot(), per-bot queues, retry loop |
|
||||||
|
| `src/infrastructure/telegram/upload-queue.ts` | **Delete**: No longer needed |
|
||||||
|
| `src/domain/ports/telegram-service.ts` | **Remove** `enqueueUpload<T>(task: () => Promise<T>): Promise<T>` from interface |
|
||||||
|
| `src/utils/chunked-storage.ts` | **No changes** — only uses `forwardToStorage()` |
|
||||||
|
| `src/env.ts` | **Remove** `uploadConcurrency` config (no longer needed) |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **429 per bot**: Mark bot rate-limited, move to next. Clear timer after `retryAfter` seconds.
|
||||||
|
- **All bots 429**: Sleep 5 seconds with jitter, then retry from bot 0.
|
||||||
|
- **Transient errors** (timeout, 5xx, connection reset): Retry on same bot (inside its queue), then on next bot.
|
||||||
|
- **Non-transient errors** (4xx other than 429, wrong file_id, auth errors): Propagate immediately.
|
||||||
|
- **MAX_RETRIES**: 10 attempts across all bots before giving up.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit: `selectBot()` returns bot with fewest pending tasks
|
||||||
|
- Unit: `selectBot()` skips rate-limited bots
|
||||||
|
- Unit: 429 on bot 0 → retries on bot 1 → succeeds
|
||||||
|
- Unit: All bots rate-limited → sleeps → retries → succeeds
|
||||||
|
- Unit: Per-bot queue has concurrency=1 (two tasks to same bot queue sequentially)
|
||||||
|
- Integration: Forward a real file through the per-bot pool
|
||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785301185,
|
||||||
|
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
description = "TeleUploader — Nix build";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [
|
||||||
|
(self: super: {
|
||||||
|
bun = super.bun.overrideAttrs (old: rec {
|
||||||
|
version = "1.3.14";
|
||||||
|
src = super.fetchzip {
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64.zip";
|
||||||
|
sha256 = "sha256:189kasncnfpq6q37q3w80dv2a92w79w20x2x6hf1fbkir5gyvzlb";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# TeleUploader package
|
||||||
|
teleuploader = pkgs.stdenvNoCC.mkDerivation rec {
|
||||||
|
pname = "teleuploader";
|
||||||
|
version = "1.2.3";
|
||||||
|
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.bun
|
||||||
|
pkgs.makeBinaryWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
# Bun cache di sandbox — prevent online fetch
|
||||||
|
# Karena bun.lock sudah di repo, bun install --frozen-lockfile
|
||||||
|
# akan pake cache, tapi di Nix sandbox gak ada internet.
|
||||||
|
# Solusi: offline flag
|
||||||
|
preBuild = ''
|
||||||
|
export HOME=$TMPDIR/home
|
||||||
|
mkdir -p $HOME
|
||||||
|
export BUN_INSTALL=$HOME/.bun
|
||||||
|
'';
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
echo "=== Installing dependencies ==="
|
||||||
|
bun install --frozen-lockfile --ignore-scripts 2>&1
|
||||||
|
|
||||||
|
echo "=== Building ==="
|
||||||
|
bun run build 2>&1
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin $out/share/teleuploader
|
||||||
|
|
||||||
|
# Copy dist files
|
||||||
|
cp -r dist $out/share/teleuploader/dist
|
||||||
|
cp src/home.html $out/share/teleuploader/ 2>/dev/null || true
|
||||||
|
cp schema.sql $out/share/teleuploader/ 2>/dev/null || true
|
||||||
|
# Carry package.json into the store so runtime version lookup in
|
||||||
|
# env.ts readPackageVersion() resolves (dist/../package.json).
|
||||||
|
cp package.json $out/share/teleuploader/package.json
|
||||||
|
|
||||||
|
# Wrap with bun from Nix store (dependency sharing!)
|
||||||
|
# Note: NO --chdir — systemd WorkingDirectory controls this
|
||||||
|
makeBinaryWrapper ${pkgs.bun}/bin/bun $out/bin/teleuploader \
|
||||||
|
--add-flags "$out/share/teleuploader/dist/index.js" \
|
||||||
|
--set-default NODE_ENV production \
|
||||||
|
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.bun ]}
|
||||||
|
|
||||||
|
# Also create the migrate wrapper
|
||||||
|
makeBinaryWrapper ${pkgs.bun}/bin/bun $out/bin/teleuploader-migrate \
|
||||||
|
--add-flags "$out/share/teleuploader/dist/migrate.js" \
|
||||||
|
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.bun ]}
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Telegram file uploader backend (S3 → Telegram)";
|
||||||
|
license = pkgs.lib.licenses.mit;
|
||||||
|
platforms = pkgs.lib.platforms.linux;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
packages = {
|
||||||
|
inherit teleuploader;
|
||||||
|
default = teleuploader;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Dev shell with bun for local development
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
pkgs.bun
|
||||||
|
pkgs.nodejs_22
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+17
-12
@@ -1,36 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "filedrop",
|
"name": "filedrop",
|
||||||
"version": "1.0.0",
|
"version": "1.2.3",
|
||||||
"description": "Telegram file uploader backend",
|
"description": "Telegram file uploader backend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/index.ts",
|
"dev": "bun --hot src/index.ts",
|
||||||
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/db/migrate.ts --target=bun --outfile=dist/migrate.js",
|
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js",
|
||||||
"start": "NODE_ENV=production bun dist/index.js",
|
"start": "NODE_ENV=production bun dist/index.js",
|
||||||
"db:migrate": "bun dist/migrate.js",
|
"db:migrate": "bun dist/migrate.js",
|
||||||
"test": "bun test test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/db.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts && bun test test/swagger.test.ts && bun test test/auth.test.ts && bun test test/auth-routes.test.ts && bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && bun test test/s3-bucket-confLine truncated
|
"test": "bun run test:unit",
|
||||||
"test:s3-auth": "bun test test/s3-auth.test.ts",
|
"test:unit": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot-pool.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot.test.tsLine truncated
|
||||||
"test:s3-ops": "bun test test/s3-operations.test.ts",
|
"test:quarantine": "bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-docker-registry.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-sdk.test.ts && bun test --preload ./test/helpers/setup-env.ts test/production-e2e.test.ts",
|
||||||
"test:web-api": "bun test test/web-api.test.ts",
|
"test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts",
|
||||||
"test:s3": "bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && bun test test/web-api.test.ts",
|
"test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts",
|
||||||
|
"test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
|
||||||
|
"test:s3": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-routing.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-range.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-object-stream.test.ts && bun test --preload ./test/helpersLine truncated
|
||||||
"lint": "bunx biome check src test",
|
"lint": "bunx biome check src test",
|
||||||
"format": "bunx biome format --write src test"
|
"format": "bunx biome format --write src test",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "^7.8.0",
|
"@prisma/engines": "^7.8.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^6.0.1",
|
||||||
"p-queue": "^9.3.0",
|
"p-queue": "^9.3.0",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"telegraf": "^4.15.0",
|
"telegraf": "^4.15.0",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1079.0",
|
"@aws-sdk/client-s3": "^3.1079.0",
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^26.2.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"typescript": "^6.0.3"
|
"husky": "^9.1.7",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -56,7 +56,8 @@ CREATE TABLE IF NOT EXISTS multipart_uploads (
|
|||||||
s3_key TEXT NOT NULL,
|
s3_key TEXT NOT NULL,
|
||||||
initiated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
initiated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
status VARCHAR DEFAULT 'in_progress',
|
status VARCHAR DEFAULT 'in_progress',
|
||||||
initiated_by TEXT
|
initiated_by TEXT,
|
||||||
|
content_type TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS multipart_parts (
|
CREATE TABLE IF NOT EXISTS multipart_parts (
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Input for the login endpoint.
|
||||||
|
* The caller provides the admin API token to obtain a session cookie.
|
||||||
|
*/
|
||||||
|
export interface LoginInput {
|
||||||
|
/** Admin API token for authentication */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active authentication session information.
|
||||||
|
*/
|
||||||
|
export interface AuthSession {
|
||||||
|
/** Authenticated username (currently always "admin") */
|
||||||
|
username: string;
|
||||||
|
/** Session expiry timestamp; null for bearer-token sessions */
|
||||||
|
expiresAt: Date | null;
|
||||||
|
/** Authentication method used */
|
||||||
|
method: 'cookie' | 'bearer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for a successful login.
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
/** Authenticated username */
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for logout.
|
||||||
|
*/
|
||||||
|
export interface LogoutResponse {
|
||||||
|
/** Whether the logout succeeded */
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for the current-user (/me) endpoint.
|
||||||
|
*/
|
||||||
|
export interface UserInfoResponse {
|
||||||
|
/** Authenticated username */
|
||||||
|
username: string;
|
||||||
|
/** ISO-8601 session expiry timestamp; null when using bearer token */
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Deduplication policy for the upload file use case.
|
||||||
|
* - `hash`: look up an existing record by content SHA-256 first (default).
|
||||||
|
* - `bucket-key`: idempotent lookup by `(bucketId, s3Key)`; never re-uploads.
|
||||||
|
* - `none`: store unconditionally.
|
||||||
|
*/
|
||||||
|
export type UploadDedupPolicy = 'hash' | 'bucket-key' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for the upload file use case.
|
||||||
|
* Carries all metadata needed to persist an uploaded file,
|
||||||
|
* including its temporary location on disk and optional bucket/S3 context.a
|
||||||
|
*/
|
||||||
|
export interface UploadInput {
|
||||||
|
/** Absolute path to the temporary file on disk */
|
||||||
|
tempPath: string;
|
||||||
|
/** SHA-256 hex digest of the file content */
|
||||||
|
fileHash: string;
|
||||||
|
/** Original file name (may include extension) */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type detected from content inspection or request header */
|
||||||
|
mimeType: string;
|
||||||
|
/**
|
||||||
|
* High-level file category (e.g. "document", "photo", "video").
|
||||||
|
* Optional — when omitted the use case derives it from MIME type + file name.
|
||||||
|
*/
|
||||||
|
fileType?: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Telegram user ID of the uploader; 0 when unknown or system */
|
||||||
|
uploaderId?: number;
|
||||||
|
/** Target bucket UUID for S3-compatible storage; null when un-bucketed */
|
||||||
|
bucketId?: string | null;
|
||||||
|
/** Object key within the bucket for S3-compatible storage; null when un-bucketed */
|
||||||
|
s3Key?: string | null;
|
||||||
|
/** Deduplication policy; defaults to `'hash'` when omitted */
|
||||||
|
dedup?: UploadDedupPolicy;
|
||||||
|
/** Prefix for chunked part file names; defaults to `direct-<hash16>` */
|
||||||
|
partPrefix?: string;
|
||||||
|
/**
|
||||||
|
* First bytes of the file for magic-byte detection.
|
||||||
|
* When omitted the use case reads them from `tempPath`.
|
||||||
|
*/
|
||||||
|
signatureBuffer?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output from the upload file use case.
|
||||||
|
* Contains the public-facing file metadata returned to the caller.
|
||||||
|
*/
|
||||||
|
export interface UploadOutput {
|
||||||
|
/** Public, shareable identifier (nanoid) */
|
||||||
|
publicId: string;
|
||||||
|
/** Stored file name (may have been normalized with extension) */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the stored file */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** High-level file category */
|
||||||
|
fileType: string;
|
||||||
|
/** ISO-8601 timestamp of when the file record was created */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Public download URL */
|
||||||
|
downloadUrl: string;
|
||||||
|
/** SHA-256 hex digest of the stored content */
|
||||||
|
fileHash: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { timingSafeCompare } from '../../shared/utils/crypto';
|
||||||
|
import type {
|
||||||
|
AuthSession,
|
||||||
|
LoginInput,
|
||||||
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
|
UserInfoResponse,
|
||||||
|
} from '../dto/auth';
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the authenticate use case. */
|
||||||
|
export interface AuthUseCaseConfig {
|
||||||
|
/** Admin API token used to authenticate login requests. */
|
||||||
|
adminApiToken: string;
|
||||||
|
/** Name of the session cookie. */
|
||||||
|
sessionCookieName: string;
|
||||||
|
/** Session lifetime in milliseconds. */
|
||||||
|
sessionMaxAgeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the authenticate use case factory. */
|
||||||
|
export interface AuthenticateUseCaseDeps {
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: AuthUseCaseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether authentication is enabled based on the configured token.
|
||||||
|
*
|
||||||
|
* @param adminApiToken - The admin API token value.
|
||||||
|
* @returns `true` if the token is non-empty (auth is enabled).
|
||||||
|
*/
|
||||||
|
const isAuthEnabled = (adminApiToken: string): boolean => adminApiToken.length > 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the login use case.
|
||||||
|
*
|
||||||
|
* Validates the provided admin API token and returns session metadata on
|
||||||
|
* success. The caller (controller/adapter) is responsible for translating
|
||||||
|
* the result into an HTTP response (e.g. setting a session cookie).
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting login input and returning a login response.
|
||||||
|
*/
|
||||||
|
export function createLoginUseCase(deps: AuthenticateUseCaseDeps) {
|
||||||
|
return async (input: LoginInput): Promise<LoginResponse> => {
|
||||||
|
if (!isAuthEnabled(deps.config.adminApiToken)) {
|
||||||
|
return { username: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timingSafeCompare(input.token, deps.config.adminApiToken)) {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { username: 'admin' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the logout use case.
|
||||||
|
*
|
||||||
|
* Always succeeds — the caller is responsible for clearing the session cookie.
|
||||||
|
*
|
||||||
|
* @returns An async function returning a logout response.
|
||||||
|
*/
|
||||||
|
export function createLogoutUseCase() {
|
||||||
|
return async (): Promise<LogoutResponse> => {
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the current-user (me) use case.
|
||||||
|
*
|
||||||
|
* Accepts an already-parsed auth session (from cookie or bearer token) and
|
||||||
|
* returns the user info response. The caller (controller/adapter) is
|
||||||
|
* responsible for extracting the session from the raw HTTP request.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting an optional session and returning user info.
|
||||||
|
*/
|
||||||
|
export function createMeUseCase(deps: AuthenticateUseCaseDeps) {
|
||||||
|
return async (session: AuthSession | null): Promise<UserInfoResponse | null> => {
|
||||||
|
if (!isAuthEnabled(deps.config.adminApiToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: session.username,
|
||||||
|
expiresAt: session.expiresAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { File } from '../../domain/entities/file';
|
||||||
|
import { buildNewFile } from '../../domain/entities/file-factory';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import type { ChunkedStorage } from '../../infrastructure/telegram/chunked-storage';
|
||||||
|
import {
|
||||||
|
checkFileSize,
|
||||||
|
cleanupTempFile,
|
||||||
|
ensureExtension,
|
||||||
|
getFileType,
|
||||||
|
} from '../../shared/utils/file';
|
||||||
|
import type { UploadInput, UploadOutput } from '../dto/upload';
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the upload-file use case. */
|
||||||
|
export interface UploadFileConfig {
|
||||||
|
/** Server base URL for constructing download links. */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Maximum chunk size in bytes for Telegram chunked uploads. */
|
||||||
|
telegramChunkSizeBytes: number;
|
||||||
|
/** Telegram chat ID where file parts are stored. */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Whether to attempt gzip compression on each chunk. */
|
||||||
|
compressChunkedUploads: boolean;
|
||||||
|
/** Minimum chunk size in bytes below which compression is skipped. */
|
||||||
|
chunkCompressionMinSizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the upload-file use case factory. */
|
||||||
|
export interface UploadFileUseCaseDeps {
|
||||||
|
/** File repository for CRUD operations on file records. */
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
/** Telegram service for forwarding file content to storage. */
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
/** Chunked storage handler for large file uploads. */
|
||||||
|
chunkedStorage: ChunkedStorage;
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: UploadFileConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the first 16 bytes from a file on disk for magic-byte detection.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the temporary file.
|
||||||
|
* @returns A buffer containing up to 16 bytes.
|
||||||
|
*/
|
||||||
|
const readSignatureBuffer = async (tempPath: string): Promise<Buffer> => {
|
||||||
|
const handle = await open(tempPath, 'r');
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(16);
|
||||||
|
const { bytesRead } = await handle.read(buf, 0, 16, 0);
|
||||||
|
return buf.subarray(0, bytesRead);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a persisted file entity to the public upload output DTO.
|
||||||
|
*
|
||||||
|
* @param file - The persisted file record.
|
||||||
|
* @param baseUrl - Server base URL for the download link.
|
||||||
|
* @returns The public `UploadOutput` DTO.
|
||||||
|
*/
|
||||||
|
const toUploadOutput = (file: File, baseUrl: string): UploadOutput => ({
|
||||||
|
publicId: file.publicId,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
sizeBytes: file.sizeBytes,
|
||||||
|
fileType: file.fileType,
|
||||||
|
createdAt: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt),
|
||||||
|
downloadUrl: `${baseUrl}/f/${file.publicId}`,
|
||||||
|
fileHash: file.fileHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the upload-file use case.
|
||||||
|
*
|
||||||
|
* Single save path for all upload entry points (multipart, JSON, web-API):
|
||||||
|
* 1. Deduplication per policy — `hash` (by content SHA-256), `bucket-key`
|
||||||
|
* (idempotent by bucket + S3 key), or `none` (store unconditionally).
|
||||||
|
* 2. Normalises the file name and MIME type based on magic bytes.
|
||||||
|
* 3. Validates the file size against Telegram type-specific limits.
|
||||||
|
* 4. Chooses a storage strategy — chunked (delegated to ChunkedStorage) or
|
||||||
|
* single-message upload.
|
||||||
|
* 5. Persists the file record.
|
||||||
|
* 6. Builds and returns the public `UploadOutput` DTO.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting `UploadInput` and returning `UploadOutput`.
|
||||||
|
*/
|
||||||
|
export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
|
||||||
|
return async (input: UploadInput): Promise<UploadOutput> => {
|
||||||
|
const dedup = input.dedup ?? 'hash';
|
||||||
|
|
||||||
|
// 1. Deduplication per policy
|
||||||
|
if (dedup === 'hash') {
|
||||||
|
const existing = await deps.fileRepo.findByHash(input.fileHash);
|
||||||
|
if (existing) {
|
||||||
|
return toUploadOutput(existing, deps.config.baseUrl);
|
||||||
|
}
|
||||||
|
} else if (dedup === 'bucket-key') {
|
||||||
|
if (!input.bucketId || !input.s3Key) {
|
||||||
|
throw new Error('bucket-key dedup requires bucketId and s3Key');
|
||||||
|
}
|
||||||
|
const existing = await deps.fileRepo.findByBucketAndKey(input.bucketId, input.s3Key);
|
||||||
|
if (existing) {
|
||||||
|
return toUploadOutput(existing, deps.config.baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Signature bytes for magic-byte-based extension detection
|
||||||
|
const signatureBuffer = input.signatureBuffer ?? (await readSignatureBuffer(input.tempPath));
|
||||||
|
|
||||||
|
const { fileName: finalFileName, mimeType } = ensureExtension(
|
||||||
|
input.fileName,
|
||||||
|
signatureBuffer,
|
||||||
|
input.mimeType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Determine Telegram file type and validate size
|
||||||
|
const fileTypeRaw = getFileType(mimeType, finalFileName);
|
||||||
|
const fileType = fileTypeRaw === 'application' ? 'document' : fileTypeRaw;
|
||||||
|
|
||||||
|
if (!checkFileSize(input.sizeBytes, fileType)) {
|
||||||
|
throw new Error(`File size exceeds ${fileType} limit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partPrefix = input.partPrefix ?? `direct-${input.fileHash.slice(0, 16)}`;
|
||||||
|
|
||||||
|
// 4. Upload — chunked via ChunkedStorage for files above the threshold,
|
||||||
|
// single-message otherwise. The temp file is always cleaned up here so
|
||||||
|
// callers never need their own cleanup block.
|
||||||
|
try {
|
||||||
|
if (input.sizeBytes > deps.config.telegramChunkSizeBytes) {
|
||||||
|
const uploadedFile = await deps.chunkedStorage.storeFileInTelegramChunks({
|
||||||
|
tempPath: input.tempPath,
|
||||||
|
partFileNamePrefix: partPrefix,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: input.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
uploaderId: input.uploaderId ?? 0,
|
||||||
|
bucketId: input.bucketId,
|
||||||
|
s3Key: input.s3Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toUploadOutput(uploadedFile, deps.config.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Single-message upload path. Pass the temp path (not an open
|
||||||
|
// stream) so the telegram service owns file I/O — test doubles that
|
||||||
|
// never touch disk keep working, and the use case stays stream-agnostic.
|
||||||
|
const forwardResult = await deps.telegramService.forwardToStorage(
|
||||||
|
input.tempPath,
|
||||||
|
finalFileName,
|
||||||
|
fileType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const singlePublicId = nanoid();
|
||||||
|
|
||||||
|
const createdFile = await deps.fileRepo.create(
|
||||||
|
buildNewFile({
|
||||||
|
publicId: singlePublicId,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageChatId: deps.config.storageChatId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: input.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
uploaderId: input.uploaderId,
|
||||||
|
fileHash: input.fileHash,
|
||||||
|
bucketId: input.bucketId,
|
||||||
|
s3Key: input.s3Key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return toUploadOutput(createdFile, deps.config.baseUrl);
|
||||||
|
} finally {
|
||||||
|
await cleanupTempFile(input.tempPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
-137
@@ -1,137 +0,0 @@
|
|||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { type Context, Telegraf } from 'telegraf';
|
|
||||||
import { db, files as fileSchema } from './db';
|
|
||||||
import { findFileByUniqueId } from './db/files';
|
|
||||||
import { config } from './env';
|
|
||||||
import {
|
|
||||||
detectFileType,
|
|
||||||
extractFileFromMessage,
|
|
||||||
getErrorMessage,
|
|
||||||
getFileSizeLimit,
|
|
||||||
type TelegramMediaMessage,
|
|
||||||
} from './utils/file';
|
|
||||||
import logger from './utils/logger';
|
|
||||||
import { forwardToStorage } from './utils/telegram';
|
|
||||||
|
|
||||||
type BotContext = {
|
|
||||||
message: TelegramMediaMessage;
|
|
||||||
from: { id: number };
|
|
||||||
chat?: { id: number };
|
|
||||||
reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaEventRegistrar = {
|
|
||||||
on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
|
|
||||||
const url = `${config.baseUrl}/f/${publicId}`;
|
|
||||||
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
|
|
||||||
reply_parameters: { message_id: ctx.message.message_id },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startBot = async (): Promise<Telegraf<Context>> => {
|
|
||||||
try {
|
|
||||||
const bot = new Telegraf(config.botToken);
|
|
||||||
|
|
||||||
bot.command('start', async (ctx) => {
|
|
||||||
await ctx.reply(
|
|
||||||
`👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` +
|
|
||||||
`File akan disimpan di private channel dan kamu dapat download link permanen.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logging middleware must be registered BEFORE the media handler so all events are captured
|
|
||||||
bot.use((ctx, next) => {
|
|
||||||
logger.info('Telegram event received', {
|
|
||||||
type: 'type' in ctx.update ? ctx.update.type : undefined,
|
|
||||||
chat_id: ctx.chat?.id,
|
|
||||||
});
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mediaBot = bot as unknown as MediaEventRegistrar;
|
|
||||||
mediaBot.on(
|
|
||||||
['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
|
|
||||||
async (ctx) => {
|
|
||||||
try {
|
|
||||||
const fileType = detectFileType(ctx.message);
|
|
||||||
const fileObj = extractFileFromMessage(ctx.message, fileType);
|
|
||||||
const { file_id, mime_type } = fileObj;
|
|
||||||
const fileSize = fileObj.file_size || 0;
|
|
||||||
const fileName =
|
|
||||||
ctx.message.document?.file_name ||
|
|
||||||
ctx.message.photo?.slice(-1)[0]?.file_name ||
|
|
||||||
ctx.message.video?.file_name ||
|
|
||||||
ctx.message.audio?.file_name ||
|
|
||||||
ctx.message.voice?.file_name ||
|
|
||||||
'file';
|
|
||||||
|
|
||||||
const maxSize = getFileSizeLimit(fileType);
|
|
||||||
|
|
||||||
if (fileSize > maxSize) {
|
|
||||||
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await findFileByUniqueId(fileObj.file_unique_id);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await replyWithDownloadUrl(ctx, existing.publicId);
|
|
||||||
logger.info('Duplicate file detected in bot, returned existing link', {
|
|
||||||
publicId: existing.publicId,
|
|
||||||
fileType,
|
|
||||||
fileName,
|
|
||||||
uploader: ctx.from.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await forwardToStorage(file_id, fileName, fileType);
|
|
||||||
const publicId = nanoid();
|
|
||||||
|
|
||||||
const uploaded = {
|
|
||||||
publicId: publicId,
|
|
||||||
telegramFileId: result.telegramFileId,
|
|
||||||
telegramFileUniqueId: result.telegramFileUniqueId,
|
|
||||||
storageChatId: config.storageChatId,
|
|
||||||
storageMessageId: result.storageMessageId,
|
|
||||||
fileName: fileName,
|
|
||||||
mimeType: mime_type || 'application/octet-stream',
|
|
||||||
sizeBytes: fileSize,
|
|
||||||
fileType: fileType,
|
|
||||||
uploaderId: ctx.from.id,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.insert(fileSchema).values(uploaded);
|
|
||||||
|
|
||||||
await replyWithDownloadUrl(ctx, publicId);
|
|
||||||
|
|
||||||
logger.info('File uploaded via bot', {
|
|
||||||
publicId,
|
|
||||||
fileType,
|
|
||||||
fileName,
|
|
||||||
uploader: ctx.from.id,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error('Bot file handler error', {
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
chat_id: ctx.chat?.id,
|
|
||||||
});
|
|
||||||
await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await bot.launch();
|
|
||||||
|
|
||||||
logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` });
|
|
||||||
|
|
||||||
return bot;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error('Failed to start bot', { error: getErrorMessage(error) });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { db } from './index';
|
|
||||||
|
|
||||||
export interface Bucket {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueryRow = Record<string, unknown>;
|
|
||||||
type QueryResult = QueryRow[];
|
|
||||||
|
|
||||||
export const createBucket = async (name: string): Promise<Bucket> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
|
|
||||||
)) as unknown as QueryResult;
|
|
||||||
const row = result[0]!;
|
|
||||||
return {
|
|
||||||
id: row.id as string,
|
|
||||||
name: row.name as string,
|
|
||||||
createdAt: new Date(row.created_at as string),
|
|
||||||
updatedAt: new Date(row.updated_at as string),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findBucketByName = async (name: string): Promise<Bucket | null> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
|
|
||||||
)) as unknown as QueryResult;
|
|
||||||
if (result.length === 0) return null;
|
|
||||||
const row = result[0]!;
|
|
||||||
return {
|
|
||||||
id: row.id as string,
|
|
||||||
name: row.name as string,
|
|
||||||
createdAt: new Date(row.created_at as string),
|
|
||||||
updatedAt: new Date(row.updated_at as string),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listBuckets = async (): Promise<Bucket[]> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
|
|
||||||
)) as unknown as QueryResult;
|
|
||||||
return result.map((row) => ({
|
|
||||||
id: row.id as string,
|
|
||||||
name: row.name as string,
|
|
||||||
createdAt: new Date(row.created_at as string),
|
|
||||||
updatedAt: new Date(row.updated_at as string),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteBucket = async (name: string): Promise<boolean> => {
|
|
||||||
// Cascade-delete rows that hold FK references to the bucket
|
|
||||||
await db
|
|
||||||
.execute(
|
|
||||||
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.execute(
|
|
||||||
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.execute(
|
|
||||||
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`DELETE FROM buckets WHERE name = ${name}`,
|
|
||||||
)) as unknown as QueryResult;
|
|
||||||
return result.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bucketExists = async (name: string): Promise<boolean> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT 1 FROM buckets WHERE name = ${name}`,
|
|
||||||
)) as unknown as QueryResult;
|
|
||||||
return result.length > 0;
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { db } from './index';
|
|
||||||
|
|
||||||
export type CompressionAlgorithm = 'gzip' | null;
|
|
||||||
|
|
||||||
export interface FilePart {
|
|
||||||
id: number;
|
|
||||||
fileId: string;
|
|
||||||
partNumber: number;
|
|
||||||
telegramFileId: string;
|
|
||||||
telegramFileUniqueId: string;
|
|
||||||
storageChatId: number;
|
|
||||||
storageMessageId: number;
|
|
||||||
sizeBytes: number;
|
|
||||||
storedSizeBytes: number;
|
|
||||||
compressionAlgorithm: CompressionAlgorithm;
|
|
||||||
etag: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NewFilePartInput = Omit<FilePart, 'id' | 'createdAt'>;
|
|
||||||
|
|
||||||
const toNumber = (value: unknown): number => Number(value ?? 0);
|
|
||||||
|
|
||||||
const mapRowToFilePart = (row: Record<string, unknown>): FilePart => ({
|
|
||||||
id: toNumber(row.id),
|
|
||||||
fileId: row.file_id as string,
|
|
||||||
partNumber: toNumber(row.part_number),
|
|
||||||
telegramFileId: row.telegram_file_id as string,
|
|
||||||
telegramFileUniqueId: row.telegram_file_unique_id as string,
|
|
||||||
storageChatId: toNumber(row.storage_chat_id),
|
|
||||||
storageMessageId: toNumber(row.storage_message_id),
|
|
||||||
sizeBytes: toNumber(row.size_bytes),
|
|
||||||
storedSizeBytes: toNumber(row.stored_size_bytes),
|
|
||||||
compressionAlgorithm: (row.compression_algorithm as CompressionAlgorithm) || null,
|
|
||||||
etag: row.etag as string,
|
|
||||||
createdAt: new Date(row.created_at as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const insertFileParts = async (parts: NewFilePartInput[]): Promise<void> => {
|
|
||||||
for (const part of parts) {
|
|
||||||
await db.execute(
|
|
||||||
sql`INSERT INTO file_parts (
|
|
||||||
file_id,
|
|
||||||
part_number,
|
|
||||||
telegram_file_id,
|
|
||||||
telegram_file_unique_id,
|
|
||||||
storage_chat_id,
|
|
||||||
storage_message_id,
|
|
||||||
size_bytes,
|
|
||||||
stored_size_bytes,
|
|
||||||
compression_algorithm,
|
|
||||||
etag
|
|
||||||
) VALUES (
|
|
||||||
${part.fileId}::uuid,
|
|
||||||
${part.partNumber},
|
|
||||||
${part.telegramFileId},
|
|
||||||
${part.telegramFileUniqueId},
|
|
||||||
${part.storageChatId},
|
|
||||||
${part.storageMessageId},
|
|
||||||
${part.sizeBytes},
|
|
||||||
${part.storedSizeBytes},
|
|
||||||
${part.compressionAlgorithm},
|
|
||||||
${part.etag}
|
|
||||||
)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listFileParts = async (fileId: string): Promise<FilePart[]> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT id,
|
|
||||||
file_id,
|
|
||||||
part_number,
|
|
||||||
telegram_file_id,
|
|
||||||
telegram_file_unique_id,
|
|
||||||
storage_chat_id,
|
|
||||||
storage_message_id,
|
|
||||||
size_bytes,
|
|
||||||
stored_size_bytes,
|
|
||||||
compression_algorithm,
|
|
||||||
etag,
|
|
||||||
created_at
|
|
||||||
FROM file_parts
|
|
||||||
WHERE file_id = ${fileId}::uuid
|
|
||||||
ORDER BY part_number`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
|
|
||||||
return result.map(mapRowToFilePart);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countFileParts = async (fileId: string): Promise<number> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT COUNT(*) AS count FROM file_parts WHERE file_id = ${fileId}::uuid`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
return toNumber(result[0]?.count);
|
|
||||||
};
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
|
||||||
import { db, files as fileSchema } from './index';
|
|
||||||
import type { File } from './schema';
|
|
||||||
|
|
||||||
export interface S3FileRecord extends File {
|
|
||||||
bucketId: string;
|
|
||||||
s3Key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const findFileByBucketAndKey = async (
|
|
||||||
bucketId: string,
|
|
||||||
s3Key: string,
|
|
||||||
): Promise<File | null> => {
|
|
||||||
const result = await db
|
|
||||||
.select()
|
|
||||||
.from(fileSchema)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fileSchema.bucketId, bucketId),
|
|
||||||
eq(fileSchema.s3Key, s3Key),
|
|
||||||
eq(fileSchema.isDeleted, false),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
return result[0] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDbRowToS3Record = (row: Record<string, unknown>): S3FileRecord => {
|
|
||||||
return {
|
|
||||||
id: row.id as string,
|
|
||||||
publicId: row.public_id as string,
|
|
||||||
telegramFileId: row.telegram_file_id as string,
|
|
||||||
telegramFileUniqueId: row.telegram_file_unique_id as string,
|
|
||||||
storageChatId: toNumber(row.storage_chat_id),
|
|
||||||
storageMessageId: toNumber(row.storage_message_id),
|
|
||||||
fileName: row.file_name as string,
|
|
||||||
mimeType: row.mime_type as string,
|
|
||||||
sizeBytes: toNumber(row.size_bytes),
|
|
||||||
fileType: row.file_type as string,
|
|
||||||
uploaderId: toNumber(row.uploader_id),
|
|
||||||
fileHash: row.file_hash as string | null,
|
|
||||||
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
|
|
||||||
archiveStorageMessageId:
|
|
||||||
row.archive_storage_message_id === null ? null : toNumber(row.archive_storage_message_id),
|
|
||||||
archiveFileName: row.archive_file_name as string | null,
|
|
||||||
archiveEntryName: row.archive_entry_name as string | null,
|
|
||||||
archiveMimeType: row.archive_mime_type as string | null,
|
|
||||||
archiveSizeBytes: row.archive_size_bytes === null ? null : toNumber(row.archive_size_bytes),
|
|
||||||
bucketId: row.bucket_id as string,
|
|
||||||
s3Key: row.s3_key as string,
|
|
||||||
storageBackend: (row.storage_backend as string) || 'telegram',
|
|
||||||
isDeleted: row.is_deleted as boolean,
|
|
||||||
multipartUploadId: row.multipart_upload_id as string | null,
|
|
||||||
partCount:
|
|
||||||
row.part_count === null || row.part_count === undefined ? null : toNumber(row.part_count),
|
|
||||||
createdAt: new Date(row.created_at as string),
|
|
||||||
updatedAt: new Date(row.updated_at as string),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeLike = (s: string): string => s.replace(/[%_\\]/g, '\\$&');
|
|
||||||
|
|
||||||
const toNumber = (value: unknown): number => Number(value ?? 0);
|
|
||||||
|
|
||||||
export const listObjectsByPrefix = async (
|
|
||||||
bucketId: string,
|
|
||||||
prefix: string,
|
|
||||||
delimiter: string | null,
|
|
||||||
maxKeys: number,
|
|
||||||
startAfter: string | null,
|
|
||||||
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> => {
|
|
||||||
let query = prefix
|
|
||||||
? sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${escapeLike(prefix)}%`}`
|
|
||||||
: sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`;
|
|
||||||
|
|
||||||
if (startAfter) {
|
|
||||||
query = sql`${query} AND s3_key > ${startAfter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
|
|
||||||
|
|
||||||
const rawResult = (await db.execute(query)) as unknown as Record<string, unknown>[];
|
|
||||||
|
|
||||||
if (delimiter === '/') {
|
|
||||||
const prefixSet = new Set<string>();
|
|
||||||
const objects: S3FileRecord[] = [];
|
|
||||||
|
|
||||||
for (const row of rawResult) {
|
|
||||||
const s3Key = row.s3_key as string;
|
|
||||||
const relativeKey = s3Key.substring(prefix.length);
|
|
||||||
const slashIndex = relativeKey.indexOf('/');
|
|
||||||
if (slashIndex >= 0) {
|
|
||||||
const folderPrefix = prefix + relativeKey.substring(0, slashIndex + 1);
|
|
||||||
if (folderPrefix !== prefix) {
|
|
||||||
prefixSet.add(folderPrefix);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
objects.push(mapDbRowToS3Record(row));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
objects: objects.slice(0, maxKeys),
|
|
||||||
prefixes: Array.from(prefixSet).sort(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record),
|
|
||||||
prefixes: [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const softDeleteFile = async (bucketId: string, s3Key: string): Promise<boolean> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
return result.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise<number> => {
|
|
||||||
let deleted = 0;
|
|
||||||
for (const key of keys) {
|
|
||||||
const ok = await softDeleteFile(bucketId, key);
|
|
||||||
if (ok) deleted++;
|
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countBucketObjects = async (bucketId: string): Promise<number> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
return Number(result[0]?.count || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findOrphanFilesByBucket = async (bucketId: string): Promise<File[]> => {
|
|
||||||
return await db
|
|
||||||
.select()
|
|
||||||
.from(fileSchema)
|
|
||||||
.where(and(eq(fileSchema.bucketId, bucketId), eq(fileSchema.isDeleted, true)))
|
|
||||||
.limit(100);
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { db, files as fileSchema } from './index';
|
|
||||||
import type { File } from './schema';
|
|
||||||
|
|
||||||
export const findFileByHash = async (hash: string): Promise<File | null> => {
|
|
||||||
const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1);
|
|
||||||
return result[0] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findFileByPublicId = async (publicId: string): Promise<File | null> => {
|
|
||||||
const result = await db
|
|
||||||
.select()
|
|
||||||
.from(fileSchema)
|
|
||||||
.where(eq(fileSchema.publicId, publicId))
|
|
||||||
.limit(1);
|
|
||||||
return result[0] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findFileByUniqueId = async (telegramFileUniqueId: string): Promise<File | null> => {
|
|
||||||
const result = await db
|
|
||||||
.select()
|
|
||||||
.from(fileSchema)
|
|
||||||
.where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId))
|
|
||||||
.limit(1);
|
|
||||||
return result[0] || null;
|
|
||||||
};
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { db } from './index';
|
|
||||||
|
|
||||||
export interface MultipartUpload {
|
|
||||||
uploadId: string;
|
|
||||||
bucketId: string;
|
|
||||||
s3Key: string;
|
|
||||||
initiatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
initiatedBy: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MultipartPart {
|
|
||||||
id: number;
|
|
||||||
uploadId: string;
|
|
||||||
partNumber: number;
|
|
||||||
telegramFileId: string;
|
|
||||||
telegramFileUniqueId: string;
|
|
||||||
storageMessageId: number;
|
|
||||||
sizeBytes: number;
|
|
||||||
etag: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createMultipartUpload = async (
|
|
||||||
bucketId: string,
|
|
||||||
s3Key: string,
|
|
||||||
initiatedBy: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const uploadId = nanoid(32);
|
|
||||||
await db.execute(
|
|
||||||
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
|
|
||||||
);
|
|
||||||
return uploadId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findMultipartUpload = async (uploadId: string): Promise<MultipartUpload | null> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
if (result.length === 0) return null;
|
|
||||||
const r = result[0]!;
|
|
||||||
return {
|
|
||||||
uploadId: r.upload_id as string,
|
|
||||||
bucketId: r.bucket_id as string,
|
|
||||||
s3Key: r.s3_key as string,
|
|
||||||
initiatedAt: new Date(r.initiated_at as string),
|
|
||||||
status: r.status as string,
|
|
||||||
initiatedBy: '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const completeMultipartUpload = async (uploadId: string): Promise<void> => {
|
|
||||||
await db.execute(
|
|
||||||
sql`UPDATE multipart_uploads SET status = 'completed' WHERE upload_id = ${uploadId}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const abortMultipartUpload = async (uploadId: string): Promise<void> => {
|
|
||||||
await db.execute(
|
|
||||||
sql`UPDATE multipart_uploads SET status = 'aborted' WHERE upload_id = ${uploadId}`,
|
|
||||||
);
|
|
||||||
// Parts are cascade-deleted by FK
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertMultipartPart = async (
|
|
||||||
part: Omit<MultipartPart, 'id' | 'createdAt'>,
|
|
||||||
): Promise<void> => {
|
|
||||||
await db.execute(
|
|
||||||
sql`INSERT INTO multipart_parts (upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag)
|
|
||||||
VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listMultipartParts = async (uploadId: string): Promise<MultipartPart[]> => {
|
|
||||||
const result = (await db.execute(
|
|
||||||
sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at
|
|
||||||
FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
return result.map((r) => ({
|
|
||||||
id: r.id as number,
|
|
||||||
uploadId: r.upload_id as string,
|
|
||||||
partNumber: r.part_number as number,
|
|
||||||
telegramFileId: r.telegram_file_id as string,
|
|
||||||
telegramFileUniqueId: r.telegram_file_unique_id as string,
|
|
||||||
storageMessageId: r.storage_message_id as number,
|
|
||||||
sizeBytes: Number(r.size_bytes),
|
|
||||||
etag: r.etag as string,
|
|
||||||
createdAt: new Date(r.created_at as string),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapRowToMultipartUpload = (r: Record<string, unknown>): MultipartUpload => ({
|
|
||||||
uploadId: r.upload_id as string,
|
|
||||||
bucketId: r.bucket_id as string,
|
|
||||||
s3Key: r.s3_key as string,
|
|
||||||
initiatedAt: new Date(r.initiated_at as string),
|
|
||||||
status: r.status as string,
|
|
||||||
initiatedBy: (r.initiated_by as string | null) || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listMultipartUploadsByBucket = async (
|
|
||||||
bucketId: string,
|
|
||||||
maxUploads: number,
|
|
||||||
keyMarker: string | null,
|
|
||||||
): Promise<{ uploads: MultipartUpload[]; isTruncated: boolean; nextKeyMarker: string | null }> => {
|
|
||||||
const limit = Math.min(Math.max(maxUploads || 1000, 1), 1000);
|
|
||||||
const result = (await db.execute(
|
|
||||||
keyMarker
|
|
||||||
? sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
|
|
||||||
FROM multipart_uploads
|
|
||||||
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress' AND s3_key > ${keyMarker}
|
|
||||||
ORDER BY s3_key, initiated_at
|
|
||||||
LIMIT ${limit + 1}`
|
|
||||||
: sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
|
|
||||||
FROM multipart_uploads
|
|
||||||
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress'
|
|
||||||
ORDER BY s3_key, initiated_at
|
|
||||||
LIMIT ${limit + 1}`,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
|
|
||||||
const uploads = result.slice(0, limit).map(mapRowToMultipartUpload);
|
|
||||||
return {
|
|
||||||
uploads,
|
|
||||||
isTruncated: result.length > limit,
|
|
||||||
nextKeyMarker: result.length > limit ? uploads.at(-1)?.s3Key || null : null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Core domain entity representing an S3-compatible storage bucket.
|
||||||
|
* Buckets group objects for the S3-compatible API layer.
|
||||||
|
*/
|
||||||
|
export interface Bucket {
|
||||||
|
/** Primary key, UUID */
|
||||||
|
id: string;
|
||||||
|
/** Bucket name (unique, max 63 chars, S3 naming convention) */
|
||||||
|
name: string;
|
||||||
|
/** Record creation timestamp */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Record last-updated timestamp */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
Loaded 50 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user