main commit

This commit is contained in:
Pavel Umansky 2026-05-13 21:49:47 +02:00
parent 436ccb2e53
commit 0506748dee
30 changed files with 1027 additions and 68 deletions

1
sk1

@ -1 +0,0 @@
Subproject commit 4a6b3a95ecefc48c2f0be168e3a76be45bb02c78

View File

@ -0,0 +1,55 @@
name: Deploy Summarizer
on:
push:
branches: [main]
paths:
- 'summarizer/**'
- '.github/workflows/deploy-summarizer.yml'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE: ghcr.io/${{ github.repository_owner }}/readitlater-summarizer
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: summarizer
push: true
tags: ${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}
- name: Deploy to instance
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SUMMARIZER_HOST }}
username: ec2-user
key: ${{ secrets.SSH_PRIVATE_KEY }}
proxy_host: ${{ secrets.WEB_HOST }}
proxy_username: ec2-user
proxy_key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
echo '${{ secrets.GITHUB_TOKEN }}' | sudo docker login ghcr.io -u ${{ github.actor }} --password-stdin
sudo docker pull ${{ env.IMAGE }}:latest
sudo docker stop summarizer || true
sudo docker rm summarizer || true
sudo docker run -d \
--name summarizer \
--restart always \
-p 8000:8000 \
${{ env.IMAGE }}:latest

81
sk1/.github/workflows/deploy-web.yml vendored Normal file
View File

@ -0,0 +1,81 @@
name: Deploy Web
on:
push:
branches: [main]
paths:
- 'web/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
env:
REGISTRY: ghcr.io
NGINX_IMAGE: ghcr.io/${{ github.repository_owner }}/readitlater-nginx
API_IMAGE: ghcr.io/${{ github.repository_owner }}/readitlater-api
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: web/nginx
push: true
tags: ${{ env.NGINX_IMAGE }}:latest,${{ env.NGINX_IMAGE }}:${{ github.sha }}
- uses: docker/build-push-action@v7
with:
context: web/api
push: true
tags: ${{ env.API_IMAGE }}:latest,${{ env.API_IMAGE }}:${{ github.sha }}
- name: Deploy to instance
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.WEB_HOST }}
username: ec2-user
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
sudo chown -R ec2-user:ec2-user /opt/app
echo "GITHUB_OWNER=${{ github.repository_owner }}" > /opt/app/.env
echo "DB_HOST=${{ secrets.DB_HOST }}" >> /opt/app/.env
echo "DB_PORT=5432" >> /opt/app/.env
echo "DB_NAME=${{ secrets.DB_NAME }}" >> /opt/app/.env
echo "DB_USER=${{ secrets.DB_USER }}" >> /opt/app/.env
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> /opt/app/.env
echo "SUMMARIZER_URL=http://${{ secrets.SUMMARIZER_HOST }}:8000" >> /opt/app/.env
echo '${{ secrets.GITHUB_TOKEN }}' | sudo docker login ghcr.io -u ${{ github.actor }} --password-stdin
sudo docker pull ${{ env.NGINX_IMAGE }}:latest
sudo docker pull ${{ env.API_IMAGE }}:latest
cd /opt/app
cat > docker-compose.yml << 'COMPOSE'
services:
nginx:
image: ghcr.io/${{ github.repository_owner }}/readitlater-nginx:latest
ports:
- "80:80"
- "443:443"
environment:
- CERTBOT_EMAIL=pavelyman76@gmail.com
depends_on:
- api
restart: always
api:
image: ghcr.io/${{ github.repository_owner }}/readitlater-api:latest
env_file: .env
restart: always
COMPOSE
sudo /usr/local/bin/docker-compose down || true
sudo /usr/local/bin/docker-compose up -d

6
sk1/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
terraform/terraform*
terraform/.terraform*
terraform/security_groups.tf
*.tfstate
*.tfstate.*
.terraform/

72
sk1/README.md Normal file
View File

@ -0,0 +1,72 @@
# Read-it-later AI Summarizer
## 1. Opis aplikácie
**Read-it-later** je moderná mikroslužbová webová aplikácia, ktorá slúži ako inteligentný archív článkov. Používateľ vloží URL adresu dlhého článku, aplikácia ho automaticky stiahne a pomocou modelu umelej inteligencie (AI) vygeneruje jeho stručné zhrnutie. Výsledok si používateľ môže neskôr kedykoľvek prečítať v responzívnom webovom rozhraní.
## 2. Architektúra a využité Cloud technológie
Aplikácia je nasadená v prostredí verejného cloudu **AWS (Amazon Web Services)** a využíva plne automatizovaný prístup Infrastructure as Code (IaC) cez Terraform.
* **Virtual Private Cloud (VPC):** Izolovaná sieť s nakonfigurovanými podsúbormi a bezpečnostnými skupinami pre riadenie sieťovej prevádzky.
* **EC2 (Elastic Compute Cloud) & Docker:** Dva dedikované virtuálne servery bežiace na OS Amazon Linux 2023. Jeden zabezpečuje web (Nginx a FastAPI) a druhý slúži pre výpočetne náročnejší AI model. Samotné nasadenie beží vo vnútri **Docker** kontajnerov orchestráciou cez Docker Compose.
* **Amazon RDS pre PostgreSQL:** Plne spravovaná relačná databáza, do ktorej sa ukladajú používateľské záznamy, originálne texty a AI zhrnutia. Trvalé zväzky (Persistent Storage - EBS a RDS Storage) sú šifrované a zabezpečujú trvalé uchovanie dát aj po reštarte služieb.
* **AWS Route 53:** Služba DNS, ktorá automaticky spravuje doménové záznamy a prekladá doménu na verejnú IP adresu (Elastic IP) hlavného servera.
* **SSL a HTTPS:** Smerovanie z verejného internetu je zabezpečené certifikátmi od Let's Encrypt, pričom Nginx slúži ako reverzný proxy server (`jonasal/nginx-certbot`).
## 3. Analýza nákladov (1 rok)
Odhadovaný prevádzkový profil: **1000 používateľov za deň** a maximálna veľkosť databázy **50 GB**.
Keďže prevádzkujeme model umelej inteligencie, je vyžadovaný aspoň jeden výkonnejší stroj (`t3.medium`). Kalkulácia nezahŕňa doménový poplatok u registrátora tretej strany.
| Služba AWS | Typ / Špecifikácia | Fakturačný interval | Cena za mesiac | Cena za 1 rok |
| :--- | :--- | :--- | :--- | :--- |
| **EC2 (Web Server)** | `t3.micro` (2 vCPU, 1 GiB RAM) + 30GB EBS gp3 | Hodinový | ~ $7.60 | ~ $91.20 |
| **EC2 (AI Server)** | `t3.medium` (2 vCPU, 4 GiB RAM) + 30GB EBS gp3 | Hodinový | ~ $30.40 | ~ $364.80 |
| **Amazon RDS** | `db.t4g.micro` (PostgreSQL) | Hodinový | ~ $11.90 | ~ $142.80 |
| **RDS Storage** | 50 GB `gp2` zväzok pre databázu | Mesačný GB | ~ $5.75 | ~ $69.00 |
| **Route 53** | 1 Hosted Zone + DNS dopyty | Mesačný | ~ $0.50 | ~ $6.00 |
| **Sieť (Data Transfer)** | Odhadovaný odchádzajúci prenos dát | Za GB | ~ $2.00 | ~ $24.00 |
| **Spolu (Odhad)** | | | **~ $58.15** | **~ $697.80** |
## 4. Opis odovzdaných súborov
* `prepare-app.sh`: Skript pre plne automatické vytvorenie klaudového prostredia.
* `remove-app.sh`: Skript pre deštrukciu všetkých cloudových služieb.
* `terraform/`: Priečinok obsahujúci kompletnú deklaratívnu definíciu AWS infraštruktúry (IaC). Obsahuje `.tf` súbory pre siete, servery, databázu a bezpečnosť.
* `web/`: Zdrojové kódy webovej časti (HTML/CSS), backendu (FastAPI v Pythone), konfiguračný súbor pre Nginx a Docker prostredie.
* `summarizer/`: Kód PyTorch AI mikroslužby vrátane `Dockerfile` a `requirements.txt` pre stiahnutie modelu BART.
* `.github/workflows/`: Skripty pre automatické CI/CD (kontinuálna integrácia a doručovanie), ktoré aplikáciu zostavia a nasadia po každej zmene v zdrojovom kóde.
## 5. Konfigurácia (Terraform & CI/CD)
Infraštruktúra je definovaná formou kódu cez Terraform. Terraform konfigurácia (`ec2.tf`, `vpc.tf`, `rds.tf`) automaticky prepojuje sieťové rozhrania, nastavuje pravidlá Firewallu (Security Groups) a vytvorí kľúče pre SSH prístup.
Softvérové nasadenie zabezpečujú bez-agentové GitHub Actions. Aplikácie sa po commite zabalia do Docker imidžov, uložia sa do GitHub Container Registry (GHCR) a následne sa prostredníctvom SSH tunelu automaticky stiahnu a spustia cez `docker-compose` na cieľových virtuálnych strojoch v AWS.
## 6. Návod na použitie
1. Otvorte ľubovoľný webový prehliadač a prejdite na adresu URL poskytnutú po inštalácii (napríklad `https://devopspavel.me`).
2. Do vstupného poľa vložte odkaz na dlhý internetový článok, ktorý si prajete zosumarizovať.
3. Kliknite na tlačidlo **Save**.
4. Systém odošle požiadavku do umelej inteligencie a o niekoľko sekúnd zobrazí originálny link spoločne so skráteným AI textom.
## 7. Záloha dát (Backup)
Záloha používateľských dát je spravovaná automaticky priamo na úrovni Amazon RDS (Automated Backups). Databáza má predvolene nastavené 7-dňové retenčné obdobie, počas ktorého AWS vykonáva denné kópie (snapshots).
V prípade potreby manuálnej zálohy (Full Backup) do súboru je nutné:
1. Pripojiť sa cez SSH na Web Server inštanciu (`t3.micro`), ktorá má prístup do privátnej siete RDS.
2. Vykonať príkaz: `pg_dump -h <adresa_rds> -U appuser -d aisummarizer -F c -f zaloha.dump`.
## 8. Záznamy o prístupoch z internetu
Logy o pripojeniach používateľov je možné sledovať priamo z Nginx kontajnera. Pre zobrazenie najnovších prístupov z internetu:
1. Pripojte sa na Web Server prostredníctvom priloženého SSH kľúča.
2. Pre zobrazenie "live" prístupov spustite: `sudo docker logs -f nginx`
Tieto logy zaznamenávajú zdrojovú IP adresu, prehliadač a typ dopytu (GET/POST).
## 9. Podmienky spustenia skriptov
Pre úspešné spustenie skriptov `prepare-app.sh` a `remove-app.sh` je nutné:
1. Mať v operačnom systéme nainštalovaný nástroj **Terraform** (verzia >= 1.0).
2. Mať nainštalovaný balík **AWS CLI**.
3. Mať v systéme nakonfigurované platné prístupové kľúče `AWS_ACCESS_KEY_ID` a `AWS_SECRET_ACCESS_KEY` do prostredia AWS.
4. Používať Unix/Linux/macOS terminál pre bezproblémové spustenie `.sh` súborov.
## 10. Zoznam externých zdrojov a využitie Generatívnej AI
Pri návrhu architektúry, vývoji aplikácie a tvorbe infraštruktúry bol využitý generatívny model umelej inteligencie **Antigravity (Google DeepMind)**.
Model bol použitý ako inteligentný agent (Pair-programming) predovšetkým na:
- Tvorbu a optimalizáciu **Python skriptov** (FastAPI backend a PyTorch AI Summarizer).
- Pripravenie deklaratívnych `.tf` konfigurácií a GitHub Actions Pipelines.
- Štruktúrovanie a formátovanie tejto dokumentácie v slovenskom jazyku.
Aplikácia interne využíva Open-Source zmenšený model `facebook/bart-large-cnn` zverejnený na platforme HuggingFace pre úlohy sumarizácie textu.

14
sk1/prepare-app.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
echo "Začínam prípravu infraštruktúry pre aplikáciu Read-it-later..."
cd terraform || exit 1
echo "Inicializujem Terraform prostredie..."
terraform init
echo "Vytváram a nasadzujem všetky služby (EC2, RDS, Sieť)..."
terraform apply -auto-approve
echo "Infraštruktúra je úspešne nasadená!"
echo "Nezabudnite skopírovať vygenerované tajné kľúče (secrets) do GitHub Actions pre automatický deploing."

13
sk1/remove-app.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
echo "Varovanie: Tento skript natrvalo vymaže celú infraštruktúru aplikácie vrátane databázy!"
read -p "Ste si istý, že chcete pokračovať? (y/N): " confirm
if [[ $confirm == [yY] || $confirm == [yY][eE][sS] ]]; then
cd terraform || exit 1
echo "Ruším všetky cloudové služby..."
terraform destroy -auto-approve
echo "Aplikácia a cloudové zdroje boli úspešne odstránené."
else
echo "Operácia zrušená."
fi

View File

@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN python -c "from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; AutoTokenizer.from_pretrained('facebook/bart-large-cnn'); AutoModelForSeq2SeqLM.from_pretrained('facebook/bart-large-cnn')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

37
sk1/summarizer/main.py Normal file
View File

@ -0,0 +1,37 @@
from fastapi import FastAPI
from pydantic import BaseModel
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
import torch
app = FastAPI()
tokenizer = AutoTokenizer.from_pretrained("facebook/bart-large-cnn")
model = AutoModelForSeq2SeqLM.from_pretrained("facebook/bart-large-cnn")
class TextRequest(BaseModel):
text: str
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/summarize")
def summarize(req: TextRequest):
inputs = tokenizer(
req.text,
max_length=1024,
truncation=True,
return_tensors="pt",
)
summary_ids = model.generate(
**inputs,
max_length=150,
min_length=40,
num_beams=4,
length_penalty=2.0,
)
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
return {"summary": summary}

View File

@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.0
transformers==4.44.0
torch==2.4.0

View File

@ -0,0 +1,9 @@
resource "aws_cloudwatch_log_group" "nginx" {
name = "/ec2/${local.name_prefix}/nginx"
retention_in_days = var.log_retention_days
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ec2/${local.name_prefix}/app"
retention_in_days = var.log_retention_days
}

20
sk1/terraform/data.tf Normal file
View File

@ -0,0 +1,20 @@
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_caller_identity" "current" {}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}

82
sk1/terraform/ec2.tf Normal file
View File

@ -0,0 +1,82 @@
resource "tls_private_key" "main" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "main" {
key_name = "${local.name_prefix}-key"
public_key = tls_private_key.main.public_key_openssh
}
resource "aws_eip" "nginx" {
domain = "vpc"
tags = {
Name = "${local.name_prefix}-nginx-eip"
}
}
resource "aws_eip_association" "nginx" {
instance_id = aws_instance.nginx.id
allocation_id = aws_eip.nginx.id
}
resource "aws_instance" "nginx" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.nginx_instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.nginx.id]
key_name = aws_key_pair.main.key_name
iam_instance_profile = aws_iam_instance_profile.ec2.name
root_block_device {
volume_size = 30
volume_type = "gp3"
encrypted = true
}
user_data = <<-EOF
#!/bin/bash
set -e
dnf update -y
dnf install -y docker
systemctl enable docker
systemctl start docker
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
mkdir -p /opt/app
EOF
tags = {
Name = "${local.name_prefix}-nginx"
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.app_instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.app.id]
key_name = aws_key_pair.main.key_name
iam_instance_profile = aws_iam_instance_profile.ec2.name
root_block_device {
volume_size = 30
volume_type = "gp3"
encrypted = true
}
user_data = <<-EOF
#!/bin/bash
set -e
dnf update -y
dnf install -y docker aws-cli
systemctl enable docker
systemctl start docker
mkdir -p /opt/app
EOF
tags = {
Name = "${local.name_prefix}-app"
}
}

60
sk1/terraform/iam.tf Normal file
View File

@ -0,0 +1,60 @@
resource "aws_iam_role" "ec2" {
name = "${local.name_prefix}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_policy" "ec2_app" {
name = "${local.name_prefix}-ec2-app"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ssm:GetParameter",
"ssm:GetParameters"
]
Resource = [
aws_ssm_parameter.db_password.arn
]
},
{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
]
Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/ec2/${local.name_prefix}*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "ec2_app" {
role = aws_iam_role.ec2.name
policy_arn = aws_iam_policy.ec2_app.arn
}
resource "aws_iam_instance_profile" "ec2" {
name = "${local.name_prefix}-ec2-profile"
role = aws_iam_role.ec2.name
}

9
sk1/terraform/locals.tf Normal file
View File

@ -0,0 +1,9 @@
locals {
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
name_prefix = "${var.project_name}-${var.environment}"
}

49
sk1/terraform/outputs.tf Normal file
View File

@ -0,0 +1,49 @@
output "vpc_id" {
value = aws_vpc.main.id
}
output "nginx_public_ip" {
value = aws_eip.nginx.public_ip
}
output "nginx_instance_id" {
value = aws_instance.nginx.id
}
output "app_instance_id" {
value = aws_instance.app.id
}
output "app_private_ip" {
value = aws_instance.app.private_ip
}
output "app_url" {
value = "https://${var.domain_name}"
}
output "rds_endpoint" {
value = aws_db_instance.main.endpoint
}
output "rds_db_name" {
value = aws_db_instance.main.db_name
}
output "rds_db_user" {
value = aws_db_instance.main.username
}
output "rds_db_password" {
value = random_password.db.result
sensitive = true
}
output "name_servers" {
value = aws_route53_zone.main.name_servers
}
output "ssh_private_key" {
value = tls_private_key.main.private_key_pem
sensitive = true
}

View File

@ -0,0 +1,28 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
provider "aws" {
region = var.aws_region
access_key = var.terraform_aws_access_key
secret_key = var.terraform_aws_secret_key
default_tags {
tags = local.common_tags
}
}

54
sk1/terraform/rds.tf Normal file
View File

@ -0,0 +1,54 @@
resource "random_password" "db" {
length = 24
special = false
}
resource "aws_ssm_parameter" "db_password" {
name = "/${var.project_name}/${var.environment}/db-password"
type = "SecureString"
value = random_password.db.result
}
resource "aws_db_subnet_group" "main" {
name = "${local.name_prefix}-db-subnet"
subnet_ids = aws_subnet.private[*].id
tags = {
Name = "${local.name_prefix}-db-subnet"
}
}
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-db"
engine = "postgres"
engine_version = var.db_engine_version
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
storage_type = "gp2"
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = random_password.db.result
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = false
publicly_accessible = false
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "mon:04:00-mon:05:00"
skip_final_snapshot = true
final_snapshot_identifier = "${local.name_prefix}-db-final"
deletion_protection = false
tags = {
Name = "${local.name_prefix}-db"
}
}

11
sk1/terraform/route53.tf Normal file
View File

@ -0,0 +1,11 @@
resource "aws_route53_zone" "main" {
name = var.domain_name
}
resource "aws_route53_record" "app" {
zone_id = aws_route53_zone.main.zone_id
name = var.domain_name
type = "A"
ttl = 300
records = [aws_eip.nginx.public_ip]
}

View File

@ -0,0 +1,83 @@
variable "project_name" {
type = string
}
variable "environment" {
type = string
}
variable "aws_region" {
type = string
}
variable "vpc_cidr" {
type = string
}
variable "public_subnet_cidrs" {
type = list(string)
}
variable "private_subnet_cidrs" {
type = list(string)
}
variable "domain_name" {
type = string
}
variable "db_name" {
type = string
}
variable "db_username" {
type = string
}
variable "db_instance_class" {
type = string
}
variable "db_allocated_storage" {
type = number
}
variable "db_engine_version" {
type = string
}
variable "nginx_instance_type" {
type = string
}
variable "app_instance_type" {
type = string
}
variable "app_port" {
type = number
}
variable "allowed_ssh_cidr" {
type = string
}
variable "log_retention_days" {
type = number
}
variable "terraform_aws_access_key" {
type = string
sensitive = true
}
variable "terraform_aws_secret_key" {
type = string
sensitive = true
}
variable "admin_email" {
type = string
}

80
sk1/terraform/vpc.tf Normal file
View File

@ -0,0 +1,80 @@
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${local.name_prefix}-vpc"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-igw"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${local.name_prefix}-public-${count.index + 1}"
Tier = "public"
}
}
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${local.name_prefix}-private-${count.index + 1}"
Tier = "private"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${local.name_prefix}-public-rt"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-private-rt"
}
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}

6
sk1/web/api/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

89
sk1/web/api/main.py Normal file
View File

@ -0,0 +1,89 @@
import os
import httpx
import trafilatura
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import asyncpg
import asyncio
from contextlib import asynccontextmanager
DB_DSN = "postgresql://{user}:{password}@{host}:{port}/{dbname}".format(
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
host=os.environ["DB_HOST"],
port=os.environ.get("DB_PORT", "5432"),
dbname=os.environ["DB_NAME"],
)
SUMMARIZER_URL = os.environ.get("SUMMARIZER_URL", "http://summarizer:8000")
pool = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
pool = await asyncpg.create_pool(DB_DSN)
await pool.execute("""
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
url TEXT NOT NULL,
title TEXT DEFAULT '',
full_text TEXT DEFAULT '',
summary TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
yield
await pool.close()
app = FastAPI(lifespan=lifespan)
class URLRequest(BaseModel):
url: str
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.get("/api/articles")
async def list_articles():
rows = await pool.fetch(
"SELECT id, url, title, summary, created_at "
"FROM articles ORDER BY created_at DESC LIMIT 50"
)
return [dict(r) for r in rows]
@app.post("/api/articles")
async def create_article(req: URLRequest):
downloaded = await asyncio.to_thread(trafilatura.fetch_url, req.url)
if not downloaded:
raise HTTPException(400, "Failed to fetch URL")
result = await asyncio.to_thread(trafilatura.bare_extraction, downloaded, include_comments=False)
if not result or not result.get("text"):
raise HTTPException(400, "Failed to extract text")
text = result["text"]
title = result.get("title", "")
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{SUMMARIZER_URL}/summarize", json={"text": text}
)
if resp.status_code != 200:
raise HTTPException(502, "Summarizer service unavailable")
summary = resp.json()["summary"]
row = await pool.fetchrow(
"INSERT INTO articles (url, title, full_text, summary) "
"VALUES ($1, $2, $3, $4) "
"RETURNING id, url, title, summary, created_at",
req.url, title, text, summary,
)
return dict(row)

View File

@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn==0.30.0
asyncpg==0.29.0
httpx==0.27.0
trafilatura==1.12.0

View File

@ -0,0 +1,13 @@
services:
nginx:
image: ghcr.io/${GITHUB_OWNER}/readitlater-nginx:latest
ports:
- "80:80"
depends_on:
- api
restart: always
api:
image: ghcr.io/${GITHUB_OWNER}/readitlater-api:latest
env_file: .env
restart: always

3
sk1/web/nginx/Dockerfile Normal file
View File

@ -0,0 +1,3 @@
FROM jonasal/nginx-certbot:latest
COPY nginx.conf /etc/nginx/user_conf.d/default.conf
COPY html/ /usr/share/nginx/html/

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Read it later</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #fff;
color: #1a1a1a;
max-width: 640px;
margin: 0 auto;
padding: 48px 24px;
}
h1 { font-size: 20px; font-weight: 600; margin-bottom: 32px; }
.input-row { display: flex; gap: 8px; margin-bottom: 12px; }
input[type="url"] {
flex: 1; padding: 10px 14px; border: 1px solid #ddd;
border-radius: 8px; font-size: 14px; outline: none;
}
input[type="url"]:focus { border-color: #999; }
button {
padding: 10px 20px; background: #1a1a1a; color: #fff;
border: none; border-radius: 8px; font-size: 14px; cursor: pointer;
}
button:hover { background: #333; }
button:disabled { background: #999; cursor: not-allowed; }
.error { color: #c00; font-size: 13px; margin-bottom: 24px; min-height: 18px; }
.articles { display: flex; flex-direction: column; gap: 24px; margin-top: 24px; }
.article { padding-bottom: 24px; border-bottom: 1px solid #eee; }
.article:last-child { border-bottom: none; }
.article-url { font-size: 12px; color: #999; word-break: break-all; margin-bottom: 4px; }
.article-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.article-summary { font-size: 14px; color: #555; line-height: 1.6; }
.article-date { font-size: 12px; color: #bbb; margin-top: 8px; }
.empty { color: #bbb; font-size: 14px; text-align: center; padding: 48px 0; }
</style>
</head>
<body>
<h1>Read it later</h1>
<form class="input-row" id="form">
<input type="url" id="url" placeholder="Paste article URL..." required>
<button type="submit" id="btn">Save</button>
</form>
<div id="error" class="error"></div>
<div class="articles" id="articles"></div>
<script>
const API = '/api';
async function load() {
try {
const r = await fetch(API + '/articles');
const data = await r.json();
const el = document.getElementById('articles');
if (!data.length) { el.innerHTML = '<div class="empty">No articles yet</div>'; return; }
el.innerHTML = data.map(a => `
<div class="article">
<div class="article-url">${a.url}</div>
<div class="article-title">${a.title || 'Untitled'}</div>
<div class="article-summary">${a.summary || 'Processing...'}</div>
<div class="article-date">${new Date(a.created_at).toLocaleDateString()}</div>
</div>`).join('');
} catch (e) {
document.getElementById('articles').innerHTML = '<div class="empty">Failed to load</div>';
}
}
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btn');
const inp = document.getElementById('url');
const err = document.getElementById('error');
btn.disabled = true; btn.textContent = '...'; err.textContent = '';
try {
const r = await fetch(API + '/articles', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url: inp.value})
});
if (!r.ok) throw new Error((await r.json()).detail || 'Error');
inp.value = '';
await load();
} catch (e) { err.textContent = e.message; }
finally { btn.disabled = false; btn.textContent = 'Save'; }
});
load();
</script>
</body>
</html>

45
sk1/web/nginx/nginx.conf Normal file
View File

@ -0,0 +1,45 @@
server {
listen 80;
server_name devopspavel.me;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name devopspavel.me;
ssl_certificate /etc/letsencrypt/live/devopspavel.me/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/devopspavel.me/privkey.pem;
# SSL Best Practices
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
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;
proxy_read_timeout 120s;
}
location /health {
return 200 'OK';
add_header Content-Type text/plain;
}
}

View File

@ -1,3 +1,2 @@
FROM nginx:latest
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./frontend/index.html /usr/share/nginx/html/index.html

View File

@ -1,66 +0,0 @@
worker_processes auto;
worker_rlimit_nofile 1035;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
}
http {
upstream backend {
server backend:5000;
}
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
client_max_body_size 10m;
client_body_buffer_size 16k;
client_header_buffer_size 1k;
large_client_header_buffers 2 1k;
client_body_timeout 12;
client_header_timeout 12;
send_timeout 10;
server_tokens off;
include /etc/nginx/mime.types;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
error_log /var/log/nginx/static_errors.log debug;
access_log off;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend/;
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;
}
}
}