feat: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-06 13:41:53 +02:00
parent 3df362f44e
commit 110d971dca
133 changed files with 4913 additions and 1439 deletions

View File

@ -2,7 +2,7 @@
## Status: WDROŻONE ✅
Aplikacja Flask została pomyślnie wdrożona na NORDABIZ-01.
Aplikacja Flask została pomyślnie wdrożona na OVH VPS inpi-vps-waw01.
## Ukończone kroki
@ -16,12 +16,12 @@ Aplikacja Flask została pomyślnie wdrożona na NORDABIZ-01.
## Dostęp
- **LAN:** http://10.22.68.249
- **LAN:** http://57.128.200.27
- **WAN:** https://nordabiznes.pl (maintenance mode w NPM)
## Ważne informacje
- **VM:** NORDABIZ-01 (ID 249, IP 10.22.68.249)
- **VM:** OVH VPS inpi-vps-waw01 (ID 249, IP 57.128.200.27)
- **Baza:** PostgreSQL `nordabiz` (80 firm)
- **Port aplikacji:** 5000
- **Venv:** /var/www/nordabiznes/venv/

View File

@ -110,7 +110,7 @@ docker exec nordabiz-postgres psql -U nordabiz_app -d nordabiz -c "SELECT slug,
## Uwagi:
- DEV: PostgreSQL via Docker na localhost:5433
- PROD: PostgreSQL na 10.22.68.249:5432
- PROD: PostgreSQL na 57.128.200.27:5432
- Dla produkcji: po przetestowaniu lokalnie, wdróż przez `/deploy`
- Nowe firmy powinny pochodzić z oficjalnej listy członków Norda Biznes
- Zawsze weryfikuj NIP przed dodaniem

View File

@ -12,7 +12,7 @@ Opcjonalny argument określa typ backupu:
## System automatycznych backupów
### Harmonogram (cron na NORDABIZ-01)
### Harmonogram (cron na OVH VPS inpi-vps-waw01)
| Typ | Częstotliwość | Godzina | Retencja | Lokalizacja |
|-----|---------------|---------|----------|-------------|
@ -25,16 +25,16 @@ Opcjonalny argument określa typ backupu:
```bash
# Ostatnie backupy hourly
ssh maciejpi@10.22.68.249 "ls -lt /var/backups/nordabiz/hourly/ | head -5"
ssh maciejpi@57.128.200.27 "ls -lt /var/backups/nordabiz/hourly/ | head -5"
# Ostatnie backupy daily
ssh maciejpi@10.22.68.249 "ls -lt /var/backups/nordabiz/daily/ | head -5"
ssh maciejpi@57.128.200.27 "ls -lt /var/backups/nordabiz/daily/ | head -5"
# Sprawdź offsite (PBS)
ssh maciejpi@10.22.68.127 "ls -lt /backup/nordabiz/daily/ | head -5"
# Rozmiar backupów
ssh maciejpi@10.22.68.249 "du -sh /var/backups/nordabiz/*"
ssh maciejpi@57.128.200.27 "du -sh /var/backups/nordabiz/*"
```
## Kroki do wykonania:
@ -48,26 +48,26 @@ docker exec nordabiz-postgres pg_dump -U nordabiz_app nordabiz > "backups/dev_$(
### 2. Backup produkcyjnej bazy PostgreSQL
Eksport do lokalnego:
```bash
ssh maciejpi@10.22.68.249 "sudo -u postgres pg_dump nordabiz" > "backups/prod_$(date +%Y%m%d_%H%M%S).sql"
ssh maciejpi@57.128.200.27 "sudo -u postgres pg_dump nordabiz" > "backups/prod_$(date +%Y%m%d_%H%M%S).sql"
```
Lub backup na serwerze:
```bash
ssh maciejpi@10.22.68.249 "sudo -u postgres pg_dump -Fc nordabiz > /tmp/backup_$(date +%Y%m%d_%H%M%S).dump"
ssh maciejpi@57.128.200.27 "sudo -u postgres pg_dump -Fc nordabiz > /tmp/backup_$(date +%Y%m%d_%H%M%S).dump"
```
### 3. Backup plików konfiguracyjnych
```bash
mkdir -p backups/config_$(date +%Y%m%d)
cp .env backups/config_$(date +%Y%m%d)/dev.env
ssh maciejpi@10.22.68.249 "cat /var/www/nordabiznes/.env" > backups/config_$(date +%Y%m%d)/prod.env
ssh maciejpi@10.22.68.249 "cat /etc/nginx/sites-available/nordabiznes" > backups/config_$(date +%Y%m%d)/nginx.conf
ssh maciejpi@57.128.200.27 "cat /var/www/nordabiznes/.env" > backups/config_$(date +%Y%m%d)/prod.env
ssh maciejpi@57.128.200.27 "cat /etc/nginx/sites-available/nordabiznes" > backups/config_$(date +%Y%m%d)/nginx.conf
```
### 4. Snapshot VM w Proxmox
Użyj skill `proxmox-manager`:
```
Utwórz snapshot VM NORDABIZ-01 (ID: 249) z opisem "Backup przed [operacja]"
Utwórz snapshot VM OVH VPS inpi-vps-waw01 (OVH VPS) z opisem "Backup przed [operacja]"
```
Lub ręcznie:
@ -83,14 +83,14 @@ ls -la backups/*.sql 2>/dev/null
Snapshoty VM (użyj skill proxmox-manager):
```
Pokaż snapshoty VM 249
Pokaż snapshoty OVH VPS
```
### 6. Przywracanie z backupu
#### Szybkie przywracanie (skrypt DR)
```bash
ssh maciejpi@10.22.68.249 "sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/nordabiz_YYYYMMDD_HH.dump"
ssh maciejpi@57.128.200.27 "sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/nordabiz_YYYYMMDD_HH.dump"
```
#### DEV (Docker PostgreSQL):
@ -100,13 +100,13 @@ docker exec -i nordabiz-postgres psql -U nordabiz_app -d nordabiz < backups/dev_
#### PROD (PostgreSQL):
```bash
cat backups/prod_YYYYMMDD_HHMMSS.sql | ssh maciejpi@10.22.68.249 "sudo -u postgres psql nordabiz"
cat backups/prod_YYYYMMDD_HHMMSS.sql | ssh maciejpi@57.128.200.27 "sudo -u postgres psql nordabiz"
```
#### Rollback VM:
Użyj skill `proxmox-manager`:
```
Przywróć VM 249 ze snapshotu backup_YYYYMMDD
Przywróć OVH VPS ze snapshotu backup_YYYYMMDD
```
## Konfiguracja cron (na serwerze PROD)
@ -162,6 +162,6 @@ sudo /var/www/nordabiznes/scripts/dr-restore.sh /path/to/backup.dump
- Snapshoty VM są najszybsze do rollbacku
- PostgreSQL dump jest przenośny między środowiskami
- DEV używa Docker PostgreSQL na localhost:5433
- PROD używa PostgreSQL na 10.22.68.249:5432
- PROD używa PostgreSQL na 57.128.200.27:5432
Data aktualizacji: 2026-02-02

View File

@ -99,4 +99,4 @@ Podsumuj w czytelnej formie:
- Monitoruj tokeny dla kontroli limitów
- Wysokie latency może wskazywać na problemy z API
- DEV: `docker exec nordabiz-postgres psql -U nordabiz_app -d nordabiz`
- PROD: `ssh maciejpi@10.22.68.249 "sudo -u postgres psql -d nordabiz"`
- PROD: `ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz"`

View File

@ -127,4 +127,4 @@ Wygeneruj raport w formacie markdown z tabelami:
- Priorytet: email > telefon > www > opis
- Weryfikuj dane przez oficjalne źródła (CEIDG, KRS)
- DEV: `docker exec nordabiz-postgres psql -U nordabiz_app -d nordabiz`
- PROD: `ssh maciejpi@10.22.68.249 "sudo -u postgres psql -d nordabiz"`
- PROD: `ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz"`

View File

@ -1,6 +1,6 @@
# Deploy NordaBiz to Production
Wykonaj deployment projektu NordaBiz na serwer produkcyjny NORDABIZ-01.
Wykonaj deployment projektu NordaBiz na serwer produkcyjny OVH VPS inpi-vps-waw01.
## Kroki do wykonania:
@ -10,7 +10,7 @@ Wykonaj deployment projektu NordaBiz na serwer produkcyjny NORDABIZ-01.
- Sprawdź czy lokalna aplikacja działa: `curl http://localhost:5000/health` lub `curl http://localhost:5001/health`
### 2. Połączenie z serwerem
- SSH do NORDABIZ-01: `ssh root@10.22.68.249`
- SSH do OVH VPS inpi-vps-waw01: `ssh maciejpi@57.128.200.27`
- Przejdź do katalogu: `cd /var/www/nordabiznes`
### 3. Deployment

View File

@ -15,54 +15,54 @@ Opcjonalny argument określa typ logów lub liczbę linii, np.:
Połącz się z serwerem i pobierz logi:
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes -n 50 --no-pager"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes -n 50 --no-pager"
```
Dla więcej linii:
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes -n 100 --no-pager"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes -n 100 --no-pager"
```
Tylko błędy:
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes -p err -n 50 --no-pager"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes -p err -n 50 --no-pager"
```
### 2. Logi Nginx (access)
```bash
ssh root@10.22.68.249 "tail -50 /var/log/nginx/access.log"
ssh maciejpi@57.128.200.27 "tail -50 /var/log/nginx/access.log"
```
### 3. Logi Nginx (error)
```bash
ssh root@10.22.68.249 "tail -50 /var/log/nginx/error.log"
ssh maciejpi@57.128.200.27 "tail -50 /var/log/nginx/error.log"
```
### 4. Logi w czasie rzeczywistym (follow)
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes -f"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes -f"
```
(Ctrl+C aby przerwać)
### 5. Logi z określonego czasu
Ostatnia godzina:
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes --since '1 hour ago' --no-pager"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes --since '1 hour ago' --no-pager"
```
Dzisiaj:
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes --since today --no-pager"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes --since today --no-pager"
```
### 6. Szukanie wzorca
```bash
ssh root@10.22.68.249 "journalctl -u nordabiznes --no-pager | grep -i 'error\|exception\|failed'"
ssh maciejpi@57.128.200.27 "journalctl -u nordabiznes --no-pager | grep -i 'error\|exception\|failed'"
```
### 7. Status usługi
```bash
ssh root@10.22.68.249 "systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "systemctl status nordabiznes"
```
## Analiza logów:
@ -74,7 +74,7 @@ Po pobraniu logów przeanalizuj je pod kątem:
- Nieautoryzowanych prób dostępu
## Uwagi:
- Serwer: NORDABIZ-01 (VM 249, IP 10.22.68.249)
- Serwer: OVH VPS inpi-vps-waw01 (OVH VPS, IP 57.128.200.27)
- Usługa systemd: `nordabiznes`
- Logi rotują automatycznie
- Dla alertów rozważ integrację z Zabbix (skill: monitoring-manager)

View File

@ -18,7 +18,7 @@ Sprawdź aktualny status projektu NordaBiz - lokalnie i na produkcji.
- Test SSL: `curl -vI https://nordabiznes.pl 2>&1 | grep -E "(SSL|expire|subject)"`
### 3. Status VM (użyj skill proxmox-manager)
- VM: NORDABIZ-01 (ID: 249)
- VM: OVH VPS inpi-vps-waw01 (OVH VPS)
- Sprawdź: CPU, RAM, uptime, snapshoty
### 4. Statystyki bazy danych (DEV via Docker)
@ -33,7 +33,7 @@ SELECT 'Wiadomości chat: ' || COUNT(*) FROM ai_chat_messages;
### 5. Statystyki bazy danych (PROD)
```bash
ssh maciejpi@10.22.68.249 "sudo -u postgres psql -d nordabiz -c \"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT 'Firmy: ' || COUNT(*) FROM companies;
SELECT 'Użytkownicy: ' || COUNT(*) FROM users;
SELECT 'Konwersacje chat: ' || COUNT(*) FROM ai_chat_conversations;
@ -52,11 +52,11 @@ Podsumuj status w formie tabeli:
| Lokalna aplikacja | OK/ERROR | port |
| Docker PostgreSQL | OK/ERROR | localhost:5433 |
| Produkcja | OK/ERROR | response time |
| VM NORDABIZ-01 | OK/ERROR | uptime |
| VM OVH VPS inpi-vps-waw01 | OK/ERROR | uptime |
| Baza danych DEV | OK/ERROR | liczba rekordów |
| Baza danych PROD | OK/ERROR | liczba rekordów |
| Git | CLEAN/DIRTY | branch |
## Uwagi:
- DEV: PostgreSQL via Docker na localhost:5433
- PROD: PostgreSQL na 10.22.68.249:5432
- PROD: PostgreSQL na 57.128.200.27:5432

67
AGENTS.md Normal file
View File

@ -0,0 +1,67 @@
# NordaBiz — Agent Instructions
Platform: katalog firm i networking dla Izby Gospodarczej Norda Biznes (Wejherowo).
Production: https://nordabiznes.pl | Status: LIVE
## Stack
- **Backend:** Flask 3.0, SQLAlchemy 2.0, Python 3.9+, PostgreSQL
- **Frontend:** HTML5, CSS3, Vanilla JS, Jinja2
- **AI:** Google Gemini 3 Flash (free tier) — moduł NordaGPT
- **Security:** Flask-Login, Flask-WTF (CSRF), Flask-Limiter
## Project Structure
```
app.py # Main Flask app (routes, auth, API)
database.py # SQLAlchemy models (Company, User, Chat, Forum...)
gemini_service.py # Google Gemini AI integration
nordabiz_chat.py # AI chat engine with company context
search_service.py # Unified SearchService (synonyms, FTS, fuzzy)
blueprints/ # 17 Flask blueprints (modular routes)
templates/ # Jinja2 templates
static/ # CSS, JS, images
database/ # SQL schemas, migrations
scripts/ # Python/Node.js utilities
tests/ # Unit + integration tests
```
## Key Conventions
- **Slug format:** kebab-case, e.g. `pixlab-sp-z-o-o`
- **NIP:** 10 digits, no dashes | **REGON:** 9 or 14 digits | **KRS:** 10 digits (companies only)
- **Categories:** `IT`, `Construction`, `Services`, `Production`, `Trade`, `Other`
- **Data quality levels:** `basic`, `enhanced`, `complete`
## Database
- **Dev:** PostgreSQL via Docker (`localhost:5433/nordabiz`)
- **Prod:** PostgreSQL on 57.128.200.27:5432 (OVH VPS inpi-vps-waw01, localhost from server)
- After creating tables: `GRANT ALL ON TABLE ... TO nordabiz_app`
- After creating sequences: `GRANT USAGE, SELECT ON SEQUENCE ... TO nordabiz_app`
## Running Locally
```bash
docker compose up -d # Start PostgreSQL
python3 app.py # Start Flask (port 5000 or 5001)
```
## Tests
```bash
pytest tests/ -v # All tests
pytest tests/unit/ -v # Unit tests (no DB)
pytest tests/integration/ -v # Integration tests (with DB)
```
## Jinja2 Templates — IMPORTANT
`{% block extra_js %}` in `base.html` is INSIDE a `<script>` tag — do NOT add your own `<script>` tags inside it.
## Code Style
- Polish language for user-facing strings, English for code/comments
- No unnecessary abstractions — keep it simple
- Security first: never hardcode API keys, always use `.env`
- Validate at system boundaries only

View File

@ -168,7 +168,7 @@ Checked deployment architecture documentation against documented server IPs, por
| Item | Documented Value | Verified in Docs | Status |
|------|------------------|------------------|--------|
| NORDABIZ-01 IP | 10.22.68.249 | ✅ Found | VERIFIED |
| NORDABIZ-01 IP | 57.128.200.27 | ✅ Found | VERIFIED |
| NORDABIZ-01 VM ID | 249 | ✅ Found | VERIFIED |
| R11-REVPROXY-01 IP | 10.22.68.250 | ✅ Found | VERIFIED |
| R11-REVPROXY-01 VM ID | 119 | ✅ Found | VERIFIED |

View File

@ -67,24 +67,23 @@ nordabiz/
- **Domena:** staging.nordabiznes.pl (NPM Proxy Host ID: 44)
- **Weryfikacja:** `curl -I https://staging.nordabiznes.pl/health`
### Production
- **Serwer:** NORDABIZ-01 (VM 249, IP 10.22.68.249)
- **Baza:** PostgreSQL na 10.22.68.249:5432
- **Reverse Proxy:** NPM na R11-REVPROXY-01 (VM 119, IP 10.22.68.250)
### Production (OVH VPS)
- **Serwer:** inpi-vps-waw01 (OVH VPS, IP 57.128.200.27)
- **Baza:** PostgreSQL na 57.128.200.27:5432
- **SSL/Proxy:** nginx na VPS (bezpośrednio, bez NPM)
- **Domena:** nordabiznes.pl (DNS w OVH, SSL Let's Encrypt)
- **SSH:** `ssh maciejpi@57.128.200.27` (ZAWSZE jako maciejpi!)
- **Ścieżka:** `/var/www/nordabiznes` | Restart: `sudo systemctl restart nordabiznes`
- **Weryfikacja:** `curl -I https://nordabiznes.pl/health`
### NPM Proxy Configuration (KRYTYCZNE!)
**⚠️ Różnice OVH VPS vs stary on-prem:**
- **Brak .git repo** na VPS — deploy przez rsync, NIE git pull
- **`.env` jest root-owned** — skrypty wymagają sudo do odczytu
- **Migracje** wymagają `sudo -u postgres psql` (app user nie ma ALTER TABLE)
**Proxy Host ID:** 27 | **Forward Port:** 5000 (NIE 80!)
### NPM Proxy Configuration (tylko staging)
```
NPM (10.22.68.250) → Backend (10.22.68.249:5000) ✓
NPM (10.22.68.250) → Backend (10.22.68.249:80) ✗ (pętla przekierowań!)
```
Na serwerze .249 nginx na porcie 80 przekierowuje na HTTPS. Flask/Gunicorn na porcie 5000. **ZAWSZE** sprawdź port po edycji NPM!
**Weryfikacja:** `curl -I https://nordabiznes.pl/health` | **Incydent:** `docs/INCIDENT_REPORT_20260102.md`
NPM dotyczy teraz **tylko staging** (Proxy Host ID: 44 dla staging.nordabiznes.pl). Produkcja nie korzysta z NPM — SSL obsługuje nginx bezpośrednio na OVH VPS.
## Git & Deployment
@ -103,40 +102,41 @@ Na serwerze .249 nginx na porcie 80 przekierowuje na HTTPS. Flask/Gunicorn na po
# 1. DEV: Push do obu repozytoriów
git push origin master && git push inpi master
# 2. STAGING: Wdrożenie i test
# 2. STAGING: Wdrożenie i test (on-prem, bez zmian)
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl reload nordabiznes"
# ⚠️ OBOWIĄZKOWO: Test manualny nowej funkcjonalności na staging!
# 3. PROD: Pull zmiany (DOPIERO PO WERYFIKACJI STAGING!)
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull"
# 3. PROD (OVH VPS): Rsync zmienionych plików (DOPIERO PO WERYFIKACJI STAGING!)
rsync -avz -e ssh --rsync-path="sudo rsync" <files> maciejpi@57.128.200.27:/var/www/nordabiznes/
# 4. PROD: Migracje SQL (jeśli są)
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/XXX_nazwa.sql"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql nordabiz -f /var/www/nordabiznes/database/migrations/XXX_nazwa.sql"
# 5. PROD: Restart + weryfikacja
ssh maciejpi@10.22.68.249 "sudo systemctl reload nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```
**⚠️ UWAGI KRYTYCZNE:**
1. **Migracje SQL** - NIE używaj `psql` bezpośrednio. Użyj `scripts/run_migration.py`
2. **Uprawnienia logów** - `sudo chown -R maciejpi:maciejpi /var/log/nordabiznes/`
3. **502 po restarcie** - Poczekaj 3-5 sekund i sprawdź ponownie
4. **Git pull** - Używaj `sudo -u www-data git pull` (www-data ma klucze SSH)
1. **Brak .git na VPS** - Deploy TYLKO przez rsync, NIE git pull
2. **Migracje SQL** - Używaj `sudo -u postgres psql` (app user nie ma ALTER TABLE)
3. **`.env` jest root-owned** - Skrypty wymagają sudo do odczytu
4. **502 po restarcie** - Poczekaj 3-5 sekund i sprawdź ponownie
5. **SSH timeout** - NIE oznacza że komenda nie została wykonana! Sprawdź `ps aux | grep <skrypt>`
6. **Staging nadal on-prem** - Git pull na .248 działa jak wcześniej (www-data ma klucze SSH)
**Skrypty Python z dostępem do bazy (WAŻNE!):**
`.env` NIE jest wczytywany przez `source .env` w kontekście SSH!
`.env` na OVH VPS jest root-owned — wymaga sudo do odczytu!
```bash
# ✅ PRAWIDŁOWO:
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && \
DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) \
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && \
sudo DATABASE_URL=\$(sudo grep DATABASE_URL .env | cut -d'=' -f2) \
/var/www/nordabiznes/venv/bin/python3 skrypt.py"
# ❌ BŁĘDNIE:
ssh maciejpi@10.22.68.249 "source .env && python3 skrypt.py"
ssh maciejpi@57.128.200.27 "source .env && python3 skrypt.py"
```
## Konwencje danych
@ -157,8 +157,10 @@ ssh maciejpi@10.22.68.249 "source .env && python3 skrypt.py"
### Deployment
- Przed wdrożeniem: `python -m py_compile app.py`
- SSH: `ssh maciejpi@10.22.68.249` (ZAWSZE jako maciejpi!)
- Ścieżka: `/var/www/nordabiznes` | Restart: `sudo systemctl reload nordabiznes`
- SSH prod: `ssh maciejpi@57.128.200.27` (OVH VPS, ZAWSZE jako maciejpi!)
- SSH staging: `ssh maciejpi@10.22.68.248` (on-prem VM 248)
- Ścieżka: `/var/www/nordabiznes` | Restart prod: `sudo systemctl restart nordabiznes`
- Deploy prod: rsync (brak .git na VPS) | Deploy staging: git pull
- **ZAWSZE** aktualizuj `release_notes` w app.py
### Szablony Jinja2 - WAŻNE!

View File

@ -68,9 +68,9 @@ grep -r "NordaBiz2025Secure" --include="*.txt" .
```
./view_maturity_results.sh:# export PGPASSWORD='your_database_password'
./view_maturity_results.sh: echo " export PGPASSWORD='your_database_password'"
./view_maturity_results.sh:ssh root@10.22.68.249 "PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U nordabiz_app -d nordabiz -c \"
./view_maturity_results.sh:ssh root@10.22.68.249 "PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U nordabiz_app -d nordabiz -c \"
./view_maturity_results.sh:ssh root@10.22.68.249 "PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U nordabiz_app -d nordabiz -c \"
./view_maturity_results.sh:ssh root@57.128.200.27 "PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U nordabiz_app -d nordabiz -c \"
./view_maturity_results.sh:ssh root@57.128.200.27 "PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U nordabiz_app -d nordabiz -c \"
./view_maturity_results.sh:ssh root@57.128.200.27 "PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U nordabiz_app -d nordabiz -c \"
```
**Analysis:**

View File

@ -100,14 +100,14 @@ graph LR
**Issue:** Line break example showed only a single node without connections:
```mermaid
A[Flask App<br/>10.22.68.249<br/>Port 5000]
A[Flask App<br/>57.128.200.27<br/>Port 5000]
```
**Fix:** Added proper graph structure with connections:
```mermaid
graph TD
A[Flask App<br/>10.22.68.249<br/>Port 5000]
B[PostgreSQL<br/>10.22.68.249<br/>Port 5432]
A[Flask App<br/>57.128.200.27<br/>Port 5000]
B[PostgreSQL<br/>57.128.200.27<br/>Port 5432]
A --> B
```
@ -188,7 +188,7 @@ Spot-checked key diagrams for accuracy:
- ✅ Identifies one-to-many, many-to-many, one-to-one
4. **Network Topology** (`07-network-topology.md`)
- ✅ Correct IP addresses (10.22.68.249, .250, .180)
- ✅ Correct IP addresses (57.128.200.27, .250, .180)
- ✅ Correct ports (5000 for Flask, 5432 for PostgreSQL)
- ✅ Shows Fortigate NAT configuration

View File

@ -271,8 +271,8 @@ Norda Biznes Partner is a **Flask-powered web platform** built with PostgreSQL,
#### 🌍 Multi-Environment Deployment
- **Development:** PostgreSQL via Docker (localhost:5433)
- **Production:** NORDABIZ-01 server (VM 249, 10.22.68.249)
- **Reverse proxy:** NPM on R11-REVPROXY-01
- **Production:** OVH VPS (57.128.200.27, inpi-vps-waw01)
- **Staging reverse proxy:** NPM on R11-REVPROXY-01
- **SSL/TLS:** Let's Encrypt with auto-renewal
- **Domain:** nordabiznes.pl (DNS in OVH)
- **WSGI:** Gunicorn production server
@ -859,9 +859,9 @@ The application is live in production at **https://nordabiznes.pl** since Novemb
| Component | Details |
|-----------|---------|
| **Server** | NORDABIZ-01 (VM 249, IP: 10.22.68.249) |
| **Database** | PostgreSQL 15 on 10.22.68.249:5432 |
| **Reverse Proxy** | Nginx Proxy Manager on R11-REVPROXY-01 (VM 119, IP: 10.22.68.250) |
| **Server** | OVH VPS inpi-vps-waw01 (IP: 57.128.200.27) |
| **Database** | PostgreSQL 15 on 57.128.200.27 (localhost:5432) |
| **Reverse Proxy** | Not used for production (direct DNS). NPM (10.22.68.250) serves staging only |
| **Domain** | nordabiznes.pl (DNS managed via OVH) |
| **SSL/TLS** | Let's Encrypt with automatic renewal |
| **WSGI Server** | Gunicorn |
@ -876,19 +876,23 @@ The project maintains two Git remotes for redundancy and deployment:
| Remote | Repository | Purpose |
|--------|------------|---------|
| **origin** (GitHub) | `git@github.com:pienczyn/nordabiz.git` | Cloud backup, public access, CI/CD ready |
| **inpi** (Gitea) | `git@10.22.68.180:maciejpi/nordabiz.git` | Internal backup, deployment source |
| **inpi** (Gitea) | `git@10.22.68.180:maciejpi/nordabiz.git` | Internal backup |
**Gitea Access:** https://10.22.68.180:3000/ (requires HTTPS)
### Deployment Workflow
```
┌─────────────┐ git push ┌─────────────┐ git pull ┌─────────────┐
│ Development │ ────────────► │ Gitea │ ◄──────────── │ Production │
│ (Local) │ │ (INPI) │ │ Server │
└─────────────┘ └─────────────┘ └─────────────┘
└──── git push ────► GitHub (cloud backup)
┌─────────────┐ git push ┌─────────────┐
│ Development │ ────────────► │ GitHub │ (cloud backup)
│ (Local) │ └─────────────┘
└──────┬──────┘
│ rsync
┌─────────────┐
│ OVH VPS │ (57.128.200.27)
│ Production │
└─────────────┘
```
#### Deployment Steps
@ -896,24 +900,19 @@ The project maintains two Git remotes for redundancy and deployment:
**1. Push Changes from Development:**
```bash
# Push to both remotes (from local development)
# Push to GitHub (and optionally Gitea)
git push origin master && git push inpi master
```
**2. Deploy to Production:**
**2. Deploy to Production (via rsync — no .git on server):**
```bash
# SSH to production server
ssh maciejpi@10.22.68.249
# Rsync to production server (from local dev machine)
rsync -avz --exclude='.git' --exclude='venv' --exclude='.env' \
./ maciejpi@57.128.200.27:/var/www/nordabiznes/
# Navigate to application directory
cd /var/www/nordabiznes
# Pull latest changes (as www-data user)
sudo -u www-data git pull
# Restart the application service
sudo systemctl restart nordabiznes
# SSH to restart
ssh maciejpi@57.128.200.27 "sudo systemctl reload nordabiznes"
# Verify deployment
curl -I https://nordabiznes.pl/health
@ -934,13 +933,9 @@ Before deploying to production, always:
### Production Configuration
**Critical NPM Proxy Configuration:**
**Network Configuration:**
```
Nginx Proxy Manager (10.22.68.250) → Backend (10.22.68.249:5000) ✓
```
**⚠️ IMPORTANT:** The NPM proxy must forward to port **5000** (Gunicorn), NOT port 80 (nginx). Forwarding to port 80 causes redirect loops.
Production traffic goes directly to OVH VPS (57.128.200.27) — DNS A record points there. NPM (10.22.68.250) is used only for staging (staging.nordabiznes.pl).
**Environment Variables:**
@ -973,7 +968,7 @@ sudo systemctl start nordabiznes
**Database Backup:**
Production server uses Proxmox Backup Server for automated VM snapshots and disaster recovery.
Production server (OVH VPS) uses local pg_dump backups with offsite sync to PBS (10.22.68.127).
**Health Check:**
@ -987,7 +982,7 @@ The application provides a health endpoint for monitoring:
```bash
# Always SSH as maciejpi user (NOT root!)
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
```
**Test Accounts:**
@ -1401,11 +1396,9 @@ python verify_all_companies_data.py # Check data quality
# Development: Push to both remotes
git push origin master && git push inpi master
# Production: Deploy to server
ssh maciejpi@10.22.68.249
cd /var/www/nordabiznes
sudo -u www-data git pull
sudo systemctl restart nordabiznes
# Production: Deploy to server (rsync, no .git on server)
rsync -avz --exclude='.git' --exclude='venv' --exclude='.env' ./ maciejpi@57.128.200.27:/var/www/nordabiznes/
ssh maciejpi@57.128.200.27 "sudo systemctl reload nordabiznes"
curl -I https://nordabiznes.pl/health # Verify deployment
```
@ -1661,8 +1654,8 @@ python run_ai_quality_tests.py -v
**Problem:** nordabiznes.pl not loading
```bash
# SSH to production server
ssh maciejpi@10.22.68.249
# SSH to production server (OVH VPS)
ssh maciejpi@57.128.200.27
# Check service status
sudo systemctl status nordabiznes
@ -1720,12 +1713,8 @@ sudo systemctl restart postgresql
**Solution:**
```bash
# Always use www-data for application operations
sudo -u www-data git pull
sudo -u www-data python3 verify_all_companies_data.py
# SSH as maciejpi (NOT root!)
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
```
### API & External Service Issues
@ -1847,8 +1836,7 @@ Additional documentation and resources for developers.
### Production Environment
- **Server:** NORDABIZ-01 (VM 249, IP: 10.22.68.249)
- **Reverse Proxy:** R11-REVPROXY-01 (VM 119, IP: 10.22.68.250)
- **Server:** OVH VPS inpi-vps-waw01 (IP: 57.128.200.27)
- **URL:** https://nordabiznes.pl
- **Status:** LIVE since 2025-11-23

17
app.py
View File

@ -218,6 +218,19 @@ app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Template filters
from zoneinfo import ZoneInfo
_WARSAW_TZ = ZoneInfo('Europe/Warsaw')
_UTC_TZ = ZoneInfo('UTC')
@app.template_filter('local_time')
def local_time_filter(dt, fmt='%d.%m.%Y %H:%M'):
"""Convert naive UTC datetime to Europe/Warsaw and format."""
if not dt:
return ''
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_UTC_TZ)
return dt.astimezone(_WARSAW_TZ).strftime(fmt)
@app.template_filter('ensure_url')
def ensure_url_filter(url):
"""Ensure URL has http:// or https:// scheme"""
@ -1436,7 +1449,7 @@ def send_error_notification(error, request_info):
{traceback_str}
{'='*50}
🔧 Sprawdź logi: ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes --since '10 minutes ago'"
🔧 Sprawdź logi: ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes --since '10 minutes ago'"
"""
body_html = f"""<!DOCTYPE html>
@ -1466,7 +1479,7 @@ def send_error_notification(error, request_info):
</div>
<div style="margin-top: 20px; padding: 15px; background: #1e3a5f; border-radius: 8px;">
<div style="color: #60a5fa;">🔧 <strong>Sprawdź logi:</strong></div>
<code style="display: block; margin-top: 10px; color: #34d399; word-break: break-all;">ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes --since '10 minutes ago'"</code>
<code style="display: block; margin-top: 10px; color: #34d399; word-break: break-all;">ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes --since '10 minutes ago'"</code>
</div>
</div>
</div>

View File

@ -443,7 +443,7 @@ def admin_status():
# ===== SERVERS PING =====
servers_status = []
servers_to_ping = [
('NORDABIZ-01', '10.22.68.249'),
('NORDABIZ-01', '57.128.200.27'),
('R11-REVPROXY-01', '10.22.68.250'),
('R11-DNS-01', '10.22.68.171'),
('R11-GIT-INPI', '10.22.68.180'),
@ -507,7 +507,7 @@ def admin_status():
{'name': 'systemd', 'version': '255', 'icon': '⚙️', 'category': 'Service Manager'},
],
'servers': [
{'name': 'NORDABIZ-01', 'ip': '10.22.68.249', 'icon': '🖥️', 'role': 'App Server (VM 249)'},
{'name': 'NORDABIZ-01', 'ip': '57.128.200.27', 'icon': '🖥️', 'role': 'App Server (VM 249)'},
{'name': 'R11-REVPROXY-01', 'ip': '10.22.68.250', 'icon': '🔀', 'role': 'Reverse Proxy (VM 119)'},
{'name': 'R11-DNS-01', 'ip': '10.22.68.171', 'icon': '📡', 'role': 'DNS Server (VM 122)'},
{'name': 'R11-GIT-INPI', 'ip': '10.22.68.180', 'icon': '📦', 'role': 'Git Server (VM 180)'},

View File

@ -12,19 +12,19 @@ database/
---
## 🚀 Instalacja na NORDABIZ-01
## 🚀 Instalacja na OVH VPS (inpi-vps-waw01)
### Krok 1: Instalacja PostgreSQL
```bash
# SSH do serwera
ssh root@10.22.68.249
ssh maciejpi@57.128.200.27
# Aktualizacja pakietów
apt update
sudo apt update
# Instalacja PostgreSQL 15
apt install -y postgresql-15 postgresql-contrib-15
sudo apt install -y postgresql-15 postgresql-contrib-15
# Sprawdzenie statusu
systemctl status postgresql

View File

@ -1,8 +1,8 @@
# Norda Biznes - Deployment Checklist
**Version:** 1.0
**Last Updated:** 2026-01-02
**Environment:** Production (NORDABIZ-01, IP: 10.22.68.249)
**Last Updated:** 2026-04-04
**Environment:** Production (OVH VPS inpi-vps-waw01, IP: 57.128.200.27)
**Audience:** DevOps, SysAdmins
---
@ -75,23 +75,23 @@ This checklist ensures safe, repeatable deployments to production with minimal r
- [ ] Secrets stored securely (LastPass, 1Password, vault)
### Access & Permissions
- [ ] SSH access to NORDABIZ-01 verified
- [ ] SSH access to OVH VPS verified
```bash
ssh maciejpi@10.22.68.249 "echo OK"
ssh maciejpi@57.128.200.27 "echo OK"
```
- [ ] PostgreSQL credentials verified (not displayed)
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "SELECT version();"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c 'SELECT version();'"
```
- [ ] www-data user can execute deployment scripts
- [ ] maciejpi user can execute deployment scripts
```bash
ssh maciejpi@10.22.68.249 "sudo -l | grep -E 'systemctl|psql'"
ssh maciejpi@57.128.200.27 "sudo -l | grep -E 'systemctl|psql'"
```
### Backup Location
- [ ] Backup destination has adequate free space
```bash
ssh maciejpi@10.22.68.249 "df -h /var/backups"
ssh maciejpi@57.128.200.27 "df -h /var/backups"
# Minimum 2GB free recommended
```
- [ ] Backup location is accessible and writable
@ -103,12 +103,12 @@ This checklist ensures safe, repeatable deployments to production with minimal r
### Application Status
- [ ] Current application is running and healthy
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes"
# Status: active (running)
```
- [ ] Application logs show no recent errors
```bash
ssh maciejpi@10.22.68.249 "sudo tail -50 /var/log/nordabiznes/*.log | grep -i error"
ssh maciejpi@57.128.200.27 "sudo tail -50 /var/log/nordabiznes/*.log | grep -i error"
# Should be empty or only non-critical errors
```
- [ ] Health check endpoint responding
@ -120,25 +120,25 @@ This checklist ensures safe, repeatable deployments to production with minimal r
### Database Status
- [ ] PostgreSQL is running
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl status postgresql"
ssh maciejpi@57.128.200.27 "sudo systemctl status postgresql"
# Status: active (running)
```
- [ ] Database is accessible
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "SELECT NOW();"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c 'SELECT NOW();'"
```
- [ ] No long-running transactions
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT pid, usename, state, query
FROM pg_stat_activity
WHERE state != 'idle' AND duration > interval '5 minutes';"
WHERE state != 'idle' AND duration > interval '5 minutes';\""
# Should be empty
```
- [ ] Database size recorded
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
SELECT pg_size_pretty(pg_database_size('nordabiz'));"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT pg_size_pretty(pg_database_size('nordabiz'));\""
# Record this value
```
@ -148,7 +148,7 @@ This checklist ensures safe, repeatable deployments to production with minimal r
- Best deployment time: off-peak (11:00-12:00, 14:00-17:00)
- [ ] No ongoing data imports or batch jobs
```bash
ssh maciejpi@10.22.68.249 "ps aux | grep -i 'python.*import'"
ssh maciejpi@57.128.200.27 "ps aux | grep -i 'python.*import'"
# Should be empty
```
@ -165,7 +165,7 @@ This checklist ensures safe, repeatable deployments to production with minimal r
- [ ] Full database backup
```bash
BACKUP_FILE="$HOME/backup_before_deployment_$(date +%Y%m%d_%H%M%S).sql"
ssh maciejpi@10.22.68.249 "sudo -u www-data pg_dump -U nordabiz_app -d nordabiz" > "$BACKUP_FILE"
ssh maciejpi@57.128.200.27 "sudo -u postgres pg_dump -d nordabiz" > "$BACKUP_FILE"
# Verify backup was created
ls -lh "$BACKUP_FILE"
# Minimum size: >5MB (should contain all schema and data)
@ -180,10 +180,10 @@ This checklist ensures safe, repeatable deployments to production with minimal r
- [ ] Backup can be restored (test on separate database)
```bash
# Optional: Create test database and restore
psql -h 10.22.68.249 -U nordabiz_app -c "CREATE DATABASE nordabiz_test;"
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz_test < "$BACKUP_FILE"
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz_test -c "SELECT COUNT(*) FROM companies;"
# Then drop test database: DROP DATABASE nordabiz_test;
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -c 'CREATE DATABASE nordabiz_test;'"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz_test" < "$BACKUP_FILE"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz_test -c 'SELECT COUNT(*) FROM companies;'"
# Then drop test database: sudo -u postgres psql -c "DROP DATABASE nordabiz_test;"
```
- [ ] Backup copied to redundant location
```bash
@ -254,15 +254,15 @@ This checklist ensures safe, repeatable deployments to production with minimal r
### Pre-SQL Execution
- [ ] Maintenance mode enabled (optional but recommended)
```bash
ssh maciejpi@10.22.68.249 "
ssh maciejpi@57.128.200.27 "
# Temporarily disable non-critical endpoints
# Or show 'maintenance' page
"
```
- [ ] Current user count recorded
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
SELECT COUNT(DISTINCT session_key) FROM django_session WHERE expire_date > NOW();"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT COUNT(DISTINCT session_key) FROM django_session WHERE expire_date > NOW();\""
# Current active users: _______
```
@ -271,7 +271,7 @@ This checklist ensures safe, repeatable deployments to production with minimal r
**IMPORTANT:** Execute SQL scripts in this exact order within a transaction:
```bash
ssh maciejpi@10.22.68.249 << 'DEPLOY_EOF'
ssh maciejpi@57.128.200.27 << 'DEPLOY_EOF'
# Start deployment
echo "=== DEPLOYMENT STARTED at $(date) ==="
@ -279,14 +279,14 @@ BACKUP_FILE="$HOME/backup_pre_deployment_$(date +%Y%m%d_%H%M%S).sql"
# Step 1: Full backup BEFORE any changes
echo "STEP 1: Creating backup..."
sudo -u www-data pg_dump -U nordabiz_app -d nordabiz > "$BACKUP_FILE"
sudo -u postgres pg_dump -d nordabiz > "$BACKUP_FILE"
echo "✓ Backup: $BACKUP_FILE"
# Step 2: Begin transaction (all SQL changes in one transaction)
echo "STEP 2: Executing SQL migrations..."
# Execute schema migrations (in order of dependency)
sudo -u www-data psql -U nordabiz_app -d nordabiz << 'SQL'
sudo -u postgres psql -d nordabiz << 'SQL'
BEGIN;
-- 2.1 News tables migration (if not already applied)
@ -314,7 +314,7 @@ echo "✓ SQL migrations completed"
# Step 3: Verify data integrity
echo "STEP 3: Verifying data integrity..."
sudo -u www-data psql -U nordabiz_app -d nordabiz << 'SQL'
sudo -u postgres psql -d nordabiz << 'SQL'
-- Check for orphaned foreign keys
SELECT 'Checking foreign key integrity...' AS status;
@ -327,7 +327,7 @@ SQL
# Step 4: Update indexes and statistics
echo "STEP 4: Optimizing database..."
sudo -u www-data psql -U nordabiz_app -d nordabiz << 'SQL'
sudo -u postgres psql -d nordabiz << 'SQL'
-- Update statistics for query planner
ANALYZE;
@ -341,14 +341,14 @@ echo "✓ Database optimized"
echo "STEP 5: Deploying application..."
cd /var/www/nordabiznes
# Pull latest code (if using git)
sudo -u www-data git pull origin master
# Deploy via rsync (no .git on OVH VPS)
# Run from LOCAL machine: rsync -avz --exclude='.git' --exclude='.env' --exclude='__pycache__' ./ maciejpi@57.128.200.27:/var/www/nordabiznes/
# Update dependencies
sudo -u www-data /var/www/nordabiznes/venv/bin/pip install -q -r requirements.txt
sudo /var/www/nordabiznes/venv/bin/pip install -q -r requirements.txt
# Validate Python syntax
sudo -u www-data /var/www/nordabiznes/venv/bin/python -m py_compile app.py
/var/www/nordabiznes/venv/bin/python -m py_compile app.py
echo "✓ Application files updated"
@ -363,7 +363,7 @@ if sudo systemctl is-active --quiet nordabiznes; then
else
echo "✗ ERROR: Application failed to start"
echo "ROLLING BACK DATABASE..."
sudo -u www-data psql -U nordabiz_app -d nordabiz < "$BACKUP_FILE"
sudo -u postgres psql -d nordabiz < "$BACKUP_FILE"
exit 1
fi
@ -401,15 +401,15 @@ If using separate SSH sessions, execute in this order:
```bash
# Session 1: Create backup
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
BACKUP_FILE="$HOME/backup_pre_deployment_$(date +%Y%m%d_%H%M%S).sql"
sudo -u www-data pg_dump -U nordabiz_app -d nordabiz > "$BACKUP_FILE"
sudo -u postgres pg_dump -d nordabiz > "$BACKUP_FILE"
echo "Backup saved to: $BACKUP_FILE"
exit
# Session 2: Execute SQL
ssh maciejpi@10.22.68.249
sudo -u www-data psql -U nordabiz_app -d nordabiz << 'EOF'
ssh maciejpi@57.128.200.27
sudo -u postgres psql -d nordabiz << 'EOF'
BEGIN;
\i /var/www/nordabiznes/database/migrate_news_tables.sql
-- ... additional SQL ...
@ -417,8 +417,8 @@ COMMIT;
EOF
# Session 3: Validate
ssh maciejpi@10.22.68.249
sudo -u www-data psql -U nordabiz_app -d nordabiz -c "SELECT COUNT(*) FROM company_news;"
ssh maciejpi@57.128.200.27
sudo -u postgres psql -d nordabiz -c "SELECT COUNT(*) FROM company_news;"
exit
```
@ -430,15 +430,15 @@ exit
```
- [ ] Database size within expected range
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
SELECT pg_size_pretty(pg_database_size('nordabiz'));"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT pg_size_pretty(pg_database_size('nordabiz'));\""
# Compare to pre-deployment size (should be similar ±10%)
```
- [ ] New tables/columns exist (if schema changes)
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT * FROM information_schema.tables
WHERE table_name IN ('company_news', 'user_notifications');"
WHERE table_name IN ('company_news', 'user_notifications');\""
```
---
@ -446,20 +446,22 @@ exit
## Phase 5: Application Deployment
### Code Deployment
- [ ] Application code pulled from Git
- [ ] Application code deployed via rsync (no .git on OVH VPS)
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull origin master"
# Run from LOCAL machine (project root):
rsync -avz --exclude='.git' --exclude='.env' --exclude='__pycache__' --exclude='venv' \
./ maciejpi@57.128.200.27:/var/www/nordabiznes/
```
- [ ] Python dependencies installed
```bash
ssh maciejpi@10.22.68.249 "
sudo -u www-data /var/www/nordabiznes/venv/bin/pip install -q -r /var/www/nordabiznes/requirements.txt
ssh maciejpi@57.128.200.27 "
sudo /var/www/nordabiznes/venv/bin/pip install -q -r /var/www/nordabiznes/requirements.txt
"
```
- [ ] Application syntax validated
```bash
ssh maciejpi@10.22.68.249 "
sudo -u www-data /var/www/nordabiznes/venv/bin/python -m py_compile /var/www/nordabiznes/app.py
ssh maciejpi@57.128.200.27 "
/var/www/nordabiznes/venv/bin/python -m py_compile /var/www/nordabiznes/app.py
echo $? # Should return 0 (success)
"
```
@ -467,16 +469,16 @@ exit
### Service Restart
- [ ] Application service restarted
```bash
ssh maciejpi@10.22.69.249 "sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
```
- [ ] Service started successfully
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl is-active nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl is-active nordabiznes"
# Expected: active
```
- [ ] Service status verified
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes --no-pager | head -10"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes --no-pager | head -10"
```
### Initial Health Checks
@ -491,7 +493,7 @@ exit
```
- [ ] No critical errors in logs
```bash
ssh maciejpi@10.22.68.249 "
ssh maciejpi@57.128.200.27 "
sudo tail -30 /var/log/nordabiznes/app.log | grep -i 'ERROR\|CRITICAL'
"
# Should be empty or only non-critical warnings
@ -521,16 +523,16 @@ exit
### Database Queries
- [ ] New tables accessible
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c '
SELECT * FROM company_news LIMIT 1;
SELECT * FROM user_notifications LIMIT 1;"
SELECT * FROM user_notifications LIMIT 1;'"
```
- [ ] Search indexes working
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
EXPLAIN ANALYZE
SELECT * FROM companies
WHERE name ILIKE '%pixlab%' LIMIT 10;"
WHERE name ILIKE '%pixlab%' LIMIT 10;\""
# Should show "Index Scan" (not "Seq Scan")
```
@ -542,8 +544,8 @@ exit
```
- [ ] Database query response time acceptable
```bash
time psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
SELECT * FROM companies WHERE category_id = 1 LIMIT 50;"
ssh maciejpi@57.128.200.27 "time sudo -u postgres psql -d nordabiz -c '
SELECT * FROM companies WHERE category_id = 1 LIMIT 50;'"
# real time should be < 100ms
```
- [ ] API endpoints respond within SLA
@ -578,22 +580,22 @@ exit
### Post-Deployment Monitoring (2 hours)
- [ ] Monitor application logs for errors
```bash
ssh maciejpi@10.22.68.249 "
ssh maciejpi@57.128.200.27 "
tail -f /var/log/nordabiznes/app.log
"
# Watch for ERROR, CRITICAL, EXCEPTION
```
- [ ] Monitor database load
```bash
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT pid, usename, state, query
FROM pg_stat_activity
WHERE datname = 'nordabiz' AND state != 'idle';"
WHERE datname = 'nordabiz' AND state != 'idle';\""
# Should be minimal
```
- [ ] Monitor system resources
```bash
ssh maciejpi@10.22.68.249 "top -b -n 1 | head -15"
ssh maciejpi@57.128.200.27 "top -b -n 1 | head -15"
```
### 24-Hour Follow-up
@ -635,13 +637,13 @@ echo ""
# Step 1: Stop application
echo "STEP 1: Stopping application..."
ssh maciejpi@10.22.68.249 "sudo systemctl stop nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl stop nordabiznes"
sleep 3
# Step 2: Restore database
echo "STEP 2: Restoring database from backup..."
ssh maciejpi@10.22.68.249 "
sudo -u www-data psql -U nordabiz_app -d nordabiz << 'SQL'
ssh maciejpi@57.128.200.27 "
sudo -u postgres psql -d nordabiz << 'SQL'
-- Drop all changes
DROP TABLE IF EXISTS company_news CASCADE;
DROP TABLE IF EXISTS user_notifications CASCADE;
@ -649,7 +651,7 @@ DROP TABLE IF EXISTS user_notifications CASCADE;
SQL
# Restore from backup
sudo -u www-data psql -U nordabiz_app -d nordabiz < $BACKUP_FILE
sudo -u postgres psql -d nordabiz < $BACKUP_FILE
"
if [ $? -ne 0 ]; then
@ -662,11 +664,8 @@ echo "✓ Database restored"
# Step 3: Restart application (previous version)
echo "STEP 3: Restarting application..."
ssh maciejpi@10.22.68.249 "
cd /var/www/nordabiznes
sudo -u www-data git checkout HEAD~1 # Revert to previous commit
sudo systemctl start nordabiznes
"
# Re-deploy previous version via rsync from local backup, then:
ssh maciejpi@57.128.200.27 "sudo systemctl start nordabiznes"
sleep 3
@ -711,44 +710,44 @@ echo "4. Schedule post-mortem review"
curl -s https://nordabiznes.pl/health | jq .
# Database connection
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "SELECT 1;"
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c 'SELECT 1;'"
# Service status
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes"
# Log tailing
ssh maciejpi@10.22.68.249 "sudo tail -f /var/log/nordabiznes/app.log"
ssh maciejpi@57.128.200.27 "sudo tail -f /var/log/nordabiznes/app.log"
# Database statistics
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC LIMIT 10;"
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC LIMIT 10;\""
```
### Monitoring Queries
```bash
# Active connections
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c '
SELECT datname, usename, count(*)
FROM pg_stat_activity
GROUP BY datname, usename;"
GROUP BY datname, usename;'"
# Long-running queries
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c \"
SELECT pid, usename, query, query_start
FROM pg_stat_activity
WHERE query != 'autovacuum'
AND query_start < NOW() - interval '5 minutes';"
AND query_start < NOW() - interval '5 minutes';\""
# Index usage
psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c '
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC LIMIT 20;"
ORDER BY idx_scan DESC LIMIT 20;'"
```
---
@ -761,7 +760,7 @@ psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "
**Solution:**
1. Check logs: `sudo journalctl -xe -u nordabiznes | tail -50`
2. Check syntax: `python -m py_compile /var/www/nordabiznes/app.py`
3. Check database connection: `psql -h 10.22.68.249 -U nordabiz_app -d nordabiz -c "SELECT 1;"`
3. Check database connection: `ssh maciejpi@57.128.200.27 "sudo -u postgres psql -d nordabiz -c 'SELECT 1;'"`
4. If database is issue, execute rollback script
5. If code is issue, revert Git commit and restart
@ -834,11 +833,12 @@ Approval:
- **Migration Scripts:** `/var/www/nordabiznes/database/*.sql`
- **Application Logs:** `/var/log/nordabiznes/app.log`
- **PostgreSQL Logs:** `sudo journalctl -u postgresql --no-pager`
- **Production Server:** `10.22.68.249` (NORDABIZ-01)
- **VPN Required:** FortiGate SSL-VPN (85.237.177.83)
- **Production Server:** `57.128.200.27` (OVH VPS inpi-vps-waw01)
- **Deploy method:** rsync (no .git on VPS), NOT git pull
- **DB access:** `sudo -u postgres psql -d nordabiz` (.env is root-owned)
---
**Last Updated:** 2026-01-02
**Last Updated:** 2026-04-04
**Maintained By:** Norda Biznes Development Team
**Next Review:** 2026-04-02 (quarterly)
**Next Review:** 2026-07-04 (quarterly)

View File

@ -47,16 +47,15 @@ git branch -d auto-claude/<task-id> # Usuń branch
| Hourly (lokalnie) | `/var/backups/nordabiz/hourly/` | 24h |
| Daily (lokalnie) | `/var/backups/nordabiz/daily/` | 30 dni |
| Offsite (PBS) | `10.22.68.127:/backup/nordabiz/` | 30 dni |
| VM Snapshots | Proxmox | 3 snapshoty |
### Szybkie przywracanie
```bash
# Lista dostępnych backupów
ssh maciejpi@10.22.68.249 "ls -lt /var/backups/nordabiz/hourly/ | head -5"
ssh maciejpi@57.128.200.27 "ls -lt /var/backups/nordabiz/hourly/ | head -5"
# Restore z backupu
ssh maciejpi@10.22.68.249 "sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/nordabiz_YYYYMMDD_HH.dump"
ssh maciejpi@57.128.200.27 "sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/nordabiz_YYYYMMDD_HH.dump"
# Weryfikacja
curl -I https://nordabiznes.pl/health

View File

@ -34,7 +34,7 @@ API_KEY = 'AIzaSyAbc123...'
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://user:CHANGE_ME@localhost/nordabiz')
# ❌ BŁĘDNIE - wartość produkcyjna jako fallback:
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://user:RealPassword@10.22.68.249/nordabiz')
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://user:RealPassword@57.128.200.27/nordabiz')
```
### 3. Przechowuj credentials w plikach .env
@ -137,9 +137,9 @@ grep -r "postgresql://.*:.*@" --include="*.py" . | grep -v "CHANGE_ME" | grep -v
### Uprawnienia plików
```bash
# Tylko właściciel może czytać plik .env
chmod 600 /var/www/nordabiznes/.env
chown www-data:www-data /var/www/nordabiznes/.env
# Tylko root może czytać plik .env (OVH VPS — .env jest root-owned)
sudo chmod 600 /var/www/nordabiznes/.env
sudo chown root:root /var/www/nordabiznes/.env
```
---

View File

@ -216,7 +216,7 @@ Skrypty w `scripts/` muszą używać **localhost (127.0.0.1)** do połączenia z
DATABASE_URL = 'postgresql://nordabiz_app:<PASSWORD_FROM_ENV>@127.0.0.1:5432/nordabiz'
# BŁĘDNIE (PostgreSQL nie akceptuje zewnętrznych połączeń):
DATABASE_URL = 'postgresql://nordabiz_app:<PASSWORD>@10.22.68.249:5432/nordabiz'
DATABASE_URL = 'postgresql://nordabiz_app:<PASSWORD>@57.128.200.27:5432/nordabiz'
```
**Pliki z konfiguracją bazy:**

View File

@ -1,8 +1,10 @@
# NordaBiz Disaster Recovery Playbook
**Wersja:** 1.0
**Wersja:** 1.1
**Data utworzenia:** 2026-02-02
**Ostatnia aktualizacja:** 2026-02-02
**Ostatnia aktualizacja:** 2026-04-04
> **UWAGA (2026-04-04):** Produkcja przeniesiona z OVH VPS inpi-vps-waw01 (VM 249, 57.128.200.27) na OVH VPS (57.128.200.27, hostname inpi-vps-waw01). Deploy via rsync (brak .git na serwerze). Migracje: `sudo -u postgres psql nordabiz`. .env jest root-owned, skrypty wymagają `sudo`.
---
@ -43,7 +45,7 @@
## Lokalizacje backupów
### Backup lokalny (NORDABIZ-01)
### Backup lokalny (OVH VPS — inpi-vps-waw01)
```
/var/backups/nordabiz/
@ -68,12 +70,13 @@
└── config/ # Mirror konfiguracji
```
### VM Snapshots (Proxmox)
### VM Snapshots (Proxmox — TYLKO STAGING)
- **Lokalizacja:** Proxmox VE (hypervisor)
- **VM ID:** 249 (NORDABIZ-01), 119 (R11-REVPROXY-01)
- **VM ID:** 248 (NORDABIZ-STAGING-01), 119 (R11-REVPROXY-01)
- **Storage:** local-lvm lub PBS
- **Dostęp:** Proxmox Web UI (https://10.22.68.10:8006)
- **UWAGA:** Produkcja nie jest na Proxmox — snapshoty dotyczą tylko staging i reverse proxy
---
@ -96,34 +99,27 @@
---
### Scenariusz 2: Awaria VM (NORDABIZ-01)
### Scenariusz 2: Awaria serwera produkcyjnego (OVH VPS)
**Objawy:**
- Brak odpowiedzi SSH
- Brak odpowiedzi SSH na 57.128.200.27
- HTTP 502 na https://nordabiznes.pl
- VM nie odpowiada w Proxmox
**Procedura:**
#### Opcja A: Restart VM
1. Zaloguj się do Proxmox: https://10.22.68.10:8006
2. VM 249 → Stop → Start
3. Poczekaj 2-3 minuty
4. Zweryfikuj: `curl -I https://nordabiznes.pl/health`
#### Opcja A: Restart VPS via OVH Panel
1. Zaloguj się do OVH Manager: https://www.ovh.com/manager/
2. Znajdź VPS inpi-vps-waw01
3. Wykonaj reboot
4. Poczekaj 2-3 minuty
5. Zweryfikuj: `curl -I https://nordabiznes.pl/health`
#### Opcja B: Rollback do snapshotu
1. Proxmox → VM 249 → Snapshots
2. Wybierz ostatni działający snapshot
3. Kliknij "Rollback"
4. Start VM
5. Zweryfikuj
#### Opcja C: Pełne odtworzenie (nowa VM)
1. Utwórz nową VM w Proxmox (4 vCPU, 8GB RAM, 100GB SSD)
#### Opcja B: Pełne odtworzenie (nowy VPS lub VM)
1. Utwórz nowy VPS w OVH lub VM w Proxmox
2. Zainstaluj Ubuntu 22.04 LTS
3. Postępuj zgodnie z sekcją [Pełne odtworzenie systemu](#pełne-odtworzenie-systemu)
**RTO:** 5 min (opcja A), 10 min (opcja B), 60 min (opcja C)
**RTO:** 5 min (opcja A), 60 min (opcja B)
---
@ -214,18 +210,15 @@ sudo /var/www/nordabiznes/scripts/dr-restore.sh /tmp/nordabiz_20260201.dump
Wykonaj gdy VM jest całkowicie niedostępna lub skompromitowana.
#### Krok 1: Przygotowanie nowej VM
#### Krok 1: Przygotowanie nowego serwera
```bash
# Na Proxmox - utwórz VM:
# - ID: 249 (lub nowy)
# Na OVH Manager - utwórz VPS lub na Proxmox - utwórz VM:
# - CPU: 4 vCPU
# - RAM: 8 GB
# - Disk: 100 GB SSD
# - Network: vmbr0
# - IP: 10.22.68.249/24
# - Gateway: 10.22.68.1
# - OS: Ubuntu 22.04 LTS
# - Publiczny IP (jeśli OVH VPS) lub IP wewnętrzny (jeśli Proxmox)
```
#### Krok 2: Instalacja pakietów
@ -261,15 +254,16 @@ sudo -u postgres psql -d nordabiz -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN S
```bash
sudo mkdir -p /var/www/nordabiznes
sudo chown www-data:www-data /var/www/nordabiznes
sudo chown maciejpi:maciejpi /var/www/nordabiznes
# Clone z Gitea
sudo -u www-data git clone https://10.22.68.180:3000/maciejpi/nordabiz.git /var/www/nordabiznes
# Sync z lokalnej maszyny deweloperskiej (brak .git na serwerze!)
rsync -avz --exclude='.git' --exclude='venv' --exclude='.env' \
/path/to/nordabiz/ maciejpi@57.128.200.27:/var/www/nordabiznes/
# Virtualenv
cd /var/www/nordabiznes
sudo -u www-data python3 -m venv venv
sudo -u www-data venv/bin/pip install -r requirements.txt
python3 -m venv venv
venv/bin/pip install -r requirements.txt
```
#### Krok 6: Przywrócenie konfiguracji
@ -277,7 +271,7 @@ sudo -u www-data venv/bin/pip install -r requirements.txt
```bash
# Skopiuj .env z backup
scp maciejpi@10.22.68.127:/backup/nordabiz/config/latest/.env /var/www/nordabiznes/.env
sudo chown www-data:www-data /var/www/nordabiznes/.env
sudo chown root:root /var/www/nordabiznes/.env
sudo chmod 600 /var/www/nordabiznes/.env
# WAŻNE: Zaktualizuj DATABASE_URL w .env jeśli zmieniłeś hasło!
@ -305,14 +299,14 @@ curl -I http://localhost:5000/health
sudo journalctl -u nordabiznes -f
```
#### Krok 9: Aktualizacja NPM (jeśli zmieniono IP)
#### Krok 9: Aktualizacja DNS (jeśli zmieniono IP)
Jeśli nowa VM ma inny IP, zaktualizuj NPM:
Jeśli nowy serwer ma inny publiczny IP, zaktualizuj DNS w OVH:
```bash
ssh maciejpi@10.22.68.250
# NPM Admin: http://10.22.68.250:81
# Proxy Host 27 → Forward Host: NOWE_IP, Port: 5000
# OVH Manager → nordabiznes.pl → DNS Zone → rekord A → NOWE_IP
# Produkcja na OVH VPS ma publiczny IP — ruch nie przechodzi przez NPM (10.22.68.250)
# NPM obsługuje tylko staging (staging.nordabiznes.pl)
```
---
@ -366,16 +360,13 @@ Wykonuj co kwartał:
curl -I https://nordabiznes.pl/health
# Sprawdź status usługi
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes"
# Sprawdź logi
ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -n 50"
ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes -n 50"
# Sprawdź NPM
# Sprawdź NPM (tylko staging)
ssh maciejpi@10.22.68.250 "docker ps | grep nginx-proxy-manager"
# Sprawdź port forwarding
ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 sqlite3 /data/database.sqlite \"SELECT forward_port FROM proxy_host WHERE id = 27;\""
```
---

View File

@ -174,7 +174,7 @@ git merge feature/phase2-auth-public
git push origin master && git push inpi master
# 3. Deploy
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo systemctl restart nordabiznes"
# 4. Weryfikacja (natychmiast!)
curl -sI https://nordabiznes.pl/ | head -3
@ -188,7 +188,7 @@ curl -sI https://nordabiznes.pl/dashboard | head -3
# Natychmiastowy rollback
git revert HEAD
git push origin master && git push inpi master
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo systemctl restart nordabiznes"
```
---

View File

@ -510,7 +510,7 @@ git add . && git commit -m "refactor(phase-X): ..."
git push origin master && git push inpi master
# 4. Deploy
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
# 5. Weryfikacja produkcji
curl https://nordabiznes.pl/health
@ -521,7 +521,7 @@ curl https://nordabiznes.pl/health
```bash
git revert HEAD
git push origin master && git push inpi master
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
```
---

View File

@ -5,10 +5,10 @@
## Klucz SSH do dodania na PBS
Klucz publiczny z NORDABIZ-01 (10.22.68.249):
Klucz publiczny z OVH VPS (57.128.200.27, inpi-vps-waw01):
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHHpPjhwjOUBTmo0MFus4QsgAlI5JxbPNlhW0aPV7vIg maciejpi@NORDABIZ-01
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHHpPjhwjOUBTmo0MFus4QsgAlI5JxbPNlhW0aPV7vIg maciejpi@inpi-vps-waw01
```
## Instrukcja konfiguracji
@ -20,7 +20,7 @@ Zaloguj się na PBS przez konsolę Proxmox lub inną metodę i wykonaj:
```bash
# Na PBS (10.22.68.127)
mkdir -p /home/maciejpi/.ssh
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHHpPjhwjOUBTmo0MFus4QsgAlI5JxbPNlhW0aPV7vIg maciejpi@NORDABIZ-01" >> /home/maciejpi/.ssh/authorized_keys
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHHpPjhwjOUBTmo0MFus4QsgAlI5JxbPNlhW0aPV7vIg maciejpi@inpi-vps-waw01" >> /home/maciejpi/.ssh/authorized_keys
chmod 700 /home/maciejpi/.ssh
chmod 600 /home/maciejpi/.ssh/authorized_keys
chown -R maciejpi:maciejpi /home/maciejpi/.ssh
@ -34,10 +34,10 @@ sudo mkdir -p /backup/nordabiz/{daily,config}
sudo chown -R maciejpi:maciejpi /backup/nordabiz
```
### Krok 3: Weryfikacja połączenia z NORDABIZ-01
### Krok 3: Weryfikacja połączenia z OVH VPS (inpi-vps-waw01)
```bash
# Na NORDABIZ-01 (10.22.68.249)
# Na OVH VPS (57.128.200.27, inpi-vps-waw01)
ssh maciejpi@10.22.68.127 "echo OK"
```
@ -46,7 +46,7 @@ ssh maciejpi@10.22.68.127 "echo OK"
Po weryfikacji połączenia, utwórz plik cron:
```bash
# Na NORDABIZ-01
# Na OVH VPS (inpi-vps-waw01)
sudo tee /etc/cron.d/nordabiz-offsite << 'EOF'
# NordaBiz Offsite Backup
SHELL=/bin/bash
@ -65,7 +65,7 @@ sudo chmod 644 /etc/cron.d/nordabiz-offsite
### Krok 5: Test synchronizacji
```bash
# Na NORDABIZ-01
# Na OVH VPS (inpi-vps-waw01)
rsync -avz --dry-run /var/backups/nordabiz/daily/ maciejpi@10.22.68.127:/backup/nordabiz/daily/
```
@ -85,5 +85,5 @@ Jeśli PBS jest niedostępny, można użyć r11-git-inpi (10.22.68.180) jako alt
ssh maciejpi@10.22.68.127 "ls -la /backup/nordabiz/daily/"
# Sprawdź logi
ssh maciejpi@10.22.68.249 "tail -20 /var/log/nordabiznes/backup.log"
ssh maciejpi@57.128.200.27 "tail -20 /var/log/nordabiznes/backup.log"
```

View File

@ -138,7 +138,7 @@ Environment variables:
## 8) Deployment and Ops
Production:
- App server: NORDABIZ-01 (10.22.68.249)
- App server: OVH VPS inpi-vps-waw01 (57.128.200.27)
- DB: PostgreSQL on same host (5432)
- Reverse proxy: NPM on 10.22.68.250
- Domain: nordabiznes.pl
@ -155,7 +155,7 @@ CRITICAL CONFIG:
Deployment workflow:
- push to GitHub and Gitea
- pull on staging, test
- pull on prod, run migrations via scripts/run_migration.py
- deploy to OVH VPS (57.128.200.27) via rsync, run migrations via sudo -u postgres psql
- restart systemd service
---

View File

@ -104,5 +104,5 @@ git commit -m "docs: Release notes vX.XX.0"
# 3. Push i deploy
git push origin master && git push inpi master
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
```

View File

@ -150,7 +150,7 @@ python3 app.py
### Step 1: SSH to Production Server
```bash
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
```
**IMPORTANT:** Always SSH as `maciejpi`, NEVER as root!
@ -168,7 +168,7 @@ sudo -u www-data nano .env
### Step 3: Set Production Credentials
```bash
# Production PostgreSQL (same server)
# Production PostgreSQL (same server — OVH VPS 57.128.200.27)
DATABASE_URL=postgresql://nordabiz_app:YOUR_PRODUCTION_PASSWORD@127.0.0.1:5432/nordabiz
# Flask Configuration
@ -186,13 +186,13 @@ GOOGLE_PLACES_API_KEY=your_production_places_key
### Step 4: Set File Permissions
```bash
# Ensure .env is readable only by www-data
sudo chown www-data:www-data /var/www/nordabiznes/.env
# Ensure .env is readable only by root (OVH VPS — .env is root-owned)
sudo chown root:root /var/www/nordabiznes/.env
sudo chmod 600 /var/www/nordabiznes/.env
# Verify permissions
ls -la /var/www/nordabiznes/.env
# Expected: -rw------- 1 www-data www-data
# Expected: -rw------- 1 root root
```
### Step 5: Restart Application
@ -245,7 +245,7 @@ nano ~/.pgpass
```
# Format: hostname:port:database:username:password
10.22.68.249:5432:nordabiz:nordabiz_app:your_production_password
57.128.200.27:5432:nordabiz:nordabiz_app:your_production_password
localhost:5433:nordabiz:nordabiz_user:nordabiz_password
```
@ -564,10 +564,10 @@ sudo systemctl restart nordabiznes
# Development
nano .env # Update DATABASE_URL with new password
# Production
ssh maciejpi@10.22.68.249
# Production (OVH VPS)
ssh maciejpi@57.128.200.27
cd /var/www/nordabiznes
sudo -u www-data nano .env # Update DATABASE_URL with new password
sudo nano .env # Update DATABASE_URL with new password (.env is root-owned)
sudo systemctl restart nordabiznes
```

View File

@ -1,7 +1,7 @@
# System Context Diagram (C4 Level 1)
**Document Version:** 1.0
**Last Updated:** 2026-01-10
**Last Updated:** 2026-04-04
**Status:** Production LIVE
**Diagram Type:** C4 Model - Level 1 (System Context)
@ -344,7 +344,7 @@ System Event → NordaBiz Hub → MS Graph API → Outlook
### Data Protection
- **Passwords:** Hashed with bcrypt
- **Sessions:** Flask session cookies (encrypted)
- **HTTPS:** Forced via NPM (SSL termination)
- **HTTPS:** Forced via nginx on OVH VPS (SSL termination with Let's Encrypt)
- **CSRF:** Flask-WTF protection enabled
---
@ -353,9 +353,13 @@ System Event → NordaBiz Hub → MS Graph API → Outlook
**Production:**
- **URL:** https://nordabiznes.pl
- **Server:** NORDABIZ-01 (10.22.68.249)
- **Proxy:** R11-REVPROXY-01 (10.22.68.250)
- **Status:** LIVE since 2025-11-23
- **Server:** OVH VPS (57.128.200.27, hostname: inpi-vps-waw01)
- **SSL:** nginx with Let's Encrypt (on the same VPS)
- **Status:** LIVE since 2025-11-23 (migrated to OVH VPS 2026-04)
**Staging:**
- **URL:** https://staging.nordabiznes.pl
- **Server:** NORDABIZ-STAGING-01 (VM 248, 10.22.68.248) -- on-prem via NPM + FortiGate
**Development:**
- **Local:** localhost:5000 or 5001

View File

@ -1,7 +1,7 @@
# Container Diagram (C4 Level 2)
**Document Version:** 1.0
**Last Updated:** 2026-01-10
**Last Updated:** 2026-04-04
**Status:** Production LIVE
**Diagram Type:** C4 Model - Level 2 (Containers)
@ -32,11 +32,11 @@ graph TB
%% System boundary
subgraph "Norda Biznes Partner System"
subgraph "R11-REVPROXY-01 [VM 119 | 10.22.68.250]"
NPM["🔒 Nginx Proxy Manager<br/>(Reverse Proxy)<br/><br/>Technology: Nginx + Docker<br/>Port: 443 (HTTPS)<br/><br/>Responsibilities:<br/>- SSL/TLS termination<br/>- Request routing<br/>- HTTP→HTTPS redirect<br/>- Let's Encrypt automation"]
subgraph "OVH VPS [inpi-vps-waw01 | 57.128.200.27]"
Nginx["🔒 Nginx<br/>(Reverse Proxy)<br/><br/>Technology: Nginx<br/>Port: 443 (HTTPS)<br/><br/>Responsibilities:<br/>- SSL/TLS termination<br/>- Request routing<br/>- HTTP→HTTPS redirect<br/>- Let's Encrypt (certbot)"]
end
subgraph "NORDABIZ-01 [VM 249 | 10.22.68.249]"
subgraph "OVH VPS [inpi-vps-waw01 | 57.128.200.27]"
WebApp["🌐 Flask Web Application<br/>(Application Server)<br/><br/>Technology: Flask 3.0 + Gunicorn<br/>Language: Python 3.9+<br/>Port: 5000<br/><br/>Responsibilities:<br/>- HTTP request handling<br/>- Business logic<br/>- Template rendering<br/>- API endpoints<br/>- Authentication & authorization<br/>- Session management"]
Database["💾 PostgreSQL Database<br/>(Data Store)<br/><br/>Technology: PostgreSQL 14<br/>Port: 5432 (localhost only)<br/><br/>Responsibilities:<br/>- Persistent data storage<br/>- Full-text search (FTS)<br/>- Fuzzy matching (pg_trgm)<br/>- Data integrity & constraints<br/>- 36 tables (companies, users, etc.)"]
@ -68,12 +68,12 @@ graph TB
end
%% User interactions
Users -->|"HTTPS<br/>Port 443"| NPM
Admin -->|"HTTPS<br/>Port 443"| NPM
Users -->|"HTTPS<br/>Port 443"| Nginx
Admin -->|"HTTPS<br/>Port 443"| Nginx
Admin -->|"SSH<br/>Port 22"| WebApp
%% NPM routing
NPM -->|"HTTP<br/>Port 5000<br/>(CRITICAL!)"| WebApp
%% Nginx routing
Nginx -->|"HTTP<br/>127.0.0.1:5000"| WebApp
%% Web app to database
WebApp -->|"SQL Queries<br/>SQLAlchemy ORM<br/>localhost:5432"| Database
@ -113,7 +113,7 @@ graph TB
class WebApp,Scripts containerStyle
class Database databaseStyle
class SearchSvc,ChatSvc,EmailSvc,GeminiSvc,KRSSvc,GBPSvc,ITSvc serviceStyle
class NPM proxyStyle
class Nginx proxyStyle
class Gemini,BraveAPI,PageSpeed,Places,KRS,MSGraph,ALEO,Rejestr externalStyle
class Users,Admin personStyle
```
@ -122,54 +122,45 @@ graph TB
## Container Descriptions
### 🔒 Nginx Proxy Manager (NPM)
### 🔒 Nginx (Reverse Proxy)
**Location:** R11-REVPROXY-01 (VM 119, IP 10.22.68.250)
**Technology:** Nginx + Docker
**Location:** OVH VPS (57.128.200.27, hostname: inpi-vps-waw01)
**Technology:** Nginx
**Protocol:** HTTPS (Port 443)
**Purpose:** SSL termination and reverse proxy
**Responsibilities:**
- Terminate SSL/TLS connections (Let's Encrypt certificates)
- Route incoming HTTPS requests to backend Flask app
- Terminate SSL/TLS connections (Let's Encrypt certificates via certbot)
- Route incoming HTTPS requests to backend Gunicorn on 127.0.0.1:5000
- Automatically renew SSL certificates
- Force HTTP→HTTPS redirects
- Basic security headers (HSTS, CSP)
- Security headers (HSTS, CSP)
**Critical Configuration:**
```
HTTPS :443 → HTTP 10.22.68.249:5000
HTTPS :443 → HTTP 127.0.0.1:5000
```
**⚠️ WARNING:** NPM **MUST** forward to port **5000**, NOT port 80!
- Port 80 on NORDABIZ-01 runs a system nginx that redirects to HTTPS
- Forwarding to port 80 causes infinite redirect loop (ERR_TOO_MANY_REDIRECTS)
- See: `docs/INCIDENT_REPORT_20260102.md`
**Verification:**
```bash
curl -I https://nordabiznes.pl/health
# Expected: HTTP 200 OK
```
**NPM Access:**
- **Web UI:** http://10.22.68.250:81
- **Proxy Host ID:** 27
- **Domain:** nordabiznes.pl
- **SSL:** Let's Encrypt (auto-renewal)
**SSL:** Let's Encrypt (certbot auto-renewal)
---
### 🌐 Flask Web Application
**Location:** NORDABIZ-01 (VM 249, IP 10.22.68.249)
**Location:** OVH VPS (57.128.200.27, hostname: inpi-vps-waw01)
**Technology:** Flask 3.0 + Gunicorn WSGI server
**Language:** Python 3.9+
**Protocol:** HTTP (Port 5000 - internal only)
**Main File:** `/var/www/nordabiznes/app.py` (13,144 lines)
**Protocol:** HTTP (Port 5000 - localhost only, via nginx proxy_pass)
**Main File:** `/var/www/nordabiznes/app.py`
**Responsibilities:**
- Handle HTTP requests from NPM
- Handle HTTP requests from nginx
- Render HTML templates (Jinja2)
- Provide REST API endpoints (90+ routes)
- User authentication (Flask-Login)
@ -208,13 +199,13 @@ sudo systemctl restart nordabiznes
sudo journalctl -u nordabiznes -f
```
**Application User:** `www-data` (NOT root or maciejpi)
**Application User:** `maciejpi`
---
### 💾 PostgreSQL Database
**Location:** NORDABIZ-01 (VM 249, IP 10.22.68.249)
**Location:** OVH VPS (57.128.200.27, hostname: inpi-vps-waw01)
**Technology:** PostgreSQL 14
**Protocol:** PostgreSQL wire protocol (Port 5432)
**Access:** **localhost ONLY** (127.0.0.1)
@ -254,10 +245,10 @@ sudo journalctl -u nordabiznes -f
**Connection Strings:**
```python
# Flask App (from NORDABIZ-01)
# Flask App (from OVH VPS)
DATABASE_URL = 'postgresql://nordabiz_app:***@127.0.0.1:5432/nordabiz'
# Background Scripts (from NORDABIZ-01)
# Background Scripts (from OVH VPS)
DATABASE_URL = 'postgresql://nordabiz_app:***@127.0.0.1:5432/nordabiz'
```
@ -274,7 +265,7 @@ Only connections from `localhost` (127.0.0.1) are allowed.
### ⚙️ Background Scripts
**Location:** NORDABIZ-01 (VM 249, IP 10.22.68.249)
**Location:** OVH VPS (57.128.200.27, hostname: inpi-vps-waw01)
**Directory:** `/var/www/nordabiznes/scripts/`
**Technology:** Python 3.9+ (same virtualenv as Flask app)
**Execution:** Manual via SSH or Cron jobs
@ -300,7 +291,7 @@ Only connections from `localhost` (127.0.0.1) are allowed.
**Execution Example:**
```bash
cd /var/www/nordabiznes
sudo -u www-data /var/www/nordabiznes/venv/bin/python3 scripts/seo_audit.py --company-id 26
/var/www/nordabiznes/venv/bin/python3 scripts/seo_audit.py --company-id 26
```
**Cron Jobs (Planned):**
@ -619,14 +610,14 @@ All external APIs are called via HTTPS with appropriate authentication.
User Browser (HTTPS :443)
NPM @ 10.22.68.250:443 (SSL termination)
Nginx @ 57.128.200.27:443 (SSL termination)
├─ Decrypt HTTPS
├─ Verify SSL certificate
└─ Forward as HTTP
Flask App @ 10.22.68.249:5000 (HTTP)
Flask App @ 127.0.0.1:5000 (HTTP)
├─ Authenticate user (session cookie)
├─ Authorize request (permissions)
@ -644,7 +635,7 @@ PostgreSQL @ 127.0.0.1:5432 (local only)
Flask App (render template / JSON)
NPM (encrypt response)
Nginx (encrypt response)
User Browser (HTTPS response)
@ -731,8 +722,8 @@ Admin Dashboard (display results)
| Zone | Components | Access Level | Trust Level |
|------|------------|--------------|-------------|
| **Public Internet** | User browsers | Untrusted | Low |
| **DMZ (Proxy)** | NPM (10.22.68.250) | Semi-trusted | Medium |
| **App Zone** | Flask App (10.22.68.249:5000) | Trusted | High |
| **Proxy (nginx)** | Nginx on OVH VPS (57.128.200.27:443) | Semi-trusted | Medium |
| **App Zone** | Flask App (127.0.0.1:5000) | Trusted | High |
| **Data Zone** | PostgreSQL (127.0.0.1:5432) | Highly trusted | Critical |
| **External APIs** | Gemini, Brave, etc. | Untrusted | Low |
@ -753,7 +744,7 @@ Admin Dashboard (display results)
### HTTPS/TLS
- **Certificate:** Let's Encrypt (auto-renewal via NPM)
- **Certificate:** Let's Encrypt (auto-renewal via certbot on OVH VPS)
- **Protocols:** TLS 1.2, TLS 1.3
- **Cipher Suites:** Modern (AES-GCM, ChaCha20-Poly1305)
- **HSTS:** Enabled (max-age=31536000)
@ -763,7 +754,7 @@ Admin Dashboard (display results)
- **Access:** Localhost only (no external connections)
- **Authentication:** Password-based (strong passwords)
- **Encryption:** At-rest encryption (OS-level)
- **Backups:** Encrypted snapshots (Proxmox Backup Server)
- **Backups:** pg_dump + offsite copy
---
@ -771,25 +762,27 @@ Admin Dashboard (display results)
### Production Environment
**NPM Container (R11-REVPROXY-01):**
```yaml
Proxy Host Configuration:
Domain: nordabiznes.pl
Scheme: http
Forward Hostname: 10.22.68.249
Forward Port: 5000 # CRITICAL: Must be 5000, not 80!
Cache Assets: Yes
Block Common Exploits: Yes
Websockets Support: No
SSL:
Force SSL: Yes
HTTP/2 Support: Yes
HSTS Enabled: Yes
Certificate: Let's Encrypt
Auto-renewal: Yes
**Nginx (OVH VPS):**
```nginx
# /etc/nginx/sites-available/nordabiznes.pl
server {
listen 443 ssl http2;
server_name nordabiznes.pl www.nordabiznes.pl;
ssl_certificate /etc/letsencrypt/live/nordabiznes.pl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nordabiznes.pl/privkey.pem;
location / {
proxy_pass http://127.0.0.1:5000;
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;
}
}
```
**Flask/Gunicorn (NORDABIZ-01):**
**Flask/Gunicorn (OVH VPS):**
```ini
# /etc/systemd/system/nordabiznes.service
[Unit]
@ -798,8 +791,8 @@ After=network.target postgresql.service
[Service]
Type=notify
User=www-data
Group=www-data
User=maciejpi
Group=maciejpi
WorkingDirectory=/var/www/nordabiznes
Environment="PATH=/var/www/nordabiznes/venv/bin"
ExecStart=/var/www/nordabiznes/venv/bin/gunicorn \
@ -814,7 +807,7 @@ ExecStart=/var/www/nordabiznes/venv/bin/gunicorn \
WantedBy=multi-user.target
```
**PostgreSQL (NORDABIZ-01):**
**PostgreSQL (OVH VPS):**
```conf
# /etc/postgresql/14/main/postgresql.conf
listen_addresses = 'localhost' # ONLY localhost!
@ -887,13 +880,13 @@ tail -f /var/log/nordabiznes/access.log
tail -f /var/log/nordabiznes/error.log
```
**NPM (Nginx Proxy Manager):**
**Nginx (OVH VPS):**
```bash
# Docker logs
docker logs -f <npm-container-id>
# Nginx access logs
tail -f /var/log/nginx/access.log
# NPM access logs
docker exec <npm-container-id> tail -f /data/logs/proxy-host-27_access.log
# Nginx error logs
tail -f /var/log/nginx/error.log
```
**PostgreSQL:**
@ -920,33 +913,22 @@ FROM pg_tables WHERE schemaname = 'public' ORDER BY pg_total_relation_size(schem
## Critical Configuration Warnings
### ⚠️ NPM Proxy Port Configuration
### ⚠️ Nginx Proxy Configuration
**CRITICAL:** NPM **MUST** forward to port **5000**, NOT 80!
**Why?**
- Port 80 on NORDABIZ-01 runs system nginx
- System nginx redirects HTTP → HTTPS
- NPM → :80 → nginx → HTTPS → NPM (infinite loop!)
- Results in: `ERR_TOO_MANY_REDIRECTS`
**Production** uses nginx on OVH VPS (57.128.200.27) as reverse proxy to Gunicorn on 127.0.0.1:5000.
**Correct Configuration:**
```
NPM (10.22.68.250:443) → Flask (10.22.68.249:5000) ✓
```
**Incorrect Configuration (causes loop):**
```
NPM (10.22.68.250:443) → Nginx (10.22.68.249:80) → NPM ✗
Nginx (57.128.200.27:443) → Gunicorn (127.0.0.1:5000) ✓
```
**Verification After Changes:**
```bash
curl -I https://nordabiznes.pl/health
# Must return: HTTP 200 OK (not redirect loop)
# Must return: HTTP 200 OK
```
**Incident Report:** `docs/INCIDENT_REPORT_20260102.md`
**Historical Note:** The old on-prem setup used NPM on 10.22.68.250 forwarding to 10.22.68.249:5000. See `docs/INCIDENT_REPORT_20260102.md` for historical port misconfiguration incident.
---
@ -962,11 +944,11 @@ listen_addresses = 'localhost' # NOT '*' or '0.0.0.0'!
**Scripts MUST use localhost (127.0.0.1):**
```python
# CORRECT (from NORDABIZ-01)
# CORRECT (from OVH VPS)
DATABASE_URL = 'postgresql://nordabiz_app:***@127.0.0.1:5432/nordabiz'
# INCORRECT (external connection attempt)
DATABASE_URL = 'postgresql://nordabiz_app:***@10.22.68.249:5432/nordabiz'
DATABASE_URL = 'postgresql://nordabiz_app:***@57.128.200.27:5432/nordabiz'
```
---
@ -980,7 +962,7 @@ DATABASE_URL = 'postgresql://nordabiz_app:***@10.22.68.249:5432/nordabiz'
- OAuth client secrets
**Storage:**
- Production: `.env` file on NORDABIZ-01
- Production: `.env` file on OVH VPS (57.128.200.27)
- Development: `.env` file on local machine
- Backup: Secure password manager (not in git!)
@ -1058,7 +1040,7 @@ DATABASE_URL = 'postgresql://nordabiz_app:***@10.22.68.249:5432/nordabiz'
---
**Document Status:** ✅ Complete
**Diagram Validated:** 2026-01-10
**Diagram Validated:** 2026-04-04
**Mermaid Syntax:** v10.6+
**Renders in:** GitHub, GitLab, VS Code (with Mermaid extension)
**Production Verified:** 2026-01-10 (via health check)
**Production Verified:** 2026-04-04 (OVH VPS migration)

View File

@ -1,12 +1,14 @@
# Deployment Architecture Diagram
**Document Version:** 1.0
**Last Updated:** 2026-01-10
**Last Updated:** 2026-04-04
**Status:** Production LIVE
**Diagram Type:** Infrastructure / Deployment View
---
> **UWAGA (2026-04-04):** Produkcja przeniesiona z OVH VPS inpi-vps-waw01 (VM 249, 57.128.200.27) na **OVH VPS (57.128.200.27, hostname inpi-vps-waw01)**. Diagramy Mermaid poniżej odzwierciedlają starą architekturę — faktyczny stan to: DNS nordabiznes.pl -> 57.128.200.27 (bezpośrednio, bez NPM). NPM (10.22.68.250) obsługuje tylko staging.
## Overview
This diagram shows the **physical deployment architecture** of the Norda Biznes Partner system. It illustrates:
@ -30,37 +32,22 @@ graph TB
%% External actors and entry points
Internet["🌐 INTERNET<br/>External Users"]
%% Public gateway
Fortigate["🛡️ FORTIGATE FIREWALL<br/>Public IP: 85.237.177.83<br/><br/>NAT Rules:<br/>• :443 → 10.22.68.250:443<br/>• :80 → 10.22.68.250:80"]
%% Internal network boundary
subgraph "INPI Internal Network (10.22.68.0/24)"
%% Reverse proxy server
subgraph "R11-REVPROXY-01<br/>VM 119 | 10.22.68.250"
NPM["🔒 NGINX PROXY MANAGER<br/>(Docker Container)<br/><br/>Ports:<br/>• :443 - HTTPS (SSL termination)<br/>• :80 - HTTP redirect<br/>• :81 - Admin UI (internal)<br/><br/>SSL: Let's Encrypt<br/>Certificate ID: 27<br/>Domains: nordabiznes.pl"]
%% Production server (OVH VPS)
subgraph "OVH VPS [inpi-vps-waw01 | 57.128.200.27]"
subgraph "Reverse Proxy"
Nginx["🔒 NGINX<br/>Ports: 443 (HTTPS), 80 (redirect)<br/><br/>SSL: Let's Encrypt (certbot)<br/>Domains: nordabiznes.pl"]
end
%% Backend application server
subgraph "NORDABIZ-01<br/>VM 249 | 10.22.68.249"
subgraph "Application Layer"
Gunicorn["🌐 GUNICORN + FLASK<br/>Port: 5000<br/>Workers: 4<br/>User: www-data<br/><br/>App: /var/www/nordabiznes<br/>Service: nordabiznes.service<br/>Timeout: 120s"]
NginxSys["⚠️ NGINX (System)<br/>Ports: 80, 443<br/><br/>Purpose: HTTP→HTTPS redirect<br/>Status: UNUSED in production<br/><br/>⚠️ NPM should NEVER use this!"]
end
subgraph "Data Layer"
PostgreSQL["💾 POSTGRESQL 14<br/>Port: 5432<br/>Listen: 127.0.0.1 ONLY<br/><br/>Database: nordabiz<br/>User: nordabiz_app<br/>Tables: 36<br/><br/>⚠️ No external connections!"]
end
subgraph "Background Jobs"
Scripts["⚙️ PYTHON SCRIPTS<br/>Execution: Cron / Manual<br/><br/>• seo_audit.py<br/>• social_media_audit.py<br/>• gbp_audit.py<br/>• fetch_company_news.py"]
end
subgraph "Application Layer"
Gunicorn["🌐 GUNICORN + FLASK<br/>Port: 127.0.0.1:5000<br/>Workers: 4<br/>User: maciejpi<br/><br/>App: /var/www/nordabiznes<br/>Service: nordabiznes.service<br/>Timeout: 120s"]
end
%% Git server
subgraph "r11-git-inpi<br/>Server | 10.22.68.180"
Gitea["📚 GITEA<br/>Port: 3000 (HTTPS)<br/><br/>Repository: maciejpi/nordabiz<br/>SSL: Self-signed<br/>Users: maciejpi, gitadmin"]
subgraph "Data Layer"
PostgreSQL["💾 POSTGRESQL 14<br/>Port: 5432<br/>Listen: 127.0.0.1 ONLY<br/><br/>Database: nordabiz<br/>User: nordabiz_app<br/>Tables: 36<br/><br/>No external connections!"]
end
subgraph "Background Jobs"
Scripts["⚙️ PYTHON SCRIPTS<br/>Execution: Cron / Manual<br/><br/>• seo_audit.py<br/>• social_media_audit.py<br/>• gbp_audit.py<br/>• fetch_company_news.py"]
end
end
@ -75,12 +62,10 @@ graph TB
end
%% User traffic flow
Internet -->|"HTTPS :443<br/>HTTP :80"| Fortigate
Fortigate -->|"NAT<br/>443→443<br/>80→80"| NPM
Internet -->|"HTTPS :443<br/>HTTP :80"| Nginx
%% NPM to backend (CRITICAL PATH)
NPM ==>|"⚠️ CRITICAL!<br/>HTTP :5000<br/>(NOT port 80!)"| Gunicorn
NPM -.->|"❌ WRONG!<br/>Creates redirect loop"| NginxSys
%% Nginx to backend
Nginx -->|"HTTP<br/>127.0.0.1:5000"| Gunicorn
%% Application to database (localhost only)
Gunicorn <-->|"SQL<br/>localhost:5432<br/>SQLAlchemy ORM"| PostgreSQL
@ -95,24 +80,15 @@ graph TB
Scripts -->|"HTTPS<br/>Audit requests"| PageSpeed
Scripts -->|"HTTPS<br/>News search"| BraveAPI
%% Git operations
Gunicorn -.->|"git pull<br/>(deployment)"| Gitea
%% Styling
classDef serverStyle fill:#2c3e50,stroke:#34495e,color:#ecf0f1,stroke-width:3px
classDef appStyle fill:#1168bd,stroke:#0b4884,color:#ffffff,stroke-width:3px
classDef dbStyle fill:#438dd5,stroke:#2e6295,color:#ffffff,stroke-width:3px
classDef proxyStyle fill:#e74c3c,stroke:#c0392b,color:#ffffff,stroke-width:4px
classDef firewallStyle fill:#f39c12,stroke:#d68910,color:#ffffff,stroke-width:4px
classDef externalStyle fill:#95a5a6,stroke:#7f8c8d,color:#ffffff,stroke-width:2px
classDef warningStyle fill:#e67e22,stroke:#d35400,color:#ffffff,stroke-width:3px
class NPM proxyStyle
class Fortigate firewallStyle
class Nginx proxyStyle
class Gunicorn,Scripts appStyle
class PostgreSQL dbStyle
class NginxSys warningStyle
class Gitea serverStyle
class Gemini,BraveAPI,PageSpeed,Places,KRS,MSGraph externalStyle
```
@ -120,35 +96,42 @@ graph TB
## Infrastructure Inventory
### Production Servers
### Production Server
| Server | VM ID | IP Address | Hostname | OS | vCPU | RAM | Disk | Hypervisor |
|--------|-------|------------|----------|-----|------|-----|------|------------|
| **NORDABIZ-01** | 249 | 10.22.68.249 | nordabiz-01 | Ubuntu 22.04 | 4 | 8 GB | 100 GB SSD | Proxmox VE |
| **R11-REVPROXY-01** | 119 | 10.22.68.250 | r11-revproxy-01 | Ubuntu 22.04 | 2 | 4 GB | 50 GB SSD | Proxmox VE |
| **r11-git-inpi** | - | 10.22.68.180 | r11-git-inpi | Ubuntu 22.04 | 2 | 4 GB | 100 GB SSD | Proxmox VE |
| Server | IP Address | Hostname | OS | vCPU | RAM | Disk | Provider |
|--------|------------|----------|-----|------|-----|------|----------|
| **OVH VPS** | 57.128.200.27 | inpi-vps-waw01 | Ubuntu 22.04 | 4 | 8 GB | 80 GB SSD | OVH Cloud (Warsaw) |
### Staging (on-prem, unchanged)
| Server | VM ID | IP Address | Hostname | OS | Hypervisor |
|--------|-------|------------|----------|-----|------------|
| **NORDABIZ-STAGING-01** | 248 | 10.22.68.248 | nordabiz-staging-01 | Ubuntu 22.04 | Proxmox VE |
**Note:** The old on-prem production VM 249 (57.128.200.27) and NPM reverse proxy (10.22.68.250) are no longer used for production. Staging still uses NPM + FortiGate path.
---
## Server Details
### NORDABIZ-01 (Backend Application Server)
### OVH VPS (Production Server)
**Infrastructure:**
- **VM ID:** 249
- **IP Address:** 10.22.68.249
- **Internal DNS:** nordabiznes.inpi.local
- **IP Address:** 57.128.200.27
- **Hostname:** inpi-vps-waw01
- **OS:** Ubuntu 22.04 LTS
- **Resources:** 4 vCPU, 8 GB RAM, 100 GB SSD
- **Hypervisor:** Proxmox VE
- **Resources:** 4 vCPU, 8 GB RAM, 80 GB SSD
- **Provider:** OVH Cloud (Warsaw datacenter)
- **DNS:** nordabiznes.pl -> 57.128.200.27 (A record in OVH DNS)
**Services Running:**
| Service | Port | Binding | User | Status | Purpose |
|---------|------|---------|------|--------|---------|
| **Gunicorn/Flask** | **5000** | **0.0.0.0** | **www-data** | **Active** | **Main Application** ✓ |
| **Nginx** | **443** | **0.0.0.0** | **root** | **Active** | **SSL termination + reverse proxy** ✓ |
| **Nginx** | **80** | **0.0.0.0** | **root** | **Active** | **HTTP→HTTPS redirect** |
| **Gunicorn/Flask** | **5000** | **127.0.0.1** | **maciejpi** | **Active** | **Main Application** ✓ |
| PostgreSQL 14 | 5432 | 127.0.0.1 | postgres | Active | Database |
| Nginx (system) | 80, 443 | 0.0.0.0 | root | Active | HTTP→HTTPS redirect ⚠️ |
| SSH | 22 | 0.0.0.0 | - | Active | Remote administration |
**Application Paths:**
@ -187,7 +170,7 @@ tail -f /var/log/nordabiznes/error.log # Error log
**SSH Access:**
```bash
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
# CRITICAL: Always use 'maciejpi' user, NEVER 'root'!
```
@ -195,8 +178,10 @@ ssh maciejpi@10.22.68.249
```ini
# /etc/systemd/system/nordabiznes.service
[Service]
User=maciejpi
Group=maciejpi
ExecStart=/var/www/nordabiznes/venv/bin/gunicorn \
--bind 0.0.0.0:5000 \
--bind 127.0.0.1:5000 \
--workers 4 \
--timeout 120 \
--access-logfile /var/log/nordabiznes/access.log \
@ -204,140 +189,37 @@ ExecStart=/var/www/nordabiznes/venv/bin/gunicorn \
app:app
```
**⚠️ WARNING - System Nginx:**
- System nginx on ports 80/443 is for HTTP→HTTPS redirects ONLY
- **NEVER** configure NPM to use port 80 or 443 on this server
- Doing so causes infinite redirect loop (ERR_TOO_MANY_REDIRECTS)
- See: `docs/INCIDENT_REPORT_20260102.md`
**Nginx Configuration:**
```nginx
# /etc/nginx/sites-available/nordabiznes.pl
server {
listen 443 ssl http2;
server_name nordabiznes.pl www.nordabiznes.pl;
ssl_certificate /etc/letsencrypt/live/nordabiznes.pl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nordabiznes.pl/privkey.pem;
---
location / {
proxy_pass http://127.0.0.1:5000;
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;
}
}
### R11-REVPROXY-01 (Reverse Proxy Server)
**Infrastructure:**
- **VM ID:** 119
- **IP Address:** 10.22.68.250
- **OS:** Ubuntu 22.04 LTS
- **Resources:** 2 vCPU, 4 GB RAM, 50 GB SSD
- **Hypervisor:** Proxmox VE
**Services Running:**
| Service | Port | Binding | Type | Purpose |
|---------|------|---------|------|---------|
| NPM (HTTPS) | 443 | 0.0.0.0 | Docker | Public HTTPS traffic (SSL termination) |
| NPM (HTTP) | 80 | 0.0.0.0 | Docker | HTTP→HTTPS redirect |
| NPM Admin UI | 81 | 0.0.0.0 | Docker | NPM management interface (internal only) |
| SSH | 22 | 0.0.0.0 | System | Remote administration |
**Docker Setup:**
```bash
# NPM container details
Container Name: nginx-proxy-manager_app_1
Image: jc21/nginx-proxy-manager:latest
Volumes:
- /docker/npm/data:/data
- /docker/npm/letsencrypt:/etc/letsencrypt
# Container management
docker ps | grep nginx-proxy-manager # Check status
docker logs nginx-proxy-manager_app_1 --tail 50 # View logs
docker exec -it nginx-proxy-manager_app_1 /bin/sh # Shell access
docker restart nginx-proxy-manager_app_1 # Restart
server {
listen 80;
server_name nordabiznes.pl www.nordabiznes.pl;
return 301 https://$server_name$request_uri;
}
```
**NPM Configuration Database:**
- **Location:** `/data/database.sqlite` (inside container)
- **Access:** SQLite database
- **Backup:** Manual via docker cp
**NPM Web UI:**
- **URL:** http://10.22.68.250:81
- **Access:** Internal network only
- **Authentication:** Admin credentials required
**SSH Access:**
**SSL Certificate Management:**
```bash
ssh maciejpi@10.22.68.250
```
**Critical Proxy Host Configuration (ID: 27):**
```sql
-- Query NPM database
docker exec nginx-proxy-manager_app_1 \
sqlite3 /data/database.sqlite \
"SELECT id, domain_names, forward_host, forward_port FROM proxy_host WHERE id = 27;"
-- Expected output:
-- 27|["nordabiznes.pl","www.nordabiznes.pl"]|10.22.68.249|5000
```
**⚠️ CRITICAL NPM CONFIGURATION:**
| Parameter | Value | Critical Notes |
|-----------|-------|----------------|
| Proxy Host ID | 27 | Fixed identifier |
| Domain Names | nordabiznes.pl, www.nordabiznes.pl | Both variants |
| Forward Scheme | http | SSL terminated at NPM |
| Forward Host | 10.22.68.249 | NORDABIZ-01 IP |
| **Forward Port** | **5000** | **MUST BE 5000, NOT 80!** ⚠️ |
| SSL Certificate | Let's Encrypt (ID 27) | Auto-renewal enabled |
| Force SSL | Yes | HTTP→HTTPS redirect |
| HTTP/2 | Yes | Performance optimization |
| HSTS | Yes | max-age=31536000 |
| Block Exploits | Yes | Security hardening |
**Verification After NPM Changes:**
```bash
curl -I https://nordabiznes.pl/health
# Expected: HTTP/2 200 OK
# If redirect loop: Check forward_port is 5000!
```
---
### r11-git-inpi (Git Repository Server)
**Infrastructure:**
- **IP Address:** 10.22.68.180
- **Hostname:** r11-git-inpi
- **OS:** Ubuntu 22.04 LTS
- **Resources:** 2 vCPU, 4 GB RAM, 100 GB SSD
**Services Running:**
| Service | Port | Protocol | Access | Purpose |
|---------|------|----------|--------|---------|
| Gitea | 3000 | HTTPS | Internal | Git repository hosting |
| SSH | 22 | SSH | Internal | Server administration |
**Gitea Configuration:**
- **Web URL:** https://10.22.68.180:3000/
- **Repository:** maciejpi/nordabiz
- **Clone URL:** https://10.22.68.180:3000/maciejpi/nordabiz.git
- **SSL Certificate:** Self-signed (SSL verification disabled)
**User Accounts:**
- `maciejpi` - Personal account (repository owner)
- `gitadmin` - Gitea administrator
**Production Deployment via Git:**
```bash
# On NORDABIZ-01
cd /var/www/nordabiznes
sudo -u www-data git -c http.sslVerify=false pull origin master
sudo systemctl restart nordabiznes
# Verify deployment
curl -I http://localhost:5000/health
```
**Git Remote Configuration:**
```bash
# Production git config (on NORDABIZ-01)
git remote -v
# inpi https://10.22.68.180:3000/maciejpi/nordabiz.git (fetch)
# inpi https://10.22.68.180:3000/maciejpi/nordabiz.git (push)
# Let's Encrypt via certbot
sudo certbot certificates # Check certificate status
sudo certbot renew # Renew certificates
sudo certbot renew --dry-run # Test renewal
```
---
@ -349,8 +231,8 @@ git remote -v
| Segment | CIDR/Address | Purpose | Security Level |
|---------|--------------|---------|----------------|
| Public Internet | 0.0.0.0/0 | External user access | Untrusted |
| WAN (Fortigate) | 85.237.177.83/32 | Public gateway | Perimeter |
| INPI LAN | 10.22.68.0/24 | Internal services network | Trusted |
| OVH VPS | 57.128.200.27/32 | Production server (direct) | Production |
| INPI LAN | 10.22.68.0/24 | Staging + internal services | Trusted |
| Localhost | 127.0.0.1/8 | Server-local services | Isolated |
### DNS Configuration
@ -359,42 +241,29 @@ git remote -v
| Type | Name | Value | TTL | Purpose |
|------|------|-------|-----|---------|
| A | nordabiznes.pl | 85.237.177.83 | 3600 | Main domain |
| A | www.nordabiznes.pl | 85.237.177.83 | 3600 | WWW subdomain |
**Internal DNS (INPI):**
| Type | Name | Value | Purpose |
|------|------|-------|---------|
| A | nordabiznes.inpi.local | 10.22.68.249 | Internal access |
| A | nordabiz-01.inpi.local | 10.22.68.249 | Server hostname |
| A | revproxy-01.inpi.local | 10.22.68.250 | Reverse proxy |
| A | git.inpi.local | 10.22.68.180 | Git server |
| A | nordabiznes.pl | 57.128.200.27 | 3600 | Main domain (OVH VPS) |
| A | www.nordabiznes.pl | 57.128.200.27 | 3600 | WWW subdomain |
| A | staging.nordabiznes.pl | 85.237.177.83 | 3600 | Staging (on-prem via FortiGate) |
---
## Port Mappings
### NORDABIZ-01 (10.22.68.249) Port Matrix
### OVH VPS (57.128.200.27) Port Matrix
| Port | Protocol | Service | Binding | Access | Purpose | Security Notes |
|------|----------|---------|---------|--------|---------|----------------|
| 22 | TCP | SSH | 0.0.0.0 | Internal only | Server administration | Key-based auth |
| 80 | TCP | Nginx (system) | 0.0.0.0 | Internal only | HTTP→HTTPS redirect | ⚠️ Not for NPM! |
| 443 | TCP | Nginx (system) | 0.0.0.0 | Internal only | HTTPS redirect | ⚠️ Not for NPM! |
| **5000** | **TCP** | **Gunicorn/Flask** | **0.0.0.0** | **Internal only** | **Main Application** | **✓ NPM uses this** |
| 22 | TCP | SSH | 0.0.0.0 | Public (key-only) | Server administration | Key-based auth |
| 80 | TCP | Nginx | 0.0.0.0 | Public | HTTP→HTTPS redirect | Auto-redirect |
| 443 | TCP | Nginx | 0.0.0.0 | Public | HTTPS (SSL termination) | Let's Encrypt |
| **5000** | **TCP** | **Gunicorn/Flask** | **127.0.0.1** | **Localhost only** | **Main Application** | **Nginx proxy_pass** |
| 5432 | TCP | PostgreSQL | 127.0.0.1 | Localhost only | Database | No external access |
| 5433 | TCP | - | - | Unused | Reserved for dev Docker | - |
**⚠️ PORT 5000 - CRITICAL NOTES:**
- This is the **ONLY** correct port for NPM to connect to
- Ports 80/443 are for nginx system service (redirects only)
- Using port 80 in NPM causes infinite redirect loop
- Always verify after NPM configuration changes
**Traffic flow:** Internet -> nginx (:443) -> proxy_pass -> Gunicorn (127.0.0.1:5000)
---
### R11-REVPROXY-01 (10.22.68.250) Port Matrix
### Port Matrix (historical on-prem, now staging only)
| Port | Protocol | Service | Binding | Access | Purpose | Security Notes |
|------|----------|---------|---------|--------|---------|----------------|
@ -403,9 +272,11 @@ git remote -v
| 81 | TCP | NPM Admin UI | 0.0.0.0 | Internal only | NPM management | Auth required |
| 443 | TCP | NPM | 0.0.0.0 | Public (via NAT) | HTTPS traffic | SSL termination |
**Note:** R11-REVPROXY-01 (10.22.68.250) with NPM is now used for staging only (staging.nordabiznes.pl).
---
### r11-git-inpi (10.22.68.180) Port Matrix
### r11-git-inpi (10.22.68.180) Port Matrix (internal, for staging)
| Port | Protocol | Service | Binding | Access | Purpose | Security Notes |
|------|----------|---------|---------|--------|---------|----------------|
@ -414,18 +285,14 @@ git remote -v
---
### Fortigate Firewall NAT Rules
### Fortigate Firewall NAT Rules (staging only)
| External Port | Protocol | Internal IP | Internal Port | Purpose | Traffic |
|---------------|----------|-------------|---------------|---------|---------|
| 443 | TCP | 10.22.68.250 | 443 | HTTPS public access | Incoming |
| 443 | TCP | 10.22.68.250 | 443 | HTTPS staging access | Incoming |
| 80 | TCP | 10.22.68.250 | 80 | HTTP redirect | Incoming |
**Firewall Rules:**
- Allow: Public → 10.22.68.250:443 (HTTPS)
- Allow: Public → 10.22.68.250:80 (HTTP, redirects to HTTPS)
- Deny: All other inbound traffic
- Allow: Internal → External (outbound API calls)
**Note:** FortiGate NAT rules are now only used for staging (staging.nordabiznes.pl). Production traffic goes directly to OVH VPS (57.128.200.27) without FortiGate.
---
@ -438,30 +305,21 @@ git remote -v
│ 1. USER BROWSER │
│ https://nordabiznes.pl │
└────────────────────────────┬────────────────────────────────────┘
│ DNS: nordabiznes.pl → 85.237.177.83
│ DNS: nordabiznes.pl → 57.128.200.27
│ HTTPS :443
┌─────────────────────────────────────────────────────────────────┐
│ 2. FORTIGATE FIREWALL (85.237.177.83) │
│ NAT: 443 → 10.22.68.250:443 │
└────────────────────────────┬────────────────────────────────────┘
│ Forward to proxy
│ HTTPS :443
┌─────────────────────────────────────────────────────────────────┐
│ 3. NPM @ R11-REVPROXY-01 (10.22.68.250:443) │
│ 2. NGINX @ OVH VPS (57.128.200.27:443) │
│ • Accept HTTPS connection │
│ • TLS handshake (Let's Encrypt certificate)
│ • TLS handshake (Let's Encrypt certificate via certbot) │
│ • Terminate SSL/TLS │
│ • Add security headers (HSTS, etc.) │
│ • Proxy pass to backend
│ • Proxy pass to Gunicorn │
└────────────────────────────┬────────────────────────────────────┘
│ ⚠️ CRITICAL PATH
│ http://10.22.68.249:5000
│ (NOT port 80!)
│ http://127.0.0.1:5000
┌─────────────────────────────────────────────────────────────────┐
4. GUNICORN @ NORDABIZ-01 (10.22.68.249:5000)
3. GUNICORN @ OVH VPS (127.0.0.1:5000)
│ • Receive HTTP request (decrypted) │
│ • Load balance across 4 workers │
│ • Pass to Flask application │
@ -469,7 +327,7 @@ git remote -v
│ Process request
┌─────────────────────────────────────────────────────────────────┐
5. FLASK APP (app.py) │
4. FLASK APP (app.py) │
│ • Rate limiting check (Flask-Limiter) │
│ • Session validation (Flask-Login) │
│ • CSRF protection (Flask-WTF) │
@ -480,7 +338,7 @@ git remote -v
│ localhost:5432
┌─────────────────────────────────────────────────────────────────┐
6. POSTGRESQL @ localhost:5432 │
5. POSTGRESQL @ localhost:5432 │
│ • Execute SQL query (SQLAlchemy ORM) │
│ • Apply constraints and indexes │
│ • Return result set │
@ -488,62 +346,32 @@ git remote -v
│ Results
┌─────────────────────────────────────────────────────────────────┐
7. FLASK APP (render response) │
6. FLASK APP (render response) │
│ • Jinja2 template rendering │
│ • JSON serialization (API routes) │
│ • Apply response headers │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP response
GUNICORN → NPM (encrypt with TLS) → FORTIGATE → USER BROWSER
GUNICORN → NGINX (encrypt with TLS) → USER BROWSER
```
**Timing Breakdown:**
- DNS resolution: ~50ms
- SSL handshake: ~100ms
- NPM proxy: ~10ms
- Nginx proxy: ~5ms
- Flask processing: ~50-500ms (depends on query complexity)
- Database query: ~10-100ms
- Template rendering: ~20-50ms
- **Total:** ~240-810ms (typical range)
- **Total:** ~235-805ms (typical range)
---
### Failed Request Flow (Port 80 Misconfiguration)
### Historical: Failed Request Flow (Port 80 Misconfiguration)
**⚠️ This caused the 2026-01-02 production incident**
**This applied to the old on-prem setup (pre-OVH migration). See `docs/INCIDENT_REPORT_20260102.md` for details.**
```
USER BROWSER
│ https://nordabiznes.pl
FORTIGATE (NAT)
NPM @ 10.22.68.250:443
│ SSL termination
│ ❌ WRONG: Proxy to http://10.22.68.249:80
NGINX (System) @ 10.22.68.249:80
│ ❌ Return: 301 → https://nordabiznes.pl
NPM (receives redirect)
│ SSL termination again
│ ❌ Proxy to http://10.22.68.249:80 (LOOP!)
... Infinite redirect loop ...
BROWSER ERROR: ERR_TOO_MANY_REDIRECTS
```
**Root Cause:** NPM forwarding to port 80 instead of port 5000
**Fix:** Change NPM `forward_port` from 80 to 5000
**Prevention:** Always verify `forward_port = 5000` after NPM changes
The old setup used NPM (10.22.68.250) forwarding to on-prem VM (57.128.200.27). Misconfiguring the forward port to 80 instead of 5000 caused an infinite redirect loop. This is no longer applicable to the current OVH VPS production setup.
---
@ -553,14 +381,13 @@ BROWSER ERROR: ERR_TOO_MANY_REDIRECTS
**Certificate Details:**
- **Provider:** Let's Encrypt
- **Managed By:** NPM (Nginx Proxy Manager)
- **Certificate ID:** 27
- **Managed By:** certbot on OVH VPS
- **Domains:**
- nordabiznes.pl
- www.nordabiznes.pl
- **Key Type:** RSA 2048-bit
- **Validity:** 90 days (auto-renewed)
- **Renewal:** Automatic (30 days before expiry)
- **Renewal:** Automatic via certbot cron/timer
**TLS Configuration:**
- **Protocols:** TLS 1.2, TLS 1.3 (TLS 1.0/1.1 disabled)
@ -568,7 +395,7 @@ BROWSER ERROR: ERR_TOO_MANY_REDIRECTS
- **HTTP/2:** Enabled
- **HSTS:** Enabled (max-age=31536000, includeSubDomains)
**Security Headers (Added by NPM):**
**Security Headers (Added by nginx):**
```http
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: SAMEORIGIN
@ -582,8 +409,8 @@ X-XSS-Protection: 1; mode=block
openssl s_client -connect nordabiznes.pl:443 -servername nordabiznes.pl < /dev/null 2>/dev/null | \
openssl x509 -noout -dates -subject -issuer
# Check expiry date
curl -vI https://nordabiznes.pl 2>&1 | grep -E "(expire|issuer)"
# Check certificate status on server
ssh maciejpi@57.128.200.27 "sudo certbot certificates"
# Test SSL configuration
curl -I https://nordabiznes.pl/health
@ -620,7 +447,9 @@ curl -I https://nordabiznes.pl/health
## Deployment Workflow
### Git-Based Deployment
### Rsync-Based Deployment (OVH VPS)
Production deployment uses rsync (no git on OVH VPS).
```
┌────────────────────────────────────────────────────────────────┐
@ -638,35 +467,32 @@ curl -I https://nordabiznes.pl/health
│ GIT REPOSITORIES │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GITEA @ r11-git-inpi (10.22.68.180:3000) │ │
│ │ Repository: maciejpi/nordabiz │ │
│ │ Purpose: Production deployment source │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GITHUB @ github.com │ │
│ │ Repository: pienczyn/nordabiz │ │
│ │ Purpose: Cloud backup, collaboration │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GITEA @ r11-git-inpi (10.22.68.180:3000) │ │
│ │ Repository: maciejpi/nordabiz │ │
│ │ Purpose: Internal backup, staging deploy │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────┬────────────────────────────────────┘
│ ssh + git pull
rsync (from dev Mac)
┌────────────────────────────────────────────────────────────────┐
│ PRODUCTION (NORDABIZ-01) │
PRODUCTION (OVH VPS 57.128.200.27)
│ │
│ 1. SSH to server: │
│ ssh maciejpi@10.22.68.249 │
│ 1. Deploy via rsync: │
│ rsync -avz --exclude='.env' --exclude='venv/' \ │
│ ./ maciejpi@57.128.200.27:/var/www/nordabiznes/ │
│ │
│ 2. Pull latest code:
cd /var/www/nordabiznes
sudo -u www-data git -c http.sslVerify=false pull
│ 2. Restart service:
ssh maciejpi@57.128.200.27 \
"sudo systemctl reload nordabiznes"
│ │
│ 3. Restart service: │
│ sudo systemctl restart nordabiznes │
│ │
│ 4. Verify deployment: │
│ curl -I http://localhost:5000/health │
│ 3. Verify deployment: │
│ curl -I https://nordabiznes.pl/health │
└────────────────────────────────────────────────────────────────┘
```
@ -674,14 +500,12 @@ curl -I https://nordabiznes.pl/health
**Deployment Checklist:**
1. ✅ Code tested locally
2. ✅ Git commit with descriptive message
3. ✅ Push to both remotes (Gitea + GitHub)
4. ✅ SSH to production server as `maciejpi`
5. ✅ Pull latest code as `www-data` user
6. ✅ Restart `nordabiznes` service
7. ✅ Verify health endpoint (localhost:5000)
8. ✅ Verify public endpoint (nordabiznes.pl)
9. ✅ Check logs for errors (`journalctl -u nordabiznes`)
10. ✅ Update release notes in `app.py`
3. ✅ Push to both remotes (GitHub + Gitea)
4. ✅ Rsync to OVH VPS
5. ✅ Reload `nordabiznes` service
6. ✅ Verify health endpoint (nordabiznes.pl)
7. ✅ Check logs for errors (`journalctl -u nordabiznes`)
8. ✅ Update release notes in `app.py`
---
@ -708,16 +532,12 @@ Content-Type: application/json
**Health Check Commands:**
```bash
# External check (via NPM)
# External check (via nginx)
curl -I https://nordabiznes.pl/health
# Expected: HTTP/2 200 OK
# Internal check (direct to Flask)
curl -I http://10.22.68.249:5000/health
# Expected: HTTP/1.1 200 OK
# Localhost check (from NORDABIZ-01)
curl -I http://localhost:5000/health
# Localhost check (from OVH VPS)
ssh maciejpi@57.128.200.27 "curl -I http://localhost:5000/health"
# Expected: HTTP/1.1 200 OK
```
@ -751,20 +571,16 @@ sudo -u postgres psql -c "SELECT version();"
sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity WHERE datname='nordabiz';"
```
**NPM Service:**
**Nginx Service (OVH VPS):**
```bash
# Check Docker container
docker ps | grep nginx-proxy-manager
# Should show container running
# Check nginx status
ssh maciejpi@57.128.200.27 "sudo systemctl status nginx"
# Check NPM logs
docker logs nginx-proxy-manager_app_1 --tail 50
# Check nginx logs
ssh maciejpi@57.128.200.27 "sudo tail -20 /var/log/nginx/error.log"
# Verify proxy configuration
docker exec nginx-proxy-manager_app_1 \
sqlite3 /data/database.sqlite \
"SELECT forward_port FROM proxy_host WHERE id = 27;"
# Expected: 5000
# Test nginx config
ssh maciejpi@57.128.200.27 "sudo nginx -t"
```
### Planned Monitoring (Not Yet Implemented)
@ -784,7 +600,7 @@ docker exec nginx-proxy-manager_app_1 \
### PostgreSQL Database Backups
**Current Status:** Manual backups only
**Backup Location:** `/backup/nordabiz/` (on NORDABIZ-01)
**Backup Location:** `/backup/nordabiz/` (on OVH VPS inpi-vps-waw01)
**Manual Backup Procedure:**
```bash
@ -824,83 +640,27 @@ curl -I http://localhost:5000/health
---
### VM Snapshots (Proxmox)
### OVH VPS Backups
**Snapshot Schedule:**
- **Daily:** 7-day retention
- **Weekly:** 4-week retention
- **Monthly:** 6-month retention
**Snapshot Storage:** Proxmox Backup Server
**Restore Procedure:**
1. Access Proxmox VE web interface
2. Navigate to VM (249 or 119)
3. Select "Snapshots" tab
4. Choose restore point
5. Confirm snapshot restoration
6. Start VM after restore
7. Verify services are running
**Recovery Time Objective (RTO):** ~15 minutes
**Recovery Point Objective (RPO):** ~24 hours (daily snapshots)
---
### NPM Configuration Backup
**Database Location:** `/data/database.sqlite` (inside NPM container)
**Manual Backup:**
```bash
# Backup NPM database
docker exec nginx-proxy-manager_app_1 cat /data/database.sqlite > \
/backup/npm/npm_$(date +%Y%m%d).sqlite
# Backup SSL certificates
docker exec nginx-proxy-manager_app_1 tar czf - /etc/letsencrypt > \
/backup/npm/letsencrypt_$(date +%Y%m%d).tar.gz
```
**Restore Procedure:**
```bash
# Stop NPM container
docker stop nginx-proxy-manager_app_1
# Restore database
cat /backup/npm/npm_20260110.sqlite | \
docker exec -i nginx-proxy-manager_app_1 sh -c 'cat > /data/database.sqlite'
# Restore SSL certificates
cat /backup/npm/letsencrypt_20260110.tar.gz | \
docker exec -i nginx-proxy-manager_app_1 sh -c 'tar xzf - -C /'
# Start NPM container
docker start nginx-proxy-manager_app_1
# Verify
curl -I https://nordabiznes.pl/health
```
**OVH Automated Backups:**
- OVH VPS snapshot backups (configured via OVH panel)
- Application code backed up via git (GitHub + Gitea)
- Database backed up via pg_dump + offsite copy
---
## Security Configuration
### Firewall Rules (Fortigate)
### OVH VPS Firewall
**The OVH VPS uses ufw (Uncomplicated Firewall) and/or OVH firewall:**
**Inbound Rules:**
```
Priority 1: ALLOW Public (0.0.0.0/0) → 10.22.68.250:443 (HTTPS)
Priority 2: ALLOW Public (0.0.0.0/0) → 10.22.68.250:80 (HTTP redirect)
Priority 100: DENY All other inbound traffic
```
**Outbound Rules:**
```
Priority 1: ALLOW 10.22.68.0/24 → Internet (HTTPS :443)
Priority 2: ALLOW 10.22.68.0/24 → Internet (DNS :53)
Priority 3: ALLOW 10.22.68.0/24 → Internet (NTP :123)
Priority 100: DENY All other outbound traffic
ALLOW 22/tcp (SSH - key-based auth only)
ALLOW 80/tcp (HTTP - redirects to HTTPS)
ALLOW 443/tcp (HTTPS - production traffic)
DENY all other inbound
```
### SSH Access Control
@ -912,10 +672,6 @@ Priority 100: DENY All other outbound traffic
- SSH key-based authentication (required)
- Password authentication disabled
**Firewall:**
- SSH accessible from internal network only (10.22.68.0/24)
- No public SSH access
**SSH Configuration:**
```bash
# /etc/ssh/sshd_config
@ -925,6 +681,11 @@ PubkeyAuthentication yes
AllowUsers maciejpi
```
**SSH Access:**
```bash
ssh maciejpi@57.128.200.27
```
### Database Security
**PostgreSQL Access Control:**
@ -1008,7 +769,7 @@ curl -I https://nordabiznes.pl/health
**Diagnosis:**
```bash
# 1. Check Gunicorn service
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo systemctl status nordabiznes
# If not running: sudo systemctl start nordabiznes
@ -1022,7 +783,7 @@ curl -I http://localhost:5000/health
# 4. Test from NPM server
ssh maciejpi@10.22.68.250
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Expected: HTTP/1.1 200 OK
```
@ -1161,7 +922,7 @@ docker restart nginx-proxy-manager_app_1
- [ ] All IP addresses are current
- [ ] All port numbers are correct
- [ ] NPM proxy configuration verified (port 5000!)
- [ ] Nginx proxy_pass configuration verified (127.0.0.1:5000)
- [ ] DNS records match actual configuration
- [ ] SSL certificate status checked
- [ ] Firewall rules documented accurately
@ -1192,23 +953,20 @@ docker restart nginx-proxy-manager_app_1
---
**Document Status:** ✅ Complete
**Diagram Validated:** 2026-01-10
**Production Verified:** 2026-01-10
**Diagram Validated:** 2026-04-04
**Production Verified:** 2026-04-04 (OVH VPS migration)
**Mermaid Syntax:** v10.6+
**Renders in:** GitHub, GitLab, VS Code (with Mermaid extension)
---
**⚠️ CRITICAL CONFIGURATION REMINDER:**
**Production traffic flow:**
```
Internet → Nginx (57.128.200.27:443) → Gunicorn (127.0.0.1:5000)
```
**NPM Proxy Host 27 MUST use:**
- **Forward Host:** 10.22.68.249
- **Forward Port:** 5000 (NOT 80!)
**Always verify after changes:**
**Verify production:**
```bash
curl -I https://nordabiznes.pl/health
# Expected: HTTP/2 200 OK
```
**See:** `docs/INCIDENT_REPORT_20260102.md` for details on port 80 incident

View File

@ -1058,15 +1058,15 @@ alembic upgrade head
**Production:**
```bash
# SSH to NORDABIZ-01
ssh maciejpi@10.22.68.249
# SSH to OVH VPS
ssh maciejpi@57.128.200.27
# Backup database
pg_dump nordabiz > backup_$(date +%Y%m%d).sql
# Apply migration
cd /var/www/nordabiznes
sudo -u www-data alembic upgrade head
/var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/XXX_nazwa.sql
# Verify
psql -U nordabiz_app -d nordabiz -c "\dt"

View File

@ -1,12 +1,14 @@
# Network Topology Diagram
**Document Version:** 1.0
**Last Updated:** 2026-01-10
**Last Updated:** 2026-04-04
**Status:** Production LIVE
**Diagram Type:** Network Topology / Infrastructure Network
---
> **UWAGA (2026-04-04):** Produkcja przeniesiona z OVH VPS inpi-vps-waw01 (VM 249, 57.128.200.27) na **OVH VPS (57.128.200.27, hostname inpi-vps-waw01)**. Diagramy Mermaid poniżej odzwierciedlają starą architekturę — ruch produkcyjny nie przechodzi już przez Fortigate/NPM. Staging (10.22.68.248) i NPM (10.22.68.250) bez zmian.
## Overview
This document provides a **network-centric view** of the Norda Biznes Partner infrastructure. It focuses on:
@ -37,90 +39,58 @@ graph TB
%% External network
subgraph "Public Internet (Untrusted)"
Users["👥 External Users<br/>Website Visitors"]
DNS_OVH["🌐 OVH DNS<br/>nordabiznes.pl<br/>85.237.177.83"]
DNS_OVH["🌐 OVH DNS<br/>nordabiznes.pl<br/>57.128.200.27"]
end
%% Perimeter security
subgraph "Network Perimeter"
Fortigate["🛡️ FORTIGATE FIREWALL<br/><br/>WAN: 85.237.177.83<br/>LAN: 10.22.68.1<br/><br/>NAT Rules:<br/>• 85.237.177.83:443 → 10.22.68.250:443<br/>• 85.237.177.83:80 → 10.22.68.250:80<br/><br/>Firewall Policy:<br/>• Allow HTTPS (443) from ANY<br/>• Allow HTTP (80) from ANY<br/>• Allow SSH (22) from ADMIN_NET<br/>• Default: DENY ALL"]
%% Production (OVH VPS - direct internet access)
subgraph "OVH Cloud (Warsaw) — PRODUCTION"
VPS["🖥️ OVH VPS inpi-vps-waw01<br/><br/>IP: 57.128.200.27<br/><br/>Services:<br/>• Nginx :443 (SSL termination)<br/>• Nginx :80 (redirect)<br/>• Gunicorn :5000 (localhost)<br/>• PostgreSQL :5432 (localhost)<br/>• SSH :22"]
end
%% Internal network zones
subgraph "INPI Internal Network (10.22.68.0/24)"
%% Staging (on-prem via FortiGate + NPM)
subgraph "INPI Internal Network (10.22.68.0/24) — STAGING"
Fortigate["🛡️ FORTIGATE<br/>WAN: 85.237.177.83<br/>LAN: 10.22.68.1<br/><br/>NAT for staging:<br/>• :443 → 10.22.68.250:443"]
%% DMZ Zone
subgraph "DMZ Zone (Semi-Trusted)"
NPM_Server["🖥️ R11-REVPROXY-01<br/><br/>IP: 10.22.68.250<br/>VM ID: 119<br/>Hostname: r11-revproxy-01<br/><br/>Services:<br/>• NPM (Docker) :443, :80, :81<br/>• SSH :22<br/><br/>Gateway: 10.22.68.1<br/>DNS: 10.22.68.1"]
end
NPM_Server["🖥️ R11-REVPROXY-01<br/>10.22.68.250<br/>NPM (staging proxy)"]
%% Application Zone
subgraph "Application Zone (Trusted)"
App_Server["🖥️ NORDABIZ-01<br/><br/>IP: 10.22.68.249<br/>VM ID: 249<br/>Hostname: nordabiz-01<br/>DNS: nordabiznes.inpi.local<br/><br/>Services:<br/>• Flask/Gunicorn :5000<br/>• PostgreSQL :5432 (localhost)<br/>• Nginx :80, :443 (system)<br/>• SSH :22<br/><br/>Gateway: 10.22.68.1<br/>DNS: 10.22.68.1"]
end
Staging_Server["🖥️ NORDABIZ-STAGING-01<br/>10.22.68.248<br/>Staging app + DB"]
%% Internal Services Zone
subgraph "Internal Services Zone (Trusted)"
Git_Server["🖥️ r11-git-inpi<br/><br/>IP: 10.22.68.180<br/>Hostname: r11-git-inpi<br/><br/>Services:<br/>• Gitea :3000 (HTTPS)<br/>• SSH :22<br/><br/>Gateway: 10.22.68.1"]
end
%% Internal DNS
Internal_DNS["🔍 Internal DNS<br/>Zone: inpi.local<br/><br/>Records:<br/>• nordabiznes.inpi.local → 10.22.68.249<br/>• git.inpi.local → 10.22.68.180"]
Git_Server["🖥️ r11-git-inpi<br/>10.22.68.180<br/>Gitea :3000"]
end
%% External services
subgraph "External APIs (Internet)"
API_Google["☁️ Google Cloud APIs<br/><br/>• Gemini AI API<br/>• PageSpeed Insights API<br/>• Places API<br/><br/>Auth: API Keys<br/>HTTPS only"]
API_MS["☁️ Microsoft Graph API<br/><br/>• Email sending<br/><br/>Auth: OAuth 2.0<br/>HTTPS only"]
API_Brave["☁️ Brave Search API<br/><br/>• News search<br/><br/>Auth: API Key<br/>HTTPS only"]
API_KRS["🏛️ KRS Open API<br/><br/>• Company registry<br/><br/>Auth: Public<br/>HTTPS only"]
Web_Scrapers["🌐 Web Scraping<br/><br/>• ALEO.com (NIP)<br/>• rejestr.io (Connections)<br/><br/>HTTPS only"]
API_Google["☁️ Google Cloud APIs<br/>Gemini AI, PageSpeed, Places"]
API_MS["☁️ Microsoft Graph API<br/>Email sending"]
API_Brave["☁️ Brave Search API"]
API_KRS["🏛️ KRS Open API"]
end
%% Network flows - User traffic
%% Production traffic (direct to OVH VPS)
Users -->|"DNS Query<br/>nordabiznes.pl"| DNS_OVH
DNS_OVH -->|"DNS Response<br/>85.237.177.83"| Users
Users -->|"HTTPS :443<br/>HTTP :80"| Fortigate
DNS_OVH -->|"DNS Response<br/>57.128.200.27"| Users
Users -->|"HTTPS :443"| VPS
%% Firewall to DMZ
Fortigate -->|"NAT + Route<br/>:443 → 10.22.68.250:443<br/>:80 → 10.22.68.250:80"| NPM_Server
%% Staging traffic (via FortiGate)
Fortigate -.->|"staging NAT<br/>→ NPM"| NPM_Server
NPM_Server -.->|"HTTP :5000"| Staging_Server
%% DMZ to Application Zone
NPM_Server ==>|"⚠️ CRITICAL<br/>HTTP :5000<br/>(NOT port 80!)"| App_Server
NPM_Server -.->|"Internal DNS Query<br/>nordabiznes.inpi.local"| Internal_DNS
%% Application to External APIs
App_Server -->|"HTTPS<br/>API Requests"| API_Google
App_Server -->|"HTTPS<br/>OAuth 2.0"| API_MS
App_Server -->|"HTTPS<br/>API Requests"| API_Brave
App_Server -->|"HTTPS<br/>API Requests"| API_KRS
App_Server -->|"HTTPS<br/>Web Scraping"| Web_Scrapers
%% Git deployment
App_Server -.->|"git pull<br/>HTTPS :3000<br/>Deployment only"| Git_Server
%% Admin SSH access
Fortigate -.->|"SSH :22<br/>Admin only"| NPM_Server
Fortigate -.->|"SSH :22<br/>Admin only"| App_Server
Fortigate -.->|"SSH :22<br/>Admin only"| Git_Server
%% API calls from production
VPS -->|"HTTPS"| API_Google
VPS -->|"HTTPS OAuth 2.0"| API_MS
VPS -->|"HTTPS"| API_Brave
VPS -->|"HTTPS"| API_KRS
%% Styling
classDef public fill:#f9f,stroke:#333,stroke-width:2px
classDef perimeter fill:#f96,stroke:#333,stroke-width:3px
classDef dmz fill:#ff9,stroke:#333,stroke-width:2px
classDef app fill:#9f9,stroke:#333,stroke-width:2px
classDef internal fill:#99f,stroke:#333,stroke-width:2px
classDef production fill:#9f9,stroke:#333,stroke-width:3px
classDef staging fill:#ff9,stroke:#333,stroke-width:2px
classDef external fill:#ccc,stroke:#333,stroke-width:1px
class Users,DNS_OVH public
class Fortigate perimeter
class NPM_Server dmz
class App_Server app
class Git_Server,Internal_DNS internal
class API_Google,API_MS,API_Brave,API_KRS,Web_Scrapers external
class VPS production
class Fortigate,NPM_Server,Staging_Server,Git_Server staging
class API_Google,API_MS,API_Brave,API_KRS external
```
---
@ -148,46 +118,31 @@ graph TB
---
### Zone 2: Network Perimeter (Firewall)
### Zone 2: Network Perimeter (FortiGate) — Staging Only
**Purpose:** Network security boundary, NAT, and traffic filtering
**Purpose:** Network security boundary for staging environment. Production traffic no longer goes through FortiGate.
**Components:**
- **Fortigate Firewall**
- Model: (Infrastructure-specific)
- WAN IP: 85.237.177.83
- LAN IP: 10.22.68.1 (gateway for internal network)
**Security Level:** **Perimeter** - First line of defense
**Security Level:** **Perimeter** - First line of defense for staging
**NAT Configuration:**
**NAT Configuration (staging only):**
| Public Address | Public Port | Internal Address | Internal Port | Protocol | Purpose |
|----------------|-------------|------------------|---------------|----------|---------|
| 85.237.177.83 | 443 | 10.22.68.250 | 443 | TCP | HTTPS to NPM |
| 85.237.177.83 | 443 | 10.22.68.250 | 443 | TCP | HTTPS to NPM (staging) |
| 85.237.177.83 | 80 | 10.22.68.250 | 80 | TCP | HTTP to NPM (redirect) |
**Firewall Rules:**
| Priority | Source | Destination | Port | Action | Purpose |
|----------|--------|-------------|------|--------|---------|
| 1 | ANY | 85.237.177.83 | 443 | ALLOW | Public HTTPS access |
| 2 | ANY | 85.237.177.83 | 80 | ALLOW | HTTP (redirect to HTTPS) |
| 3 | ADMIN_NET | 10.22.68.0/24 | 22 | ALLOW | SSH administration |
| 4 | 10.22.68.0/24 | ANY | 443 | ALLOW | Outbound HTTPS (APIs) |
| 5 | 10.22.68.0/24 | ANY | 80 | ALLOW | Outbound HTTP (fallback) |
| 99 | ANY | ANY | ANY | DENY | Default deny all |
**Notes:**
- Stateful firewall maintains connection state
- Return traffic automatically allowed for established connections
- No inbound SSH from public internet (admin access via internal network only)
**Note:** Production (nordabiznes.pl) DNS now points directly to OVH VPS (57.128.200.27), bypassing FortiGate entirely.
---
### Zone 3: DMZ Zone (Semi-Trusted)
### Zone 3: DMZ Zone (Semi-Trusted) — Staging Only
**Purpose:** SSL termination, reverse proxy, and public-facing services
**Purpose:** SSL termination and reverse proxy for staging environment only.
**Network:** 10.22.68.250/32 (single host)
@ -195,15 +150,16 @@ graph TB
- **R11-REVPROXY-01** (VM 119)
- IP: 10.22.68.250
- Services: Nginx Proxy Manager (Docker), SSH
- Handles: staging.nordabiznes.pl
**Security Level:** **Semi-Trusted** - Exposed to internet traffic, hardened configuration
**Security Level:** **Semi-Trusted** - Exposed to staging traffic
**Inbound Traffic:**
- From Internet (via Fortigate NAT): HTTPS :443, HTTP :80
- From ADMIN_NET: SSH :22
**Outbound Traffic:**
- To Application Zone (10.22.68.249): HTTP :5000 (Flask/Gunicorn)
- To Application Zone (57.128.200.27): HTTP :5000 (Flask/Gunicorn)
- To Internet: HTTPS (for Let's Encrypt ACME challenge, Docker image updates)
**Security Controls:**
@ -218,7 +174,7 @@ graph TB
# NPM Proxy Host Configuration (Host ID: 27)
domain_names:
- nordabiznes.pl
forward_host: 10.22.68.249
forward_host: 57.128.200.27
forward_port: 5000 # ⚠️ MUST be 5000, NOT 80!
ssl:
certificate_id: 27
@ -230,7 +186,7 @@ ssl:
**Common Misconfiguration:**
```yaml
# ❌ WRONG - Causes infinite redirect loop
forward_port: 80 # This forwards to nginx on NORDABIZ-01, which redirects to HTTPS
forward_port: 80 # This forwards to nginx on OVH VPS inpi-vps-waw01, which redirects to HTTPS
```
**Correct Configuration:**
@ -246,7 +202,7 @@ curl -I https://nordabiznes.pl/health
# Expected: HTTP/2 200 (success)
# Test internal routing (from NPM server)
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Expected: HTTP/1.1 200 (success)
```
@ -254,27 +210,26 @@ curl -I http://10.22.68.249:5000/health
---
### Zone 4: Application Zone (Trusted)
### Zone 4: Application Zone — Production (OVH VPS)
**Purpose:** Application hosting, business logic processing
**Network:** 10.22.68.249/32 (single host)
**Network:** 57.128.200.27 (OVH VPS, public IP)
**Components:**
- **NORDABIZ-01** (VM 249)
- IP: 10.22.68.249
- Internal DNS: nordabiznes.inpi.local
- Services: Flask/Gunicorn :5000, PostgreSQL :5432 (localhost), Nginx :80/443 (unused), SSH :22
- **OVH VPS** (inpi-vps-waw01)
- IP: 57.128.200.27
- Services: Nginx :443/:80, Gunicorn :5000 (localhost), PostgreSQL :5432 (localhost), SSH :22
**Security Level:** **Trusted** - Internal zone, application processing
**Security Level:** **Production** - Public-facing, secured by nginx + ufw
**Inbound Traffic:**
- From DMZ (10.22.68.250): HTTP :5000 (NPM → Gunicorn)
- From ADMIN_NET: SSH :22
- From Internet: HTTPS :443 (nginx SSL termination)
- From Internet: HTTP :80 (redirects to HTTPS)
- SSH :22 (key-based auth only)
**Outbound Traffic:**
- To External APIs: HTTPS :443 (Google, Microsoft, Brave, KRS)
- To Git Server (10.22.68.180): HTTPS :3000 (deployment)
- To Internet: HTTP/HTTPS (web scraping ALEO.com, rejestr.io)
**Localhost Services (127.0.0.1):**
@ -293,7 +248,7 @@ curl -I http://10.22.68.249:5000/health
**Network Configuration:**
```
IP Address: 10.22.68.249/24
IP Address: 57.128.200.27/24
Gateway: 10.22.68.1
DNS: 10.22.68.1
```
@ -324,7 +279,7 @@ DNS: 10.22.68.1
**Security Level:** **Trusted** - Internal services, no public exposure
**Inbound Traffic:**
- From Application Zone (10.22.68.249): HTTPS :3000 (git pull for deployment)
- From Application Zone (57.128.200.27): HTTPS :3000 (git pull for deployment)
- From ADMIN_NET: SSH :22, HTTPS :3000 (git operations)
**Outbound Traffic:**
@ -365,7 +320,7 @@ DNS: 10.22.68.1
**Network Flow:**
```
App Server (10.22.68.249)
App Server (57.128.200.27)
→ Fortigate (10.22.68.1)
→ Internet Gateway
→ External API (HTTPS :443)
@ -394,7 +349,7 @@ App Server (10.22.68.249)
|------------|----------|-------|---------|------|
| 10.22.68.1 | fortigate-lan | N/A | Default gateway | Perimeter |
| 10.22.68.180 | r11-git-inpi | N/A | Gitea server | Internal Services |
| 10.22.68.249 | nordabiz-01 | 249 | Application + DB server | Application |
| 57.128.200.27 | nordabiz-01 | 249 | Application + DB server | Application |
| 10.22.68.250 | r11-revproxy-01 | 119 | NPM reverse proxy | DMZ |
### Reserved Ranges
@ -417,15 +372,15 @@ App Server (10.22.68.249)
| HTTPS | NPM | 10.22.68.250 | 443 | TCP | Public (via NAT) | SSL termination |
| HTTP | NPM | 10.22.68.250 | 80 | TCP | Public (via NAT) | Redirect to HTTPS |
| **Application Services** |
| Flask/Gunicorn | NORDABIZ-01 | 10.22.68.249 | 5000 | TCP | Internal only | Web application |
| PostgreSQL | NORDABIZ-01 | 127.0.0.1 | 5432 | TCP | Localhost only | Database |
| Nginx (unused) | NORDABIZ-01 | 10.22.68.249 | 80 | TCP | Internal only | HTTP redirect (not used) |
| Nginx (unused) | NORDABIZ-01 | 10.22.68.249 | 443 | TCP | Internal only | SSL (not used) |
| Flask/Gunicorn | OVH VPS inpi-vps-waw01 | 57.128.200.27 | 5000 | TCP | Internal only | Web application |
| PostgreSQL | OVH VPS inpi-vps-waw01 | 127.0.0.1 | 5432 | TCP | Localhost only | Database |
| Nginx (unused) | OVH VPS inpi-vps-waw01 | 57.128.200.27 | 80 | TCP | Internal only | HTTP redirect (not used) |
| Nginx (unused) | OVH VPS inpi-vps-waw01 | 57.128.200.27 | 443 | TCP | Internal only | SSL (not used) |
| **Internal Services** |
| Gitea | r11-git-inpi | 10.22.68.180 | 3000 | TCP | Internal only | Git repository |
| NPM Admin | NPM | 10.22.68.250 | 81 | TCP | Internal only | NPM web UI |
| **Administration** |
| SSH | NORDABIZ-01 | 10.22.68.249 | 22 | TCP | Admin network | Remote admin |
| SSH | OVH VPS inpi-vps-waw01 | 57.128.200.27 | 22 | TCP | Admin network | Remote admin |
| SSH | NPM | 10.22.68.250 | 22 | TCP | Admin network | Remote admin |
| SSH | r11-git-inpi | 10.22.68.180 | 22 | TCP | Admin network | Remote admin |
@ -435,9 +390,9 @@ App Server (10.22.68.249)
| Source | Destination | Protocol | Purpose | Notes |
|--------|-------------|----------|---------|-------|
| NPM :443 | 10.22.68.249:5000 | HTTP | HTTPS requests → Flask | ✅ CORRECT |
| NPM :80 | 10.22.68.249:5000 | HTTP | HTTP requests → Flask | ✅ CORRECT |
| NPM :443 | 10.22.68.249:80 | HTTP | HTTPS requests → Nginx | ❌ WRONG - Redirect loop! |
| NPM :443 | 57.128.200.27:5000 | HTTP | HTTPS requests → Flask | ✅ CORRECT |
| NPM :80 | 57.128.200.27:5000 | HTTP | HTTP requests → Flask | ✅ CORRECT |
| NPM :443 | 57.128.200.27:80 | HTTP | HTTPS requests → Nginx | ❌ WRONG - Redirect loop! |
**Why Port 5000 is Critical:**
1. NPM terminates SSL at port 443
@ -496,7 +451,7 @@ whois nordabiznes.pl
| Record Type | Name | Value | Purpose |
|-------------|------|-------|---------|
| A | nordabiznes.inpi.local | 10.22.68.249 | Application server |
| A | nordabiznes.inpi.local | 57.128.200.27 | Application server |
| A | git.inpi.local | 10.22.68.180 | Gitea server |
| A | npm.inpi.local | 10.22.68.250 | NPM server |
@ -515,7 +470,7 @@ whois nordabiznes.pl
**Configuration:**
```bash
# /etc/resolv.conf on NORDABIZ-01
# /etc/resolv.conf on OVH VPS inpi-vps-waw01
nameserver 10.22.68.1
search inpi.local
```
@ -528,12 +483,12 @@ search inpi.local
All internal servers use **10.22.68.1** (Fortigate LAN interface) as default gateway.
### Routing Table (NORDABIZ-01 Example)
### Routing Table (OVH VPS inpi-vps-waw01 Example)
```bash
# ip route show
default via 10.22.68.1 dev ens18 # All non-local traffic → Fortigate
10.22.68.0/24 dev ens18 proto kernel scope link src 10.22.68.249 # Local subnet
10.22.68.0/24 dev ens18 proto kernel scope link src 57.128.200.27 # Local subnet
127.0.0.0/8 dev lo # Localhost
```
@ -550,7 +505,7 @@ Fortigate WAN (85.237.177.83:443)
↓ NAT translation
Fortigate LAN → NPM (10.22.68.250:443)
↓ SSL termination, proxy
NPM → Flask/Gunicorn (10.22.68.249:5000)
NPM → Flask/Gunicorn (57.128.200.27:5000)
↓ HTTP request processing
Flask → PostgreSQL (127.0.0.1:5432)
↓ SQL query
@ -569,7 +524,7 @@ Fortigate WAN → User Browser (85.237.177.83:443)
#### Example 2: Application Calls External API (Gemini AI)
```
Flask Application (10.22.68.249)
Flask Application (57.128.200.27)
↓ HTTPS :443
Default Gateway (10.22.68.1)
↓ NAT + Firewall
@ -579,7 +534,7 @@ Google Gemini API (generativelanguage.googleapis.com)
↓ API response
Internet → Fortigate WAN
↓ NAT reverse
Fortigate LAN → Flask Application (10.22.68.249)
Fortigate LAN → Flask Application (57.128.200.27)
```
**Total Hops:** 3 (internal) + variable (internet) + 2 (return) ≈ 15-25 hops
@ -588,7 +543,7 @@ Fortigate LAN → Flask Application (10.22.68.249)
#### Example 3: Git Pull for Deployment
```
Flask Application (10.22.68.249)
Flask Application (57.128.200.27)
↓ git pull over HTTPS :3000
Local routing (same subnet)
↓ Direct connection
@ -712,7 +667,7 @@ openssl s_client -connect nordabiznes.pl:443 -servername nordabiznes.pl
**Internal Health Checks:**
```bash
# From NORDABIZ-01, check Gunicorn
# From OVH VPS inpi-vps-waw01, check Gunicorn
curl -I http://127.0.0.1:5000/health
# Expected: HTTP/1.1 200 OK
@ -789,13 +744,13 @@ curl http://10.22.68.250:443
# If fails: NPM service down
# Check if Gunicorn is responding
curl http://10.22.68.249:5000/health
curl http://57.128.200.27:5000/health
# If fails: Gunicorn service down
```
**Resolution:**
1. Restart NPM: `docker restart nginx-proxy-manager` (on R11-REVPROXY-01)
2. Restart Gunicorn: `sudo systemctl restart nordabiznes` (on NORDABIZ-01)
2. Restart Gunicorn: `sudo systemctl restart nordabiznes` (on OVH VPS inpi-vps-waw01)
3. Check Fortigate firewall rules (requires admin access)
---
@ -834,7 +789,7 @@ openssl s_client -connect nordabiznes.pl:443 -servername nordabiznes.pl | openss
**Diagnosis:**
```bash
# Check network latency
ping -c 10 10.22.68.249
ping -c 10 57.128.200.27
# Expected: <2ms average
# Check database performance
@ -895,7 +850,7 @@ NPM forwarding to port 80 instead of port 5000
```bash
# Check NPM configuration (from R11-REVPROXY-01)
docker exec -it nginx-proxy-manager cat /data/nginx/proxy_host/27.conf
# Look for: proxy_pass http://10.22.68.249:XXXX
# Look for: proxy_pass http://57.128.200.27:XXXX
# XXXX should be 5000, NOT 80
```
@ -932,7 +887,7 @@ graph LR
NPM_NIC["NPM NIC<br/>10.22.68.250<br/>MAC: xx:xx:xx:xx:xx:01"]
APP_NIC["NORDABIZ-01 NIC<br/>10.22.68.249<br/>MAC: xx:xx:xx:xx:xx:02"]
APP_NIC["OVH VPS inpi-vps-waw01 NIC<br/>57.128.200.27<br/>MAC: xx:xx:xx:xx:xx:02"]
GIT_NIC["Git Server NIC<br/>10.22.68.180<br/>MAC: xx:xx:xx:xx:xx:03"]
end
@ -953,7 +908,7 @@ sequenceDiagram
participant DNS as OVH DNS
participant FW as Fortigate
participant NPM as NPM (10.22.68.250)
participant App as Flask (10.22.68.249)
participant App as Flask (57.128.200.27)
participant DB as PostgreSQL (127.0.0.1)
User->>DNS: DNS query: nordabiznes.pl
@ -964,7 +919,7 @@ sequenceDiagram
FW->>NPM: HTTPS :443 (10.22.68.250)
Note over NPM: SSL termination<br/>Decrypt HTTPS → HTTP
NPM->>App: HTTP :5000 (10.22.68.249)
NPM->>App: HTTP :5000 (57.128.200.27)
Note over App: Flask processes request
App->>DB: SQL query (localhost:5432)
@ -984,7 +939,7 @@ sequenceDiagram
participant User as User Browser
participant FW as Fortigate
participant NPM as NPM (10.22.68.250)
participant Nginx as Nginx (10.22.68.249:80)
participant Nginx as Nginx (57.128.200.27:80)
User->>FW: HTTPS :443
FW->>NPM: HTTPS :443
@ -1039,13 +994,13 @@ docker cp nginx-proxy-manager:/tmp/npm-backup.tar.gz ./npm-backup-$(date +%Y%m%d
**PostgreSQL Configuration:**
```bash
# Backup PostgreSQL config (from NORDABIZ-01)
# Backup PostgreSQL config (from OVH VPS inpi-vps-waw01)
sudo tar czf postgresql-config-backup-$(date +%Y%m%d).tar.gz /etc/postgresql/14/main/
```
**Network Configuration:**
```bash
# Backup network config (from NORDABIZ-01)
# Backup network config (from OVH VPS inpi-vps-waw01)
sudo tar czf network-config-backup-$(date +%Y%m%d).tar.gz /etc/netplan/ /etc/systemd/network/
```

View File

@ -1,12 +1,14 @@
# Critical Configurations Reference
**Document Version:** 1.0
**Last Updated:** 2026-01-10
**Status:** Production LIVE
**Last Updated:** 2026-04-04
**Status:** Production LIVE (OVH VPS)
**Diagram Type:** Configuration Reference / Operations Guide
---
> **NOTE (2026-04-04):** Production migrated from on-prem VM 249 (10.22.68.249) to OVH VPS (57.128.200.27, hostname inpi-vps-waw01). NPM proxy host 27 configuration now applies to staging only. Production uses nginx on the VPS directly. Deploy via rsync (no git on OVH VPS).
## Overview
This document provides a **comprehensive reference** of all critical configurations for the Norda Biznes Partner infrastructure. It serves as the **single source of truth** for:
@ -54,19 +56,32 @@ This document provides a **comprehensive reference** of all critical configurati
---
## NPM Reverse Proxy Configuration
## Production Reverse Proxy Configuration (OVH VPS)
### ⚠️ CRITICAL: Port 5000 Configuration
Production uses nginx on OVH VPS (57.128.200.27) as a reverse proxy to Gunicorn on 127.0.0.1:5000.
**Proxy Host ID:** 27
**Domains:** nordabiznes.pl, www.nordabiznes.pl
**Traffic flow:** Internet -> nginx (57.128.200.27:443) -> Gunicorn (127.0.0.1:5000)
> **CRITICAL WARNING:** NPM must forward to **port 5000**, NOT port 80!
>
> Forwarding to port 80 causes an infinite redirect loop (ERR_TOO_MANY_REDIRECTS).
> This was the root cause of the 2026-01-02 production incident.
>
> See: [INCIDENT_REPORT_20260102.md](../INCIDENT_REPORT_20260102.md)
**SSL:** Let's Encrypt via certbot (auto-renewal)
**Verification:**
```bash
curl -I https://nordabiznes.pl/health
# Expected: HTTP/2 200 OK
```
---
## NPM Reverse Proxy Configuration (STAGING ONLY)
> **NOTE:** This section now applies to **staging** (staging.nordabiznes.pl) only.
> Production no longer uses NPM.
### Port 5000 Configuration (Staging)
**Proxy Host ID:** 44 (staging)
> **HISTORICAL WARNING:** The 2026-01-02 production incident was caused by NPM forwarding to port 80 instead of 5000. See: [INCIDENT_REPORT_20260102.md](../INCIDENT_REPORT_20260102.md)
### Complete NPM Configuration
@ -78,7 +93,7 @@ This document provides a **comprehensive reference** of all critical configurati
"www.nordabiznes.pl"
],
"forward_scheme": "http",
"forward_host": "10.22.68.249",
"forward_host": "57.128.200.27",
"forward_port": 5000, // ⚠️ CRITICAL: Must be 5000, NOT 80!
"certificate_id": 27,
"ssl_forced": true,
@ -115,7 +130,7 @@ This document provides a **comprehensive reference** of all critical configurati
ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
sqlite3 /data/database.sqlite \
\"SELECT id, domain_names, forward_host, forward_port FROM proxy_host WHERE id = 27;\""
# Expected output: 27|["nordabiznes.pl","www.nordabiznes.pl"]|10.22.68.249|5000
# Expected output: 27|["nordabiznes.pl","www.nordabiznes.pl"]|57.128.200.27|5000
# 2. Verify website is accessible
curl -I https://nordabiznes.pl/health
@ -182,21 +197,22 @@ else:
## Port Mappings Reference
### Complete Port Matrix
### Production Port Matrix (OVH VPS 57.128.200.27)
| Server | Service | Port | Protocol | Access | Purpose |
|--------|---------|------|----------|--------|---------|
| **Fortigate (WAN)** | Public Gateway | 443 | HTTPS | Public | SSL entry point |
| Fortigate (WAN) | HTTP Redirect | 80 | HTTP | Public | Redirect to HTTPS |
| **R11-REVPROXY-01** | NPM Proxy | 443 | HTTPS | LAN | SSL termination |
| R11-REVPROXY-01 | NPM Proxy | 80 | HTTP | LAN | HTTP → HTTPS redirect |
| R11-REVPROXY-01 | NPM Admin | 81 | HTTP | LAN | Admin panel |
| R11-REVPROXY-01 | SSH | 22 | SSH | Admin | Remote management |
| **NORDABIZ-01** | **Flask/Gunicorn** | **5000** | **HTTP** | **LAN** | **Application (CRITICAL!)** |
| NORDABIZ-01 | PostgreSQL | 5432 | TCP | Localhost | Database |
| NORDABIZ-01 | Nginx (System) | 80 | HTTP | LAN | ⚠️ DO NOT USE (causes redirect loop) |
| NORDABIZ-01 | Nginx (System) | 443 | HTTPS | LAN | ⚠️ DO NOT USE |
| NORDABIZ-01 | SSH | 22 | SSH | Admin | Remote management |
| **OVH VPS** | Nginx (HTTPS) | 443 | HTTPS | Public | SSL termination + proxy |
| OVH VPS | Nginx (HTTP) | 80 | HTTP | Public | Redirect to HTTPS |
| OVH VPS | **Gunicorn/Flask** | **5000** | HTTP | **Localhost** | **Application** |
| OVH VPS | PostgreSQL | 5432 | TCP | Localhost | Database |
| OVH VPS | SSH | 22 | SSH | Public (key-only) | Remote management |
### Staging Port Matrix (on-prem, via FortiGate + NPM)
| **OVH VPS inpi-vps-waw01** | **Flask/Gunicorn** | **5000** | **HTTP** | **LAN** | **Application (CRITICAL!)** |
| OVH VPS inpi-vps-waw01 | PostgreSQL | 5432 | TCP | Localhost | Database |
| OVH VPS inpi-vps-waw01 | Nginx (System) | 80 | HTTP | LAN | ⚠️ DO NOT USE (causes redirect loop) |
| OVH VPS inpi-vps-waw01 | Nginx (System) | 443 | HTTPS | LAN | ⚠️ DO NOT USE |
| OVH VPS inpi-vps-waw01 | SSH | 22 | SSH | Admin | Remote management |
| **r11-git-inpi** | Gitea HTTPS | 3000 | HTTPS | LAN | Git repository |
| r11-git-inpi | SSH | 22 | SSH | Admin | Remote management |
@ -210,9 +226,9 @@ Public → Internal NAT Mappings:
Internal → Internal Routing:
10.22.68.250:* → 10.22.68.249:5000 (NPM → Flask) ⚠️ CRITICAL PORT
10.22.68.249:* → 127.0.0.1:5432 (Flask → PostgreSQL)
10.22.68.249:* → 10.22.68.180:3000 (Flask → Gitea)
10.22.68.250:* → 57.128.200.27:5000 (NPM → Flask) ⚠️ CRITICAL PORT
57.128.200.27:* → 127.0.0.1:5432 (Flask → PostgreSQL)
57.128.200.27:* → 10.22.68.180:3000 (Flask → Gitea)
```
### Port Usage by Zone
@ -226,7 +242,7 @@ Internal → Internal Routing:
- Port 80 (HTTP) - NPM HTTP redirect
- Port 81 (HTTP) - NPM admin panel (internal only)
**Application Zone (NORDABIZ-01):**
**Application Zone (OVH VPS inpi-vps-waw01):**
- **Port 5000 (HTTP) - Flask/Gunicorn application** ⚠️ CRITICAL
- Port 5432 (TCP) - PostgreSQL (localhost only)
- Port 80/443 (HTTP/HTTPS) - System nginx (DO NOT USE for app)
@ -426,7 +442,7 @@ FLASK_ENV=development
### Production Database
**Server:** NORDABIZ-01 (10.22.68.249)
**Server:** OVH VPS (57.128.200.27, hostname: inpi-vps-waw01)
**DBMS:** PostgreSQL 14
**Database Name:** `nordabiz`
**Port:** 5432 (localhost only)
@ -545,7 +561,7 @@ sudo -u postgres psql -U nordabiz_app nordabiz < /tmp/nordabiz_backup.sql
```bash
gunicorn \
--bind 0.0.0.0:5000 \
--bind 127.0.0.1:5000 \
--workers 4 \
--timeout 120 \
--max-requests 1000 \
@ -561,7 +577,7 @@ gunicorn \
# Recommended workers formula
workers = (2 * num_cpu_cores) + 1
# For NORDABIZ-01 (4 vCPUs)
# For OVH VPS inpi-vps-waw01 (4 vCPUs)
workers = (2 * 4) + 1 = 9 # Maximum
workers = 4 # Current (conservative)
```
@ -596,13 +612,13 @@ Requires=postgresql.service
[Service]
Type=simple
User=www-data
Group=www-data
User=maciejpi
Group=maciejpi
WorkingDirectory=/var/www/nordabiznes
Environment="PATH=/var/www/nordabiznes/venv/bin"
EnvironmentFile=/var/www/nordabiznes/.env
ExecStart=/var/www/nordabiznes/venv/bin/gunicorn \
--bind 0.0.0.0:5000 \
--bind 127.0.0.1:5000 \
--workers 4 \
--timeout 120 \
--max-requests 1000 \
@ -691,37 +707,32 @@ sudo systemctl restart nordabiznes
| **inpi** (primary) | `https://10.22.68.180:3000/maciejpi/nordabiz.git` | Internal Gitea (deployment source) |
| **origin** | `git@github.com:pienczyn/nordabiz.git` | GitHub (cloud backup) |
### Production Git Configuration
### Production Deployment (OVH VPS — no git)
**Repository Location:** `/var/www/nordabiznes/`
**User:** `www-data`
**Branch:** `master`
**Deployment method:** rsync from development machine (no git on OVH VPS)
**Git Configuration (`/var/www/nordabiznes/.git/config`):**
```ini
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
**Application Location:** `/var/www/nordabiznes/`
**User:** `maciejpi`
[remote "inpi"]
url = https://10.22.68.180:3000/maciejpi/nordabiz.git
fetch = +refs/heads/*:refs/remotes/inpi/*
### Deployment Workflow
[remote "origin"]
url = git@github.com:pienczyn/nordabiz.git
fetch = +refs/heads/*:refs/remotes/origin/*
```bash
# On development machine (Mac)
git push origin master # Push to GitHub
git push inpi master # Push to internal Gitea
[branch "master"]
remote = inpi
merge = refs/heads/master
# Deploy to production via rsync
rsync -avz --exclude='.env' --exclude='venv/' --exclude='.git/' \
./ maciejpi@57.128.200.27:/var/www/nordabiznes/
[http]
sslVerify = false # Required for self-signed Gitea cert
# Restart service
ssh maciejpi@57.128.200.27 "sudo systemctl reload nordabiznes"
# Verify
curl -I https://nordabiznes.pl/health
```
### Gitea Server Configuration
### Gitea Server Configuration (staging deploy source)
**Server:** r11-git-inpi
**IP:** 10.22.68.180
@ -730,42 +741,6 @@ sudo systemctl restart nordabiznes
**User:** `maciejpi`
**Repository:** `maciejpi/nordabiz`
### Deployment Workflow
```bash
# On development machine (Mac)
git push inpi master # Push to internal Gitea
git push origin master # Backup to GitHub (optional)
# On production server (NORDABIZ-01)
ssh maciejpi@10.22.68.249
cd /var/www/nordabiznes
sudo -u www-data git pull # Pull from Gitea
sudo systemctl restart nordabiznes # Restart application
curl -I https://nordabiznes.pl/health # Verify deployment
```
### Git Commands for Production
```bash
# Check current branch and status
sudo -u www-data git branch
sudo -u www-data git status
# Pull latest changes
sudo -u www-data git pull
# View commit history
sudo -u www-data git log --oneline -10
# Rollback to previous commit (emergency)
sudo -u www-data git reset --hard HEAD~1
# Force pull (discard local changes)
sudo -u www-data git fetch --all
sudo -u www-data git reset --hard inpi/master
```
### SSH Keys for Git Access
**Production Server:**
@ -881,16 +856,16 @@ Policy 5: Default Deny
Action: DENY
```
### Linux iptables (NORDABIZ-01)
### OVH VPS Firewall (ufw)
**Status:** Not actively used (relies on Fortigate firewall)
**Status:** Active on OVH VPS (production does not use FortiGate)
**Default Policy:**
```bash
# Check iptables status
sudo iptables -L -n -v
# Check ufw status
sudo ufw status verbose
# Expected: Mostly ACCEPT policies (Fortigate handles filtering)
# Expected rules: ALLOW 22/tcp, 80/tcp, 443/tcp
```
### PostgreSQL Access Control
@ -939,7 +914,7 @@ sudo -u postgres psql -U nordabiz_app nordabiz < \
- **Location:** Proxmox Backup Server
- **Schedule:** Weekly
- **Retention:** 4 weeks
- **VM ID:** 249 (NORDABIZ-01)
- **VM ID:** 249 (OVH VPS inpi-vps-waw01)
### Configuration Backups
@ -1043,7 +1018,7 @@ sudo chmod 600 /home/maciejpi/backups/.env.backup
4. **Apply change to production**
```bash
# SSH to production
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
# Pull changes
cd /var/www/nordabiznes
@ -1138,7 +1113,7 @@ ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
\"SELECT id, domain_names, forward_host, forward_port FROM proxy_host WHERE id = 27;\""
# Expected output:
# 27|["nordabiznes.pl","www.nordabiznes.pl"]|10.22.68.249|5000
# 27|["nordabiznes.pl","www.nordabiznes.pl"]|57.128.200.27|5000
# ^^^^
# MUST BE 5000!
@ -1193,7 +1168,7 @@ sudo journalctl -u nordabiznes -p err -n 20
| Server | IP | SSH User | Purpose |
|--------|-----|----------|---------|
| NORDABIZ-01 | 10.22.68.249 | `maciejpi` | Application server |
| OVH VPS inpi-vps-waw01 | 57.128.200.27 | `maciejpi` | Application server |
| R11-REVPROXY-01 | 10.22.68.250 | `maciejpi` | NPM proxy |
| r11-git-inpi | 10.22.68.180 | `maciejpi` | Gitea repository |
@ -1206,7 +1181,7 @@ sudo journalctl -u nordabiznes -p err -n 20
curl -I https://nordabiznes.pl/health
# 2. Check service status
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes"
# 3. Check NPM proxy configuration
ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
@ -1214,10 +1189,10 @@ ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
\"SELECT forward_port FROM proxy_host WHERE id = 27;\""
# 4. View recent errors
ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -p err -n 20"
ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes -p err -n 20"
# 5. Restart application
ssh maciejpi@10.22.68.249 "sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
```
### Documentation Resources
@ -1238,7 +1213,7 @@ ssh maciejpi@10.22.68.249 "sudo systemctl restart nordabiznes"
```
Proxy Host ID: 27
Domains: nordabiznes.pl, www.nordabiznes.pl
Backend: 10.22.68.249:5000 ⚠️ PORT 5000 (NOT 80!)
Backend: 57.128.200.27:5000 ⚠️ PORT 5000 (NOT 80!)
SSL: Let's Encrypt (auto-renew)
```
@ -1271,7 +1246,7 @@ curl -I https://nordabiznes.pl/health # Test health
### Quick Reference: Emergency Rollback
```bash
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
cd /var/www/nordabiznes
sudo systemctl stop nordabiznes
sudo -u www-data git reset --hard HEAD~1

View File

@ -1,8 +1,8 @@
# Security Architecture
**Document Version:** 1.0
**Last Updated:** 2026-01-10
**Status:** Production LIVE
**Last Updated:** 2026-04-04
**Status:** Production LIVE (OVH VPS)
**Diagram Type:** Security Architecture / Threat Model
---
@ -60,35 +60,29 @@ graph TB
Internet["🌐 Public Internet<br/><br/>Trust Level: NONE<br/>Access: Anonymous users<br/>Threat Level: HIGH<br/><br/>Threats:<br/>• DDoS attacks<br/>• SQL injection<br/>• XSS attacks<br/>• CSRF attacks<br/>• Brute force<br/>• Bot traffic"]
end
subgraph "Zone 1: Network Perimeter (SECURITY BOUNDARY)"
Fortigate["🛡️ FORTIGATE FIREWALL<br/><br/>Trust Level: BOUNDARY<br/>Controls:<br/>NAT (85.237.177.83 → 10.22.68.250)<br/>• Port filtering (443, 80, 22)<br/>• Stateful inspection<br/>• DDoS protection<br/>• Intrusion prevention<br/><br/>Default Policy: DENY ALL"]
subgraph "Zone 1: Nginx Reverse Proxy (SECURITY BOUNDARY)"
Nginx["🔒 NGINX REVERSE PROXY<br/>IP: 57.128.200.27<br/><br/>Trust Level: BOUNDARY<br/>Controls:<br/>SSL/TLS termination (Let's Encrypt)<br/>• HTTP → HTTPS redirect<br/>• Request filtering<br/>• HSTS enforcement<br/>• Security headers<br/><br/>Exposed Ports: 443, 80<br/>Proxy to: 127.0.0.1:5000"]
end
subgraph "Zone 2: DMZ - Reverse Proxy (SEMI-TRUSTED)"
DMZ["🖥️ NPM REVERSE PROXY<br/>IP: 10.22.68.250<br/><br/>Trust Level: LOW<br/>Controls:<br/>• SSL/TLS termination<br/>• HTTP → HTTPS redirect<br/>• Let's Encrypt certificates<br/>• Request filtering (block exploits)<br/>• WebSocket upgrade control<br/>• HSTS enforcement<br/><br/>Exposed Ports: 443, 80, 81 (admin)<br/>Allowed Outbound: App Zone only"]
end
subgraph "Zone 3: Application Zone (TRUSTED)"
AppZone["🖥️ APPLICATION SERVER<br/>IP: 10.22.68.249<br/><br/>Trust Level: MEDIUM<br/>Controls:<br/>• Flask-Login authentication<br/>• CSRF protection (Flask-WTF)<br/>• Rate limiting (Flask-Limiter)<br/>• Input sanitization<br/>• XSS prevention<br/>• SQL injection prevention (SQLAlchemy ORM)<br/>• Session security (secure cookies)<br/><br/>Exposed Ports: 5000 (internal), 22 (SSH)<br/>Allowed Outbound: Internet (APIs), Data Zone"]
subgraph "Zone 2: Application Zone (TRUSTED)"
AppZone["🖥️ APPLICATION SERVER<br/>OVH VPS (57.128.200.27)<br/><br/>Trust Level: MEDIUM<br/>Controls:<br/>• Flask-Login authentication<br/>• CSRF protection (Flask-WTF)<br/>• Rate limiting (Flask-Limiter)<br/>• Input sanitization<br/>• XSS prevention<br/>• SQL injection prevention (SQLAlchemy ORM)<br/>• Session security (secure cookies)<br/><br/>Gunicorn: 127.0.0.1:5000 (localhost only)<br/>SSH: 22 (key-based auth)"]
end
subgraph "Zone 4: Data Zone (HIGHLY TRUSTED)"
DataZone["🗄️ DATABASE SERVER<br/>IP: 10.22.68.249:5432<br/><br/>Trust Level: HIGH<br/>Controls:<br/>• PostgreSQL authentication<br/>• Localhost-only binding (127.0.0.1)<br/>• Role-based access control<br/>• Connection encryption (SSL/TLS)<br/>• pg_hba.conf restrictions<br/>• Database user separation<br/><br/>Exposed Ports: 5432 (localhost only)<br/>Allowed Connections: Application Zone only"]
DataZone["🗄️ DATABASE SERVER<br/>IP: 57.128.200.27:5432<br/><br/>Trust Level: HIGH<br/>Controls:<br/>• PostgreSQL authentication<br/>• Localhost-only binding (127.0.0.1)<br/>• Role-based access control<br/>• Connection encryption (SSL/TLS)<br/>• pg_hba.conf restrictions<br/>• Database user separation<br/><br/>Exposed Ports: 5432 (localhost only)<br/>Allowed Connections: Application Zone only"]
end
subgraph "Zone 5: External APIs (THIRD-PARTY)"
APIs["☁️ EXTERNAL APIs<br/><br/>Trust Level: THIRD-PARTY<br/>Services:<br/>• Google Gemini AI<br/>• Google PageSpeed Insights<br/>• Google Places API<br/>• Microsoft Graph API<br/>• Brave Search API<br/>• KRS Open API<br/><br/>Controls:<br/>• API key authentication<br/>• OAuth 2.0 (MS Graph)<br/>• HTTPS/TLS 1.2+ only<br/>• Rate limiting (client-side)<br/>• API key rotation<br/>• Cost tracking"]
end
Internet -->|"HTTPS :443<br/>HTTP :80"| Fortigate
Fortigate -->|"NAT + Filter<br/>Allow: 443, 80"| DMZ
DMZ -->|"HTTP :5000<br/>(internal network)"| AppZone
Internet -->|"HTTPS :443<br/>HTTP :80"| Nginx
Nginx -->|"HTTP :5000<br/>(localhost)"| AppZone
AppZone -->|"PostgreSQL :5432<br/>(localhost)"| DataZone
AppZone -->|"HTTPS<br/>(API requests)"| APIs
style Internet fill:#ff6b6b,color:#fff
style Fortigate fill:#f59e0b,color:#fff
style DMZ fill:#fbbf24,color:#000
style Nginx fill:#f59e0b,color:#fff
style AppZone fill:#10b981,color:#fff
style DataZone fill:#3b82f6,color:#fff
style APIs fill:#8b5cf6,color:#fff
@ -98,35 +92,31 @@ graph TB
| Boundary | Between Zones | Security Controls | Threat Mitigation |
|----------|---------------|-------------------|-------------------|
| **External → Perimeter** | Internet → Fortigate | NAT, port filtering, stateful firewall | DDoS, port scanning, unauthorized access |
| **Perimeter → DMZ** | Fortigate → NPM | Port restrictions (443, 80), SSL enforcement | Man-in-the-middle, protocol attacks |
| **DMZ → Application** | NPM → Flask | Internal network isolation, port 5000 only | Lateral movement, privilege escalation |
| **External → Proxy** | Internet → Nginx | SSL termination, request filtering, HSTS | DDoS, port scanning, unauthorized access |
| **Proxy → Application** | Nginx → Gunicorn | Localhost-only binding (127.0.0.1:5000) | Lateral movement, privilege escalation |
| **Application → Data** | Flask → PostgreSQL | Localhost-only binding, role-based access | SQL injection, unauthorized data access |
| **Application → Internet** | Flask → External APIs | HTTPS/TLS, API key authentication, rate limiting | API key theft, cost overrun, data leakage |
### 1.3 Network Segmentation
**Physical Segmentation:**
- **10.22.68.0/24** - Internal INPI network (RFC 1918 private addressing)
- **85.237.177.83** - Public IP (NAT at Fortigate)
**Production (OVH VPS):**
- **57.128.200.27** - Public IP (OVH VPS, direct internet access)
- **Nginx:** Port 443/80 (public, SSL termination)
- **Gunicorn:** 127.0.0.1:5000 (localhost only, via nginx proxy_pass)
- **PostgreSQL:** 127.0.0.1:5432 (localhost only)
**Logical Segmentation:**
- **DMZ:** 10.22.68.250 (reverse proxy only)
- **Application:** 10.22.68.249 (Flask app, no direct Internet access for incoming)
- **Data:** 10.22.68.249:5432 (localhost binding, no network exposure)
**Staging (on-prem):**
- **10.22.68.0/24** - Internal INPI network
- **85.237.177.83** - Public IP (NAT at FortiGate for staging)
- FortiGate + NPM (10.22.68.250) for staging.nordabiznes.pl
**Firewall Rules (Fortigate):**
**OVH VPS Firewall (ufw):**
```
# Inbound (WAN → LAN)
allow tcp/443 from ANY to 10.22.68.250 # HTTPS to NPM
allow tcp/80 from ANY to 10.22.68.250 # HTTP to NPM (redirects to HTTPS)
allow tcp/22 from ADMIN_NET to 10.22.68.249 # SSH (admin only)
deny all from ANY to ANY # Default deny
# Outbound (LAN → WAN)
allow tcp/443 from 10.22.68.249 to ANY # API calls (HTTPS)
allow tcp/80 from 10.22.68.249 to ANY # HTTP (rare, redirects)
deny all from 10.22.68.250 to ANY # NPM cannot initiate outbound (except Let's Encrypt)
# Production firewall rules
allow tcp/443 from ANY # HTTPS
allow tcp/80 from ANY # HTTP (redirect to HTTPS)
allow tcp/22 from ANY # SSH (key-based auth only)
deny all other inbound
```
### 1.4 Attack Surface
@ -146,8 +136,8 @@ deny all from 10.22.68.250 to ANY # NPM cannot initiate outbound (except L
**Reduced Attack Surface:**
- PostgreSQL: Localhost-only binding (no network exposure)
- SSH: Restricted to admin network (firewall rule)
- NPM Admin UI: Port 81 (internal network only)
- Gunicorn: Localhost-only binding (127.0.0.1:5000, not exposed to internet)
- SSH: Key-based authentication only (password auth disabled)
---
@ -670,7 +660,7 @@ def ratelimit_handler(e):
#### 4.4.1 Security Headers
**HTTP Security Headers (Configured in NPM):**
**HTTP Security Headers (Configured in nginx):**
```
# HSTS (HTTP Strict Transport Security)
@ -693,7 +683,7 @@ Referrer-Policy: strict-origin-when-cross-origin
```
**Current Status:**
- ✅ HSTS enabled (NPM configuration)
- ✅ HSTS enabled (nginx configuration)
- ✅ X-Frame-Options: SAMEORIGIN
- ✅ X-Content-Type-Options: nosniff
- ❌ Content-Security-Policy (not yet implemented - requires frontend refactoring)
@ -704,7 +694,7 @@ Referrer-Policy: strict-origin-when-cross-origin
- **Certificate Provider:** Let's Encrypt (free, auto-renewal)
- **Certificate Type:** RSA 2048-bit
- **TLS Protocols:** TLS 1.2, TLS 1.3 only
- **HTTP → HTTPS Redirect:** Enforced at NPM
- **HTTP → HTTPS Redirect:** Enforced at nginx
- **HSTS:** Enabled (max-age=31536000)
**Cipher Suites (Modern):**
@ -716,11 +706,11 @@ ECDHE-RSA-AES128-GCM-SHA256
ECDHE-RSA-AES256-GCM-SHA384
```
**SSL Termination:** At NPM reverse proxy (10.22.68.250)
**Internal Communication:** HTTP (10.22.68.250 → 10.22.68.249:5000)
**SSL Termination:** At nginx on OVH VPS (57.128.200.27)
**Internal Communication:** HTTP (nginx → 127.0.0.1:5000)
**Certificate Renewal:**
- Automatic via NPM + Let's Encrypt
- Automatic via certbot + Let's Encrypt (on OVH VPS)
- Renewal frequency: Every 90 days
- Grace period: 30 days before expiry
@ -851,16 +841,17 @@ SMTP_PASSWORD=<password>
#### 5.2.1 External Attack Surface (Internet-facing)
**NPM Reverse Proxy (10.22.68.250:443, :80):**
**Nginx Reverse Proxy (57.128.200.27:443, :80):**
- **Exposed Services:** HTTPS (443), HTTP (80, redirects to HTTPS)
- **Attack Vectors:**
- DDoS attacks (mitigated by Fortigate)
- DDoS attacks (mitigated by OVH DDoS protection)
- SSL/TLS vulnerabilities (mitigated by modern cipher suites)
- HTTP request smuggling (mitigated by NPM validation)
- HTTP request smuggling (mitigated by nginx validation)
- **Mitigation:**
- Fortigate stateful firewall + DDoS protection
- OVH network-level DDoS protection
- Let's Encrypt TLS 1.2/1.3 only
- NPM request filtering ("block exploits" enabled)
- ufw firewall on VPS
- nginx request filtering
**Public Endpoints:**
- `/` (Company directory)
@ -909,7 +900,7 @@ SMTP_PASSWORD=<password>
#### 5.2.4 Database Attack Surface
**PostgreSQL (10.22.68.249:5432):**
**PostgreSQL (57.128.200.27:5432):**
- **Binding:** Localhost only (127.0.0.1)
- **Authentication:** Password-based (pg_hba.conf)
- **Encryption:** SSL/TLS for connections (planned)
@ -1249,12 +1240,12 @@ def send_chat_message(id):
# Inbound rules
allow tcp/443 from ANY to 10.22.68.250 # HTTPS to NPM
allow tcp/80 from ANY to 10.22.68.250 # HTTP to NPM
allow tcp/22 from ADMIN_NET to 10.22.68.249 # SSH (admin only)
allow tcp/22 from ADMIN_NET to 57.128.200.27 # SSH (admin only)
deny all from ANY to ANY # Default deny
# Outbound rules
allow tcp/443 from 10.22.68.249 to ANY # API calls (HTTPS)
allow tcp/80 from 10.22.68.249 to ANY # HTTP (rare)
allow tcp/443 from 57.128.200.27 to ANY # API calls (HTTPS)
allow tcp/80 from 57.128.200.27 to ANY # HTTP (rare)
deny all from 10.22.68.250 to ANY # NPM cannot initiate outbound
```
@ -1267,7 +1258,7 @@ deny all from 10.22.68.250 to ANY # NPM cannot initiate outbound
**Network Segmentation:**
- **DMZ Zone:** 10.22.68.250 (NPM only)
- **Application Zone:** 10.22.68.249 (Flask + PostgreSQL)
- **Application Zone:** 57.128.200.27 (Flask + PostgreSQL)
- **Internal Services:** 10.22.68.180 (Git server)
**Network Security Best Practices:**
@ -1558,8 +1549,8 @@ ssh maciejpi@10.22.68.250
docker ps | grep npm
# 4. Check network connectivity
ping 10.22.68.249
curl http://10.22.68.249:5000/health
ping 57.128.200.27
curl http://57.128.200.27:5000/health
```
**Recovery:**
@ -1590,7 +1581,7 @@ docker restart <npm-container-id>
**Lessons Learned:**
- Document critical configurations (port mappings) in architecture docs
- Add verification steps after NPM configuration changes
- Add verification steps after nginx configuration changes
- Implement monitoring to detect redirect loops
---

View File

@ -1,7 +1,7 @@
# 11. Troubleshooting Guide
**Document Type:** Operations Guide
**Last Updated:** 2026-01-10
**Last Updated:** 2026-04-04
**Maintainer:** DevOps Team
---
@ -80,7 +80,7 @@ graph TD
- Browser error: `ERR_TOO_MANY_REDIRECTS`
- Portal completely inaccessible via https://nordabiznes.pl
- Internal access works fine (http://10.22.68.249:5000)
- Internal access works fine (http://57.128.200.27:5000)
- Affects 100% of external users
#### Root Cause
@ -103,15 +103,15 @@ docker exec nginx-proxy-manager_app_1 \
"SELECT id, domain_names, forward_host, forward_port FROM proxy_host WHERE id = 27;"
# Expected output:
# 27|["nordabiznes.pl","www.nordabiznes.pl"]|10.22.68.249|5000
# 27|["nordabiznes.pl","www.nordabiznes.pl"]|57.128.200.27|5000
# If forward_port shows 80 → PROBLEM FOUND!
# 2. Test backend directly
curl -I http://10.22.68.249:80/
curl -I http://57.128.200.27:80/
# If this returns 301 redirect → confirms issue
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Should return 200 OK if Flask is running
```
@ -125,7 +125,7 @@ open http://10.22.68.250:81
# 2. Navigate to: Proxy Hosts → nordabiznes.pl (ID 27)
# 3. Edit configuration:
# - Forward Hostname/IP: 10.22.68.249
# - Forward Hostname/IP: 57.128.200.27
# - Forward Port: 5000 (CRITICAL!)
# - Scheme: http
# 4. Save and test
@ -142,7 +142,7 @@ NPM_URL = "http://10.22.68.250:81/api"
data = {
"domain_names": ["nordabiznes.pl", "www.nordabiznes.pl"],
"forward_scheme": "http",
"forward_host": "10.22.68.249",
"forward_host": "57.128.200.27",
"forward_port": 5000, # CRITICAL: Must be 5000!
"certificate_id": 27,
"ssl_forced": True,
@ -191,14 +191,14 @@ docker logs nginx-proxy-manager_app_1 --tail 20
#### Root Causes
1. Flask/Gunicorn service stopped
2. Backend server (10.22.68.249) unreachable
2. Backend server (57.128.200.27) unreachable
3. Firewall blocking port 5000
#### Diagnosis
```bash
# 1. Check Flask service status
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo systemctl status nordabiznes
# 2. Check if port 5000 is listening
@ -250,10 +250,10 @@ sudo -u www-data /var/www/nordabiznes/venv/bin/python3 app.py
```bash
# Test connectivity from NPM to backend
ssh maciejpi@10.22.68.250
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Check firewall rules
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo iptables -L -n | grep 5000
```
@ -287,7 +287,7 @@ curl -I https://nordabiznes.pl/health
```bash
# 1. Check Gunicorn worker status
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
ps aux | grep gunicorn
# Look for zombie workers or high CPU usage
@ -440,7 +440,7 @@ nslookup nordabiznes.pl 8.8.8.8
# 2. Check internal DNS (inpi.local)
nslookup nordabiznes.inpi.local 10.22.68.1
# Should return: 10.22.68.249
# Should return: 57.128.200.27
# 3. Test from different locations
curl -I -H "Host: nordabiznes.pl" http://85.237.177.83/health
@ -486,7 +486,7 @@ curl -I -H "Host: nordabiznes.pl" http://85.237.177.83/health
```bash
# 1. Check service status
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo systemctl status nordabiznes
# 2. Check recent logs
@ -609,7 +609,7 @@ curl http://localhost:5000/health
# Look for JavaScript errors
# 2. Check Flask logs
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo journalctl -u nordabiznes -n 50 --no-pager | grep ERROR
# 3. Check template rendering
@ -691,7 +691,7 @@ sudo -u www-data psql -h localhost -U nordabiz_app -d nordabiz -c "SELECT 1;"
curl "https://nordabiznes.pl/search?q=test" -v
# 2. Check search_service.py logs
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo journalctl -u nordabiznes -n 100 | grep -i search
# 3. Test database FTS
@ -786,7 +786,7 @@ Quick check:
```bash
# 1. Verify Gemini API key
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo -u www-data cat /var/www/nordabiznes/.env | grep GEMINI_API_KEY
# Should not be empty
@ -818,7 +818,7 @@ curl -H "x-goog-api-key: YOUR_API_KEY" \
```bash
# 1. Check PostgreSQL service
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo systemctl status postgresql
# 2. Check PostgreSQL is listening
@ -986,7 +986,7 @@ SELECT * FROM companies WHERE name ILIKE '%test%' LIMIT 10;
```bash
# 1. Check disk usage
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
df -h
# Check /var/lib/postgresql usage
@ -1140,7 +1140,7 @@ sudo -u www-data psql -h localhost -U nordabiz_app -d nordabiz
```bash
# 1. Check API usage in database
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo -u www-data psql -h localhost -U nordabiz_app -d nordabiz
-- Gemini API usage today
@ -1248,7 +1248,7 @@ curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-pre
-d '{"contents":[{"parts":[{"text":"Hello, test"}]}]}'
# 2. Check Flask logs for Gemini errors
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo journalctl -u nordabiznes -n 100 | grep -i gemini
# 3. Check conversation ownership
@ -1368,7 +1368,7 @@ PAGESPEED_KEY=$(sudo -u www-data grep GOOGLE_PAGESPEED_API_KEY /var/www/nordabiz
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://nordabiznes.pl&key=$PAGESPEED_KEY"
# 2. Check audit logs
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo journalctl -u nordabiznes -n 100 | grep -i pagespeed
# 3. Check recent audits
@ -1495,7 +1495,7 @@ sudo systemctl restart nordabiznes
```bash
# 1. Check user exists and is active
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo -u www-data psql -h localhost -U nordabiz_app -d nordabiz
SELECT id, email, is_active, email_verified, failed_login_attempts
@ -1777,7 +1777,7 @@ WHERE email = 'user@example.com';
time curl -I https://nordabiznes.pl/
# 2. Check Gunicorn worker status
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
ps aux | grep gunicorn
# Look for: worker processes (should be 4-8)
@ -1917,7 +1917,7 @@ done
```bash
# 1. Check memory usage
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
free -h
# 2. Check which process using memory
@ -2006,7 +2006,7 @@ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```bash
# 1. Check CPU usage
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
top -n 1
# Look for processes using >80% CPU
@ -2093,7 +2093,7 @@ docker ps | grep nginx-proxy-manager
# Should show: Up X hours
# Flask service health
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo systemctl status nordabiznes
# Should show: active (running)
```
@ -2172,7 +2172,7 @@ ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```bash
# Check last backup
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
ls -lah /backup/nordabiz/ | head -10
# Expected: Daily backups (.sql files)
@ -2204,7 +2204,7 @@ curl -I https://nordabiznes.pl/health
# If fails, proceed
# 2. Check from internal network
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
curl -I http://localhost:5000/health
# If this works → Network/NPM issue
# If this fails → Application issue
@ -2232,7 +2232,7 @@ docker exec nginx-proxy-manager_app_1 \
sqlite3 /data/database.sqlite \
"SELECT id, forward_host, forward_port FROM proxy_host WHERE id = 27;"
# Must show: 27|10.22.68.249|5000
# Must show: 27|57.128.200.27|5000
# 3. Check Fortigate NAT
# Access Fortigate admin panel
@ -2243,7 +2243,7 @@ docker exec nginx-proxy-manager_app_1 \
```bash
# 1. Check Flask service
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
sudo systemctl status nordabiznes
# If failed, check logs
@ -2361,7 +2361,7 @@ pg_restore -t companies -d nordabiz /backup/nordabiz/latest.sql
```bash
# 1. ISOLATE the server
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
# Block all incoming traffic except your IP
sudo iptables -A INPUT -s YOUR_IP -j ACCEPT
@ -2447,11 +2447,11 @@ sudo systemctl restart nordabiznes
echo "=== Application Health ===" && \
curl -I https://nordabiznes.pl/health && \
echo -e "\n=== Service Status ===" && \
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes --no-pager | head -5" && \
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes --no-pager | head -5" && \
echo -e "\n=== Database Connection ===" && \
ssh maciejpi@10.22.68.249 "sudo -u www-data psql -h localhost -U nordabiz_app -d nordabiz -c 'SELECT count(*) FROM companies;'" && \
ssh maciejpi@57.128.200.27 "sudo -u www-data psql -h localhost -U nordabiz_app -d nordabiz -c 'SELECT count(*) FROM companies;'" && \
echo -e "\n=== Server Load ===" && \
ssh maciejpi@10.22.68.249 "uptime"
ssh maciejpi@57.128.200.27 "uptime"
```
### 10.2 NPM Proxy Diagnostics
@ -2469,7 +2469,7 @@ ssh maciejpi@10.22.68.250 "docker logs nginx-proxy-manager_app_1 --tail 20 -f"
ssh maciejpi@10.22.68.250 "docker ps | grep nginx-proxy-manager"
# Test backend from NPM server
ssh maciejpi@10.22.68.250 "curl -I http://10.22.68.249:5000/health"
ssh maciejpi@10.22.68.250 "curl -I http://57.128.200.27:5000/health"
```
### 10.3 Database Diagnostics
@ -2508,10 +2508,10 @@ for i in {1..10}; do
done
# Server resource usage
ssh maciejpi@10.22.68.249 "top -b -n 1 | head -20"
ssh maciejpi@57.128.200.27 "top -b -n 1 | head -20"
# Disk usage
ssh maciejpi@10.22.68.249 "df -h && echo -e '\n=== Top 10 Directories ===\n' && du -sh /* 2>/dev/null | sort -rh | head -10"
ssh maciejpi@57.128.200.27 "df -h && echo -e '\n=== Top 10 Directories ===\n' && du -sh /* 2>/dev/null | sort -rh | head -10"
# Network connectivity
ping -c 5 nordabiznes.pl
@ -2525,7 +2525,7 @@ echo | openssl s_client -servername nordabiznes.pl -connect nordabiznes.pl:443 2
```bash
# Test all external APIs
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
# Gemini API
GEMINI_KEY=$(sudo -u www-data grep GEMINI_API_KEY .env | cut -d= -f2)
@ -2549,16 +2549,16 @@ curl -s "https://api-krs.ms.gov.pl/api/krs/OdpisAktualny/0000878913" | jq '.odpi
```bash
# Check current deployment version
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && git log --oneline -5"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && git log --oneline -5"
# Check for uncommitted changes
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && git status"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && git status"
# Check remote sync
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && git remote -v && git fetch && git status"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && git remote -v && git fetch && git status"
# Verify file permissions
ssh maciejpi@10.22.68.249 "ls -la /var/www/nordabiznes/ | head -10"
ssh maciejpi@57.128.200.27 "ls -la /var/www/nordabiznes/ | head -10"
```
---

View File

@ -157,12 +157,12 @@ graph TB
NPM[NPM Reverse Proxy<br/>:443 SSL/TLS<br/>⚠️ Forwards to :5000]
end
subgraph "Application Zone - 10.22.68.249"
subgraph "Application Zone - 57.128.200.27"
Flask[Flask/Gunicorn<br/>:5000 HTTP<br/>90+ routes, 7 services]
Scripts[Background Scripts<br/>SEO, Social, News]
end
subgraph "Data Zone - 10.22.68.249"
subgraph "Data Zone - 57.128.200.27"
PostgreSQL[(PostgreSQL<br/>:5432<br/>36 tables, 11 domains)]
end
@ -252,7 +252,7 @@ graph TB
> ⚠️ **Database Access**
>
> PostgreSQL only accepts connections from **localhost (127.0.0.1)** for security.
> All scripts must connect via localhost, not the external IP 10.22.68.249.
> All scripts must connect via localhost, not the external IP 57.128.200.27.
>
> **See:** [Critical Configurations](08-critical-configurations.md#database-configuration)
@ -331,8 +331,8 @@ erDiagram
```mermaid
graph LR
Internet((Internet)) -->|HTTPS| NPM[NPM Proxy<br/>10.22.68.250:443]
NPM -->|Port 5000| Flask[Flask App<br/>10.22.68.249:5000]
Flask -->|Port 5432| DB[(PostgreSQL<br/>10.22.68.249:5432)]
NPM -->|Port 5000| Flask[Flask App<br/>57.128.200.27:5000]
Flask -->|Port 5432| DB[(PostgreSQL<br/>57.128.200.27:5432)]
```
#### How to View and Edit Diagrams
@ -390,7 +390,7 @@ Create an HTML file with Mermaid script:
**2. Naming Conventions**
- Use descriptive node labels: `[Flask App]` not `[App]`
- Include IPs/ports in infrastructure diagrams: `[Server<br/>10.22.68.249:5000]`
- Include IPs/ports in infrastructure diagrams: `[Server<br/>57.128.200.27:5000]`
- Use consistent colors/styling across related diagrams
**3. Comments and Documentation**
@ -443,8 +443,8 @@ graph LR
```mermaid
%% Use <br/> for line breaks in labels
graph TD
A[Flask App<br/>10.22.68.249<br/>Port 5000]
B[PostgreSQL<br/>10.22.68.249<br/>Port 5432]
A[Flask App<br/>57.128.200.27<br/>Port 5000]
B[PostgreSQL<br/>57.128.200.27<br/>Port 5432]
A --> B
```
@ -586,14 +586,14 @@ sudo journalctl -u nordabiznes -f
sudo systemctl status postgresql
# Deploy updates (after git push)
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
```
### Key File Locations
- **Application:** `/var/www/nordabiznes/`
- **Environment:** `/var/www/nordabiznes/.env`
- **Logs:** `/var/log/nordabiznes/` (check systemd journal)
- **Database:** PostgreSQL on 10.22.68.249:5432
- **Database:** PostgreSQL on 57.128.200.27:5432
- **Backups:** Proxmox Backup Server (VM snapshots)
## Quick Links

View File

@ -865,7 +865,7 @@ PageSpeed API quota remaining: 24,950
```bash
# Connect to server
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
# Navigate to application directory
cd /var/www/nordabiznes
@ -895,7 +895,7 @@ Scripts in `scripts/` must use **localhost (127.0.0.1)** for PostgreSQL:
DATABASE_URL = 'postgresql://nordabiz_app:NordaBiz2025Secure@127.0.0.1:5432/nordabiz'
# WRONG (PostgreSQL doesn't accept external connections):
DATABASE_URL = 'postgresql://nordabiz_app:NordaBiz2025Secure@10.22.68.249:5432/nordabiz'
DATABASE_URL = 'postgresql://nordabiz_app:NordaBiz2025Secure@57.128.200.27:5432/nordabiz'
```
### 7.5 Cron Job (Automated Audits)

View File

@ -20,7 +20,7 @@ This document describes the **complete HTTP request flow** for the Norda Biznes
**Key Infrastructure:**
- **Public Entry:** 85.237.177.83:443 (Fortigate NAT)
- **Reverse Proxy:** NPM on 10.22.68.250:443 (SSL termination)
- **Backend Application:** Flask/Gunicorn on 10.22.68.249:5000
- **Backend Application:** Flask/Gunicorn on 57.128.200.27:5000
- **Protocol Flow:** HTTPS → NPM → HTTP → Flask → HTTP → NPM → HTTPS
**⚠️ CRITICAL CONFIGURATION:**
@ -47,7 +47,7 @@ sequenceDiagram
participant Browser
participant Fortigate as 🛡️ Fortigate Firewall<br/>85.237.177.83
participant NPM as 🔒 NPM Reverse Proxy<br/>10.22.68.250:443
participant Flask as 🌐 Flask/Gunicorn<br/>10.22.68.249:5000
participant Flask as 🌐 Flask/Gunicorn<br/>57.128.200.27:5000
participant DB as 💾 PostgreSQL<br/>localhost:5432
Note over User,DB: SUCCESSFUL REQUEST FLOW
@ -87,8 +87,8 @@ sequenceDiagram
actor User
participant Browser
participant NPM as 🔒 NPM Reverse Proxy<br/>10.22.68.250:443
participant NginxSys as ⚠️ Nginx System<br/>10.22.68.249:80
participant Flask as 🌐 Flask/Gunicorn<br/>10.22.68.249:5000
participant NginxSys as ⚠️ Nginx System<br/>57.128.200.27:80
participant Flask as 🌐 Flask/Gunicorn<br/>57.128.200.27:5000
Note over User,Flask: FAILED REQUEST FLOW (REDIRECT LOOP)
@ -201,7 +201,7 @@ Firewall: ALLOW from any to 85.237.177.83:443 (state: NEW,ESTABLISHED)
-- Result:
domain_names: ["nordabiznes.pl", "www.nordabiznes.pl"]
forward_scheme: "http"
forward_host: "10.22.68.249"
forward_host: "57.128.200.27"
forward_port: 5000 ← CRITICAL!
ssl_forced: true
certificate_id: 27
@ -209,8 +209,8 @@ Firewall: ALLOW from any to 85.237.177.83:443 (state: NEW,ESTABLISHED)
5. **⚠️ CRITICAL ROUTING DECISION:**
```
✓ CORRECT: Forward to http://10.22.68.249:5000
❌ WRONG: Forward to http://10.22.68.249:80 (causes redirect loop!)
✓ CORRECT: Forward to http://57.128.200.27:5000
❌ WRONG: Forward to http://57.128.200.27:80 (causes redirect loop!)
```
6. **Forward to Backend (HTTP, unencrypted):**
@ -230,7 +230,7 @@ Firewall: ALLOW from any to 85.237.177.83:443 (state: NEW,ESTABLISHED)
|-----------|-------|-------|
| Domain Names | nordabiznes.pl, www.nordabiznes.pl | Primary + www alias |
| Forward Scheme | http | NPM→Backend uses HTTP (secure internal network) |
| Forward Host | 10.22.68.249 | NORDABIZ-01 backend server |
| Forward Host | 57.128.200.27 | NORDABIZ-01 backend server |
| **Forward Port** | **5000** | **Flask/Gunicorn port (CRITICAL!)** |
| SSL Certificate | 27 (Let's Encrypt) | Auto-renewal enabled |
| SSL Forced | Yes | Redirect HTTP→HTTPS |
@ -247,7 +247,7 @@ ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
\"SELECT id, domain_names, forward_host, forward_port FROM proxy_host WHERE id = 27;\""
# Expected output:
# 27|["nordabiznes.pl","www.nordabiznes.pl"]|10.22.68.249|5000
# 27|["nordabiznes.pl","www.nordabiznes.pl"]|57.128.200.27|5000
```
---
@ -255,7 +255,7 @@ ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
### 2.3 Layer 3: Flask/Gunicorn Application (Request Processing)
**Server:** NORDABIZ-01 (VM 249)
**IP:** 10.22.68.249
**IP:** 57.128.200.27
**Port:** 5000
**Technology:** Gunicorn 20.1.0 + Flask 3.0
@ -398,11 +398,11 @@ ExecStart=/var/www/nordabiznes/venv/bin/gunicorn \
**Verification Command:**
```bash
# Test Flask directly (from server)
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Expected: HTTP/1.1 200 OK
# Check Gunicorn workers
ssh maciejpi@10.22.68.249 "ps aux | grep gunicorn"
ssh maciejpi@57.128.200.27 "ps aux | grep gunicorn"
# Expected: 1 master + 4 worker processes
```
@ -467,10 +467,10 @@ engine = create_engine(DATABASE_URL,
**Verification:**
```bash
# Check PostgreSQL status
ssh maciejpi@10.22.68.249 "sudo systemctl status postgresql"
ssh maciejpi@57.128.200.27 "sudo systemctl status postgresql"
# Test connection (from server)
ssh maciejpi@10.22.68.249 "psql -U nordabiz_app -h 127.0.0.1 -d nordabiz -c 'SELECT COUNT(*) FROM companies;'"
ssh maciejpi@57.128.200.27 "psql -U nordabiz_app -h 127.0.0.1 -d nordabiz -c 'SELECT COUNT(*) FROM companies;'"
# Expected: 80
```
@ -482,7 +482,7 @@ ssh maciejpi@10.22.68.249 "psql -U nordabiz_app -h 127.0.0.1 -d nordabiz -c 'SEL
```mermaid
sequenceDiagram
participant Flask as 🌐 Flask/Gunicorn<br/>10.22.68.249:5000
participant Flask as 🌐 Flask/Gunicorn<br/>57.128.200.27:5000
participant NPM as 🔒 NPM Reverse Proxy<br/>10.22.68.250:443
participant Fortigate as 🛡️ Fortigate Firewall<br/>85.237.177.83
participant Browser
@ -573,14 +573,14 @@ x-request-id: abc123def456
│ Certificate: Let's Encrypt (nordabiznes.pl) │
│ │
│ ⚠️ CRITICAL ROUTING DECISION: │
│ ✓ Forward to: http://10.22.68.249:5000 (CORRECT) │
│ ❌ DO NOT use: http://10.22.68.249:80 (WRONG!) │
│ ✓ Forward to: http://57.128.200.27:5000 (CORRECT) │
│ ❌ DO NOT use: http://57.128.200.27:80 (WRONG!) │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP (Port 5000) ✓
┌─────────────────────────────────────────────────────────────────┐
│ FLASK/GUNICORN (NORDABIZ-01) │
│ IP: 10.22.68.249:5000 │
│ IP: 57.128.200.27:5000 │
│ Binding: 0.0.0.0:5000 │
│ Workers: 4 (Gunicorn) │
│ Function: Application logic, template rendering │
@ -858,10 +858,10 @@ ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
# If shows: 80 ← PROBLEM!
# 2. Test direct backend access
curl -I http://10.22.68.249:80/
curl -I http://57.128.200.27:80/
# If returns: HTTP 301 → Problem confirmed
curl -I http://10.22.68.249:5000/
curl -I http://57.128.200.27:5000/
# Should return: HTTP 200 OK
```
@ -893,25 +893,25 @@ curl -I https://nordabiznes.pl/health
**Diagnosis:**
```bash
# 1. Check if Gunicorn is running
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes"
# Expected: Active (running)
# 2. Check if port 5000 is listening
ssh maciejpi@10.22.68.249 "sudo netstat -tlnp | grep 5000"
ssh maciejpi@57.128.200.27 "sudo netstat -tlnp | grep 5000"
# Expected: 0.0.0.0:5000 ... gunicorn
# 3. Test direct connection
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Expected: HTTP 200 OK
```
**Solution:**
```bash
# Restart Gunicorn
ssh maciejpi@10.22.68.249 "sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
# Check logs
ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -n 50"
ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes -n 50"
```
---
@ -930,14 +930,14 @@ ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -n 50"
**Diagnosis:**
```bash
# 1. Check Gunicorn worker status
ssh maciejpi@10.22.68.249 "ps aux | grep gunicorn"
ssh maciejpi@57.128.200.27 "ps aux | grep gunicorn"
# Look for workers in state 'R' (running) vs 'S' (sleeping)
# 2. Check application logs
ssh maciejpi@10.22.68.249 "tail -f /var/log/nordabiznes/error.log"
ssh maciejpi@57.128.200.27 "tail -f /var/log/nordabiznes/error.log"
# 3. Check database connections
ssh maciejpi@10.22.68.249 "sudo -u postgres psql -c \
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -c \
\"SELECT count(*) FROM pg_stat_activity WHERE datname='nordabiz';\""
```
@ -985,7 +985,7 @@ curl -I https://nordabiznes.pl/health
# Expected: HTTP/2 200 OK
# Internal access test (from INPI network)
curl -I http://10.22.68.249:5000/health
curl -I http://57.128.200.27:5000/health
# Expected: HTTP/1.1 200 OK
```
@ -1001,36 +1001,36 @@ ssh maciejpi@10.22.68.250 "docker exec nginx-proxy-manager_app_1 \
**Application Status:**
```bash
# Service status
ssh maciejpi@10.22.68.249 "sudo systemctl status nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl status nordabiznes"
# Worker processes
ssh maciejpi@10.22.68.249 "ps aux | grep gunicorn | grep -v grep"
ssh maciejpi@57.128.200.27 "ps aux | grep gunicorn | grep -v grep"
# Recent logs
ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -n 20 --no-pager"
ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes -n 20 --no-pager"
```
**Database Status:**
```bash
# PostgreSQL status
ssh maciejpi@10.22.68.249 "sudo systemctl status postgresql"
ssh maciejpi@57.128.200.27 "sudo systemctl status postgresql"
# Connection count
ssh maciejpi@10.22.68.249 "sudo -u postgres psql -c \
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -c \
\"SELECT count(*) FROM pg_stat_activity WHERE datname='nordabiz';\""
# Database size
ssh maciejpi@10.22.68.249 "sudo -u postgres psql -c \
ssh maciejpi@57.128.200.27 "sudo -u postgres psql -c \
\"SELECT pg_size_pretty(pg_database_size('nordabiz'));\""
```
**Network Connectivity:**
```bash
# Test NPM → Flask connectivity
ssh maciejpi@10.22.68.250 "curl -I http://10.22.68.249:5000/health"
ssh maciejpi@10.22.68.250 "curl -I http://57.128.200.27:5000/health"
# Test Flask → Database connectivity
ssh maciejpi@10.22.68.249 "psql -U nordabiz_app -h 127.0.0.1 \
ssh maciejpi@57.128.200.27 "psql -U nordabiz_app -h 127.0.0.1 \
-d nordabiz -c 'SELECT 1;'"
```
@ -1050,7 +1050,7 @@ ssh maciejpi@10.22.68.249 "psql -U nordabiz_app -h 127.0.0.1 \
"id": 27,
"domain_names": ["nordabiznes.pl", "www.nordabiznes.pl"],
"forward_scheme": "http",
"forward_host": "10.22.68.249",
"forward_host": "57.128.200.27",
"forward_port": 5000,
"access_list_id": 0,
"certificate_id": 27,
@ -1075,7 +1075,7 @@ NPM_URL = "http://10.22.68.250:81/api"
data = {
"domain_names": ["nordabiznes.pl", "www.nordabiznes.pl"],
"forward_scheme": "http",
"forward_host": "10.22.68.249",
"forward_host": "57.128.200.27",
"forward_port": 5000, # CRITICAL!
"certificate_id": 27,
"ssl_forced": True,
@ -1186,19 +1186,19 @@ ssh maciejpi@10.22.68.250 "docker logs -f nginx-proxy-manager_app_1"
**Gunicorn Logs:**
```bash
# Application logs (systemd journal)
ssh maciejpi@10.22.68.249 "sudo journalctl -u nordabiznes -f"
ssh maciejpi@57.128.200.27 "sudo journalctl -u nordabiznes -f"
# Access logs (file-based)
ssh maciejpi@10.22.68.249 "tail -f /var/log/nordabiznes/access.log"
ssh maciejpi@57.128.200.27 "tail -f /var/log/nordabiznes/access.log"
# Error logs
ssh maciejpi@10.22.68.249 "tail -f /var/log/nordabiznes/error.log"
ssh maciejpi@57.128.200.27 "tail -f /var/log/nordabiznes/error.log"
```
**PostgreSQL Logs:**
```bash
# Query logs (if enabled)
ssh maciejpi@10.22.68.249 "sudo tail -f /var/log/postgresql/postgresql-14-main.log"
ssh maciejpi@57.128.200.27 "sudo tail -f /var/log/postgresql/postgresql-14-main.log"
```
### 10.2 Key Metrics to Monitor

View File

@ -61,7 +61,7 @@ Internet → FortiGate (NAT/VPN) → [OPNsense Bridge VM 155] → Serwery INPI
- **Scenariusze:** HTTP brute-force, path traversal, scanner detection, bad user agents
- **Instalacja:** Docker sidecar obok NPM
#### Agent na NordaBiz (VM 249, 10.22.68.249)
#### Agent na NordaBiz (VM 249, 57.128.200.27)
- **Parser:** Flask/Gunicorn access logs
- **Scenariusze:** Login brute-force, honeypot triggers, rate limit abuse
- **Instalacja:** Pakiet systemowy

View File

@ -14,7 +14,7 @@
## Task 1: Install CrowdSec on NordaBiz (VM 249)
**Target:** 10.22.68.249 (NORDABIZ-01)
**Target:** 57.128.200.27 (NORDABIZ-01)
CrowdSec agent na serwerze NordaBiz — parsuje logi Flask/Gunicorn, wykrywa brute-force i honeypot.
@ -25,14 +25,14 @@ CrowdSec agent na serwerze NordaBiz — parsuje logi Flask/Gunicorn, wykrywa bru
**Step 1: Install CrowdSec repository and package**
```bash
ssh maciejpi@10.22.68.249 "curl -s https://install.crowdsec.net | sudo sh"
ssh maciejpi@10.22.68.249 "sudo apt install -y crowdsec"
ssh maciejpi@57.128.200.27 "curl -s https://install.crowdsec.net | sudo sh"
ssh maciejpi@57.128.200.27 "sudo apt install -y crowdsec"
```
**Step 2: Verify CrowdSec is running**
```bash
ssh maciejpi@10.22.68.249 "sudo cscli version && sudo systemctl status crowdsec --no-pager"
ssh maciejpi@57.128.200.27 "sudo cscli version && sudo systemctl status crowdsec --no-pager"
```
Expected: CrowdSec running, version displayed.
@ -42,14 +42,14 @@ Expected: CrowdSec running, version displayed.
NordaBiz runs behind nginx on port 80, Flask/Gunicorn on port 5000.
```bash
ssh maciejpi@10.22.68.249 "sudo cscli collections install crowdsecurity/nginx"
ssh maciejpi@10.22.68.249 "sudo cscli collections install crowdsecurity/base-http-scenarios"
ssh maciejpi@57.128.200.27 "sudo cscli collections install crowdsecurity/nginx"
ssh maciejpi@57.128.200.27 "sudo cscli collections install crowdsecurity/base-http-scenarios"
```
**Step 4: Configure acquisition for nginx access logs**
```bash
ssh maciejpi@10.22.68.249 "sudo tee /etc/crowdsec/acquis.d/nordabiz.yaml << 'EOF'
ssh maciejpi@57.128.200.27 "sudo tee /etc/crowdsec/acquis.d/nordabiz.yaml << 'EOF'
filenames:
- /var/log/nginx/access.log
labels:
@ -60,7 +60,7 @@ EOF"
**Step 5: Configure acquisition for NordaBiz security log (honeypot)**
```bash
ssh maciejpi@10.22.68.249 "sudo tee /etc/crowdsec/acquis.d/nordabiz-security.yaml << 'EOF'
ssh maciejpi@57.128.200.27 "sudo tee /etc/crowdsec/acquis.d/nordabiz-security.yaml << 'EOF'
filenames:
- /var/log/nordabiznes/security.log
labels:
@ -71,8 +71,8 @@ EOF"
**Step 6: Restart CrowdSec and verify**
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl restart crowdsec"
ssh maciejpi@10.22.68.249 "sudo cscli metrics"
ssh maciejpi@57.128.200.27 "sudo systemctl restart crowdsec"
ssh maciejpi@57.128.200.27 "sudo cscli metrics"
```
Expected: Metrics show nginx parser processing lines from access.log.
@ -80,8 +80,8 @@ Expected: Metrics show nginx parser processing lines from access.log.
**Step 7: Verify installed collections and scenarios**
```bash
ssh maciejpi@10.22.68.249 "sudo cscli collections list"
ssh maciejpi@10.22.68.249 "sudo cscli scenarios list"
ssh maciejpi@57.128.200.27 "sudo cscli collections list"
ssh maciejpi@57.128.200.27 "sudo cscli scenarios list"
```
Expected: crowdsecurity/nginx, crowdsecurity/base-http-scenarios, crowdsecurity/linux listed.
@ -172,7 +172,7 @@ Navigate to https://app.crowdsec.net and create an account. Copy the enrollment
**Step 2: Enroll NordaBiz instance**
```bash
ssh maciejpi@10.22.68.249 "sudo cscli console enroll --name NORDABIZ-01 --tags nordabiz --tags inpi YOUR-ENROLL-KEY"
ssh maciejpi@57.128.200.27 "sudo cscli console enroll --name NORDABIZ-01 --tags nordabiz --tags inpi YOUR-ENROLL-KEY"
```
**Step 3: Enroll NPM instance**
@ -192,7 +192,7 @@ In Console → Blocklists tab → subscribe both engines to relevant blocklists.
**Step 6: Verify blocklist decisions**
```bash
ssh maciejpi@10.22.68.249 "sudo cscli metrics show decisions"
ssh maciejpi@57.128.200.27 "sudo cscli metrics show decisions"
ssh maciejpi@10.22.68.250 "sudo cscli metrics show decisions"
```
@ -643,7 +643,7 @@ ssh root@10.22.68.123 "qm set 119 --net0 virtio=CURRENT_MAC,bridge=vmbr1"
# From management network
curl -sI https://nordabiznes.pl/health | head -3
ping -c 3 10.22.68.250
ping -c 3 10.22.68.249
ping -c 3 57.128.200.27
```
Expected: All services reachable through bridge.
@ -677,7 +677,7 @@ done
# OPNsense WebGUI → Services → Intrusion Detection → Alerts
# 2. Check CrowdSec decisions on all three servers
ssh maciejpi@10.22.68.249 "sudo cscli decisions list"
ssh maciejpi@57.128.200.27 "sudo cscli decisions list"
ssh maciejpi@10.22.68.250 "sudo cscli decisions list"
ssh maciejpi@10.22.68.155 "sudo cscli decisions list"

View File

@ -1191,25 +1191,25 @@ Test messaging features manually on staging.
- [ ] **Step 1: Deploy**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull"
```
- [ ] **Step 2: Run migration**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/063_message_attachments.sql"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/063_message_attachments.sql"
```
- [ ] **Step 3: Create upload directory**
```bash
ssh maciejpi@10.22.68.249 "mkdir -p /var/www/nordabiznes/static/uploads/messages && sudo chown -R maciejpi:maciejpi /var/www/nordabiznes/static/uploads/messages"
ssh maciejpi@57.128.200.27 "mkdir -p /var/www/nordabiznes/static/uploads/messages && sudo chown -R maciejpi:maciejpi /var/www/nordabiznes/static/uploads/messages"
```
- [ ] **Step 4: Restart and verify**
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```

View File

@ -1139,6 +1139,6 @@ Then manually test `/pej`, `/pej/local-content`, `/pej/aktualnosci` on staging.
- [ ] **Step 4: Deploy to production (AFTER staging verification)**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```

File diff suppressed because it is too large Load Diff

View File

@ -257,7 +257,7 @@ Type "Co wiesz o mnie?" — verify AI lists your profile data.
- [ ] **Step 4: Deploy to production**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```
@ -918,7 +918,7 @@ ssh maciejpi@10.22.68.248 "journalctl -u nordabiznes -n 30 --no-pager | grep 'Ro
- [ ] **Step 3: Deploy to production**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```
@ -1343,7 +1343,7 @@ git commit -m "feat(nordagpt): streaming UI — word-by-word response with think
SSE requires Nginx to NOT buffer the response. The streaming endpoint sets `X-Accel-Buffering: no` header. Verify NPM custom config allows this:
```bash
ssh maciejpi@10.22.68.249 "cat /etc/nginx/sites-enabled/nordabiznes.conf 2>/dev/null || echo 'Using NPM proxy'"
ssh maciejpi@57.128.200.27 "cat /etc/nginx/sites-enabled/nordabiznes.conf 2>/dev/null || echo 'Using NPM proxy'"
```
If using NPM, the `X-Accel-Buffering: no` header should be sufficient. If not, add to NPM custom Nginx config for nordabiznes.pl:
@ -1364,7 +1364,7 @@ Test on staging: open chat, send message, verify text appears word-by-word.
- [ ] **Step 3: Deploy to production**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```
@ -1901,10 +1901,10 @@ ssh maciejpi@10.22.68.248 "sudo systemctl restart nordabiznes"
- [ ] **Step 4: Deploy to production**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull"
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/092_ai_user_memory.sql"
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/093_ai_conversation_summary.sql"
ssh maciejpi@10.22.68.249 "sudo systemctl restart nordabiznes"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/092_ai_user_memory.sql"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/093_ai_conversation_summary.sql"
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
curl -sI https://nordabiznes.pl/health | head -3
```

View File

@ -10,7 +10,7 @@
**Spec:** `docs/superpowers/specs/2026-03-31-event-guests-design.md`
**Deployment:** Staging first (`10.22.68.248`), user tests, then production (`10.22.68.249`).
**Deployment:** Staging first (`10.22.68.248`), user tests, then production (`57.128.200.27`).
---
@ -749,19 +749,19 @@ Expected: `HTTP/2 200`
- [ ] **Step 1: Deploy to production**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull"
```
- [ ] **Step 2: Run migration on production**
```bash
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/096_event_guests.sql"
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/096_event_guests.sql"
```
- [ ] **Step 3: Reload service**
```bash
ssh maciejpi@10.22.68.249 "sudo systemctl reload nordabiznes"
ssh maciejpi@57.128.200.27 "sudo systemctl reload nordabiznes"
```
- [ ] **Step 4: Verify production**

View File

@ -17,7 +17,7 @@ Podejście B: UptimeRobot (zewnętrzny monitoring) + wewnętrzny health logger z
## Architektura
```
UptimeRobot.com (free) NORDABIZ-01 (10.22.68.249)
UptimeRobot.com (free) NORDABIZ-01 (57.128.200.27)
│ sprawdza co 5 min │ wewnętrzny logger co 5 min
│ HTTPS → nordabiznes.pl │ app/db/cpu/ram/disk → PostgreSQL
│ │

View File

@ -0,0 +1,189 @@
# Messaging Redesign — Conversation-Based System
**Data:** 2026-03-27
**Status:** Zaakceptowany
**Zakres:** Przebudowa systemu wiadomości z email-like (Odebrane/Wysłane) na konwersacyjny (Messenger/WhatsApp)
## Decyzje architektoniczne
1. **Ujednolicony model** — 1:1 i grupy w jednym modelu `Conversation` + `Message`. Rozmowa 1:1 = konwersacja z 2 uczestnikami.
2. **Real-time: SSE** — Server-Sent Events + Redis pub/sub. Jedno połączenie SSE per użytkownik.
3. **Migracja danych** — istniejące wiadomości migrowane do nowego modelu. Stare tabele zostają jako backup.
## Model danych
### conversations
| Kolumna | Typ | Opis |
|---------|-----|------|
| id | Serial PK | |
| name | String(255), nullable | Null dla 1:1, nadana nazwa dla grup |
| is_group | Boolean | False = 1:1, True = grupa |
| owner_id | FK → users | Twórca |
| created_at | DateTime | |
| updated_at | DateTime | Aktualizowane przy każdej wiadomości |
| last_message_id | FK → messages, nullable | Denormalizacja dla listy |
### conversation_members
| Kolumna | Typ | Opis |
|---------|-----|------|
| conversation_id | FK → conversations, PK | |
| user_id | FK → users, PK | |
| role | String(20) | 'owner', 'member' |
| last_read_at | DateTime | Read receipts |
| is_muted | Boolean | Wyciszenie email + push |
| is_archived | Boolean | Ukrycie z listy |
| joined_at | DateTime | |
| added_by_id | FK → users, nullable | |
### messages
| Kolumna | Typ | Opis |
|---------|-----|------|
| id | Serial PK | |
| conversation_id | FK → conversations | |
| sender_id | FK → users | |
| content | Text | HTML (Quill) |
| reply_to_id | FK → messages, nullable | Cytowanie |
| edited_at | DateTime, nullable | |
| is_deleted | Boolean | Soft delete |
| link_preview | JSONB, nullable | {url, title, description, image} |
| created_at | DateTime | |
### message_reactions
| Kolumna | Typ | Opis |
|---------|-----|------|
| id | Serial PK | |
| message_id | FK → messages | |
| user_id | FK → users | |
| emoji | String(10) | |
| created_at | DateTime | |
| UNIQUE | (message_id, user_id, emoji) | |
### message_pins
| Kolumna | Typ | Opis |
|---------|-----|------|
| id | Serial PK | |
| conversation_id | FK → conversations | |
| message_id | FK → messages | |
| pinned_by_id | FK → users | |
| created_at | DateTime | |
### message_attachments
Istniejąca tabela. Nowa kolumna `new_message_id` FK → messages, nullable.
## SSE Real-time
### Endpoint
`GET /api/messages/stream` — jedno połączenie per użytkownik.
### Zdarzenia
| Event | Dane | Kiedy |
|-------|------|-------|
| new_message | conversation_id, message JSON | Nowa wiadomość |
| message_read | conversation_id, user_id, read_at | Przeczytano |
| typing | conversation_id, user_id, user_name | Ktoś pisze (TTL 3s) |
| reaction | message_id, user_id, emoji, action | Reakcja |
| message_edited | message_id, new_content, edited_at | Edycja |
| message_deleted | message_id, conversation_id | Usunięcie |
| message_pinned | message_id, conversation_id, pinned_by | Przypięcie |
| presence | user_id, status, last_seen | Online/offline |
### Infrastruktura
- Redis pub/sub do rozgłaszania między workerami Gunicorn
- Online status: Redis SETEX z TTL 60s, heartbeat co 30s
- Typing: POST /api/conversations/<id>/typing → Redis publish, TTL 3s
## API Endpoints
### Konwersacje
| Method | URL | Opis |
|--------|-----|------|
| GET | /wiadomosci | Widok konwersacyjny (HTML) |
| GET | /api/conversations | Lista konwersacji JSON |
| POST | /api/conversations | Nowa (deduplikacja 1:1) |
| GET | /api/conversations/<id> | Szczegóły + członkowie |
| PATCH | /api/conversations/<id> | Edytuj nazwę/opis |
| DELETE | /api/conversations/<id> | Usuń (owner) |
| POST | /api/conversations/<id>/members | Dodaj członka |
| DELETE | /api/conversations/<id>/members/<uid> | Usuń członka |
| PATCH | /api/conversations/<id>/settings | Mute/archive |
### Wiadomości
| Method | URL | Opis |
|--------|-----|------|
| GET | /api/conversations/<id>/messages | Paginacja cursor-based |
| POST | /api/conversations/<id>/messages | Wyślij |
| PATCH | /api/messages/<id> | Edytuj (swoje, max 24h) |
| DELETE | /api/messages/<id> | Soft delete (swoje) |
| POST | /api/messages/<id>/forward | Przekaż |
| POST | /api/conversations/<id>/read | Oznacz przeczytane |
| POST | /api/conversations/<id>/typing | Typing indicator |
### Reakcje i przypięcia
| Method | URL | Opis |
|--------|-----|------|
| POST | /api/messages/<id>/reactions | Dodaj |
| DELETE | /api/messages/<id>/reactions/<emoji> | Usuń |
| POST | /api/messages/<id>/pin | Przypnij |
| DELETE | /api/messages/<id>/pin | Odepnij |
| GET | /api/conversations/<id>/pins | Lista przypiętych |
### Inne
| Method | URL | Opis |
|--------|-----|------|
| GET | /api/messages/stream | SSE |
| GET | /api/users/presence | Online status (batch) |
| POST | /api/messages/upload | Upload pliku |
## Frontend
### Desktop
- Lewy panel (380px): lista konwersacji posortowana po updated_at
- Prawy panel: nagłówek (avatar, imię, status, typing) + wiadomości (bąbelki) + input (Quill)
### Wiadomości
- Bąbelki: moje (niebieskie, prawo) / cudze (szare, lewo)
- Separatory dat
- Reply-to: cytat nad odpowiedzią
- Edytowane: etykieta "(edytowano)"
- Usunięte: "Wiadomość usunięta"
- Załączniki inline (obrazy jako podgląd, pliki jako pill)
- Reakcje: pill badges pod bąbelkiem
- Link preview: karta z tytułem + opisem
- Read receipts 1:1: ptaszki (wysłano/doręczono/przeczytano), hover → timestampy
- Read receipts grupa: awatary (max 4 + "+N")
### Menu kontekstowe (hover/long-press)
Odpowiedz, Reaguj (6 emoji), Przekaż, Przypnij, Edytuj, Usuń
### Mobile (< 768px)
Lista LUB chat (nie oba). Przycisk "Wróć". Menu kontekstowe jako bottom sheet.
## Email notifications
```
if member.is_muted → nie wysyłaj
elif not user.notify_email_messages → nie wysyłaj
else → wysyłaj
```
Wyciszona konwersacja: ikona 🔇 na liście.
## Link preview
- Backend wykrywa URL, pobiera stronę (timeout 3s), parsuje og:title/og:description/og:image
- Fallback: <title> + <meta description>
- Tylko pierwszy URL, brak preview dla wewnętrznych linków
- Zapisane w messages.link_preview (JSONB)
## Migracja danych
1. Prywatne wiadomości: grupowanie po parach sender/recipient → conversation (is_group=False)
2. Grupy: message_group → conversation (is_group=True)
3. Załączniki: nowy FK new_message_id
4. Walidacja: count before = count after, read receipts zachowane
5. Stare tabele zostają jako backup
## Zależności infrastrukturalne
- Redis na VM produkcyjnej (pub/sub + presence cache)
- Nginx: SSE wymaga wyłączenia buforowania (`proxy_buffering off`) dla /api/messages/stream

View File

@ -1,12 +1,11 @@
# Zabbix Monitoring Setup - NORDABIZ-01
# Zabbix Monitoring Setup - OVH VPS (inpi-vps-waw01)
## Informacje o serwerze
| Parametr | Wartosc |
|----------|---------|
| **Nazwa hosta** | NORDABIZ-01 |
| **VM ID** | 249 |
| **IP** | 10.22.68.249 |
| **Nazwa hosta** | inpi-vps-waw01 |
| **IP** | 57.128.200.27 |
| **OS** | Ubuntu 22.04 LTS |
| **Aplikacja** | Flask (NordaBiznes Partner) |
| **Baza danych** | PostgreSQL 15 (localhost:5432) |
@ -29,7 +28,7 @@
Polacz sie z serwerem i sprawdz czy agent jest zainstalowany:
```bash
ssh maciejpi@10.22.68.249
ssh maciejpi@57.128.200.27
# Sprawdz status agenta (Zabbix Agent 2 - nowsza wersja)
systemctl status zabbix-agent2
@ -100,7 +99,7 @@ Server=10.22.68.126
ServerActive=10.22.68.126
# Nazwa hosta - MUSI byc identyczna jak w Zabbix Server
Hostname=NORDABIZ-01
Hostname=inpi-vps-waw01
# Port nasluchiwania (domyslny)
ListenPort=10050
@ -165,7 +164,7 @@ sudo nano /etc/zabbix/zabbix_agent2.d/nordabiznes.conf
```ini
# ============================================
# NordaBiznes Partner - Custom Zabbix Monitoring
# Server: NORDABIZ-01 (10.22.68.249)
# Server: OVH VPS inpi-vps-waw01 (57.128.200.27)
# ============================================
# --- FLASK APPLICATION ---
@ -283,11 +282,11 @@ zabbix_agent2 -t nordabiznes.disk_usage_mb
| Pole | Wartosc |
|------|---------|
| Host name | NORDABIZ-01 |
| Visible name | NordaBiznes Partner (10.22.68.249) |
| Host name | inpi-vps-waw01 |
| Visible name | NordaBiznes Partner (57.128.200.27) |
| Templates | Linux by Zabbix agent, PostgreSQL by Zabbix agent 2 |
| Host groups | Linux servers, Web servers, Databases |
| Interfaces | Agent: 10.22.68.249:10050 |
| Interfaces | Agent: 57.128.200.27:10050 |
### 5.2 Utworzenie dedykowanego template'u
@ -319,14 +318,14 @@ Utworz nowy template dla NordaBiznes:
| Name | Expression | Severity |
|------|------------|----------|
| NordaBiznes app is down | last(/NORDABIZ-01/nordabiznes.health)=0 | High |
| NordaBiznes service stopped | last(/NORDABIZ-01/nordabiznes.service_status)=0 | High |
| PostgreSQL is down | last(/NORDABIZ-01/postgresql.status)=0 | Disaster |
| High response time (>2s) | last(/NORDABIZ-01/nordabiznes.response_time)>2000 | Warning |
| Low Gunicorn workers | last(/NORDABIZ-01/nordabiznes.workers)<2 | Warning |
| High DB connections (>80) | last(/NORDABIZ-01/postgresql.connections)>80 | Warning |
| High memory usage (>500MB) | last(/NORDABIZ-01/nordabiznes.memory_mb)>500 | Warning |
| Disk usage >5GB | last(/NORDABIZ-01/nordabiznes.disk_usage_mb)>5120 | Warning |
| NordaBiznes app is down | last(/inpi-vps-waw01/nordabiznes.health)=0 | High |
| NordaBiznes service stopped | last(/inpi-vps-waw01/nordabiznes.service_status)=0 | High |
| PostgreSQL is down | last(/inpi-vps-waw01/postgresql.status)=0 | Disaster |
| High response time (>2s) | last(/inpi-vps-waw01/nordabiznes.response_time)>2000 | Warning |
| Low Gunicorn workers | last(/inpi-vps-waw01/nordabiznes.workers)<2 | Warning |
| High DB connections (>80) | last(/inpi-vps-waw01/postgresql.connections)>80 | Warning |
| High memory usage (>500MB) | last(/inpi-vps-waw01/nordabiznes.memory_mb)>500 | Warning |
| Disk usage >5GB | last(/inpi-vps-waw01/nordabiznes.disk_usage_mb)>5120 | Warning |
---
@ -336,20 +335,20 @@ Utworz nowy template dla NordaBiznes:
```bash
# Na serwerze Zabbix (10.22.68.126)
zabbix_get -s 10.22.68.249 -k agent.ping
zabbix_get -s 57.128.200.27 -k agent.ping
# Oczekiwany wynik: 1
zabbix_get -s 10.22.68.249 -k nordabiznes.health
zabbix_get -s 57.128.200.27 -k nordabiznes.health
# Oczekiwany wynik: 1
zabbix_get -s 10.22.68.249 -k postgresql.status
zabbix_get -s 57.128.200.27 -k postgresql.status
# Oczekiwany wynik: 1
```
### 6.2 Test lokalnie na NORDABIZ-01
### 6.2 Test lokalnie na inpi-vps-waw01
```bash
# Na serwerze NORDABIZ-01
# Na serwerze inpi-vps-waw01
zabbix_agent2 -t agent.ping
zabbix_agent2 -t nordabiznes.health
zabbix_agent2 -t nordabiznes.service_status
@ -384,7 +383,7 @@ Uzycie: `ssl.days_until_expiry[nordabiznes.pl]`
4. **Graph: Memory Usage** - nordabiznes.memory_mb (trend)
5. **Graph: DB Connections** - postgresql.connections (trend)
6. **Counter: Chat Messages Today** - nordabiznes.chat_messages_today
7. **Top Hosts: Active Problems** - filtry dla NORDABIZ-01
7. **Top Hosts: Active Problems** - filtry dla inpi-vps-waw01
---

View File

@ -0,0 +1,827 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NordaBiznes — Wiadomości (mockup konwersacyjny)</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e4e7ec;
--text: #1a1d23;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--accent: #2563eb;
--accent-light: #eff4ff;
--accent-hover: #1d4ed8;
--bubble-mine: #2563eb;
--bubble-mine-text: #ffffff;
--bubble-theirs: #f0f1f3;
--bubble-theirs-text: #1a1d23;
--unread: #ef4444;
--online: #22c55e;
--hover: #f8f9fb;
--active: #eff4ff;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--radius: 12px;
--radius-sm: 8px;
}
body {
font-family: 'DM Sans', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
/* ── Top bar (simulating portal nav) ── */
.topbar {
height: 56px;
background: #1e3a5f;
display: flex;
align-items: center;
padding: 0 24px;
gap: 16px;
}
.topbar-logo {
color: white;
font-weight: 700;
font-size: 16px;
letter-spacing: -0.3px;
}
.topbar-logo span { color: #60a5fa; }
.topbar-nav {
display: flex;
gap: 4px;
margin-left: 32px;
}
.topbar-nav a {
color: rgba(255,255,255,0.65);
text-decoration: none;
font-size: 13.5px;
padding: 6px 14px;
border-radius: 6px;
transition: all 0.15s;
font-weight: 500;
}
.topbar-nav a:hover { color: white; background: rgba(255,255,255,0.08); }
.topbar-nav a.active {
color: white;
background: rgba(255,255,255,0.12);
}
.topbar-badge {
background: var(--unread);
color: white;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 10px;
margin-left: 4px;
vertical-align: top;
}
/* ── Main layout ── */
.messages-container {
display: flex;
height: calc(100vh - 56px);
}
/* ── Left panel: conversation list ── */
.conversations-panel {
width: 380px;
min-width: 380px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.conversations-header {
padding: 20px 20px 12px;
border-bottom: 1px solid var(--border);
}
.conversations-header h2 {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.4px;
margin-bottom: 14px;
}
.search-box {
position: relative;
}
.search-box input {
width: 100%;
padding: 9px 12px 9px 36px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13.5px;
font-family: inherit;
background: var(--bg);
color: var(--text);
outline: none;
transition: border-color 0.15s;
}
.search-box input:focus { border-color: var(--accent); }
.search-box input::placeholder { color: var(--text-muted); }
.search-box svg {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
.new-message-btn {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: var(--accent);
color: white;
border: none;
width: 30px;
height: 30px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.new-message-btn:hover { background: var(--accent-hover); }
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
}
.conversation-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.12s;
position: relative;
}
.conversation-item:hover { background: var(--hover); }
.conversation-item.active { background: var(--active); }
.conversation-item.unread .conv-name { font-weight: 700; }
.conversation-item.unread .conv-preview { color: var(--text); font-weight: 500; }
.conv-avatar {
width: 46px;
height: 46px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 16px;
color: white;
flex-shrink: 0;
position: relative;
}
.conv-avatar.green { background: #059669; }
.conv-avatar.blue { background: #2563eb; }
.conv-avatar.purple { background: #7c3aed; }
.conv-avatar.orange { background: #ea580c; }
.conv-avatar.teal { background: #0d9488; }
.conv-avatar.rose { background: #e11d48; }
.conv-avatar.group { background: #475569; font-size: 14px; }
.conv-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.conv-avatar .online-dot {
position: absolute;
bottom: 1px;
right: 1px;
width: 11px;
height: 11px;
background: var(--online);
border: 2px solid var(--panel);
border-radius: 50%;
}
.conv-content {
flex: 1;
min-width: 0;
}
.conv-top {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 3px;
}
.conv-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
margin-left: 8px;
flex-shrink: 0;
}
.conversation-item.unread .conv-time { color: var(--accent); font-weight: 600; }
.conv-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.conv-preview {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.35;
}
.conv-preview .you { color: var(--text-muted); }
.unread-badge {
background: var(--accent);
color: white;
font-size: 10.5px;
font-weight: 700;
min-width: 20px;
height: 20px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
flex-shrink: 0;
margin-left: 8px;
}
.conv-group-tag {
font-size: 11px;
color: var(--text-muted);
margin-left: 6px;
font-weight: 400;
}
/* ── Right panel: chat view ── */
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
}
.chat-header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.chat-header-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
color: white;
background: #059669;
}
.chat-header-info h3 {
font-size: 15px;
font-weight: 650;
letter-spacing: -0.2px;
}
.chat-header-info .subtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 1px;
}
.chat-header-actions {
display: flex;
gap: 4px;
}
.chat-header-actions button {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
}
.chat-header-actions button:hover {
background: var(--hover);
color: var(--text);
}
/* ── Messages area ── */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px 24px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.date-separator {
text-align: center;
margin: 16px 0;
position: relative;
}
.date-separator span {
background: var(--bg);
padding: 0 14px;
font-size: 11.5px;
color: var(--text-muted);
font-weight: 500;
position: relative;
z-index: 1;
}
.date-separator::before {
content: '';
position: absolute;
top: 50%;
left: 10%;
right: 10%;
height: 1px;
background: var(--border);
}
.message-row {
display: flex;
margin-bottom: 2px;
}
.message-row.mine { justify-content: flex-end; }
.message-row.theirs { justify-content: flex-start; }
.message-bubble {
max-width: 520px;
padding: 10px 14px;
font-size: 14px;
line-height: 1.5;
position: relative;
word-wrap: break-word;
}
.message-row.mine .message-bubble {
background: var(--bubble-mine);
color: var(--bubble-mine-text);
border-radius: 16px 16px 4px 16px;
}
.message-row.theirs .message-bubble {
background: var(--bubble-theirs);
color: var(--bubble-theirs-text);
border-radius: 16px 16px 16px 4px;
}
/* consecutive messages get flat edges */
.message-row.mine + .message-row.mine .message-bubble {
border-radius: 16px 4px 4px 16px;
}
.message-row.mine:has(+ .message-row.mine) .message-bubble {
border-radius: 16px 16px 4px 16px;
}
.message-row.theirs + .message-row.theirs .message-bubble {
border-radius: 16px 16px 16px 4px;
}
.message-time {
font-size: 11px;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.message-row.mine .message-time {
color: rgba(255,255,255,0.6);
justify-content: flex-end;
}
.message-row.theirs .message-time {
color: var(--text-muted);
}
.read-check {
color: rgba(255,255,255,0.6);
}
.read-check.read { color: #93c5fd; }
.message-subject {
font-size: 11.5px;
font-weight: 600;
opacity: 0.7;
margin-bottom: 4px;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.message-row.theirs .message-subject { color: var(--text-muted); }
/* ── Input area ── */
.chat-input-area {
background: var(--panel);
border-top: 1px solid var(--border);
padding: 16px 24px;
}
.chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
transition: border-color 0.15s;
}
.chat-input-wrapper:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
}
.chat-input-wrapper textarea {
flex: 1;
border: none;
background: none;
font-family: inherit;
font-size: 14px;
color: var(--text);
resize: none;
outline: none;
min-height: 22px;
max-height: 120px;
line-height: 1.5;
}
.chat-input-wrapper textarea::placeholder { color: var(--text-muted); }
.input-actions {
display: flex;
gap: 2px;
align-items: center;
}
.input-actions button {
background: none;
border: none;
color: var(--text-muted);
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
}
.input-actions button:hover { color: var(--text-secondary); background: rgba(0,0,0,0.04); }
.send-btn {
background: var(--accent) !important;
color: white !important;
border-radius: 8px !important;
width: 36px !important;
height: 32px !important;
}
.send-btn:hover { background: var(--accent-hover) !important; }
/* ── Empty state (no conversation selected) ── */
.chat-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
gap: 12px;
}
.chat-empty svg { opacity: 0.3; }
.chat-empty p { font-size: 14px; }
/* ── Scrollbar ── */
.conversation-list::-webkit-scrollbar,
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.conversation-list::-webkit-scrollbar-thumb,
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
border-radius: 3px;
}
/* ── Watermark ── */
.mockup-badge {
position: fixed;
bottom: 12px;
right: 12px;
background: rgba(0,0,0,0.7);
color: white;
font-size: 11px;
padding: 4px 10px;
border-radius: 20px;
font-weight: 500;
z-index: 100;
pointer-events: none;
}
</style>
</head>
<body>
<!-- Top bar -->
<div class="topbar">
<div class="topbar-logo">Norda<span>Biznes</span></div>
<nav class="topbar-nav">
<a href="#">Firmy</a>
<a href="#">Forum</a>
<a href="#">Kalendarz</a>
<a href="#" class="active">Wiadomości<span class="topbar-badge">2</span></a>
<a href="#">NordaGPT</a>
</nav>
</div>
<div class="messages-container">
<!-- Left: Conversation list -->
<div class="conversations-panel">
<div class="conversations-header">
<h2>Wiadomości</h2>
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" placeholder="Szukaj rozmów...">
<button class="new-message-btn" title="Nowa wiadomość">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
</div>
</div>
<div class="conversation-list">
<!-- Active conversation: Magdalena -->
<div class="conversation-item active unread" onclick="selectConv(this)">
<div class="conv-avatar green">MK</div>
<div class="conv-content">
<div class="conv-top">
<span class="conv-name">Magdalena Kloska</span>
<span class="conv-time">11:54</span>
</div>
<div class="conv-bottom">
<span class="conv-preview">witam, weszlam w skladki i mam pytanie...</span>
<span class="unread-badge">2</span>
</div>
</div>
</div>
<!-- Group conversation -->
<div class="conversation-item" onclick="selectConv(this)">
<div class="conv-avatar group">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="conv-content">
<div class="conv-top">
<span class="conv-name">Modul skladek<span class="conv-group-tag">7 os.</span></span>
<span class="conv-time">24.03</span>
</div>
<div class="conv-bottom">
<span class="conv-preview"><span class="you">Ty: </span>Jest gotowa taka funkcjonalnosc dla roli kierownika...</span>
</div>
</div>
</div>
<!-- Artur -->
<div class="conversation-item" onclick="selectConv(this)">
<div class="conv-avatar blue">AW</div>
<div class="conv-content">
<div class="conv-top">
<span class="conv-name">Artur Wiertel</span>
<span class="conv-time">20.03</span>
</div>
<div class="conv-bottom">
<span class="conv-preview">fajnie to wyglada.</span>
</div>
</div>
</div>
<!-- Roman -->
<div class="conversation-item" onclick="selectConv(this)">
<div class="conv-avatar purple">RW</div>
<div class="conv-content">
<div class="conv-top">
<span class="conv-name">Roman Wiercinski</span>
<span class="conv-time">18.03</span>
</div>
<div class="conv-bottom">
<span class="conv-preview"><span class="you">Ty: </span>Dzien dobry, przesylam podsumowanie...</span>
</div>
</div>
</div>
<!-- Leszek -->
<div class="conversation-item" onclick="selectConv(this)">
<div class="conv-avatar teal">LG</div>
<div class="conv-content">
<div class="conv-top">
<span class="conv-name">Leszek Glaza</span>
<span class="conv-time">15.03</span>
</div>
<div class="conv-bottom">
<span class="conv-preview"><span class="you">Ty: </span>Lista firm z kontaktami gotowa.</span>
</div>
</div>
</div>
</div>
</div>
<!-- Right: Chat view -->
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<div class="chat-header-avatar">MK</div>
<div class="chat-header-info">
<h3>Magdalena Kloska</h3>
<div class="subtitle">Kierownik Biura &middot; Izba Norda Biznes</div>
</div>
</div>
<div class="chat-header-actions">
<button title="Szukaj w rozmowie">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<button title="Wiecej">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
</div>
</div>
<div class="chat-messages">
<div class="date-separator"><span>19 marca 2026</span></div>
<!-- My message -->
<div class="message-row mine">
<div class="message-bubble">
<div class="message-subject">Skladki i NIP-y nowych czlonkow</div>
Pani Magdaleno, importuje dane o skladkach z pliku Excel do systemu portalu. Wiekszosc firm dopasowala sie automatycznie, ale kilka nazw wymaga weryfikacji.
Prosze o sprawdzenie — czy te firmy maja profile na portalu pod inna nazwa?
EKOZUK, FRESH BIKE, JANTAR, MACIEJ HALAS, MARKISOL, N33, PGK, SKLEPY LORD, WW GLASS
<div class="message-time">
19:49
<svg class="read-check read" width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 6l3.5 4L11 2"/><path d="M5 6l3.5 4L15 2"/></svg>
</div>
</div>
</div>
<div class="message-row mine">
<div class="message-bubble">
<div class="message-subject">Kalendarz — wydarzenia zewnetrzne</div>
Pani Magdo, dodalismy przed chwila tez informacje o wydarzeniach zewnetrznych. One w kalendarzu sa widoczne...
<div class="message-time">
12:06
<svg class="read-check read" width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 6l3.5 4L11 2"/><path d="M5 6l3.5 4L15 2"/></svg>
</div>
</div>
</div>
<div class="message-row theirs">
<div class="message-bubble">
z teog co widze na szybko i odnotowalam to firm ted jest w3pisana dwa razy ale nip ten sam tutaj...
<div class="message-time">13:30</div>
</div>
</div>
<div class="message-row mine">
<div class="message-bubble">
Ok, pani Magdo, ogarne te tematy, jak tylko wroce do biura.
<div class="message-time">
13:54
<svg class="read-check read" width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 6l3.5 4L11 2"/><path d="M5 6l3.5 4L15 2"/></svg>
</div>
</div>
</div>
<div class="date-separator"><span>25 marca 2026</span></div>
<div class="message-row theirs">
<div class="message-bubble">
dzien dobry do jutra wlacznie mam opieke ale staram sie ogrniac tematy ile sie da.
EKOZUK- EKOFABRYKA TO JEST TO SAMO
FRESH BIKE fresh bike oraz jantar- to jest podspolka da Eura Tech - te na niebiesko nie placa skladeek indywidualnych ale firma wchodzaca do Nordy moze wniesc kilka swoich spolek placac wieksza skladke.
MACIEJ HALAS- prosze ich nie uwzlgedniac - rezygnacja z czlonkostwa.
MARKISOL tak samo rezygnajca
N33 to tezs jest pod spolk awraz z Termo
PGK Pucka Gospodarka Komunalna Sp. z o.o. NIP: 587-02-00-062
SKLEPY LORD: Lord sp. z o.o. nip 5882533102
WW GLASS tak samo podspolka innej firmy glownje - tutaj lenap hale
<div class="message-time">11:47</div>
</div>
</div>
<div class="message-row theirs">
<div class="message-bubble">
witam, weszlam w skladki i mam pytanie jesli ktos np zaplaci zaleglosc lub kolejna rate jaqk to zmienic w systemie ?
<div class="message-time">11:54</div>
</div>
</div>
<div class="date-separator"><span>27 marca 2026</span></div>
<div class="message-row mine">
<div class="message-bubble">
Pani Magdaleno, informacje o firmach przyjete, wprowadzam poprawki w systemie.
Dwa pytania:
1. N33 i Termo — pod jaka firme glowna wchodza?
2. Potrzebuje NIP-y nowych czlonkow: Audioline, Coach 4 You, Digital Technik, Ekonsult, GoodWill, IBET, Prospoland, Steamset.
Co do skladek — panel Administracja, Skladki, przy danym miesiacu "Oznacz jako oplacone". Mozna wpisac kwote i date wplaty.
<div class="message-time">
11:07
<svg class="read-check" width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 6l3.5 4L11 2"/><path d="M5 6l3.5 4L15 2"/></svg>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="chat-input-area">
<div class="chat-input-wrapper">
<textarea rows="1" placeholder="Napisz wiadomosc..."></textarea>
<div class="input-actions">
<button title="Dolacz plik">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<button class="send-btn" title="Wyslij">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="mockup-badge">MOCKUP — widok konwersacyjny</div>
<script>
function selectConv(el) {
document.querySelectorAll('.conversation-item').forEach(i => i.classList.remove('active'));
el.classList.add('active');
}
</script>
</body>
</html>

View File

@ -186,7 +186,7 @@ blog, contact_form, detailed_services, analytics, live_chat
---
**Report generated by Norda Biznes Digital Maturity Platform**
*Data source: PostgreSQL database on NORDABIZ-01 (10.22.68.249)*
*Data source: PostgreSQL database on NORDABIZ-01 (57.128.200.27)*
*Phase: ETAP 1 - Foundation*
---

View File

@ -0,0 +1,6 @@
{
"vps-2025-model1": "available",
"vps-2025-model2": "available",
"vps-2025-model3": "available",
"vps-2025-model4": "available"
}

View File

@ -0,0 +1,54 @@
"""Audit active companies for missing KRS/CEIDG data."""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
db_url = os.environ.get('DATABASE_URL', 'postgresql://nordabiz_app:nordabiz_pass@localhost:5433/nordabiz')
engine = create_engine(db_url)
with engine.connect() as conn:
r = conn.execute(text(
"SELECT id, name, nip, krs, legal_form, address_street, address_city, "
"email, phone, website, data_quality, fee_included_in_parent "
"FROM companies WHERE status = 'active' ORDER BY name"
))
no_nip = []
no_address = []
no_contact = []
no_legal_form = []
basic_quality = []
for row in r:
cid, name, nip, krs, legal_form, street, city, email, phone, website, quality, fee_in_parent = row
if not nip and not fee_in_parent:
no_nip.append((cid, name))
if not street and not city:
no_address.append((cid, name))
if not email and not phone:
no_contact.append((cid, name))
if not legal_form:
no_legal_form.append((cid, name))
if quality == 'basic':
basic_quality.append((cid, name, nip))
print(f'=== Firmy BEZ NIP (nie-podspolki): {len(no_nip)} ===')
for cid, name in no_nip:
print(f' id={cid:4d} {name}')
print(f'\n=== Firmy BEZ adresu: {len(no_address)} ===')
for cid, name in no_address:
print(f' id={cid:4d} {name}')
print(f'\n=== Firmy BEZ kontaktu (email+telefon): {len(no_contact)} ===')
for cid, name in no_contact:
print(f' id={cid:4d} {name}')
print(f'\n=== Firmy BEZ formy prawnej: {len(no_legal_form)} ===')
for cid, name in no_legal_form:
print(f' id={cid:4d} {name}')
print(f'\n=== Firmy z data_quality=basic (minimalne dane): {len(basic_quality)} ===')
for cid, name, nip in basic_quality:
print(f' id={cid:4d} nip={str(nip or "BRAK"):15s} {name}')

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Company, Category
# DEV: localhost:5433, PROD: 10.22.68.249:5432
# DEV: localhost:5433, PROD: 57.128.200.27:5432
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://nordabiz_app:dev_password@localhost:5433/nordabiz')

340
scripts/ovh_vps_monitor.py Normal file
View File

@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""
OVH VPS Availability Monitor for Warsaw (WAW)
Sprawdza dostępność VPS-1, VPS-2, VPS-3, VPS-4 w Warszawie.
Powiadamia przez macOS notification + opcjonalnie Telegram.
Użycie:
python3 scripts/ovh_vps_monitor.py # jednorazowe sprawdzenie
python3 scripts/ovh_vps_monitor.py --daemon # co 10 minut
Konfiguracja OVH API (jednorazowo):
1. Wejdź na https://eu.api.ovh.com/createToken/
2. Zaloguj się kontem pm861830-ovh
3. Ustaw:
- GET /order/cart/*
- POST /order/cart/*
- DELETE /order/cart/*
4. Wpisz klucze do ~/.ovh.conf (format poniżej)
~/.ovh.conf:
[default]
endpoint=ovh-eu
application_key=TWÓJ_APP_KEY
application_secret=TWÓJ_APP_SECRET
consumer_key=TWÓJ_CONSUMER_KEY
"""
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
# --- Configuration ---
MODELS = {
'vps-2025-model1': 'VPS-1 (4 vCores, 8 GB, 75 GB SSD) — 23,50 PLN/m',
'vps-2025-model2': 'VPS-2 (6 vCores, 12 GB, 100 GB NVMe) — 36,21 PLN/m',
'vps-2025-model3': 'VPS-3 (8 vCores, 24 GB, 200 GB NVMe) — 72,42 PLN/m',
'vps-2025-model4': 'VPS-4 (12 vCores, 48 GB, 300 GB NVMe) — 133,96 PLN/m',
}
TARGET_DC = 'WAW'
CHECK_INTERVAL = 600 # 10 minut
STATE_FILE = Path(__file__).parent / '.ovh_vps_monitor_state.json'
TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
def check_availability_curl():
"""Sprawdza dostępność VPS w WAW przez publiczne API OVH (cart flow)."""
import urllib.request
import urllib.error
results = {}
# 1. Utwórz koszyk
req = urllib.request.Request(
'https://eu.api.ovh.com/1.0/order/cart',
data=json.dumps({'ovhSubsidiary': 'PL'}).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
cart = json.loads(resp.read())
cart_id = cart['cartId']
except Exception as e:
print(f'[ERROR] Nie mogę utworzyć koszyka: {e}')
return {}
# 2. Dla każdego modelu — dodaj do koszyka i sprawdź DC
for plan_code, plan_name in MODELS.items():
try:
# Dodaj VPS do koszyka
add_req = urllib.request.Request(
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/vps',
data=json.dumps({
'planCode': plan_code,
'duration': 'P1M',
'pricingMode': 'default',
'quantity': 1
}).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(add_req, timeout=15) as resp:
item = json.loads(resp.read())
item_id = item['itemId']
# Skonfiguruj WAW
dc_req = urllib.request.Request(
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/item/{item_id}/configuration',
data=json.dumps({'label': 'vps_datacenter', 'value': TARGET_DC}).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(dc_req, timeout=15) as resp:
dc_result = json.loads(resp.read())
# Skonfiguruj OS i region
for cfg in [
{'label': 'vps_os', 'value': 'Ubuntu 24.04'},
{'label': 'region', 'value': 'europe'},
]:
cfg_req = urllib.request.Request(
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/item/{item_id}/configuration',
data=json.dumps(cfg).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(cfg_req, timeout=15) as resp:
pass
# Sprawdź podsumowanie koszyka (publiczne, nie wymaga auth)
summary_req = urllib.request.Request(
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/summary',
method='GET'
)
try:
with urllib.request.urlopen(summary_req, timeout=15) as resp:
summary = json.loads(resp.read())
results[plan_code] = 'available'
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else ''
if 'stock' in error_body.lower() or 'unavailable' in error_body.lower():
results[plan_code] = 'out_of_stock'
elif e.code == 401:
# Auth wymagane — nie wiemy na pewno, spróbuj auth flow
results[plan_code] = 'needs_auth'
else:
results[plan_code] = 'unknown'
# Usuń item z koszyka
del_req = urllib.request.Request(
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/item/{item_id}',
method='DELETE'
)
try:
with urllib.request.urlopen(del_req, timeout=15) as resp:
pass
except Exception:
pass
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else ''
if 'stock' in error_body.lower() or 'not available' in error_body.lower():
results[plan_code] = 'out_of_stock'
else:
results[plan_code] = f'error: {e.code}'
except Exception as e:
results[plan_code] = f'error: {e}'
# Usuń koszyk
try:
del_cart = urllib.request.Request(
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}',
method='DELETE'
)
urllib.request.urlopen(del_cart, timeout=15)
except Exception:
pass
return results
def check_availability_ovh_lib():
"""Sprawdza dostępność VPS w WAW przez ovh Python library (z autoryzacją)."""
try:
import ovh
except ImportError:
print('[WARN] Brak biblioteki ovh — pip3 install ovh')
return {}
conf_path = Path.home() / '.ovh.conf'
if not conf_path.exists():
print(f'[WARN] Brak {conf_path} — użyj trybu bez autoryzacji')
return {}
try:
client = ovh.Client()
except Exception as e:
print(f'[ERROR] Nie mogę połączyć z OVH API: {e}')
return {}
results = {}
for plan_code, plan_name in MODELS.items():
try:
# Utwórz koszyk
cart = client.post('/order/cart', ovhSubsidiary='PL')
cart_id = cart['cartId']
client.post(f'/order/cart/{cart_id}/assign')
# Dodaj VPS
item = client.post(f'/order/cart/{cart_id}/vps',
planCode=plan_code, duration='P1M',
pricingMode='default', quantity=1)
item_id = item['itemId']
# Skonfiguruj WAW + OS + region
client.post(f'/order/cart/{cart_id}/item/{item_id}/configuration',
label='vps_datacenter', value=TARGET_DC)
client.post(f'/order/cart/{cart_id}/item/{item_id}/configuration',
label='vps_os', value='Ubuntu 24.04')
client.post(f'/order/cart/{cart_id}/item/{item_id}/configuration',
label='region', value='europe')
# Validate checkout (GET = validate, POST = place order)
checkout = client.get(f'/order/cart/{cart_id}/checkout')
# Jeśli doszliśmy tutaj — VPS jest dostępny w WAW!
results[plan_code] = 'available'
# Cleanup
client.delete(f'/order/cart/{cart_id}')
except Exception as e:
error_msg = str(e).lower()
if 'stock' in error_msg or 'not available' in error_msg or 'unavailable' in error_msg:
results[plan_code] = 'out_of_stock'
elif 'expired' in error_msg:
results[plan_code] = 'error_expired'
else:
results[plan_code] = f'error: {e}'
# Cleanup
try:
client.delete(f'/order/cart/{cart_id}')
except Exception:
pass
return results
def notify_macos(title, message):
"""Powiadomienie macOS."""
subprocess.run([
'osascript', '-e',
f'display notification "{message}" with title "{title}" sound name "Glass"'
], check=False)
def notify_telegram(message):
"""Powiadomienie Telegram."""
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return
import urllib.request
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
data = json.dumps({
'chat_id': TELEGRAM_CHAT_ID,
'text': message,
'parse_mode': 'Markdown'
}).encode()
req = urllib.request.Request(url, data=data,
headers={'Content-Type': 'application/json'})
try:
urllib.request.urlopen(req, timeout=10)
except Exception as e:
print(f'[WARN] Telegram notification failed: {e}')
def load_state():
"""Wczytaj poprzedni stan."""
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {}
def save_state(state):
"""Zapisz stan."""
STATE_FILE.write_text(json.dumps(state, indent=2))
def run_check():
"""Główna funkcja sprawdzająca."""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f'\n[{now}] Sprawdzam dostępność VPS w {TARGET_DC}...')
# Preferuj auth flow (dokładniejszy), fallback na public API
conf_path = Path.home() / '.ovh.conf'
if conf_path.exists():
print(' Tryb: OVH API (z autoryzacją)')
results = check_availability_ovh_lib()
else:
print(' Tryb: Public API (bez autoryzacji — mniej dokładny)')
results = check_availability_curl()
if not results:
print(' [WARN] Brak wyników — problem z API?')
return
prev_state = load_state()
new_available = []
for plan_code, status in results.items():
plan_name = MODELS.get(plan_code, plan_code)
icon = '' if status == 'available' else '' if 'stock' in str(status) else ''
print(f' {icon} {plan_name}: {status}')
# Czy to nowa dostępność?
prev = prev_state.get(plan_code, '')
if status == 'available' and prev != 'available':
new_available.append(plan_name)
# Powiadom o nowych dostępnościach
if new_available:
msg_lines = ['🟢 VPS dostępny w Warszawie!'] + [f'{n}' for n in new_available]
msg_lines.append(f'\n🔗 https://www.ovhcloud.com/pl/vps/')
message = '\n'.join(msg_lines)
print(f'\n 🔔 POWIADOMIENIE: {message}')
notify_macos('OVH VPS Warszawa!', '\n'.join(new_available))
notify_telegram(message)
else:
print(' Brak nowych dostępności.')
save_state(results)
def main():
daemon = '--daemon' in sys.argv
if daemon:
print(f'Uruchamiam monitoring VPS w {TARGET_DC} (co {CHECK_INTERVAL}s)...')
print(f'Stan zapisywany w: {STATE_FILE}')
print('Ctrl+C aby zatrzymać\n')
while True:
try:
run_check()
time.sleep(CHECK_INTERVAL)
except KeyboardInterrupt:
print('\nZatrzymano.')
break
else:
run_check()
if __name__ == '__main__':
main()

103
scripts/sync_staging_db.sh Executable file
View File

@ -0,0 +1,103 @@
#!/bin/bash
# Sync staging database to local Docker dev environment
# Usage: ./scripts/sync_staging_db.sh
#
# Can run interactively or via launchd (auto mode).
# In auto mode: silently skips if Docker/VPN unavailable, logs to file.
#
# Prerequisites:
# - SSH access to staging (maciejpi@10.22.68.248)
# - Docker container 'nordabiz-postgres' running locally
# - Local DB: nordabiz_app/dev_password on port 5433
set -euo pipefail
STAGING_HOST="maciejpi@10.22.68.248"
STAGING_DB="nordabiz_staging"
LOCAL_CONTAINER="nordabiz-postgres"
LOCAL_DB="nordabiz"
LOCAL_USER="nordabiz_app"
DUMP_FILE="/tmp/nordabiz_staging_dump.sql"
LOG_FILE="$HOME/.local/log/nordabiz-db-sync.log"
STAMP_FILE="$HOME/.local/state/nordabiz-db-sync-last"
# Ensure log/state dirs exist
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STAMP_FILE")"
# Detect interactive vs auto mode
AUTO=false
if [ "${1:-}" = "--auto" ]; then
AUTO=true
fi
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
if [ "$AUTO" = true ]; then
echo "$msg" >> "$LOG_FILE"
else
echo "$1"
fi
}
fail() {
log "SKIP: $1"
exit 0 # exit 0 in auto mode so launchd doesn't retry
}
log "=== NordaBiz: Sync staging DB → local dev ==="
# Check Docker container
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${LOCAL_CONTAINER}$"; then
if [ "$AUTO" = true ]; then
fail "Docker container '${LOCAL_CONTAINER}' not running"
else
echo "ERROR: Docker container '${LOCAL_CONTAINER}' not running."
echo "Start it: docker compose up -d"
exit 1
fi
fi
# Check SSH/VPN
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "${STAGING_HOST}" "echo ok" &>/dev/null; then
if [ "$AUTO" = true ]; then
fail "Cannot reach staging (VPN probably off)"
else
echo "ERROR: Cannot connect to staging (${STAGING_HOST})."
echo "Check VPN connection."
exit 1
fi
fi
# Step 1: Dump staging
log "[1/3] Dumping staging database..."
ssh "${STAGING_HOST}" "sudo -u postgres pg_dump ${STAGING_DB}" > "${DUMP_FILE}"
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
log " Done (${DUMP_SIZE})"
# Step 2: Restore to container
log "[2/3] Restoring to local Docker..."
docker cp "${DUMP_FILE}" "${LOCAL_CONTAINER}:/tmp/staging_dump.sql"
docker exec "${LOCAL_CONTAINER}" bash -c "
psql -U ${LOCAL_USER} -d template1 -c \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${LOCAL_DB}' AND pid <> pg_backend_pid();\" 2>/dev/null
psql -U ${LOCAL_USER} -d template1 -c 'DROP DATABASE IF EXISTS ${LOCAL_DB};'
psql -U ${LOCAL_USER} -d template1 -c 'CREATE DATABASE ${LOCAL_DB} OWNER ${LOCAL_USER};'
psql -U ${LOCAL_USER} -d ${LOCAL_DB} < /tmp/staging_dump.sql 2>/dev/null
rm /tmp/staging_dump.sql
"
# Step 3: Verify
log "[3/3] Verifying..."
COUNTS=$(docker exec "${LOCAL_CONTAINER}" psql -U "${LOCAL_USER}" -d "${LOCAL_DB}" -t -c "
SELECT json_build_object(
'users', (SELECT count(*) FROM users),
'companies', (SELECT count(*) FROM companies),
'user_companies', (SELECT count(*) FROM user_companies)
);")
log " ${COUNTS}"
# Cleanup and stamp
rm -f "${DUMP_FILE}"
date '+%Y-%m-%d %H:%M:%S' > "$STAMP_FILE"
log "=== Sync complete ==="

View File

@ -543,7 +543,7 @@
{% endif %}
</td>
<td class="log-time">
{{ log.created_at.strftime('%d.%m.%Y %H:%M') }}
{{ log.created_at|local_time('%d.%m.%Y %H:%M') }}
</td>
</tr>
{% endfor %}

View File

@ -505,7 +505,7 @@
{% endif %}
</td>
<td class="log-time">
{{ log.created_at.strftime('%d.%m.%Y %H:%M') }}
{{ log.created_at|local_time('%d.%m.%Y %H:%M') }}
</td>
</tr>
{% endfor %}

View File

@ -268,7 +268,7 @@
{% endif %}
</td>
<td>{{ ann.author.name if ann.author else '-' }}</td>
<td>{{ ann.created_at.strftime('%Y-%m-%d %H:%M') if ann.created_at else '-' }}</td>
<td>{{ ann.created_at|local_time('%Y-%m-%d %H:%M') if ann.created_at else '-' }}</td>
<td class="views-count">{{ ann.views_count or 0 }}</td>
<td class="actions-cell">
<a href="{{ url_for('admin.admin_announcements_edit', id=ann.id) }}" class="btn btn-secondary btn-small">

View File

@ -160,7 +160,7 @@
<div class="status-info">
<strong>Status:</strong> {{ announcement.status_label }}
{% if announcement.published_at %}
| <strong>Opublikowano:</strong> {{ announcement.published_at.strftime('%Y-%m-%d %H:%M') }}
| <strong>Opublikowano:</strong> {{ announcement.published_at|local_time('%Y-%m-%d %H:%M') }}
{% endif %}
{% if announcement.views_count %}
| <strong>Wyswietlenia:</strong> {{ announcement.views_count }}
@ -242,7 +242,7 @@
<div class="form-group">
<label for="expires_at">Data wygasniecia</label>
<input type="datetime-local" id="expires_at" name="expires_at"
value="{{ announcement.expires_at.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.expires_at else '' }}">
value="{{ announcement.expires_at|local_time('%Y-%m-%dT%H:%M') if announcement and announcement.expires_at else '' }}">
<p class="form-hint">Pozostaw puste aby nie wygasalo</p>
</div>
</div>

View File

@ -95,7 +95,7 @@
<tbody>
{% for click in clicks %}
<tr>
<td>{{ click.clicked_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ click.clicked_at|local_time('%Y-%m-%d %H:%M') }}</td>
<td>
{% if click.user %}
{{ click.user.email }}

View File

@ -680,7 +680,7 @@
<div class="stat-label">Użytkownicy</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ company.created_at.strftime('%d.%m.%Y') if company.created_at else '---' }}</div>
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ company.created_at|local_time('%d.%m.%Y') if company.created_at else '---' }}</div>
<div class="stat-label">Utworzono</div>
</div>
</div>
@ -798,7 +798,7 @@
<div class="action-status">
{% if enrichment.registry.done %}
<span class="status-dot green"></span>
Wykonano{% if enrichment.registry.date %} {{ enrichment.registry.date.strftime('%d.%m.%Y') }}{% endif %}
Wykonano{% if enrichment.registry.date %} {{ enrichment.registry.date|local_time('%d.%m.%Y') }}{% endif %}
{% else %}
<span class="status-dot gray"></span>
Nie wykonano
@ -828,7 +828,7 @@
<div class="action-status">
{% if enrichment.seo.done %}
<span class="status-dot green"></span>
Wykonano{% if enrichment.seo.date %} {{ enrichment.seo.date.strftime('%d.%m.%Y') }}{% endif %}
Wykonano{% if enrichment.seo.date %} {{ enrichment.seo.date|local_time('%d.%m.%Y') }}{% endif %}
{% else %}
<span class="status-dot gray"></span>
Nie wykonano
@ -875,7 +875,7 @@
<div class="action-status">
{% if enrichment.gbp.done %}
<span class="status-dot green"></span>
Wykonano{% if enrichment.gbp.date %} {{ enrichment.gbp.date.strftime('%d.%m.%Y') }}{% endif %}
Wykonano{% if enrichment.gbp.date %} {{ enrichment.gbp.date|local_time('%d.%m.%Y') }}{% endif %}
{% else %}
<span class="status-dot gray"></span>
Nie wykonano

View File

@ -270,7 +270,7 @@
</div>
<div class="info-item">
<span class="info-label">Data:</span>
{{ req.created_at.strftime('%Y-%m-%d %H:%M') if req.created_at else '-' }}
{{ req.created_at|local_time('%Y-%m-%d %H:%M') if req.created_at else '-' }}
</div>
<div class="info-item">
<span class="info-label">Źródło:</span>

View File

@ -580,7 +580,7 @@
<p>Przegląd kompletności i jakości danych {{ total }} firm w katalogu</p>
</div>
<div class="dq-timestamp">
Stan na {{ now.strftime('%d.%m.%Y, %H:%M') }}
Stan na {{ now|local_time('%d.%m.%Y, %H:%M') }}
</div>
</div>

View File

@ -450,9 +450,9 @@
<td style="text-align:center;">
{% if cf.reminder %}
{% if cf.reminder.is_read %}
<span style="color:var(--success);font-size:16px;cursor:default;" title="Odczytano {{ cf.reminder.read_at.strftime('%d.%m %H:%M') if cf.reminder.read_at else '' }}. Wysłano {{ cf.reminder.sent_at.strftime('%d.%m %H:%M') }}"></span>
<span style="color:var(--success);font-size:16px;cursor:default;" title="Odczytano {{ cf.reminder.read_at|local_time('%d.%m %H:%M') if cf.reminder.read_at else '' }}. Wysłano {{ cf.reminder.sent_at|local_time('%d.%m %H:%M') }}"></span>
{% else %}
<span style="color:var(--text-secondary);font-size:16px;cursor:default;" title="Wysłano {{ cf.reminder.sent_at.strftime('%d.%m.%Y %H:%M') }}, jeszcze nie odczytano"></span>
<span style="color:var(--text-secondary);font-size:16px;cursor:default;" title="Wysłano {{ cf.reminder.sent_at|local_time('%d.%m.%Y %H:%M') }}, jeszcze nie odczytano"></span>
{% endif %}
{% endif %}
</td>

View File

@ -587,7 +587,7 @@
{{ status_labels.get(topic.status, 'Nowy') }}
</span>
</td>
<td class="topic-meta">{{ topic.created_at.strftime('%d.%m.%Y') }}</td>
<td class="topic-meta">{{ topic.created_at|local_time('%d.%m.%Y') }}</td>
<td>
<div class="action-buttons">
<button class="btn-icon {% if topic.is_pinned %}active{% endif %}"
@ -652,7 +652,7 @@
<div class="reply-meta">
{{ reply.author.name or reply.author.email.split('@')[0] }}
w temacie <a href="{{ url_for('forum_topic', topic_id=reply.topic_id) }}">{{ reply.topic.title[:30] }}{% if reply.topic.title|length > 30 %}...{% endif %}</a>
&bull; {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}
&bull; {{ reply.created_at|local_time('%d.%m.%Y %H:%M') }}
</div>
</div>
<button class="btn-icon danger"

View File

@ -386,7 +386,7 @@
</span>
</td>
<td>{{ topic.author.name or topic.author.email.split('@')[0] }}</td>
<td>{{ topic.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ topic.created_at|local_time('%d.%m.%Y %H:%M') }}</td>
<td>{{ topic.days_waiting }} dni</td>
</tr>
{% endfor %}

View File

@ -123,7 +123,7 @@
<strong>{{ topic.title }}</strong>
<div class="deleted-meta">
Autor: {{ topic.author.name or topic.author.email.split('@')[0] }}
&bull; Utworzono: {{ topic.created_at.strftime('%d.%m.%Y %H:%M') }}
&bull; Utworzono: {{ topic.created_at|local_time('%d.%m.%Y %H:%M') }}
</div>
</div>
<button class="btn-restore" onclick="restoreTopic({{ topic.id }})">
@ -134,7 +134,7 @@
{{ topic.content[:300] }}{% if topic.content|length > 300 %}...{% endif %}
</div>
<div class="deleted-info">
Usunięto: {{ topic.deleted_at.strftime('%d.%m.%Y %H:%M') if topic.deleted_at else 'brak daty' }}
Usunięto: {{ topic.deleted_at|local_time('%d.%m.%Y %H:%M') if topic.deleted_at else 'brak daty' }}
{% if topic.deleter %}przez {{ topic.deleter.name or topic.deleter.email.split('@')[0] }}{% endif %}
</div>
</div>
@ -157,7 +157,7 @@
<div class="deleted-meta">
W temacie: <a href="{{ url_for('forum_topic', topic_id=reply.topic_id) }}" target="_blank">{{ reply.topic.title }}</a>
<br>Autor: {{ reply.author.name or reply.author.email.split('@')[0] }}
&bull; Utworzono: {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}
&bull; Utworzono: {{ reply.created_at|local_time('%d.%m.%Y %H:%M') }}
</div>
</div>
<button class="btn-restore" onclick="restoreReply({{ reply.id }})">
@ -168,7 +168,7 @@
{{ reply.content[:300] }}{% if reply.content|length > 300 %}...{% endif %}
</div>
<div class="deleted-info">
Usunięto: {{ reply.deleted_at.strftime('%d.%m.%Y %H:%M') if reply.deleted_at else 'brak daty' }}
Usunięto: {{ reply.deleted_at|local_time('%d.%m.%Y %H:%M') if reply.deleted_at else 'brak daty' }}
{% if reply.deleter %}przez {{ reply.deleter.name or reply.deleter.email.split('@')[0] }}{% endif %}
</div>
</div>

View File

@ -200,7 +200,7 @@
<span class="report-reason reason-{{ report.reason }}">{{ reason_labels.get(report.reason, report.reason) }}</span>
<span class="report-meta">
Zgłoszone przez {{ report.reporter.name or report.reporter.email.split('@')[0] }}
&bull; {{ report.created_at.strftime('%d.%m.%Y %H:%M') }}
&bull; {{ report.created_at|local_time('%d.%m.%Y %H:%M') }}
</span>
</div>
<span class="report-meta">
@ -242,7 +242,7 @@
<div class="report-meta">
{% if report.reviewed_by %}
Rozpatrzone przez {{ report.reviewer.name or report.reviewer.email.split('@')[0] }}
&bull; {{ report.reviewed_at.strftime('%d.%m.%Y %H:%M') }}
&bull; {{ report.reviewed_at|local_time('%d.%m.%Y %H:%M') }}
{% endif %}
{% if report.review_note %}
<br>Notatka: {{ report.review_note }}

View File

@ -696,8 +696,8 @@
<td class="date-cell">
{% if company.audit_date %}
{% set days_ago = (now - company.audit_date).days %}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.audit_date.strftime('%Y-%m-%d %H:%M') }}">
{{ company.audit_date.strftime('%d.%m.%Y') }}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.audit_date|local_time('%Y-%m-%d %H:%M') }}">
{{ company.audit_date|local_time('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>

View File

@ -354,7 +354,7 @@
Health Check Dashboard
</h1>
<div class="health-meta">
<span>Ostatnia aktualizacja: <strong id="last-update">{{ generated_at.strftime('%H:%M:%S') }}</strong></span>
<span>Ostatnia aktualizacja: <strong id="last-update">{{ generated_at|local_time('%H:%M:%S') }}</strong></span>
<span>Auto-refresh: <strong id="countdown">2:00</strong></span>
</div>
</div>

View File

@ -662,7 +662,7 @@
data-zarzad="{{ company.people_count|default(0, true) }}"
data-pkd="{{ company.pkd_count|default(0, true) }}"
data-status="{{ 'audited' if company.krs_last_audit_at else 'pending' }}"
data-date="{{ company.krs_last_audit_at.strftime('%Y%m%d') if company.krs_last_audit_at else '00000000' }}">
data-date="{{ company.krs_last_audit_at|local_time('%Y%m%d') if company.krs_last_audit_at else '00000000' }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
</td>
@ -720,8 +720,8 @@
</td>
<td class="date-cell">
{% if company.krs_last_audit_at %}
<span title="{{ company.krs_last_audit_at.strftime('%Y-%m-%d %H:%M') }}">
{{ company.krs_last_audit_at.strftime('%d.%m.%Y') }}
<span title="{{ company.krs_last_audit_at|local_time('%Y-%m-%d %H:%M') }}">
{{ company.krs_last_audit_at|local_time('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>

View File

@ -258,8 +258,8 @@
</td>
<td>
{% if app.submitted_at %}
{{ app.submitted_at.strftime('%Y-%m-%d') }}
<br><small class="text-muted">{{ app.submitted_at.strftime('%H:%M') }}</small>
{{ app.submitted_at|local_time('%Y-%m-%d') }}
<br><small class="text-muted">{{ app.submitted_at|local_time('%H:%M') }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}

View File

@ -729,7 +729,7 @@
<div class="print-header">
<h2>Izba Gospodarcza Norda Biznes</h2>
<p>Deklaracja członkowska nr {{ application.id }} — {{ application.company_name }}</p>
<p>Data złożenia: {{ application.submitted_at.strftime('%d.%m.%Y, %H:%M') if application.submitted_at else 'brak' }} | Status: {{ application.status_label }}</p>
<p>Data złożenia: {{ application.submitted_at|local_time('%d.%m.%Y, %H:%M') if application.submitted_at else 'brak' }} | Status: {{ application.status_label }}</p>
</div>
<a href="{{ url_for('admin.admin_membership') }}" class="back-link">
@ -944,7 +944,7 @@
<div class="data-value">
{% if application.declaration_accepted %}
✅ Zaakceptowane
<br><small>{{ application.declaration_accepted_at.strftime('%Y-%m-%d %H:%M') if application.declaration_accepted_at else '' }}</small>
<br><small>{{ application.declaration_accepted_at|local_time('%Y-%m-%d %H:%M') if application.declaration_accepted_at else '' }}</small>
<br><small>IP: {{ application.declaration_ip_address or '-' }}</small>
{% else %}
❌ Nie zaakceptowane
@ -1071,7 +1071,7 @@
<strong>Oczekuje na akceptację użytkownika</strong>
<br>Zaproponowano zmiany danych z rejestru. Użytkownik musi je zaakceptować lub odrzucić.
{% if application.proposed_changes_at %}
<br><small>Zaproponowano: {{ application.proposed_changes_at.strftime('%Y-%m-%d %H:%M') }}</small>
<br><small>Zaproponowano: {{ application.proposed_changes_at|local_time('%Y-%m-%d %H:%M') }}</small>
{% endif %}
{% if application.proposed_changes_comment %}
<br><small>Komentarz: {{ application.proposed_changes_comment }}</small>
@ -1119,7 +1119,7 @@
</div>
<div class="tracker-label">{{ step.label }}</div>
{% if step.date %}
<div class="tracker-date">{{ step.date.strftime('%d.%m.%Y') }}<br>{{ step.date.strftime('%H:%M') }}</div>
<div class="tracker-date">{{ step.date|local_time('%d.%m.%Y') }}<br>{{ step.date|local_time('%H:%M') }}</div>
{% endif %}
</div>
@ -1162,7 +1162,7 @@
<div class="history-dot dot-green"></div>
<div class="history-content">
<div class="history-title">Deklaracja zatwierdzona</div>
<div class="history-meta">{{ application.updated_at.strftime('%d.%m.%Y %H:%M') }}</div>
<div class="history-meta">{{ application.updated_at|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
</div>
{% endif %}
@ -1171,7 +1171,7 @@
<div class="history-dot dot-yellow"></div>
<div class="history-content">
<div class="history-title">Rozpoczęto rozpatrywanie</div>
<div class="history-meta">{{ application.reviewed_at.strftime('%d.%m.%Y %H:%M') }}</div>
<div class="history-meta">{{ application.reviewed_at|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
</div>
{% endif %}
@ -1180,7 +1180,7 @@
<div class="history-dot dot-blue"></div>
<div class="history-content">
<div class="history-title">Deklaracja złożona</div>
<div class="history-meta">{{ application.submitted_at.strftime('%d.%m.%Y %H:%M') }}{% if application.user %} · {{ application.user.name }}{% endif %}</div>
<div class="history-meta">{{ application.submitted_at|local_time('%d.%m.%Y %H:%M') }}{% if application.user %} · {{ application.user.name }}{% endif %}</div>
</div>
</div>
{% endif %}
@ -1226,11 +1226,11 @@
</div>
<div class="data-item">
<div class="data-label">Data zgłoszenia</div>
<div class="data-value">{{ application.created_at.strftime('%Y-%m-%d %H:%M') if application.created_at else '-' }}</div>
<div class="data-value">{{ application.created_at|local_time('%Y-%m-%d %H:%M') if application.created_at else '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Data wysłania</div>
<div class="data-value">{{ application.submitted_at.strftime('%Y-%m-%d %H:%M') if application.submitted_at else '-' }}</div>
<div class="data-value">{{ application.submitted_at|local_time('%Y-%m-%d %H:%M') if application.submitted_at else '-' }}</div>
</div>
</div>
</div>

View File

@ -211,7 +211,7 @@
<p class="subtitle">Deklaracja Przystąpienia do Izby nr {{ app.id }}</p>
<p class="company-name">{{ app.company_name }}</p>
<p class="meta">
Data złożenia: {{ app.submitted_at.strftime('%d.%m.%Y, godz. %H:%M') if app.submitted_at else 'brak' }}
Data złożenia: {{ app.submitted_at|local_time('%d.%m.%Y, godz. %H:%M') if app.submitted_at else 'brak' }}
&nbsp;&bull;&nbsp; Status: {{ app.status_label }}
</p>
</div>
@ -320,7 +320,7 @@
</div>
<div class="date">
{% if app.declaration_accepted_at %}
{{ app.declaration_accepted_at.strftime('%d.%m.%Y, %H:%M') }}
{{ app.declaration_accepted_at|local_time('%d.%m.%Y, %H:%M') }}
{% endif %}
</div>
</div>

View File

@ -284,7 +284,7 @@
<!-- Latest audit header -->
<h2 style="font-size: var(--font-size-lg); margin-bottom: var(--spacing-md); color: var(--text-secondary);">
Ostatni audyt: {{ latest.audited_at.strftime('%d.%m.%Y %H:%M') }}
Ostatni audyt: {{ latest.audited_at|local_time('%d.%m.%Y %H:%M') }}
{% if latest.notes %}<span style="font-size: var(--font-size-sm); font-weight: 400;"> &mdash; {{ latest.notes }}</span>{% endif %}
</h2>
@ -452,7 +452,7 @@
{% set f_cit = f.get('citations', []) %}
{% set f_cit_found = f_cit|selectattr('status', 'equalto', 'found')|list|length if f_cit else 0 %}
<tr>
<td>{{ a.audited_at.strftime('%d.%m %H:%M') }}</td>
<td>{{ a.audited_at|local_time('%d.%m %H:%M') }}</td>
{# PageSpeed scores #}
{% macro std(val, good=90, ok=50) %}<td>{% if val is not none %}<span class="score-badge {{ 'good' if val >= good else ('ok' if val >= ok else 'bad') }}">{{ val }}</span>{% else %}&mdash;{% endif %}</td>{% endmacro %}

View File

@ -63,7 +63,7 @@
</a>
<h1 style="margin-top: var(--spacing-sm);">Audyt SEO #{{ audit.id }}</h1>
<p style="color: var(--text-secondary);">
{{ audit.url }} &mdash; {{ audit.audited_at.strftime('%d.%m.%Y %H:%M') }}
{{ audit.url }} &mdash; {{ audit.audited_at|local_time('%d.%m.%Y %H:%M') }}
{% if audit.notes %} &mdash; {{ audit.notes }}{% endif %}
</p>
</div>

View File

@ -415,7 +415,7 @@
{{ rec.recommendation_text }}
</div>
</td>
<td class="recommendation-meta">{{ rec.created_at.strftime('%d.%m.%Y') }}</td>
<td class="recommendation-meta">{{ rec.created_at|local_time('%d.%m.%Y') }}</td>
<td>
{% if rec.status == 'pending' %}
<span class="badge badge-pending">Oczekuje</span>

View File

@ -494,7 +494,7 @@
<div class="section-header">
<h2>🌍 Statystyki GeoIP Blocking</h2>
<div style="text-align: right; font-size: var(--font-size-xs); color: var(--text-secondary);">
<div>Ostatnia aktualizacja: <span id="geoip-timestamp" style="font-family: monospace; font-weight: 500;">{{ generated_at.strftime('%H:%M:%S') }}</span></div>
<div>Ostatnia aktualizacja: <span id="geoip-timestamp" style="font-family: monospace; font-weight: 500;">{{ generated_at|local_time('%H:%M:%S') }}</span></div>
<div style="margin-top: 4px;">
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; margin-right: 4px; animation: pulse 2s infinite;"></span>
Auto-refresh: <span id="geoip-countdown">5:00</span>
@ -579,7 +579,7 @@
<td><span class="ip-address">{{ alert.ip_address or '-' }}</span></td>
<td>{{ alert.user_email or '-' }}</td>
<td><span class="badge badge-{{ alert.status }}">{{ alert.status }}</span></td>
<td><span class="timestamp">{{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td><span class="timestamp">{{ alert.created_at|local_time('%Y-%m-%d %H:%M') }}</span></td>
<td>
{% if alert.status == 'new' %}
<div class="action-buttons">
@ -642,7 +642,7 @@
{% if log.entity_name %}<br><small>{{ log.entity_name }}</small>{% endif %}
</td>
<td><span class="ip-address">{{ log.ip_address or '-' }}</span></td>
<td><span class="timestamp">{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td><span class="timestamp">{{ log.created_at|local_time('%Y-%m-%d %H:%M') }}</span></td>
</tr>
{% endfor %}
</tbody>
@ -677,7 +677,7 @@
<tr class="locked-row">
<td>{{ user.email }}</td>
<td>{{ user.failed_login_attempts }}</td>
<td><span class="timestamp">{{ user.locked_until.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td><span class="timestamp">{{ user.locked_until|local_time('%Y-%m-%d %H:%M') }}</span></td>
<td>
<form method="POST" action="{{ url_for('admin.unlock_account', user_id=user.id) }}" style="display:inline;">
{{ csrf_token() }}

View File

@ -249,7 +249,7 @@
{% if analysis and analysis.seo_audited_at %}
<div class="audit-info">
<span>Ostatni audyt: <strong>{{ analysis.seo_audited_at.strftime('%d.%m.%Y %H:%M') }}</strong></span>
<span>Ostatni audyt: <strong>{{ analysis.seo_audited_at|local_time('%d.%m.%Y %H:%M') }}</strong></span>
{% if analysis.cms_detected %}<span>CMS: <strong>{{ analysis.cms_detected }}</strong></span>{% endif %}
{% if analysis.hosting_provider %}<span>Hosting: <strong>{{ analysis.hosting_provider }}</strong></span>{% endif %}
{% if analysis.load_time_ms %}<span>Czas ladowania: <strong>{{ analysis.load_time_ms }}ms</strong></span>{% endif %}
@ -1124,7 +1124,7 @@
{% if analysis.ssl_expires_at %}
<div class="detail-item">
<span class="detail-label">Certyfikat SSL wygasa</span>
<span class="detail-value">{{ analysis.ssl_expires_at.strftime('%d.%m.%Y') }}</span>
<span class="detail-value">{{ analysis.ssl_expires_at|local_time('%d.%m.%Y') }}</span>
</div>
{% endif %}
{% if analysis.ssl_issuer %}
@ -1196,7 +1196,7 @@
{% if analysis.last_modified_at %}
<div class="detail-item">
<span class="detail-label">Ostatnia modyfikacja strony</span>
<span class="detail-value">{{ analysis.last_modified_at.strftime('%d.%m.%Y %H:%M') }}</span>
<span class="detail-value">{{ analysis.last_modified_at|local_time('%d.%m.%Y %H:%M') }}</span>
</div>
{% endif %}
</div>
@ -1214,7 +1214,7 @@
{% if analysis.analyzed_at %}
<div class="detail-item">
<span class="detail-label">Data audytu</span>
<span class="detail-value">{{ analysis.analyzed_at.strftime('%d.%m.%Y %H:%M') }}</span>
<span class="detail-value">{{ analysis.analyzed_at|local_time('%d.%m.%Y %H:%M') }}</span>
</div>
{% endif %}
{% if analysis.audit_source %}

View File

@ -907,8 +907,8 @@
<td class="date-cell hide-mobile">
{% if company.last_verified %}
{% set days_ago = (now - company.last_verified).days %}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.last_verified.strftime('%Y-%m-%d %H:%M') }}">
{{ company.last_verified.strftime('%d.%m.%Y') }}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.last_verified|local_time('%Y-%m-%d %H:%M') }}">
{{ company.last_verified|local_time('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">-</span>

View File

@ -955,13 +955,13 @@
{% if p.verified_at %}
<div class="provenance-detail">
<span class="label">Zweryfikowano:</span>
{{ p.verified_at.strftime('%d.%m.%Y %H:%M') }}
{{ p.verified_at|local_time('%d.%m.%Y %H:%M') }}
</div>
{% endif %}
{% if p.last_checked_at %}
<div class="provenance-detail">
<span class="label">Ostatni check:</span>
{{ p.last_checked_at.strftime('%d.%m.%Y %H:%M') }}
{{ p.last_checked_at|local_time('%d.%m.%Y %H:%M') }}
</div>
{% endif %}
<div class="provenance-detail">
@ -1017,7 +1017,7 @@
<a href="{{ url_for('admin.social_publisher_company_settings', company_id=company.id) }}">Wybierz stronę &rarr;</a>
{% endif %}
{% if p.oauth_last_sync %}
<span style="margin-left: auto; color: var(--text-secondary);">Sync: {{ p.oauth_last_sync.strftime('%d.%m.%Y') }}</span>
<span style="margin-left: auto; color: var(--text-secondary);">Sync: {{ p.oauth_last_sync|local_time('%d.%m.%Y') }}</span>
{% endif %}
</div>
{% elif p.oauth_connected and p.oauth_expired %}

View File

@ -453,7 +453,7 @@
{% endif %}
{% endif %}
{% if fb.last_checked_at %}
<div style="margin-top: var(--spacing-sm); font-size: 11px; opacity: 0.6;">Ostatnia synchronizacja: {{ fb.last_checked_at.strftime('%d.%m.%Y %H:%M') }}</div>
<div style="margin-top: var(--spacing-sm); font-size: 11px; opacity: 0.6;">Ostatnia synchronizacja: {{ fb.last_checked_at|local_time('%d.%m.%Y %H:%M') }}</div>
{% endif %}
<div style="margin-top: var(--spacing-sm); font-size: 11px; opacity: 0.5;">
Publikowanie nie działa? Zmiana hasła FB lub usunięcie aplikacji wymaga ponownego połączenia w
@ -582,11 +582,11 @@
</td>
<td style="white-space: nowrap; font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if post.published_at %}
{{ post.published_at.strftime('%Y-%m-%d %H:%M') }}
{{ post.published_at|local_time('%Y-%m-%d %H:%M') }}
{% elif post.scheduled_at %}
{{ post.scheduled_at.strftime('%Y-%m-%d %H:%M') }}
{{ post.scheduled_at|local_time('%Y-%m-%d %H:%M') }}
{% else %}
{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '-' }}
{{ post.created_at|local_time('%Y-%m-%d %H:%M') if post.created_at else '-' }}
{% endif %}
</td>
<td class="engagement-cell">
@ -638,7 +638,7 @@
<h3>Posty z Facebooka
{% if cached_fb_posts.get(company_id_key) %}
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: 400;">
({{ cached_fb_posts[company_id_key].total_count }} postów, cache z {{ cached_fb_posts[company_id_key].cached_at.strftime('%d.%m %H:%M') }})
({{ cached_fb_posts[company_id_key].total_count }} postów, cache z {{ cached_fb_posts[company_id_key].cached_at|local_time('%d.%m %H:%M') }})
</span>
{% endif %}
</h3>

View File

@ -220,7 +220,7 @@
| Status: {% if config.is_active %}<span class="status-active">Aktywna</span>{% else %}Nieaktywna{% endif %}
| Debug: {% if config.debug_mode %}<span style="color: var(--warning); font-weight: 600;">Wlaczony</span>{% else %}Wylaczony{% endif %}
{% if config.updated_at %}
| Ostatnia aktualizacja: {{ config.updated_at.strftime('%Y-%m-%d %H:%M') }}
| Ostatnia aktualizacja: {{ config.updated_at|local_time('%Y-%m-%d %H:%M') }}
{% endif %}
</div>
{% endif %}

View File

@ -492,7 +492,7 @@
| <strong>Model AI:</strong> {{ post.ai_model }}
{% endif %}
{% if post.published_at %}
| <strong>Opublikowano:</strong> {{ post.published_at.strftime('%Y-%m-%d %H:%M') }}
| <strong>Opublikowano:</strong> {{ post.published_at|local_time('%Y-%m-%d %H:%M') }}
{% endif %}
{% if post.status == 'published' and post.meta_post_id %}
|

View File

@ -370,8 +370,8 @@
</div>
<div class="refresh-info">
<div class="label">Ostatnia aktualizacja</div>
<div class="timestamp" id="last-update">{{ generated_at.strftime('%H:%M:%S') }}</div>
<div class="label" style="margin-top: var(--spacing-xs);">{{ generated_at.strftime('%d.%m.%Y') }}</div>
<div class="timestamp" id="last-update">{{ generated_at|local_time('%H:%M:%S') }}</div>
<div class="label" style="margin-top: var(--spacing-xs);">{{ generated_at|local_time('%d.%m.%Y') }}</div>
<div class="next-refresh">
<span class="refresh-indicator"></span>
Auto-refresh: <span id="countdown">5:00</span>

View File

@ -380,7 +380,7 @@
</div>
<div class="refresh-info">
<div class="label">Ostatnia aktualizacja</div>
<div class="timestamp" id="refresh-time">{{ now.strftime('%H:%M:%S') if now is defined else '--:--:--' }}</div>
<div class="timestamp" id="refresh-time">{{ now|local_time('%H:%M:%S') if now is defined else '--:--:--' }}</div>
</div>
</div>

View File

@ -334,7 +334,7 @@
{% for s in recent_sessions %}
<tr data-user="{{ s.user_name }}" data-device="{{ s.device_type }}" data-browser="{{ s.browser }}{{ ' (PWA)' if s.is_pwa }}">
<td>{{ s.user_name }}</td>
<td data-sort-value="{{ s.started_at.strftime('%Y%m%d%H%M') if s.started_at else '0' }}">{{ s.started_at.strftime('%d.%m.%Y %H:%M') if s.started_at else '-' }}</td>
<td data-sort-value="{{ s.started_at|local_time('%Y%m%d%H%M') if s.started_at else '0' }}">{{ s.started_at|local_time('%d.%m.%Y %H:%M') if s.started_at else '-' }}</td>
<td>
{% set dt = s.device_type|lower %}
<span class="device-badge device-{{ dt if dt in ['desktop','mobile','tablet'] else 'other' }}">
@ -386,7 +386,7 @@
<td class="num">{{ u.session_count }}</td>
<td class="num">{{ u.total_time_min }}</td>
<td class="num">{{ u.total_pages }}</td>
<td data-sort-value="{{ u.last_login.strftime('%Y%m%d%H%M') if u.last_login else '0' }}">{{ u.last_login.strftime('%d.%m.%Y %H:%M') if u.last_login else '-' }}</td>
<td data-sort-value="{{ u.last_login|local_time('%Y%m%d%H%M') if u.last_login else '0' }}">{{ u.last_login|local_time('%d.%m.%Y %H:%M') if u.last_login else '-' }}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -911,7 +911,7 @@
{% if msg.feedback_rating == 2 %}Pomocne{% else %}Do poprawy{% endif %}
</span>
<span style="font-size: var(--font-size-xs); color: var(--text-muted);">
{{ msg.feedback_at.strftime('%d.%m %H:%M') if msg.feedback_at else '' }}
{{ msg.feedback_at|local_time('%d.%m %H:%M') if msg.feedback_at else '' }}
</span>
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); max-height: 50px; overflow: hidden;">

View File

@ -156,11 +156,11 @@
</div>
<div class="profile-meta-item">
<span class="profile-meta-label">Rejestracja</span>
<span class="profile-meta-value">{{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'N/A' }}</span>
<span class="profile-meta-value">{{ user.created_at|local_time('%d.%m.%Y') if user.created_at else 'N/A' }}</span>
</div>
<div class="profile-meta-item">
<span class="profile-meta-label">Ostatni login</span>
<span class="profile-meta-value">{{ user.last_login.strftime('%d.%m.%Y %H:%M') if user.last_login else 'Nigdy' }}</span>
<span class="profile-meta-value">{{ user.last_login|local_time('%d.%m.%Y %H:%M') if user.last_login else 'Nigdy' }}</span>
</div>
</div>
</div>
@ -221,7 +221,7 @@
{% if resolution.first_symptom %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Pierwszy objaw</div>
<div class="resolution-detail-value">{{ resolution.first_symptom.strftime('%d.%m.%Y %H:%M') }}</div>
<div class="resolution-detail-value">{{ resolution.first_symptom|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
{% endif %}
<div class="resolution-detail-item">
@ -231,7 +231,7 @@
{% if resolution.last_reset %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Ostatni reset</div>
<div class="resolution-detail-value">{{ resolution.last_reset.strftime('%d.%m.%Y %H:%M') }}</div>
<div class="resolution-detail-value">{{ resolution.last_reset|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
{% endif %}
<div class="resolution-detail-item">
@ -275,7 +275,7 @@
<div class="timeline-body">
<div class="timeline-desc {% if event.css == 'danger' %}css-danger{% elif event.css == 'warning' %}css-warning{% elif event.css == 'success' %}css-success{% endif %}">{{ event.desc }}</div>
{% if event.detail %}<div class="timeline-detail">{{ event.detail }}</div>{% endif %}
<div class="timeline-time">{{ event.time.strftime('%d.%m.%Y %H:%M') }}</div>
<div class="timeline-time">{{ event.time|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
</div>
{% endfor %}
@ -361,7 +361,7 @@
<tr>
<td style="max-width: 300px; word-break: break-all; font-size: var(--font-size-xs);">{{ e.message[:100] }}</td>
<td style="font-size: var(--font-size-xs);">{{ e.url[:50] if e.url else 'N/A' }}</td>
<td style="white-space: nowrap;">{{ e.occurred_at.strftime('%d.%m %H:%M') }}</td>
<td style="white-space: nowrap;">{{ e.occurred_at|local_time('%d.%m %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
@ -382,7 +382,7 @@
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</td>
<td style="color: var(--error); font-weight: 600;">{{ p.load_time_ms }}ms</td>
<td style="white-space: nowrap;">{{ p.viewed_at.strftime('%d.%m %H:%M') }}</td>
<td style="white-space: nowrap;">{{ p.viewed_at|local_time('%d.%m %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
@ -409,7 +409,7 @@
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
{% for s in search_queries %}
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
"{{ s.query }}" <span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ s.searched_at.strftime('%d.%m') }}</span>
"{{ s.query }}" <span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ s.searched_at|local_time('%d.%m') }}</span>
</span>
{% endfor %}
</div>

View File

@ -1243,8 +1243,8 @@
</select>
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
data-sort-value="{{ user.created_at.strftime('%Y%m%d%H%M') }}">
{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}
data-sort-value="{{ user.created_at|local_time('%Y%m%d%H%M') }}">
{{ user.created_at|local_time }}
{% if user.created_by_id and creators_map.get(user.created_by_id) %}
<br><span style="font-size: 0.75rem; color: var(--text-muted, #9CA3AF);" title="Dodany przez {{ creators_map[user.created_by_id] }}">dodał: {{ creators_map[user.created_by_id] }}</span>
{% else %}
@ -1252,9 +1252,9 @@
{% endif %}
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
data-sort-value="{{ user.last_login.strftime('%Y%m%d%H%M') if user.last_login else '0' }}">
data-sort-value="{{ user.last_login|local_time('%Y%m%d%H%M') if user.last_login else '0' }}">
{% if user.last_login %}
{{ user.last_login.strftime('%d.%m.%Y %H:%M') }}
{{ user.last_login|local_time }}
{% else %}
<span style="color: var(--text-muted, #9CA3AF);">Nigdy</span>
{% endif %}

View File

@ -1639,7 +1639,7 @@
{% endif %}
<span>{{ news.source_name or news.source_domain or '-' }}</span>
{% set news_year = news.published_at.year if news.published_at else None %}
<span>{{ news.published_at.strftime('%d.%m.%Y') if news.published_at else (news.created_at.strftime('%d.%m.%Y') if news.created_at else '-') }}</span>
<span>{{ news.published_at|local_time('%d.%m.%Y') if news.published_at else (news.created_at|local_time('%d.%m.%Y') if news.created_at else '-') }}</span>
{% if news_year and news_year < min_year %}
<span class="old-news-badge">⚠️ Sprzed {{ min_year }}</span>
{% endif %}
@ -1917,7 +1917,7 @@
<div class="fetch-job">
<div>
<strong>{{ job.search_query }}</strong>
<br><small class="text-muted">{{ job.created_at.strftime('%d.%m.%Y %H:%M') if job.created_at else '-' }}</small>
<br><small class="text-muted">{{ job.created_at|local_time('%d.%m.%Y %H:%M') if job.created_at else '-' }}</small>
</div>
<div>
Znaleziono: {{ job.results_found or 0 }} | Nowych: {{ job.results_new or 0 }}

View File

@ -450,7 +450,7 @@
{% if news.status == 'pending' %}Oczekuje{% elif news.status == 'approved' %}Zatwierdzony{% else %}Odrzucony{% endif %}
</span>
</td>
<td>{{ news.published_at.strftime('%d.%m.%Y') if news.published_at else (news.created_at.strftime('%d.%m.%Y') if news.created_at else '-') }}</td>
<td>{{ news.published_at|local_time('%d.%m.%Y') if news.published_at else (news.created_at|local_time('%d.%m.%Y') if news.created_at else '-') }}</td>
<td style="white-space: nowrap;">
{% if news.status == 'pending' %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>

View File

@ -857,8 +857,8 @@
<td class="date-cell">
{% if company.seo_audited_at %}
{% set days_ago = (now - company.seo_audited_at).days %}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.seo_audited_at.strftime('%Y-%m-%d %H:%M') }}">
{{ company.seo_audited_at.strftime('%d.%m.%Y') }}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.seo_audited_at|local_time('%Y-%m-%d %H:%M') }}">
{{ company.seo_audited_at|local_time('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>

View File

@ -386,7 +386,7 @@
</span>
{% endfor %}
<span class="meta-date">
{{ announcement.published_at.strftime('%d %B %Y') if announcement.published_at else '' }}
{{ announcement.published_at|local_time('%d %B %Y') if announcement.published_at else '' }}
</span>
{% if announcement.author %}
<span class="meta-author">
@ -491,7 +491,7 @@
{{ other.title }}
</a>
<div class="date">
{{ other.published_at.strftime('%d.%m.%Y') if other.published_at else '' }}
{{ other.published_at|local_time('%d.%m.%Y') if other.published_at else '' }}
</div>
</div>
{% endfor %}

View File

@ -330,7 +330,7 @@
<div class="card-footer">
<span class="card-date">
{{ ann.published_at.strftime('%d.%m.%Y') if ann.published_at else '' }}
{{ ann.published_at|local_time('%d.%m.%Y') if ann.published_at else '' }}
</span>
<a href="{{ url_for('announcement_detail', slug=ann.slug) }}" class="card-link">
Czytaj wiecej &rarr;

View File

@ -751,7 +751,7 @@
<td style="font-size: var(--font-size-sm); color: var(--text-muted)">{{ doc.original_filename }}</td>
<td style="font-size: var(--font-size-sm)">{{ doc.size_display }}</td>
<td style="font-size: var(--font-size-sm); color: var(--text-muted)">
{{ doc.uploaded_at.strftime('%d.%m.%Y') if doc.uploaded_at else '—' }}
{{ doc.uploaded_at|local_time('%d.%m.%Y') if doc.uploaded_at else '—' }}
{% if doc.uploader %}<br>{{ doc.uploader.name }}{% endif %}
</td>
<td>

View File

@ -307,7 +307,7 @@
</div>
<div class="classified-stats">
<span>{{ classified.views_count }} wyswietl.</span>
<span>{{ classified.created_at.strftime('%d.%m.%Y') }}</span>
<span>{{ classified.created_at|local_time('%d.%m.%Y') }}</span>
</div>
</div>
</div>

View File

@ -746,9 +746,9 @@
<div class="stats-bar">
<span>{{ classified.views_count }} wyswietlen</span>
<span>Dodano: {{ classified.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
<span>Dodano: {{ classified.created_at|local_time('%d.%m.%Y %H:%M') }}</span>
{% if classified.expires_at %}
<span>Wygasa: {{ classified.expires_at.strftime('%d.%m.%Y') }}</span>
<span>Wygasa: {{ classified.expires_at|local_time('%d.%m.%Y') }}</span>
{% endif %}
</div>
@ -807,7 +807,7 @@
{% if q.author.company %}<span style="color: var(--text-secondary); font-weight: normal;"> - {{ q.author.company.name }}</span>{% endif %}
{% if not q.answer %}<span class="pending-badge">Oczekuje na odpowiedz</span>{% endif %}
</div>
<div class="question-date">{{ q.created_at.strftime('%d.%m.%Y %H:%M') }}</div>
<div class="question-date">{{ q.created_at|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
{% if classified.author_id == current_user.id %}
<div class="question-actions">

Some files were not shown because too many files have changed in this diff Show More