initial commit
This commit is contained in:
commit
5e5aff990d
16 changed files with 1737 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
9
.env
Normal file
9
.env
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.npmrc
|
||||
.ssl/ca
|
||||
.ssl/certs
|
||||
dist/
|
||||
42
.ssl/gen-ca.sh
Executable file
42
.ssl/gen-ca.sh
Executable 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
116
.ssl/gen-cert.sh
Executable 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
23
Dockerfile
Normal 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
54
README.md
Normal 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
63
docker-compose.yml
Normal 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
25
nginx.conf
Normal 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
26
package.json
Normal 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
1201
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
15
reqs.txt
Normal file
15
reqs.txt
Normal 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 с инструкцией по запуску (3–5 команд).
|
||||
22
src/db.ts
Normal file
22
src/db.ts
Normal 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
85
src/http/motd.ts
Normal 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
36
src/index.ts
Normal 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
13
tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue