Merge pull request 'Slave/scaffold dev docker' (#1) from slave/scaffold-dev-docker into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-27 21:43:50 +00:00
39 changed files with 6185 additions and 1 deletions
+37
View File
@@ -0,0 +1,37 @@
COMPOSE_PROJECT_NAME=private_image_dev
POSTGRES_HOST=postgres
POSTGRES_DB=private_image
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5432
PGADMIN_DEFAULT_EMAIL=admin@example.com
PGADMIN_DEFAULT_PASSWORD=admin
PGADMIN_PORT=5555
BACKEND_PORT=3000
FRONTEND_PORT=5173
NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
MAX_IMAGES_PER_UPLOAD=10
MULTER_MAX_FILE_BYTES=10485760
CAPTCHA_ENABLED=true
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
ALTCHA_MAX_NUMBER=50000
NEVER_MODE_INACTIVITY_DAYS=365
THROTTLE_UPLOAD_TTL_MS=60000
THROTTLE_UPLOAD_LIMIT=20
THROTTLE_HOTLINK_PER_IP_TTL_MS=60000
THROTTLE_HOTLINK_PER_IP_LIMIT=1000
THROTTLE_HOTLINK_PER_ID_TTL_MS=60000
THROTTLE_HOTLINK_PER_ID_LIMIT=120
VITE_MAX_IMAGES_PER_UPLOAD=10
+17
View File
@@ -0,0 +1,17 @@
.env.dev
.env.prod
/deploy/certs
/deploy/certbot/www
/backend/node_modules
/backend/dist
/backend/*.tsbuildinfo
/backend/storage
/frontend/node_modules
/frontend/dist
.vscode
.idea
*.log
+13
View File
@@ -0,0 +1,13 @@
{
"printWidth": 120,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": true,
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "lf",
"arrowParens": "avoid"
}
+20
View File
@@ -0,0 +1,20 @@
# Private Image
Self-hosted, privacy-focused image hosting. Images are metadata-stripped and encrypted in the browser before upload. Supports **zero-knowledge** viewer links and **hotlink** mode for embeds.
## Development
### Start dev environment
```bash
cp .env.example .env.dev
docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
```
### Install npm packages for code editor visibility
```bash
cd backend && npm i && cd ../frontend && npm i
```
-1
View File
@@ -1 +0,0 @@
Private Image
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /backend
RUN apk add --no-cache curl
COPY package*.json .
RUN npm ci
COPY . .
CMD ["npm", "run", "start:dev"]
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+4353
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "backend",
"version": "0.0.1",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.0.0",
"@types/express": "^5.0.0",
"@types/node": "^22.10.7",
"typescript": "~6.0.2"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import appConfig from './config';
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig]
}),
HealthCheckModule
]
})
export class AppModule {}
+22
View File
@@ -0,0 +1,22 @@
import { registerAs } from '@nestjs/config';
import { AppConfig } from '../types/AppConfig';
import { NodeEnv } from '../types/NodeEnv';
const env = (key: string): string => process.env[key] || '';
const envInt = (key: string): number => parseInt(env(key), 10);
export const getAppConfig = (): AppConfig => {
const rawOrigins = env('CORS_ORIGINS');
return {
port: envInt('BACKEND_PORT'),
nodeEnv: env('NODE_ENV') as NodeEnv,
corsOrigins: rawOrigins
.split(',')
.map(o => o.trim())
.filter(Boolean)
};
};
export default registerAs('app', getAppConfig);
+30
View File
@@ -0,0 +1,30 @@
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './AppModule';
import { AppConfig } from './types/AppConfig';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const { port, corsOrigins } = configService.get('app') as AppConfig;
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true }
})
);
app.enableCors({
origin: corsOrigins,
credentials: true
});
await app.listen(port);
}
void bootstrap();
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthCheckController } from './controllers/HealthCheckController';
@Module({
controllers: [HealthCheckController]
})
export class HealthCheckModule {}
@@ -0,0 +1,10 @@
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import type { Response } from 'express';
@Controller('/health-check')
export class HealthCheckController {
@Get('/')
getStatus(@Res() res: Response) {
return res.sendStatus(HttpStatus.OK);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NodeEnv } from './NodeEnv';
export type AppConfig = {
port: number;
nodeEnv: NodeEnv;
corsOrigins: string[];
};
+4
View File
@@ -0,0 +1,4 @@
export enum NodeEnv {
Development = 'development',
Production = 'production'
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*spec.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"],
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
cd "$ROOT_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
if [[ -z "${CLEARNET_DOMAIN:-}" ]]; then
echo "CLEARNET_DOMAIN is not set in .env.prod" >&2
exit 1
fi
LIVE_DIR="${ROOT_DIR}/deploy/certs/live/${CLEARNET_DOMAIN}"
if [[ -f "${LIVE_DIR}/fullchain.pem" ]]; then
echo "Certificates already exist at deploy/certs/live/${CLEARNET_DOMAIN}" >&2
exit 1
fi
mkdir -p "$LIVE_DIR"
openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
-keyout "${LIVE_DIR}/privkey.pem" \
-out "${LIVE_DIR}/fullchain.pem" \
-subj "/CN=${CLEARNET_DOMAIN}"
echo "Temporary self-signed certificates created at deploy/certs/live/${CLEARNET_DOMAIN}"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
cd "$ROOT_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2
exit 1
fi
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --build
echo "Pruning unused Docker data older than 24h..."
docker system prune -af --filter "until=24h"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
CERTBOT_EMAIL=""
usage() {
cat <<EOF
Usage: $(basename "$0") --email you@example.com
Obtain Let's Encrypt certificates for CLEARNET_DOMAIN and www.CLEARNET_DOMAIN using
the webroot challenge. Nginx must be running and serving /.well-known/acme-challenge/
from deploy/certbot/www.
Environment is read from .env.prod (CLEARNET_DOMAIN).
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--email)
CERTBOT_EMAIL="$2"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
if [[ -z "${CLEARNET_DOMAIN:-}" ]]; then
echo "CLEARNET_DOMAIN is not set in .env.prod" >&2
exit 1
fi
if [[ -z "$CERTBOT_EMAIL" ]]; then
echo "Pass --email for Let's Encrypt registration." >&2
usage >&2
exit 1
fi
mkdir -p "${ROOT_DIR}/deploy/certbot/www" "${ROOT_DIR}/deploy/certs"
docker run --rm \
-v "${ROOT_DIR}/deploy/certbot/www:/var/www/certbot" \
-v "${ROOT_DIR}/deploy/certs:/etc/letsencrypt" \
certbot/certbot certonly \
--webroot \
-w /var/www/certbot \
-d "$CLEARNET_DOMAIN" \
-d "www.${CLEARNET_DOMAIN}" \
--email "$CERTBOT_EMAIL" \
--agree-tos \
--non-interactive
if [[ ! -f "${ROOT_DIR}/deploy/certs/live/${CLEARNET_DOMAIN}/fullchain.pem" ]]; then
echo "Expected certificates at deploy/certs/live/${CLEARNET_DOMAIN}" >&2
exit 1
fi
echo "Certificates issued at deploy/certs/live/${CLEARNET_DOMAIN}"
echo "Reload nginx: docker compose --env-file .env.prod -f docker-compose.prod.yml exec nginx nginx -s reload"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
cd "$ROOT_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}" >&2
exit 1
fi
mkdir -p "${ROOT_DIR}/deploy/certbot/www" "${ROOT_DIR}/deploy/certs"
docker run --rm \
-v "${ROOT_DIR}/deploy/certbot/www:/var/www/certbot" \
-v "${ROOT_DIR}/deploy/certs:/etc/letsencrypt" \
certbot/certbot renew \
--webroot \
-w /var/www/certbot
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec nginx nginx -s reload
echo "Certificate renewal complete; nginx reloaded."
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
cd "$ROOT_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}" >&2
exit 1
fi
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec tor \
cat /var/lib/tor/hs/hostname
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
git pull
"${ROOT_DIR}/deploy/scripts/deploy.sh"
+7
View File
@@ -0,0 +1,7 @@
FROM alpine:3.20
RUN apk add --no-cache tor
COPY torrc /etc/tor/torrc
CMD ["tor", "-f", "/etc/tor/torrc"]
+5
View File
@@ -0,0 +1,5 @@
SocksPort 0
Log notice stdout
HiddenServiceDir /var/lib/tor/hs/
HiddenServicePort 80 nginx:8080
+73
View File
@@ -0,0 +1,73 @@
services:
postgres:
image: postgres:16
container_name: ${COMPOSE_PROJECT_NAME}_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- private_image_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
pgadmin:
image: dpage/pgadmin4:8
container_name: ${COMPOSE_PROJECT_NAME}_pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
ports:
- '${PGADMIN_PORT}:80'
volumes:
- private_image_pgadmin_data:/var/lib/pgadmin
depends_on:
postgres:
condition: service_healthy
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: ${COMPOSE_PROJECT_NAME}_backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env.dev
ports:
- '${BACKEND_PORT}:${BACKEND_PORT}'
volumes:
- ./backend/src:/backend/src
healthcheck:
test: ['CMD-SHELL', 'curl -f http://backend:${BACKEND_PORT}/api/health-check/']
interval: 1s
timeout: 30s
retries: 20
start_period: 10s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: ${COMPOSE_PROJECT_NAME}_frontend
restart: unless-stopped
env_file:
- .env.dev
ports:
- '${FRONTEND_PORT}:5173'
volumes:
- ./frontend/src:/frontend/src
depends_on:
backend:
condition: service_healthy
volumes:
private_image_postgres_data:
private_image_pgadmin_data:
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /frontend
COPY package*.json .
RUN npm ci
ADD . .
CMD ["npm", "run", "dev"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Private Image</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1163
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.32"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}
+32
View File
@@ -0,0 +1,32 @@
<template>
<main class="home">
<h1>Private Image</h1>
</main>
</template>
<style scoped>
.home {
box-sizing: border-box;
min-height: 100vh;
margin: 0;
padding: 2rem;
font-family:
system-ui,
-apple-system,
Segoe UI,
Roboto,
sans-serif;
line-height: 1.5;
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 600;
}
p {
margin: 0;
color: #444;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
plugins: [vue()],
server: {
port: 5173
}
});
View File