Files
akademiawodociagowca/README.md
T
debian 2c3fbc6236
Deploy PROD / deploy (push) Successful in 10s
Skrypt wdrozenia w repozytorium, dokumentacja komend compose
SKRYPT WDROZENIA
`deploy-prod.sh` istnial wylacznie na serwerze, tak samo jak wczesniej cztery
zmienne LiveKit w compose: kto odtwarzalby instancje z repozytorium, nie
dostalby go wcale. Wchodzi do `scripts/` w wersji dzialajacej, z jedna zmiana:
sprawdzeniem czystosci repozytorium na starcie.

Powod jest praktyczny. `git merge --ff-only` odrzuca wdrozenie, gdy na serwerze
sa lokalne zmiany, ale robi to CICHO: deploy konczy sie statusem "sukces",
a kod nie wchodzi na serwer. Zdarzylo sie to dzis i wykrylo dopiero reczne
`git log` na produkcji. Teraz skrypt przerywa i wypisuje pliki.

NAZWA PROJEKTU W KOMENDACH COMPOSE
README uczyl komend produkcyjnych BEZ `-p`, czyli dokladnie tych, ktore
wchodza w cudzy projekt. Katalog aplikacji nazywa sie `app`, wiec compose
bierze z niego nazwe projektu, a na serwerze z kilkoma aplikacjami trafia
w nie te. Zdarzylo sie realnie: `up -d --force-recreate coturn` utworzylo
kontener w projekcie drugiej aplikacji tego samego wlasciciela, walczacy
o ten sam port 3478. Jedynym ostrzezeniem byl komunikat "Found orphan
containers", latwy do przeoczenia.

Poprawione 11 komend produkcyjnych plus dwie nowe sekcje: o nazwie projektu
i o tym, ze `docker restart` NIE przeladowuje zmiennych z `.env` (argumenty
sa zamrozone przy tworzeniu kontenera, wiec po zmianie hasla kontener wstaje
ze stara wartoscia, wygladajac na dzialajacy poprawnie). Komendy lokalne
zostaja bez `-p`: tam nazwa z katalogu nikomu nie wchodzi w droge.

INSTRUKCJA TWORZENIA WEBINARU
Uzupelniona o funkcje dodane w ostatnich dniach: link do sali trafiajacy do
uczestnika automatycznie (potwierdzenie, przypomnienia, strona zgloszenia),
nagrywanie wlaczane przyciskiem w panelu prowadzenia oraz rozmycie tla.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:27:30 +02:00

974 lines
36 KiB
Markdown

# Platforma webinarowa
Platforma do organizacji szkolen i webinarow. Osobna instancja dla kazdego klienta,
roznice miedzy instancjami wylacznie w konfiguracji.
Backend obsluguje konfiguracje instancji, wysylke e-mail i SMS, webinary, zapisy
uczestnikow oraz uwierzytelnianie JWT. Frontend jest na razie strona startowa
sprawdzajaca polaczenie z backendem.
## Uruchomienie lokalne
Wymagany Docker z wtyczka Compose. Nic poza tym nie jest potrzebne, Maven i Node
dzialaja wewnatrz obrazow.
### 1. Przygotuj konfiguracje
```bash
cp .env.example .env
```
Wartosci domyslne wystarczaja do uruchomienia lokalnego. Przed wdrozeniem u klienta
ustaw wlasne `DB_PASSWORD`, `JWT_SECRET` i klucze MinIO.
### 2. Zbuduj i uruchom kontenery
```bash
docker compose up -d --build
```
Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut. Kolejne
korzystaja z cache.
### 3. Sprawdz, czy dziala
```bash
curl http://localhost:8080/api/health
```
Oczekiwana odpowiedz:
```json
{"status":"UP","version":"0.1.0"}
```
Aplikacja jest dostepna pod <http://localhost:8081>. Strona startowa pokazuje status
i wersje backendu pobrane przez `/api/health`.
## Dostepne adresy
| Usluga | Adres | Uwagi |
| --- | --- | --- |
| Frontend | <http://localhost:8081> | nginx serwuje SPA i proxuje `/api` |
| Backend | <http://localhost:8080/api> | bezposrednio, z pominieciem nginx |
| Swagger UI | <http://localhost:8080/api/swagger-ui.html> | tylko przy `SWAGGER_ENABLED=true` |
| Konsola MinIO | <http://localhost:9001> | login z `MINIO_ACCESS_KEY` |
| PostgreSQL | `localhost:5432` | dane z `.env` |
Porty zmieniasz zmiennymi `*_PORT_EXPOSED` w `.env`.
## Strona publiczna
Czesc dostepna bez logowania, dla uczestnikow.
| Ekran | Sciezka |
| --- | --- |
| Strona webinaru z formularzem zapisu | `/w/{slug}` |
| Wlasny zapis, kalendarz i rezygnacja | `/zapis/{token}` |
Adres do rozeslania uczestnikom to `APP_PUBLIC_URL` z ustawien plus `/w/{slug}`,
na przyklad <http://localhost:8081/w/zamowienia-publiczne-2026>.
Formularz jest ukrywany, gdy zapisy nie przyjmuja zgloszen. Uczestnik widzi wtedy
powod: brak miejsc, miniety termin, zapisy zamkniete recznie, webinar odwolany albo
webinar juz sie odbyl. Wersje robocze nie sa dostepne publicznie.
## Adresy: korzen nalezy do goscia, panel ma wlasny prefiks
Pod <http://localhost:8081> jest **publiczna strona glowna Akademii**, dostepna bez
logowania. Panel administracyjny siedzi pod `/panel`.
### Publiczne, bez logowania
| Ekran | Sciezka |
| --- | --- |
| Strona glowna Akademii | `/` |
| Kursy, webinary, cennik | `/kursy`, `/webinary`, `/cennik` |
| Strona kursu | `/kurs/{slug}` |
| Strona zapisu na webinar | `/w/{slug}` |
| Weryfikacja certyfikatu | `/certyfikat/{numer}` |
| Logowanie (wspolne) | `/logowanie` |
### Konta kursantow
Rejestracja i odzyskiwanie hasla dotycza **wylacznie kont uczestnikow**. Konta panelu
zaklada administrator instancji.
| Ekran | Sciezka |
| --- | --- |
| Rejestracja | `/rejestracja` |
| Przypomnienie hasla | `/przypomnienie-hasla` |
| Ustawienie nowego hasla | `/reset-hasla/{token}` |
| Potwierdzenie adresu | `/potwierdzenie/{token}` |
| Pulpit kursanta | `/moje-konto` |
### Panel administracyjny
| Ekran | Sciezka | Dostep |
| --- | --- | --- |
| Pulpit | `/panel` | ADMIN, MODERATOR |
| Webinary | `/panel/webinary` | ADMIN, MODERATOR |
| Formularz webinaru | `/panel/webinary/nowy`, `/panel/webinary/{id}/edycja` | ADMIN, MODERATOR |
| Zapisani | `/panel/webinary/{id}/zapisy` | ADMIN, MODERATOR |
| Kursy | `/panel/kursy` | ADMIN, MODERATOR |
| Ustawienia | `/panel/ustawienia` | tylko ADMIN |
| Uzytkownicy | `/panel/uzytkownicy` | tylko ADMIN |
Stare adresy panelu (`/ustawienia`, `/uzytkownicy`, `/kontakty`, `/zapytania`,
`/zamowienia`, `/banery`, `/pulpit`) oraz stare adresy Akademii (`/akademia/*`)
**przekierowuja na nowe**, wiec zapisane zakladki dzialaja dalej.
**Wyjatek: `/kursy` i `/webinary` nie przekierowuja.** Oba adresy naleza teraz do
publicznych list, bo sa krotkie i trafiaja do wyszukiwarek oraz materialow
reklamowych. Kolizje rozstrzygnieto na korzysc czesci publicznej, swiadomie
godzac sie na to, ze administrator z zapisana zakladka trafi na oferte. Lagodzi
to pasek pokazywany na obu listach **wylacznie osobom z rola ADMIN lub
MODERATOR**: mowi, ze panel jest pod `/panel`, prowadzi tam odnosnikiem i znika
na stale po zamknieciu.
### Jedno logowanie dla obu swiatow
Formularz pod `/logowanie` jest wspolny. Backend (`POST /api/auth/login/unified`)
sprawdza haslo **osobno** w kontach panelu i w kontach kursantow, po czym odpowiada,
dokad kierowac: front niczego nie zgaduje.
Hasla obu swiatow pozostaja **niezalezne**. Token panelu powstaje wylacznie wtedy, gdy
haslo pasowalo do konta panelu, wiec wyciek hasla kursanta nadal nie otwiera panelu.
Gdy to samo haslo pasuje w obu miejscach, uzytkownik dostaje wybor, dokad wejsc.
Dotychczasowe `POST /api/auth/login` i `POST /api/academy/login` **dzialaja bez zmian**.
Terminy sa prezentowane w strefie czasowej webinaru, zawsze z jawnym oznaczeniem strefy.
## Logowanie
Endpointy `/api/admin/**` wymagaja tokenu JWT. Pierwsze konto administratora powstaje
przy pierwszym starcie z `ADMIN_EMAIL` i `ADMIN_PASSWORD` z pliku `.env`.
Gdy `ADMIN_PASSWORD` jest puste, haslo jest losowane i wypisane **jeden raz** w logu:
```bash
docker compose logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO"
```
Logowanie:
```bash
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@localhost","password":"twoje-haslo"}'
```
Token z odpowiedzi doklada sie do kolejnych zadan:
```bash
TOKEN="wklej-token"
curl http://localhost:8080/api/admin/webinars -H "Authorization: Bearer $TOKEN"
```
Zmiana wlasnego hasla:
```bash
curl -X POST http://localhost:8080/api/auth/change-password \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"currentPassword":"stare","newPassword":"nowe-haslo-min-10-znakow"}'
```
### Role
| Sciezka | ADMIN | MODERATOR | bez tokenu |
| --- | --- | --- | --- |
| `/api/health`, `/api/public/**` | tak | tak | tak |
| `/api/admin/webinars/**` | tak | tak | 401 |
| `/api/admin/settings/**` | tak | 403 | 401 |
| `/api/admin/users/**` | tak | 403 | 401 |
### Blokada po nieudanych logowaniach
Po 5 nieudanych probach dla adresu e-mail (albo 20 dla adresu IP) logowanie jest
odrzucane przez 15 minut, takze przy poprawnym hasle. Odpowiedz jest taka sama jak
przy zlym hasle. Liczniki sa w pamieci i znikaja po restarcie kontenera.
## Konfiguracja instancji przez API
Ustawienia trzymane sa w tabeli `app_settings` i maja pierwszenstwo przed zmiennymi
srodowiskowymi. Zmiana dziala od razu, bez restartu aplikacji.
Lista ustawien:
```bash
curl http://localhost:8080/api/admin/settings -H "Authorization: Bearer $TOKEN"
```
Wszystkie ponizsze wywolania wymagaja tokenu administratora, patrz sekcja Logowanie.
Wartosci kluczy typu PASSWORD nigdy nie wracaja w odpowiedzi. Zamiast wartosci jest flaga
`valueSet`, ktora mowi czy haslo jest ustawione.
### Ustawienie konta SMTP
```bash
curl -X PUT http://localhost:8080/api/admin/settings \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"MAIL_HOST": "pzators.nazwa.pl",
"MAIL_PORT": "465",
"MAIL_SSL_ENABLED": "true",
"MAIL_USERNAME": "powiadomienia@twojadomena.pl",
"MAIL_PASSWORD": "haslo-do-konta",
"MAIL_FROM_NAME": "Nazwa nadawcy",
"APP_BRAND_NAME": "Nazwa marki",
"APP_PUBLIC_URL": "https://webinar.twojadomena.pl"
}'
```
Puste pole przy `MAIL_PASSWORD` oznacza "nie zmieniaj" i nie kasuje zapisanego hasla.
### Test wysylki e-mail
```bash
curl -X POST http://localhost:8080/api/admin/settings/test-email \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"recipient":"twoj@adres.pl"}'
```
Odpowiedz zawiera `success` oraz komunikat. Przy bledzie jest to pelna tresc bledu z serwera
SMTP, na przyklad `535 5.7.0 Authentication error`.
### Bramka SMS
```bash
curl -X PUT http://localhost:8080/api/admin/settings \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"SMS_ENABLED": "true",
"SMS_API_KEY": "klucz-api-bramki",
"SMS_CREATOR": "Webinar"
}'
curl -X POST http://localhost:8080/api/admin/settings/test-sms \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"recipient":"600100200"}'
```
## Wdrozenie na serwerze klienta
Kazdy klient dostaje wlasna instancje: wlasny serwer, wlasna domena, wlasna baza.
Kod jest ten sam, roznice wylacznie w konfiguracji.
### Co jest po stronie klienta, a co po naszej
Aplikacja nasluchuje **po HTTP, wewnatrz sieci Dockera**, na petli lokalnej serwera
(domyslnie `127.0.0.1:8081`). Nie obsluguje SSL i nie jest wystawiona bezposrednio
do internetu.
**Certyfikat SSL i reverse proxy sa po stronie serwera klienta.** Administrator
serwera musi postawic przed aplikacja nginx, Caddy, Traefik albo inny serwer,
ktory zakonczy polaczenie HTTPS i przekaze ruch na port aplikacji. Bez tego
aplikacja nie bedzie dostepna z zewnatrz, i tak ma byc.
Przykladowa konfiguracja nginx po stronie klienta:
```nginx
server {
listen 443 ssl http2;
server_name webinary.klient.pl;
ssl_certificate /etc/letsencrypt/live/webinary.klient.pl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/webinary.klient.pl/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# aplikacja czyta adres uczestnika z tego naglowka, wiec musi byc ustawiony
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# naglowek User-Agent jest potrzebny do rozpoznania robotow komunikatorow
proxy_set_header User-Agent $http_user_agent;
}
}
server {
listen 80;
server_name webinary.klient.pl;
return 301 https://$host$request_uri;
}
```
### Krok 1: przygotowanie serwera
Wymagany Docker z wtyczka Compose. Sprawdz:
```bash
docker --version
docker compose version
```
Sklonuj repozytorium i wejdz do katalogu projektu:
```bash
git clone <adres-repozytorium> webinar-platform
cd webinar-platform
```
### Krok 2: konfiguracja instancji
```bash
cp .env.production.example .env.production
chmod 600 .env.production
```
Wygeneruj sekrety i wpisz je do `.env.production`:
```bash
# sekret JWT, minimum 32 znaki. Aplikacja nie wystartuje bez niego.
openssl rand -base64 48
# haslo bazy danych
openssl rand -base64 24
# klucz tajny MinIO
openssl rand -base64 24
```
Uzupelnij w `.env.production`:
| Zmienna | Wartosc |
| --- | --- |
| `JWT_SECRET` | wynik `openssl rand -base64 48` |
| `DB_PASSWORD` | wynik `openssl rand -base64 24` |
| `MINIO_ACCESS_KEY` | dowolny login, np. `webinar-minio` |
| `MINIO_SECRET_KEY` | wynik `openssl rand -base64 24` |
| `ADMIN_EMAIL` | adres pierwszego administratora |
| `ADMIN_PASSWORD` | zostaw **puste**, haslo zostanie wylosowane |
| `APP_PUBLIC_URL` | `https://webinary.klient.pl`, bez ukosnika na koncu |
| `CORS_ALLOWED_ORIGINS` | to samo co `APP_PUBLIC_URL` |
`ADMIN_PASSWORD` lepiej zostawic puste: haslo zostanie wygenerowane losowo
i pokazane raz w logu, wiec nie bedzie lezalo w pliku na dysku.
Konfiguracji SMTP i bramki SMS **nie ma w tym pliku**. Ustawia sie ja w panelu
po uruchomieniu, w kroku 5.
### Krok 3: sprawdzenie konfiguracji
Przed pierwszym uruchomieniem:
```bash
./scripts/check-config.sh --skip-db
```
Skrypt **odmawia startu** (kod wyjscia 1), gdy `JWT_SECRET` jest pusty, krotszy
niz 32 znaki albo rowny wartosci przykladowej, gdy `ADMIN_PASSWORD` jest rowne
przykladowej, albo gdy `APP_PUBLIC_URL` jest pusty lub wskazuje na `localhost`.
Kazdy z tych przypadkow kosztuje na produkcji wiecej niz nieudany start:
przykladowy sekret oznacza, ze **kazdy, kto widzial repozytorium, moze podrobic
token administratora**, a adres lokalny oznacza martwe linki w kazdym mailu.
Po uruchomieniu warto powtorzyc bez `--skip-db`: skrypt odpyta wtedy instancje
o klucze wymagane, ktore ustawia sie w panelu (SMTP, bramka SMS, dostawca wideo).
Lista wymaganych kluczy pochodzi z katalogu `SettingKey` w backendzie, wiec nie
rozjedzie sie z kodem przy dodaniu nowego ustawienia.
### Krok 4: uruchomienie
```bash
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --build
```
Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut.
Sprawdz, czy wszystkie kontenery dzialaja:
```bash
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production ps
```
### Krok 5: haslo administratora i pierwsze logowanie
Jesli `ADMIN_PASSWORD` bylo puste, odczytaj wygenerowane haslo z logu:
```bash
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production \
logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO"
```
Haslo jest pokazane **tylko raz**. Zapisz je w menedzerze hasel, zaloguj sie
do panelu pod `https://webinary.klient.pl/logowanie` i zmien je od razu:
Ustawienia konta, albo `POST /api/auth/change-password`.
### Krok 6: konfiguracja poczty i bramki SMS przez panel
SMTP i bramka SMS konfigurowane sa **wylacznie w panelu**, nie w pliku `.env`.
Wartosci trafiaja do bazy, wiec zmiana dziala od razu, bez restartu kontenera,
a klient moze poprawic je samodzielnie.
1. Zaloguj sie do panelu i wejdz w **Ustawienia**.
2. Uzupelnij sekcje **Konto e-mail (SMTP)**:
- `MAIL_HOST` serwer poczty, na przyklad `smtp.domena.pl`
- `MAIL_PORT` 465 dla SSL albo 587 dla STARTTLS
- `MAIL_SSL_ENABLED` wlaczone dla portu 465
- `MAIL_USERNAME` pelny adres e-mail nadawcy
- `MAIL_PASSWORD` haslo do konta
- `MAIL_FROM_NAME` nazwa widoczna dla odbiorcy
3. Kliknij **Zapisz ustawienia**.
4. W bloku **Test konfiguracji** pod ta sekcja wpisz swoj adres i kliknij **Wyslij**.
Przy powodzeniu zobaczysz potwierdzenie, przy bledzie pelna tresc odpowiedzi
serwera SMTP, na przyklad `535 5.7.0 Authentication error`.
5. Sprawdz, czy wiadomosc dotarla, takze do folderu ze spamem.
6. Analogicznie uzupelnij sekcje **Bramka SMS** i przetestuj ja przyciskiem
**Wyslij** w bloku testu.
Haslo SMTP i klucz API bramki sa sekretami: nigdy nie wracaja w odpowiedzi API,
a puste pole przy zapisie oznacza brak zmiany, nie skasowanie wartosci.
### Krok 7: weryfikacja wdrozenia
Pelny test odbiorczy, punkt po punkcie, jest w osobnym pliku:
**[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)**. Obejmuje obie sciezki
(webinarowa i Akademii) oraz to, czego nie da sie sprawdzic lokalnie: notyfikacje
ITN Autopay, nagrywanie przez Egress, webhook dostawcy wideo i zajawki
w komunikatorach.
Skrocone sprawdzenie:
```bash
# aplikacja odpowiada wewnatrz serwera
curl http://127.0.0.1:8081/api/health
# aplikacja odpowiada przez reverse proxy, z zewnatrz
curl https://webinary.klient.pl/api/health
```
Oczekiwana odpowiedz w obu przypadkach:
```json
{"status":"UP","version":"0.1.0"}
```
Sprawdz jeszcze:
- panel otwiera sie pod `https://webinary.klient.pl/logowanie` i logowanie dziala,
- baza i MinIO **nie** sa dostepne z zewnatrz:
`nc -zv webinary.klient.pl 5432` oraz `nc -zv webinary.klient.pl 9000`
powinny odmowic polaczenia,
- utworz probny webinar, opublikuj go i wejdz na `/w/{slug}` z telefonu,
- zapisz sie na probny webinar i sprawdz, czy przyszedl mail z zalacznikiem
`invite.ics`,
- usun probny webinar przed przekazaniem instancji klientowi.
### Aktualizacja instancji
```bash
git pull
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --build
```
Migracje Flyway wykonuja sie automatycznie przy starcie backendu. Przed
aktualizacja wykonaj kopie zapasowa, patrz sekcja nizej.
### Nazwa projektu jest obowiazkowa, gdy serwer dzieli kilka aplikacji
**`-p <nazwa>` w kazdej komendzie `docker compose`.** Bez tej flagi compose
bierze nazwe projektu **z katalogu roboczego**, a katalogi bywaja nazwane
ogolnie (`app`, `www`, `docker`). Na serwerze z kilkoma aplikacjami konczy sie
to wejsciem w cudzy projekt: `up -d` restartuje wtedy nie te uslugi, a
`up -d <usluga>` tworzy obcy kontener walczacy o ten sam port hosta.
Zdarzylo sie to realnie na instancji, gdzie katalog nazywal sie `app`, a na tym
samym serwerze stala druga aplikacja w projekcie o tej samej nazwie. Jedynym
ostrzezeniem byl latwy do przeoczenia komunikat "Found orphan containers".
Nazwa projektu ma odpowiadac instancji, nie katalogowi. Po kazdej recznej
komendzie warto sprawdzic, czy nie przybylo kontenerow z obcym prefiksem:
```bash
docker ps --format '{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Status}}' | sort
```
### Zmienne z `.env` wymagaja odtworzenia kontenera, nie restartu
**`docker restart` NIE przeladowuje `.env`.** Argumenty uruchomienia sa
zamrozone w konfiguracji kontenera przy jego tworzeniu, wiec po zmianie hasla
albo klucza kontener wstaje ze **stara wartoscia**, wygladajac przy tym na
dzialajacy poprawnie. Objawia sie to dopiero tym, ze usluga cicho nie
uwierzytelnia sie u drugiej strony.
```bash
# zmiana wartosci w .env: konieczne odtworzenie
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --force-recreate coturn
# sprawdzenie, ze kontener ma wartosc z pliku, bez pokazywania sekretu
docker inspect akademia-coturn-1 --format '{{json .Config.Cmd}}' | grep -o 'webinar:[^"]*' | cut -d: -f2 | sha256sum | cut -c1-16
grep '^TURN_PASSWORD=' .env.production | cut -d= -f2- | sha256sum | cut -c1-16
```
### Zatrzymanie i restart
```bash
# restart pojedynczej uslugi (bez zmian w .env)
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production restart backend
# zatrzymanie calosci, dane w wolumenach zostaja
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production down
```
Nie uzywaj `down -v` na produkcji: ta komenda usuwa wolumeny wraz z danymi.
### Wdrozenie automatyczne
`scripts/deploy-prod.sh` pobiera zmiany z `main` i przebudowuje obrazy.
Wolany przez Gitea Actions po kazdym pushu, dziala takze recznie. Skrypt
**przerywa wdrozenie, gdy repozytorium na serwerze ma lokalne zmiany**:
`git merge --ff-only` i tak by ich nie nadpisal, ale robil to cicho, wiec
deploy konczyl sie statusem "sukces", a kod nie wchodzil na serwer.
## Kopia zapasowa i odtworzenie
### Wykonanie kopii
```bash
./scripts/backup.sh
```
Kopia trafia do `./backups/RRRRMMDD-GGMMSS/` i zawiera:
| Plik | Zawartosc |
| --- | --- |
| `baza.dump` | zrzut PostgreSQL w formacie custom |
| `minio.tar.gz` | zawartosc wolumenu MinIO |
| `env.production.kopia` | konfiguracja instancji, **zawiera sekrety** |
| `INFO.txt` | data, nazwa serwera, instrukcja odtworzenia |
Katalog docelowy mozna podac jako argument:
```bash
./scripts/backup.sh /mnt/backup/webinar
```
Skrypt zachowuje 14 ostatnich kopii i usuwa starsze. Zmien zmienna `KEEP_LAST`,
gdy potrzebujesz innej retencji.
### Kopia automatyczna
Wpis do crona, codziennie o 3:00:
```bash
crontab -e
```
```
0 3 * * * cd /opt/webinar-platform && ./scripts/backup.sh /mnt/backup/webinar >> /var/log/webinar-backup.log 2>&1
```
Kopie zawieraja sekrety, wiec katalog docelowy musi miec ograniczone uprawnienia,
a jesli trafiaja poza serwer, powinny byc szyfrowane.
#### Ile miejsca potrzeba
Rozmiar kopii to prawie w calosci **nagrania**: baza nawet przy kilku tysiacach
zapisow rzadko przekracza 100 MB, a jedno nagranie godzinnego webinaru w 720p
wazy 400 MB do 1 GB.
| Skladnik | Rozmiar jednej kopii |
| --- | --- |
| Baza (`baza.dump`, format custom) | 5-100 MB |
| Materialy i obrazki w tresci | 50-500 MB |
| Certyfikaty PDF | okolo 200 kB na sztuke |
| **Nagrania** | **400 MB - 1 GB na godzine nagrania** |
Wzor na miejsce potrzebne przy domyslnym `KEEP_LAST=14`:
```
miejsce = (rozmiar wolumenu MinIO + rozmiar bazy) x 14
```
Przyklad: instancja z 20 godzinami nagran zajmuje okolo 12 GB, wiec 14 kopii
wymaga **okolo 170 GB**. Przy takiej skali warto:
- zmniejszyc `KEEP_LAST` do 7 i trzymac kopie tygodniowe osobno,
- albo wylaczyc nagrania z kopii codziennej i archiwizowac je rzadziej: pliki
w MinIO sa **niezmienne po wgraniu**, wiec kopia sprzed tygodnia zawiera
dokladnie te same nagrania co dzisiejsza.
**Sprawdzenie biezacego rozmiaru:**
```bash
# wolumen MinIO, czyli nagrania, materialy, certyfikaty i obrazki
docker system df -v | grep minio-data
# baza
docker compose -p akademia -f docker-compose.prod.yml exec -T postgres \
psql -U webinar -d webinar -c "SELECT pg_size_pretty(pg_database_size('webinar'));"
```
**Kopia bez nagran**, gdy miejsce jest ograniczone: zrzut samej bazy wystarczy
do odtworzenia calej konfiguracji, wszystkich zapisow, kursow i wynikow.
Zginie wtedy wylacznie **tresc plikow**, a wpisy w bazie zostana i beda
wskazywac na nieistniejace obiekty.
```
0 3 * * * cd /opt/webinar-platform && SKIP_MINIO=1 ./scripts/backup.sh >> /var/log/webinar-backup.log 2>&1
0 4 * * 0 cd /opt/webinar-platform && ./scripts/backup.sh /mnt/backup/webinar-pelna >> /var/log/webinar-backup.log 2>&1
```
Pierwszy wpis: codziennie sama baza. Drugi: raz w tygodniu calosc.
### Odtworzenie
```bash
./scripts/restore.sh ./backups/20260828-143000
```
Skrypt pokazuje metryczke kopii i wymaga wpisania `ODTWORZ`, zeby kontynuowac.
Nastepnie zatrzymuje backend, odtwarza baze i pliki MinIO, i uruchamia backend
z powrotem.
**Odtworzenie nadpisuje biezace dane.** Przed odtworzeniem wykonaj kopie stanu
biezacego, zeby dalo sie wrocic:
```bash
./scripts/backup.sh
./scripts/restore.sh ./backups/20260828-143000
```
Po odtworzeniu sprawdz:
```bash
curl http://127.0.0.1:8081/api/health
```
oraz zaloguj sie do panelu i obejrzyj liste webinarow i zapisow.
### Odtworzenie na nowym serwerze
1. Zainstaluj Dockera, sklonuj repozytorium.
2. Skopiuj `env.production.kopia` z kopii jako `.env.production` i ustaw
`chmod 600`. Zachowaj **ten sam** `JWT_SECRET`, inaczej wszyscy zostana
wylogowani, oraz to samo `DB_PASSWORD`, bo odtwarzana baza go uzywa.
3. Uruchom uslugi: `docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d`.
4. Odtworz dane: `./scripts/restore.sh <katalog-kopii>`.
5. Przekieruj domene na nowy serwer i sprawdz `/api/health`.
## Wideo: wybor dostawcy
Platforma nie jest przywiazana do jednego dostawcy. Wybor jest na dwoch
poziomach: ustawienie instancji (`Ustawienia > Wideo`) i opcjonalny wybor przy
pojedynczym webinarze, ktory ma pierwszenstwo.
### Co gwarantuje ktory dostawca
| Dostawca | Uprawnienia | Nagrywanie | Wymaga |
| --- | --- | --- | --- |
| **LIVEKIT** | egzekwuje **serwer**, na podstawie podpisanego tokenu | automatyczne, prosto do MinIO | wlasny serwer albo LiveKit Cloud |
| **JITSI** z JWT | serwer egzekwuje **tylko role moderatora** | reczne wgranie pliku | wlasny serwer Jitsi z JWT |
| **JITSI** bez JWT | **tylko interfejs**, pokoj otwarty dla kazdego | reczne wgranie pliku | nic, dziala na `meet.jit.si` |
| **DAILY** | egzekwuje serwer | pobierane od dostawcy | konto Daily.co |
| **NONE** | brak, organizator wkleja wlasny link | reczne wgranie pliku | nic |
**Roznica miedzy LiveKit a Jitsi bez JWT jest zasadnicza, nie kosmetyczna.**
Przy LiveKit uczestnik bez prawa publikacji nie wlaczy kamery, bo serwer odrzuci
strumien. Przy Jitsi bez JWT ukrycie przycisku kamery jest jedynym, co go
powstrzymuje, a wystarczy wejsc wlasnym klientem, zeby to obejsc.
### Wlasny serwer LiveKit
Kontenery sa w profilu `livekit`, wylaczonym domyslnie: instancja w trybie NONE
albo na Jitsi nie uruchamia niczego zbednego.
```bash
# uzupelnij w .env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, TURN_PASSWORD
docker compose -p akademia --profile livekit -f docker-compose.prod.yml --env-file .env.production up -d
```
Ten sam klucz i sekret wpisz w panelu (`Ustawienia > Wideo`): tutaj konfiguruja
serwer, w panelu sluza do podpisywania tokenow wejscia.
**Porty do otwarcia na firewallu:**
| Port | Protokol | Do czego |
| --- | --- | --- |
| 7880 | TCP | sygnalizacja WebSocket i API serwera |
| 7881 | TCP | media po TCP, droga zapasowa gdy UDP jest zablokowane |
| 50000-50100 | **UDP** | **glowna droga audio i wideo** |
| 49160-49200 | UDP | TURN, dla uczestnikow za restrykcyjnym NAT-em |
**Zakres UDP jest obowiazkowy.** Bez niego uczestnicy dolacza do pokoju i zobacza
liste obecnych, ale nie beda sie slyszec ani widziec. To najczestsza przyczyna
zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze.
TURN (`coturn`) dziala w `network_mode: host`, bo musi widziec prawdziwe adresy
IP: za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera.
### Pulapki uruchomieniowe LiveKit
Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze
wdrozenie**, a zadna nie daje czytelnego komunikatu.
**1. Zakres portow UDP musi byc wolny w systemie.**
Windows rezerwuje zakresy dla Hyper-V i WSL (sprawdzenie:
`netsh interface ipv4 show excludedportrange protocol=udp`). Domyslne
50000-50100 wpada w rezerwacje 50000-50059 i kontener nie wstaje z bledem
"ports are not available". Zmiana `LIVEKIT_UDP_RANGE` w `.env` wystarcza:
zakres idzie do serwera zmienna `UDP_PORT` i **musi byc taki sam po obu
stronach mapowania portow**, inaczej Docker odrzuca konfiguracje.
**2. `use_external_ip: true` nadpisuje `NODE_IP`.**
Przy uruchomieniu lokalnym serwer wykrywa publiczny adres lacza i rozglasza go
uczestnikom, mimo ustawionego `NODE_IP=127.0.0.1`. Przegladarka na tej samej
maszynie probuje sie tam polaczyc i nie zestawia mediow. Objaw jest mylacy:
**uczestnik wchodzi do pokoju i widzi liste obecnych, ale nikogo nie slyszy
i nie widzi**. Do testow lokalnych trzeba ustawic `use_external_ip: false`
w `livekit/livekit.yaml`.
**3. Adres serwera to dwie rozne wartosci.**
`LIVEKIT_URL` opisuje adres widziany przez **przegladarke** (`ws://localhost:7880`
lokalnie). Backend w kontenerze trafilby pod nim sam w siebie, wiec do wywolan
API i Egress sluzy osobne `LIVEKIT_INTERNAL_URL` (`http://livekit:7880`).
Bez tego nagrywanie konczy sie bledem "Failed to connect to localhost:7880"
przy poprawnie dzialajacym serwerze.
**4. Wersja serwera musi nadazac za `livekit-client`.**
Klient 2.22 laczy sie sciezka `/rtc/v1`, ktorej serwer 1.8 nie zna: przegladarka
dostaje 404 i komunikat "v1 RTC path not found. Consider upgrading your LiveKit
server version". Sala wyglada na dzialajaca, ale nie plynie ani obraz, ani
dzwiek. Obraz jest przypiety do `v1.13`; **podnoszac bibliotekę we froncie,
sprawdz wersje serwera**.
**5. Nagrywanie wymaga Egress i Redisa.**
Sam serwer LiveKit nie nagrywa. Egress dostaje zadanie **przez Redisa**, wiec
bez niego serwer przyjmuje zadanie, nie ma go komu przekazac, a nagrywanie
konczy sie timeoutem. Oba kontenery sa w profilu `livekit`.
**6. Egress uruchamia prawdziwa przegladarke.**
Stad `cap_add: SYS_ADMIN` (piaskownica Chrome) i `shm_size: 1gb` (domyslne
64 MB nie wystarcza i przegladarka wysypuje sie w polowie nagrania).
**7. Nie uzywaj `network_mode: host` dla Egress na Docker Desktop.**
Na Windows i macOS kontenery chodza w maszynie wirtualnej, wiec tryb host laczy
kontener z siecia **tej maszyny**, a nie z hostem: kontener widzi wtedy adres
`192.168.65.x`, a nie adres systemu. Skutek jest widoczny dopiero w logach
serwera: kandydaci ICE Egress maja **puste adresy IP** (`udp host :41557`),
serwer odrzuca je jako `[filtered]`, a polaczenie mediow przechodzi
`connected -> disconnected -> failed`. Nagranie konczy sie wtedy bledem
"Start signal not received", ktory nie ma z sygnalem nic wspolnego.
Egress ma zostac w zwyklej sieci Compose. **`network_mode: bridge` tez jest
bledem**: to domyslna siec Dockera, a nie siec Compose, wiec kontener przestaje
widziec `redis` i `livekit` po nazwie (`lookup redis: no such host`).
**8. Egress otwiera strone szablonu.**
Domyslnie pobiera ja z `https://template.livekit.io`, czyli z internetu:
**instancja w sieci zamknietej nie nagra niczego**. Dlatego wskazujemy wlasny
szablon (`LIVEKIT_TEMPLATE_URL`, domyslnie `http://frontend/nagrywanie`),
serwowany z naszego frontu.
### Stan weryfikacji nagrywania
**Nagrywanie przez Egress nie zostalo sprawdzone end-to-end.** Zweryfikowane
zostalo wszystko, co do niego prowadzi, ale nie powstal plik.
Co **dziala** i jest potwierdzone na zywo:
- spotkania: obraz i dzwiek plyna miedzy uczestnikami (zmierzone przyrostem
bajtow w statystykach WebRTC odbiorcy),
- uprawnienia egzekwuje serwer: uczestnik z rola SPEAKER opublikowal audio,
a **tej samej probie dla wideo serwer nie nadal zadnego wpisu o sciezce**,
- Egress laczy sie z Redisem (`service ready`), przyjmuje zadanie, dolacza do
pokoju i **zestawia polaczenie mediow po UDP** z prawidlowym adresem,
- szablon nagrywania jest pobierany i wykonywany: jego JavaScript laczy sie
z pokojem jako uczestnik `EG_*`.
Gdzie proces sie **zatrzymuje**: szablon nie wola `window.START_RECORDING()`,
bo w chwili nagrywania **nie zasubskrybowal zadnej sciezki**. Egress przerywa
wtedy nagranie z kodem 412 "Start signal not received".
Przyczyna po stronie srodowiska, nie kodu: zeby to domknac, potrzebny jest
uczestnik faktycznie publikujacy obraz **w tej samej sieci co Egress**.
W srodowisku testowym na Windows serwer rozglasza jeden adres (`NODE_IP`),
wiec albo dziala przegladarka na hoscie, albo kontenery w sieci Dockera, ale
nie oba naraz. Na Linuksie, gdzie kontenery i przegladarka widza ten sam
adres, ten podzial nie wystepuje.
**Funkcjonalnie nic to nie blokuje**: reczne wgranie pliku nagrania w zakladce
"Po webinarze" dziala i pozostaje droga awaryjna dla kazdego dostawcy.
Przed pierwszym wdrozeniem z automatycznym nagrywaniem nalezy te sciezke
przejsc na docelowym serwerze.
### Jitsi
Zakladamy serwer **zewnetrzny**, wskazywany domena w `Ustawienia > Wideo`.
Nie ma go w `docker-compose.yml`: wlasny serwer Jitsi to kilkanascie uslug
i osobne wdrozenie, a `meet.jit.si` dziala bez konfiguracji.
Nagrywanie Jitsi wymaga komponentu Jibri, ktorego nie zakladamy: to osobna
usluga z wlasna przegladarka i wlasnym zuzyciem CPU na kazde nagranie. Panel
podpowiada w tym przypadku reczne wgranie pliku w zakladce "Po webinarze".
## Platnosci Autopay
Kursy platne rozlicza Autopay (dawniej BlueMedia). Integracja obejmuje
przedtransakcje, notyfikacje ITN i nadanie dostepu do kursu.
### Czego NIE DA SIE sprawdzic na localhost
**ITN wymaga publicznej domeny z waznym certyfikatem.** Autopay sam laczy sie
z naszym serwerem, wiec `localhost` jest dla niego nieosiagalny. Sprawdzone
lokalnie zostalo wszystko, co dzieje sie **po** odebraniu komunikatu:
| Element | Jak sprawdzony |
| --- | --- |
| Skrot na przykladzie z dokumentacji | `AutopayHashTest`, 11 testow |
| Pomijanie pustych pol wraz z separatorem | `AutopayHashTest` |
| Parsowanie ITN i odpornosc na XXE | `AutopayItnTest`, 11 testow |
| Weryfikacja podpisu, kwoty i waluty | test przeplywu na instancji |
| Idempotencja i FAILURE po SUCCESS | test przeplywu na instancji |
### Konfiguracja przed testem
Ustawienia w panelu, `Ustawienia -> Aplikacja`:
| Klucz | Wartosc na czas testow |
| --- | --- |
| `AUTOPAY_ENABLED` | `true` |
| `AUTOPAY_MODE` | `TEST` |
| `AUTOPAY_SERVICE_ID` | identyfikator z panelu Autopay |
| `AUTOPAY_SHARED_KEY` | klucz wspoldzielony z panelu Autopay |
| `AUTOPAY_HASH_ALGORITHM` | zgodny z ustawieniem po stronie operatora |
| `AUTOPAY_ITN_ALLOWED_IPS` | puste, dopoki nie znasz aktualnych adresow |
**Adresy srodowisk sa zaszyte w kodzie i nie sa ustawieniem:**
`https://testpay.autopay.eu` dla testow, `https://pay.autopay.eu` dla produkcji.
Pomylka oznaczalaby przyjmowanie prawdziwych pieniedzy na srodowisku testowym
albo transakcje testowe na produkcji, ktore nigdy sie nie rozlicza.
W panelu Autopay trzeba wskazac adres notyfikacji:
```
https://TWOJA-DOMENA/api/webhooks/autopay/itn
```
### Co przetestowac po wdrozeniu
Kolejnosc ma znaczenie: kazdy kolejny punkt zaklada, ze poprzedni przeszedl.
1. **Przedtransakcja odpowiada.** Kup kurs platny jako kursant. Powinienes
trafic na bramke Autopay, a nie zobaczyc bledu. W logu backendu nie moze byc
ostrzezenia `brak linku kontynuacji`. Zamowienie dostaje status `PENDING`.
2. **Blad konfiguracji jest czytelny.** Wpisz zly `AUTOPAY_SERVICE_ID`
i sprobuj kupic: kursant ma zobaczyc komunikat o niedostepnosci platnosci,
a zamowienie zostac w `NEW`, gotowe do ponowienia na tym samym numerze.
W logu pojawi sie `SERVICE_NOT_FOUND`.
3. **Platnosc udana nadaje dostep.** Zaplac w srodowisku testowym operatora.
Po powrocie strona pokazuje oczekiwanie na potwierdzenie, a dostep pojawia
sie dopiero **po komunikacie ITN**. W panelu, na karcie zamowienia, ma byc
widoczny komunikat `SUCCESS` z oznaczeniem `podpis poprawny`
i `przetworzony`.
4. **Powrot z bramki niczego nie nadaje.** Wejdz recznie na adres powrotu bez
platnosci. Dostepu ma nie byc: adres powrotu to zwykle przekierowanie
przegladarki, ktore da sie wywolac samemu.
5. **Platnosc nieudana nie nadaje dostepu.** Powtorz zakup i wybierz w bramce
scenariusz niepowodzenia. Zamowienie ma dostac `FAILED`, dostepu ma nie byc.
6. **FAILURE po SUCCESS nie odbiera dostepu.** To najwazniejszy przypadek:
zaplac skutecznie, a potem doprowadz do komunikatu `FAILURE` z **innym**
`remoteID`. Dostep ma **zostac**. Dokumentacja operatora opisuje ten
przypadek wprost, a jego zlamanie zabiera kurs osobie, ktora zaplacila.
7. **Powtorzona notyfikacja nie dubluje niczego.** Autopay ponawia komunikat
az do potwierdzenia. Po kilku powtorkach ma byc **jeden** zapis na kurs
i jeden mail, a w dzienniku kilka wpisow, z ktorych tylko pierwszy ma
`przetworzony`.
8. **Kwota niezgodna jest odrzucana.** Jesli operator udostepnia narzedzie do
recznego wyslania ITN, wyslij komunikat z inna kwota niz zamowiona: ma dostac
`NOTCONFIRMED`, a dostepu ma nie byc.
9. **Odpowiedz jest poprawnie podpisana.** Operator odrzuci potwierdzenie
z bledny skrotem i bedzie ponawial notyfikacje. Objawem jest rosnaca liczba
wpisow w dzienniku dla tego samego zamowienia.
### Scenariusze testowe operatora
Dane kart i przelewow testowych sa w dokumentacji Autopay:
- Dokumentacja techniczna: <https://developers.autopay.pl/>
- Panel srodowiska testowego: <https://testpay.autopay.eu/>
Scenariusze udanej i nieudanej platnosci, wraz z danymi testowymi, operator
udostepnia w panelu po zalozeniu konta testowego. **Nie zgadywac ich**: numery
kart testowych zmieniaja sie miedzy wersjami uslugi.
### Gdzie szukac przy problemie
- **Karta zamowienia w panelu** (`Akademia -> Zamowienia`) pokazuje pelny
dziennik komunikatow ITN wraz z surowa trescia i informacja, czy podpis sie
zgadzal. Przy sporze o platnosc to jedyny dowod.
- **Komunikat odrzucony tez jest zapisany**, z oznaczeniem `podpis niepoprawny`.
Brak wpisu oznacza, ze notyfikacja w ogole nie dotarla: sprawdz adres
w panelu operatora i czy domena ma wazny certyfikat.
- `docker compose -p akademia -f docker-compose.prod.yml logs -f backend | grep -i autopay`
### Reczne nadanie dostepu
Przy platnosci przelewem tradycyjnym: karta zamowienia, przycisk
**Nadaj dostep recznie**. Notatka jest wymagana, bo bez niej po miesiacu nie
da sie ustalic, czy pieniadze wplynely i kto to sprawdzil.
## Testy
Testy integracyjne wymagaja dzialajacego kontenera postgres, bo sprawdzaja blokade
wiersza w prawdziwej bazie.
```bash
docker compose up -d postgres
docker run --rm --network webinar-platform_default \
-e TEST_DB_HOST=postgres \
-v "$(pwd)/backend:/b" -v webinar-m2:/root/.m2 -w /b \
maven:3.9-eclipse-temurin-21 mvn -B test
```
Najwazniejszy z nich, `RegistrationConcurrencyTest`, uruchamia 40 rownoczesnych zapisow
na webinar z limitem 10 miejsc i sprawdza, ze w bazie powstalo dokladnie 10 rekordow.
## Zatrzymanie
```bash
docker compose down
```
Dane w wolumenach zostaja zachowane. Aby usunac takze dane:
```bash
docker compose down -v
```
## Praca nad frontendem bez Dockera
Serwer deweloperski Vite z hot reloadem, przy backendzie dzialajacym w Dockerze:
```bash
cd frontend
npm install
npm run dev
```
Frontend startuje na <http://localhost:5173>. Aby odpytywal backend bezposrednio,
utworz `frontend/.env.local`:
```
VITE_API_URL=http://localhost:8080/api
```
Origin `http://localhost:5173` jest juz na liscie `CORS_ALLOWED_ORIGINS`.
## Struktura
```
backend/ Spring Boot, pakiet com.softspm.webinar
frontend/ React + TypeScript + Vite, nginx w obrazie produkcyjnym
docker-compose.yml
.env.example
```
Migracje bazy trafiaja do `backend/src/main/resources/db/migration` jako `Vn__opis.sql`.
Schemat tworzy wylacznie Flyway, Hibernate go tylko weryfikuje (`ddl-auto: validate`).
Test automatycznego deployu.