initial commit

This commit is contained in:
Roman Siverov 2026-06-04 14:11:13 +03:00
commit 5e5aff990d
16 changed files with 1737 additions and 0 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

9
.env Normal file
View file

@ -0,0 +1,9 @@
# Этот файл содержит переменные окружения для локального запуска.
# Реальные пароли и другие секреты заменяются при развертывании.
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=motd
POSTGRES_USER=user
POSTGRES_PASSWORD=super_secure_password
REDIS_URL=redis://redis:6379

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.npmrc
.ssl/ca
.ssl/certs
dist/

42
.ssl/gen-ca.sh Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env sh
set -euo pipefail
CA_DIR="${CA_DIR:-$(dirname $0)/ca}"
CA_KEY="${CA_DIR}/ca.key"
CA_CERT="${CA_DIR}/ca.pem"
CA_SUBJECT="${CA_SUBJECT:-/C=UZ/O=Local CA/CN=Local Root CA}"
CA_DAYS="${CA_DAYS:-3650}"
CA_KEY_BITS="${CA_KEY_BITS:-4096}"
mkdir -p "${CA_DIR}"
chmod 700 "${CA_DIR}"
if [[ -f "${CA_KEY}" || -f "${CA_CERT}" ]]; then
echo "CA already exists:"
echo " ${CA_KEY}"
echo " ${CA_CERT}"
echo "Nothing to do."
exit 0
fi
openssl genrsa -out "${CA_KEY}" "${CA_KEY_BITS}"
chmod 600 "${CA_KEY}"
openssl req \
-x509 \
-new \
-nodes \
-key "${CA_KEY}" \
-sha256 \
-days "${CA_DAYS}" \
-subj "${CA_SUBJECT}" \
-out "${CA_CERT}"
chmod 644 "${CA_CERT}"
echo "CA created:"
echo " key: ${CA_KEY}"
echo " cert: ${CA_CERT}"
openssl x509 -in "${CA_CERT}" -noout -subject -issuer -dates

116
.ssl/gen-cert.sh Executable file
View file

@ -0,0 +1,116 @@
#!/bin/sh
set -eu
CA_DIR="${CA_DIR:-$(dirname $0)/ca}"
CERTS_DIR="${CERTS_DIR:-$(dirname $0)/certs}"
CA_KEY="${CA_DIR}/ca.key"
CA_CERT="${CA_DIR}/ca.pem"
NAME="${1:-}"
DAYS="${DAYS:-825}"
KEY_BITS="${KEY_BITS:-2048}"
if [ -z "${NAME}" ]; then
echo "Usage:"
echo " $0 <name> [san1 san2 ...]"
echo
echo "Examples:"
echo " $0 api.local api.local localhost 127.0.0.1"
echo " $0 client-1"
exit 1
fi
if [ ! -f "${CA_KEY}" ] || [ ! -f "${CA_CERT}" ]; then
echo "CA files not found. Run gen-ca.sh first."
exit 1
fi
shift || true
if [ -e "${CERTS_DIR}/${NAME}" ]; then
echo "Certificate for ${NAME} already exists."
echo "Nothing to do."
exit 0
fi
mkdir -p "${CERTS_DIR}/${NAME}"
KEY="${CERTS_DIR}/${NAME}/${NAME}.key.pem"
CSR="${CERTS_DIR}/${NAME}/${NAME}.csr.pem"
CERT="${CERTS_DIR}/${NAME}/${NAME}.cert.pem"
EXT="${CERTS_DIR}/${NAME}/${NAME}.ext.cnf"
FULLCHAIN="${CERTS_DIR}/${NAME}/${NAME}.fullchain.pem"
SUBJECT="${SUBJECT:-/C=US/O=Localhost LLC/CN=${NAME}}"
cat > "${EXT}" <<EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = ${NAME}
[v3_req]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names
[alt_names]
EOF
i=1
if [ "$#" -gt 0 ]; then
for san in "$@"; do
case "${san}" in
*[!0-9.]* | .* | *.)
echo "DNS.${i} = ${san}" >> "${EXT}"
;;
*.*.*.*)
echo "IP.${i} = ${san}" >> "${EXT}"
;;
*)
echo "DNS.${i} = ${san}" >> "${EXT}"
;;
esac
i=$((i + 1))
done
else
echo "DNS.1 = ${NAME}" >> "${EXT}"
fi
openssl genrsa -out "${KEY}" "${KEY_BITS}"
chmod 600 "${KEY}"
openssl req \
-new \
-key "${KEY}" \
-subj "${SUBJECT}" \
-out "${CSR}"
openssl x509 \
-req \
-in "${CSR}" \
-CA "${CA_CERT}" \
-CAkey "${CA_KEY}" \
-CAcreateserial \
-out "${CERT}" \
-days "${DAYS}" \
-sha256 \
-extfile "${EXT}" \
-extensions v3_req
cat "${CERT}" "${CA_CERT}" > "${FULLCHAIN}"
chmod 644 "${CSR}" "${CERT}" "${EXT}" "${FULLCHAIN}"
echo "Certificate issued:"
echo " key: ${KEY}"
echo " csr: ${CSR}"
echo " cert: ${CERT}"
echo " fullchain: ${FULLCHAIN}"
openssl x509 -in "${CERT}" -noout -subject -issuer -dates -ext subjectAltName

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# --- Stage 1: Build ---
FROM node:22.22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm build
# --- Stage 2: Run ---
FROM node:22.22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]

54
README.md Normal file
View file

@ -0,0 +1,54 @@
# Simple MOTD Web App
Простенькое MOTD бекенд-приложение на веб-технологиях.
Предназначено для получения, передачи и сохранения MOTD (Message-of-the-Day).
## Стек
Приложение и обвязка написаны на скорую руку, примерно за час. **Нюансы**:
- И базы, и приложение, и балансировщик записаны в одной композиции
- .env файл используется как для приложения, так и для контейнера с базой данных
- Генерация CA + пары сертификат-ключ происходят при сборке контейнера с приложением, один раз. При пересборке используются уже существующие сертификаты - в CI их можно подсунуть перед сборкой.
- Само приложение (только приложение - директория `src/`) сгенерировано нейронкой
- Никакого CI/CD
Чтобы этих нюансов не было - полагается трудоустройство, делать что-то полноценное за бесплатно я не собираюсь.
Стек включает в себя:
- Nginx
- PostgreSQL
- Redis
- Node.js (TypeScript)
## Сборка и запуск
Требования:
- Подключение к интернету
- Незанятый порт 443
- Docker
1. Выполним команды:
```bash
docker run --rm -v ./.ssl:/ssl --entrypoint /bin/sh alpine/openssl -c "/ssl/gen-ca.sh && /ssl/gen-cert.sh localhost"
docker compose build
docker compose up -d
```
2. Перейдём к разделу [Использование](#использование).
## Использование
Получить MOTD:
```bash
curl -k https://localhost/motd
```
Установить MOTD:
```bash
curl -k -X POST https://localhost/motd \
-H "Content-Type: application/json" \
-d '{"message":"Hello from MOTD app"}'
```
## P.S.
Честно говоря, тестовые задания - это развод гоев на бесплатную рабочую силу, сомнительные конторы их очень любят.<br>
В вашем случае я хотя бы увидел живого человека - обычно своё время на такое я не трачу.

63
docker-compose.yml Normal file
View file

@ -0,0 +1,63 @@
services:
postgres:
image: postgres:16
container_name: motd-postgres
env_file: .env
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 10s
timeout: 5s
retries: 15
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7
container_name: motd-redis
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 10s
timeout: 5s
retries: 15
ports:
- '6379:6379'
app:
image: artifacts.siverov.com/library/motd-web-app
build: .
env_file: .env
container_name: motd-app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: [ "CMD", "nc", "-vz", "::1", "3000" ]
interval: 10s
timeout: 5s
retries: 15
nginx:
image: nginx:1.27-alpine
container_name: motd-nginx
ports:
- '80:80'
- '443:443'
depends_on:
app:
condition: service_started
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./.ssl/certs:/etc/nginx/certs:ro
healthcheck:
test: [ "CMD", "nc", "-vz", "::1", "80" ]
interval: 10s
timeout: 5s
retries: 15
volumes:
postgres_data: {}

25
nginx.conf Normal file
View file

@ -0,0 +1,25 @@
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/nginx/certs/localhost/localhost.cert.pem;
ssl_certificate_key /etc/nginx/certs/localhost/localhost.key.pem;
ssl_trusted_certificate /etc/nginx/certs/localhost/localhost.fullchain.pem;
client_max_body_size 10m;
location / {
proxy_pass http://app:3000;
limit_req zone=per_ip nodelay;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "motd-app",
"version": "1.0.0",
"description": "Simple MOTD app with TypeScript, Redis, and Postgres",
"author": "Roman Siverov",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"pg": "^8.12.0",
"redis": "^4.6.14"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.7.4",
"@types/pg": "^8.20.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
},
"packageManager": "pnpm@10.32.1"
}

1201
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

15
reqs.txt Normal file
View file

@ -0,0 +1,15 @@
Подготовьте docker-compose.yml и конфигурационные файлы для локального стенда веб-приложения:
Состав:
1. Nginx — reverse proxy, TLS termination (self-signed cert), слушает :443.
2. Любое веб-приложение (например, httpbin, Grafana или простой Node.js/Python HTTP-сервер) — backend на :8080.
3. PostgreSQL 15 — база данных.
4. Redis — кэш сессий.
Требования:
- Nginx проксирует на backend с правильными заголовками X-Forwarded-For/Proto/Host.
- healthcheck для всех четырёх сервисов.
- .env файл для паролей и переменных (не хардкодить в compose).
- nginx.conf с rate limiting (10 req/s на IP) и ограничением размера тела запроса (10MB).
- Сгенерировать self-signed сертификат через скрипт gen-certs.sh (openssl).
- README.md с инструкцией по запуску (35 команд).

22
src/db.ts Normal file
View file

@ -0,0 +1,22 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
dotenv.config();
export const pgPool = new Pool({
host: process.env.POSTGRES_HOST,
port: Number(process.env.POSTGRES_PORT || 5432),
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
});
export async function initDb(): Promise<void> {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS motd (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
}

85
src/http/motd.ts Normal file
View file

@ -0,0 +1,85 @@
import { Express } from 'express';
import { redisClient } from '../index.js';
import { pgPool } from '../db.js';
export async function httpMotd(app: Express) {
app.get('/motd', async (_req, res) => {
try {
const cached = await redisClient.get('motd:current');
if (cached) {
return res.json({
source: 'redis',
message: cached,
});
}
const result = await pgPool.query(
`
SELECT message
FROM motd
ORDER BY created_at DESC, id DESC
LIMIT 1
`
);
if (result.rowCount === 0) {
return res.status(404).json({
error: 'No MOTD set',
});
}
const message = result.rows[0].message as string;
await redisClient.set('motd:current', message, {
EX: 60,
});
return res.json({
source: 'postgres',
message,
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: 'Internal server error',
});
}
});
app.post('/motd', async (req, res) => {
try {
const message = req.body?.message;
if (typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({
error: 'message must be a non-empty string',
});
}
const trimmedMessage = message.trim();
await pgPool.query(
`
INSERT INTO motd (message)
VALUES ($1)
`,
[trimmedMessage]
);
await redisClient.set('motd:current', trimmedMessage, {
EX: 60,
});
return res.status(201).json({
message: 'MOTD updated',
motd: trimmedMessage,
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: 'Internal server error',
});
}
});
}

36
src/index.ts Normal file
View file

@ -0,0 +1,36 @@
import dotenv from 'dotenv';
import express from 'express';
import { createClient } from 'redis';
import { initDb } from './db.js';
import { httpMotd } from './http/motd.js';
dotenv.config();
const app = express();
app.use(express.json());
const port = Number(3000);
export const redisClient = createClient({
url: process.env.REDIS_URL,
});
redisClient.on('error', (err) => {
console.error('Redis error:', err);
});
async function main(): Promise<void> {
await redisClient.connect();
await initDb();
await httpMotd(app);
app.listen(port, () => {
console.log(`Listening on port ${port}`);
});
}
main().catch((error) => {
console.error('Failed to start app:', error);
process.exit(1);
});

13
tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}