@@ -0,0 +1,78 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Zarzadzanie kluczami API. Dostep wylacznie dla roli ADMIN, wymuszony regula
|
||||
* w SecurityConfig na sciezce /admin/api-keys/**.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/api-keys")
|
||||
@Tag(name = "Klucze API (admin)", description = "Zarzadzanie kluczami do publicznego API")
|
||||
public class ApiKeyAdminController {
|
||||
|
||||
private final ApiKeyService service;
|
||||
|
||||
public ApiKeyAdminController(ApiKeyService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista kluczy API. Bez samego klucza, tylko prefiks.")
|
||||
public List<ApiKeyView> list() {
|
||||
return service.list();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Utworzenie klucza. Surowy klucz zwracany jeden raz.")
|
||||
public ApiKeyCreated create(@Valid @RequestBody CreateRequest request) {
|
||||
return service.create(request.name());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/regenerate")
|
||||
@Operation(summary = "Wygenerowanie nowego klucza dla tego odbiorcy. Nowy klucz zwracany raz, stary przestaje dzialac.")
|
||||
public ApiKeyCreated regenerate(@PathVariable Long id) {
|
||||
return service.regenerate(id);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/activate")
|
||||
@Operation(summary = "Wlaczenie klucza")
|
||||
public ApiKeyView activate(@PathVariable Long id) {
|
||||
return service.setActive(id, true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/deactivate")
|
||||
@Operation(summary = "Wylaczenie klucza. Odbiera dostep natychmiast.")
|
||||
public ApiKeyView deactivate(@PathVariable Long id) {
|
||||
return service.setActive(id, false);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Skasowanie klucza na trwale")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
}
|
||||
|
||||
/** Zadanie utworzenia klucza: nazwa odbiorcy. */
|
||||
public record CreateRequest(
|
||||
@NotBlank(message = "Nazwa klucza jest wymagana")
|
||||
@Size(max = 120, message = "Nazwa klucza jest zbyt długa")
|
||||
String name) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
/**
|
||||
* Odpowiedz na utworzenie klucza: widok plus SUROWY klucz.
|
||||
*
|
||||
* Surowy klucz (`key`) jest w tej odpowiedzi jeden jedyny raz i nie da sie go
|
||||
* pozniej odczytac: w bazie jest tylko skrot. Panel ma go od razu pokazac
|
||||
* administratorowi do skopiowania i przekazania odbiorcy.
|
||||
*/
|
||||
public record ApiKeyCreated(ApiKeyView apiKey, String key) {
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
import com.softspm.webinar.domain.ApiKey;
|
||||
import com.softspm.webinar.repository.ApiKeyRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Zarzadzanie kluczami API i ich weryfikacja.
|
||||
*
|
||||
* GENEROWANIE
|
||||
* ---------------------------------------------------------------------------
|
||||
* Klucz to 32 bajty (256 bitow) z SecureRandom, zakodowane base64url bez
|
||||
* dopelnienia - 43 znaki bezpieczne w naglowku HTTP i w adresie. 256 bitow
|
||||
* losowych czyni zgadywanie i atak slownikowy bezcelowymi.
|
||||
*
|
||||
* PRZECHOWYWANIE
|
||||
* ---------------------------------------------------------------------------
|
||||
* W bazie jest WYLACZNIE SHA-256 klucza. Surowy klucz zwracamy raz, przy
|
||||
* utworzeniu, i nigdzie go nie zapisujemy: wyciek bazy nie wydaje dzialajacych
|
||||
* kluczy. Skrot jest bez soli, bo przy 256 bitach losowych sol nic nie wnosi,
|
||||
* a deterministyczny skrot pozwala odnalezc klucz jednym zapytaniem.
|
||||
*/
|
||||
@Service
|
||||
public class ApiKeyService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiKeyService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
/** Dlugosc klucza w bajtach przed zakodowaniem. */
|
||||
private static final int KEY_BYTES = 32;
|
||||
|
||||
/** Ile pierwszych znakow klucza zapisujemy jawnie, do rozpoznania w panelu. */
|
||||
private static final int PREFIX_LENGTH = 8;
|
||||
|
||||
private final ApiKeyRepository repository;
|
||||
|
||||
public ApiKeyService(ApiKeyRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApiKeyView> list() {
|
||||
return repository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(ApiKeyView::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy klucz i zwraca go RAZ w postaci surowej.
|
||||
*
|
||||
* Surowy klucz jest w odpowiedzi tylko teraz: kolejne odczyty listy pokaza
|
||||
* juz wylacznie prefiks. Administrator ma go od razu przekazac odbiorcy.
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyCreated create(String rawName) {
|
||||
String name = rawName == null ? "" : rawName.trim();
|
||||
if (name.isEmpty()) {
|
||||
throw new ValidationException("Nazwa klucza jest wymagana");
|
||||
}
|
||||
if (name.length() > 120) {
|
||||
throw new ValidationException("Nazwa klucza jest zbyt długa");
|
||||
}
|
||||
|
||||
String secret = generateKey();
|
||||
String hash = sha256Hex(secret);
|
||||
|
||||
// kolizja skrotu przy 256 bitach losowych jest praktycznie niemozliwa,
|
||||
// ale unikalny indeks i tak jej pilnuje; sprawdzenie tutaj daje czytelny
|
||||
// komunikat zamiast bledu integralnosci
|
||||
if (repository.existsByKeyHash(hash)) {
|
||||
throw new ValidationException("Wystąpił konflikt przy generowaniu klucza, spróbuj ponownie");
|
||||
}
|
||||
|
||||
ApiKey entity = new ApiKey();
|
||||
entity.setName(name);
|
||||
entity.setKeyHash(hash);
|
||||
entity.setKeyPrefix(secret.substring(0, PREFIX_LENGTH));
|
||||
entity.setActive(true);
|
||||
|
||||
ApiKey saved = repository.save(entity);
|
||||
log.info("Utworzono klucz API '{}' (prefiks {})", saved.getName(), saved.getKeyPrefix());
|
||||
|
||||
// surowy klucz wychodzi wylacznie tutaj i nie jest nigdzie zapisywany
|
||||
return new ApiKeyCreated(ApiKeyView.of(saved), secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje NOWY klucz dla istniejacego odbiorcy (rotacja).
|
||||
*
|
||||
* Nazwa i tozsamosc wpisu zostaja, ale poprzedni klucz przestaje dzialac
|
||||
* natychmiast: podmieniamy skrot i prefiks, a `last_used_at` zerujemy, bo
|
||||
* dotyczylo poprzedniego klucza. Surowy nowy klucz zwracamy raz, jak przy
|
||||
* tworzeniu. Sluzy do odzyskania dostepu po zgubieniu klucza, ktorego z
|
||||
* bazy odczytac sie nie da (jest tylko skrot).
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyCreated regenerate(Long id) {
|
||||
ApiKey key = require(id);
|
||||
|
||||
String secret = generateKey();
|
||||
String hash = sha256Hex(secret);
|
||||
if (repository.existsByKeyHash(hash)) {
|
||||
throw new ValidationException("Wystąpił konflikt przy generowaniu klucza, spróbuj ponownie");
|
||||
}
|
||||
|
||||
key.setKeyHash(hash);
|
||||
key.setKeyPrefix(secret.substring(0, PREFIX_LENGTH));
|
||||
// poprzedni klucz przestaje dzialac; ostatnie uzycie dotyczylo jego
|
||||
key.setLastUsedAt(null);
|
||||
key.setActive(true);
|
||||
key.setUpdatedAt(OffsetDateTime.now());
|
||||
|
||||
log.info("Wygenerowano ponownie klucz API '{}' (nowy prefiks {})",
|
||||
key.getName(), key.getKeyPrefix());
|
||||
return new ApiKeyCreated(ApiKeyView.of(key), secret);
|
||||
}
|
||||
|
||||
/** Wlacza albo wylacza klucz. Wylaczony przestaje dzialac natychmiast. */
|
||||
@Transactional
|
||||
public ApiKeyView setActive(Long id, boolean active) {
|
||||
ApiKey key = require(id);
|
||||
key.setActive(active);
|
||||
key.setUpdatedAt(OffsetDateTime.now());
|
||||
log.info("Klucz API '{}' (prefiks {}) {}",
|
||||
key.getName(), key.getKeyPrefix(), active ? "wlaczony" : "wylaczony");
|
||||
return ApiKeyView.of(key);
|
||||
}
|
||||
|
||||
/** Kasuje klucz na trwale. */
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
ApiKey key = require(id);
|
||||
repository.delete(key);
|
||||
log.info("Skasowano klucz API '{}' (prefiks {})", key.getName(), key.getKeyPrefix());
|
||||
}
|
||||
|
||||
/**
|
||||
* Weryfikacja klucza z naglowka.
|
||||
*
|
||||
* Zwraca prawde dla klucza, ktory istnieje i jest aktywny. Przy trafieniu
|
||||
* odnotowuje `last_used_at`, zeby w panelu bylo widac, czy klucz jeszcze
|
||||
* zyje. Skrot liczymy zawsze, takze dla pustego wejscia, i porownanie idzie
|
||||
* w bazie po indeksie, wiec sciezka nie ujawnia, czy sam skrot pasowal.
|
||||
*/
|
||||
@Transactional
|
||||
public boolean verify(String rawKey) {
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String hash = sha256Hex(rawKey.trim());
|
||||
Optional<ApiKey> found = repository.findByKeyHashAndActiveTrue(hash);
|
||||
if (found.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
ApiKey key = found.get();
|
||||
key.setLastUsedAt(OffsetDateTime.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
private ApiKey require(Long id) {
|
||||
return repository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono klucza API"));
|
||||
}
|
||||
|
||||
/** 32 bajty z SecureRandom zakodowane base64url bez dopelnienia. */
|
||||
private String generateKey() {
|
||||
byte[] bytes = new byte[KEY_BYTES];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/** SHA-256 wejscia jako 64 znaki hex. */
|
||||
static String sha256Hex(String input) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
hex.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// SHA-256 jest w kazdej maszynie wirtualnej Javy: ten wyjatek nie wystapi
|
||||
throw new IllegalStateException("Brak algorytmu SHA-256", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
import com.softspm.webinar.domain.ApiKey;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Widok klucza API dla panelu.
|
||||
*
|
||||
* NIGDY nie zawiera samego klucza ani jego skrotu: klucz istnieje w postaci
|
||||
* surowej wylacznie w chwili utworzenia (zwraca go `ApiKeyCreated`), a skrotu
|
||||
* nie wolno wystawiac. Panel rozpoznaje klucz po `prefix`.
|
||||
*/
|
||||
public record ApiKeyView(
|
||||
Long id,
|
||||
String name,
|
||||
String prefix,
|
||||
boolean active,
|
||||
OffsetDateTime lastUsedAt,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
public static ApiKeyView of(ApiKey entity) {
|
||||
return new ApiKeyView(
|
||||
entity.getId(),
|
||||
entity.getName(),
|
||||
entity.getKeyPrefix(),
|
||||
entity.isActive(),
|
||||
entity.getLastUsedAt(),
|
||||
entity.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
import com.softspm.webinar.web.UnauthorizedException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Publiczne API tylko do odczytu, chronione kluczem API.
|
||||
*
|
||||
* DOSTEP
|
||||
* ---------------------------------------------------------------------------
|
||||
* Sciezka jest pod /public/**, wiec Spring Security ja przepuszcza (permitAll).
|
||||
* Uwierzytelnienie robi TEN kontroler, sprawdzajac naglowek X-API-Key przez
|
||||
* ApiKeyService.verify. Ten sam wzorzec co webhooki dostawcy wideo: otwarte
|
||||
* w Security, a sekret sprawdzany w kontrolerze. Brak naglowka, pusty, bledny
|
||||
* albo wylaczony klucz daje jednakowo 401, zeby odpowiedz nie ujawniala,
|
||||
* ktory z warunkow zawiodl.
|
||||
*
|
||||
* WERSJA W SCIEZCE
|
||||
* ---------------------------------------------------------------------------
|
||||
* `/v1/` w adresie pozwala kiedys wystawic `/v2/` bez psucia integracji juz
|
||||
* dzialajacych u odbiorcow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/public/v1")
|
||||
@Tag(name = "Publiczne API (klucz)", description = "Aktywne webinary i kursy, dostep przez naglowek X-API-Key")
|
||||
public class PublicApiController {
|
||||
|
||||
/** Naglowek z kluczem. */
|
||||
public static final String API_KEY_HEADER = "X-API-Key";
|
||||
|
||||
private final ApiKeyService apiKeys;
|
||||
private final PublicApiService data;
|
||||
|
||||
public PublicApiController(ApiKeyService apiKeys, PublicApiService data) {
|
||||
this.apiKeys = apiKeys;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
@GetMapping("/webinars")
|
||||
@Operation(summary = "Aktywne webinary (opublikowane i niezakonczone). Wymaga X-API-Key.")
|
||||
public List<PublicApiDtos.WebinarItem> webinars(
|
||||
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey) {
|
||||
authorize(apiKey);
|
||||
return data.activeWebinars();
|
||||
}
|
||||
|
||||
@GetMapping("/courses")
|
||||
@Operation(summary = "Aktywne kursy (opublikowane). Wymaga X-API-Key.")
|
||||
public List<PublicApiDtos.CourseItem> courses(
|
||||
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey) {
|
||||
authorize(apiKey);
|
||||
return data.activeCourses();
|
||||
}
|
||||
|
||||
/** Odrzuca zadanie bez waznego klucza. Jeden komunikat dla wszystkich przyczyn. */
|
||||
private void authorize(String apiKey) {
|
||||
if (!apiKeys.verify(apiKey)) {
|
||||
throw new UnauthorizedException("Nieprawidłowy lub brakujący klucz API.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Widoki publicznego API z kluczem.
|
||||
*
|
||||
* ZWRACAMY WYLACZNIE DANE JAWNE PLUS STATYSTYKI.
|
||||
* ---------------------------------------------------------------------------
|
||||
* To sa te same dane, ktore i tak widzi gosc na stronie, powiekszone o liczby
|
||||
* zapisanych i kursantow. NIGDY nie wolno dopisac tu danych uczestnikow,
|
||||
* adresu `joinUrl` (link do sali), tresci platnych lekcji ani niczego, co nie
|
||||
* jest publiczne. Kazde pole jest wypisane recznie, tak jak w PublicConfig:
|
||||
* przez to nowe pole encji nie wycieka samym faktem dodania go do modelu.
|
||||
*/
|
||||
public final class PublicApiDtos {
|
||||
|
||||
private PublicApiDtos() {
|
||||
}
|
||||
|
||||
/** Aktywny webinar. */
|
||||
public record WebinarItem(
|
||||
String slug,
|
||||
String title,
|
||||
String description,
|
||||
OffsetDateTime startsAt,
|
||||
OffsetDateTime endsAt,
|
||||
String timezone,
|
||||
boolean registrationOpen,
|
||||
Integer capacity,
|
||||
long registeredCount,
|
||||
String registrationUrl) {
|
||||
}
|
||||
|
||||
/** Aktywny (opublikowany) kurs. */
|
||||
public record CourseItem(
|
||||
String slug,
|
||||
String title,
|
||||
String subtitle,
|
||||
String description,
|
||||
String category,
|
||||
String level,
|
||||
Integer durationMinutes,
|
||||
boolean free,
|
||||
BigDecimal priceGross,
|
||||
String currency,
|
||||
long enrolledCount,
|
||||
String courseUrl) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.softspm.webinar.apikey;
|
||||
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.CourseRepository;
|
||||
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Sklada dane publicznego API: aktywne webinary i aktywne kursy, wraz ze
|
||||
* statystykami.
|
||||
*
|
||||
* Wszystko dzieje sie w jednej transakcji, bo widoki sa skladane z encji
|
||||
* pobieranych leniwie (granica transakcji: encje nie opuszczaja serwisu).
|
||||
* Liczby zapisanych i kursantow ida ZAPYTANIAMI ZBIORCZYMI, nie po jednym na
|
||||
* pozycje, tym samym wzorcem co listy w panelu.
|
||||
*/
|
||||
@Service
|
||||
public class PublicApiService {
|
||||
|
||||
private final WebinarRepository webinars;
|
||||
private final CourseRepository courses;
|
||||
private final RegistrationRepository registrations;
|
||||
private final EnrollmentRepository enrollments;
|
||||
private final SettingsService settings;
|
||||
|
||||
public PublicApiService(WebinarRepository webinars,
|
||||
CourseRepository courses,
|
||||
RegistrationRepository registrations,
|
||||
EnrollmentRepository enrollments,
|
||||
SettingsService settings) {
|
||||
this.webinars = webinars;
|
||||
this.courses = courses;
|
||||
this.registrations = registrations;
|
||||
this.enrollments = enrollments;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PublicApiDtos.WebinarItem> activeWebinars() {
|
||||
List<Webinar> active = webinars.findActive(OffsetDateTime.now());
|
||||
Map<Long, Long> counts = registeredCounts(active);
|
||||
String base = publicBaseUrl();
|
||||
|
||||
return active.stream()
|
||||
.map(w -> new PublicApiDtos.WebinarItem(
|
||||
w.getSlug(),
|
||||
w.getTitle(),
|
||||
w.getDescription(),
|
||||
w.getStartsAt(),
|
||||
w.getEndsAt(),
|
||||
w.getTimezone(),
|
||||
w.isRegistrationOpen(),
|
||||
w.getCapacity(),
|
||||
counts.getOrDefault(w.getId(), 0L),
|
||||
base == null ? null : base + "/w/" + w.getSlug()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PublicApiDtos.CourseItem> activeCourses() {
|
||||
List<Course> published = courses.findPublished();
|
||||
Map<Long, Long> counts = enrolledCounts(published);
|
||||
String base = publicBaseUrl();
|
||||
|
||||
return published.stream()
|
||||
.map(c -> new PublicApiDtos.CourseItem(
|
||||
c.getSlug(),
|
||||
c.getTitle(),
|
||||
c.getSubtitle(),
|
||||
c.getDescription(),
|
||||
c.getCategory(),
|
||||
c.getLevel() == null ? null : c.getLevel().name(),
|
||||
c.getDurationMinutes(),
|
||||
c.isFree(),
|
||||
c.getPriceGross(),
|
||||
c.getCurrency(),
|
||||
counts.getOrDefault(c.getId(), 0L),
|
||||
base == null ? null : base + "/kurs/" + c.getSlug()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Liczba zajetych miejsc per webinar, jednym zapytaniem zbiorczym. */
|
||||
private Map<Long, Long> registeredCounts(List<Webinar> list) {
|
||||
if (list.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> ids = list.stream().map(Webinar::getId).toList();
|
||||
Map<Long, Long> result = new HashMap<>();
|
||||
for (Object[] row : registrations.countActiveByWebinarIds(ids)) {
|
||||
result.put((Long) row[0], (Long) row[1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liczba kursantow per kurs: ACTIVE i COMPLETED razem.
|
||||
*
|
||||
* Oba stany oznaczaja realnego kursanta (dostep do materialow zostaje po
|
||||
* ukonczeniu). CANCELLED i EXPIRED sie nie licza. `countByCourseIds`
|
||||
* przyjmuje jeden status, wiec sumujemy dwa przebiegi.
|
||||
*/
|
||||
private Map<Long, Long> enrolledCounts(List<Course> list) {
|
||||
if (list.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> ids = list.stream().map(Course::getId).toList();
|
||||
Map<Long, Long> result = new HashMap<>();
|
||||
addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.ACTIVE));
|
||||
addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.COMPLETED));
|
||||
return result;
|
||||
}
|
||||
|
||||
private void addCounts(Map<Long, Long> target, List<Object[]> rows) {
|
||||
for (Object[] row : rows) {
|
||||
Long id = (Long) row[0];
|
||||
Long count = (Long) row[1];
|
||||
target.merge(id, count, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publiczny adres instancji do budowania linkow, albo null.
|
||||
*
|
||||
* Zwracamy null zamiast rzucac wyjatek: brak adresu nie moze wywrocic
|
||||
* calego API, a pozycja bez linku nadal niesie sensowne dane. Tak samo
|
||||
* jak SummaryService pomija link do nagrania, gdy adresu nie ma.
|
||||
*/
|
||||
private String publicBaseUrl() {
|
||||
String url = settings.get(SettingKey.APP_PUBLIC_URL);
|
||||
if (url == null || url.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = url.trim();
|
||||
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,13 @@ public class SecurityConfig {
|
||||
auth.requestMatchers("/admin/banners/**").hasRole("ADMIN");
|
||||
auth.requestMatchers("/admin/settings/**").hasRole("ADMIN");
|
||||
auth.requestMatchers("/admin/users/**").hasRole("ADMIN");
|
||||
/*
|
||||
* Klucze API otwieraja dostep do calego publicznego API,
|
||||
* wiec zarzadza nimi wylacznie ADMIN, jak ustawieniami
|
||||
* i kontami. Sam publiczny endpoint /public/v1/** jest w
|
||||
* permitAll powyzej i sprawdza klucz w kontrolerze.
|
||||
*/
|
||||
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN");
|
||||
|
||||
// pozostale operacje administracyjne takze dla moderatora
|
||||
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Klucz API do publicznego API tylko do odczytu.
|
||||
*
|
||||
* W bazie jest WYLACZNIE skrot SHA-256 klucza (`keyHash`), nigdy sam klucz.
|
||||
* Surowy klucz istnieje tylko w chwili utworzenia i jest pokazany raz.
|
||||
* `keyPrefix` to kilka pierwszych znakow klucza, trzymane jawnie wylacznie po
|
||||
* to, zeby rozpoznac klucz na liscie w panelu.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "api_keys")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ApiKey {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** Nazwa odbiorcy, komu klucz wydano. */
|
||||
@Column(name = "name", nullable = false, length = 120)
|
||||
private String name;
|
||||
|
||||
/** SHA-256 klucza jako 64 znaki hex. */
|
||||
@Column(name = "key_hash", nullable = false, length = 64)
|
||||
private String keyHash;
|
||||
|
||||
/** Kilka pierwszych znakow klucza, jawnie, tylko do rozpoznania w panelu. */
|
||||
@Column(name = "key_prefix", nullable = false, length = 16)
|
||||
private String keyPrefix;
|
||||
|
||||
@Column(name = "active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private OffsetDateTime lastUsedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = now;
|
||||
}
|
||||
if (this.updatedAt == null) {
|
||||
this.updatedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.softspm.webinar.repository;
|
||||
|
||||
import com.softspm.webinar.domain.ApiKey;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
|
||||
|
||||
/**
|
||||
* Klucz po skrocie, tylko aktywny.
|
||||
*
|
||||
* Weryfikacja liczy SHA-256 klucza z naglowka i szuka po nim: skrot jest
|
||||
* deterministyczny (klucz ma 256 bitow losowych, wiec sol jest zbedna),
|
||||
* dzieki czemu wyszukanie to jedno zapytanie po indeksie UNIQUE. Wylaczony
|
||||
* klucz nie jest znajdowany, wiec dezaktywacja odbiera dostep natychmiast.
|
||||
*/
|
||||
Optional<ApiKey> findByKeyHashAndActiveTrue(String keyHash);
|
||||
|
||||
boolean existsByKeyHash(String keyHash);
|
||||
|
||||
/** Lista do panelu, najnowsze na gorze. */
|
||||
List<ApiKey> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -95,6 +95,23 @@ public interface WebinarRepository extends JpaRepository<Webinar, Long> {
|
||||
""")
|
||||
List<Webinar> findUpcoming(@Param("now") OffsetDateTime now, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Webinary aktywne dla publicznego API: opublikowane i jeszcze nie
|
||||
* zakonczone.
|
||||
*
|
||||
* Podzial idzie po `endsAt`, nie po statusie FINISHED, dokladnie jak w
|
||||
* panelu (nic w aplikacji nie ustawia FINISHED). W przeciwienstwie do
|
||||
* `findUpcoming` warunkiem jest `endsAt > now`, nie `startsAt > now`, wiec
|
||||
* trwajacy webinar takze jest aktywny.
|
||||
*/
|
||||
@Query("""
|
||||
select w from Webinar w
|
||||
where w.status = com.softspm.webinar.domain.WebinarStatus.PUBLISHED
|
||||
and w.endsAt > :now
|
||||
order by w.startsAt asc
|
||||
""")
|
||||
List<Webinar> findActive(@Param("now") OffsetDateTime now);
|
||||
|
||||
/**
|
||||
* Lista webinarow z wyszukiwaniem po tytule i filtrami.
|
||||
*
|
||||
|
||||
@@ -43,6 +43,16 @@ public class ApiExceptionHandler {
|
||||
return ResponseEntity.badRequest().body(ApiError.of(e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Brak waznego klucza API w publicznym API. Zawsze 401 z tym samym
|
||||
* komunikatem, niezaleznie od tego, czy naglowka nie bylo, czy klucz byl
|
||||
* bledny albo wylaczony.
|
||||
*/
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ApiError> handleUnauthorized(UnauthorizedException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiError.of(e.getMessage()));
|
||||
}
|
||||
|
||||
/** Bledy adnotacji walidacyjnych na polach DTO. */
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiError> handleFieldErrors(MethodArgumentNotValidException e) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.softspm.webinar.web;
|
||||
|
||||
/**
|
||||
* Zadanie bez waznego uwierzytelnienia, mapowane na 401.
|
||||
*
|
||||
* Uzywane przez publiczne API z kluczem: brak naglowka, pusty albo bledny
|
||||
* klucz oraz klucz wylaczony daja ten sam wynik, zeby odpowiedz nie ujawniala,
|
||||
* ktory z tych warunkow zawiodl.
|
||||
*/
|
||||
public class UnauthorizedException extends RuntimeException {
|
||||
|
||||
public UnauthorizedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Klucze API do publicznego, chronionego API tylko do odczytu.
|
||||
--
|
||||
-- DO CZEGO SLUZA
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Zewnetrzny odbiorca (inna strona klienta, integracja, agregator) pobiera
|
||||
-- liste aktywnych webinarow i aktywnych kursow przez /api/public/v1/**,
|
||||
-- podajac klucz w naglowku X-API-Key. Mozna wydac wiele kluczy dla roznych
|
||||
-- odbiorcow, wlaczac je i wylaczac oraz kasowac niezaleznie.
|
||||
--
|
||||
-- W BAZIE JEST SKROT, NIE KLUCZ
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Trzymamy WYLACZNIE SHA-256 klucza (key_hash), nigdy samego klucza. Surowy
|
||||
-- klucz pokazujemy jednorazowo w chwili utworzenia i nie da sie go odtworzyc:
|
||||
-- wyciek bazy nie wydaje wtedy dzialajacych kluczy, dokladnie jak przy hasle.
|
||||
-- Skrot jest deterministyczny (bez soli), bo klucz ma 256 bitow losowych z
|
||||
-- SecureRandom - slownik teczowy nie ma czego zbudowac, a deterministyczny
|
||||
-- skrot pozwala wyszukac klucz jednym zapytaniem przy weryfikacji.
|
||||
--
|
||||
-- key_prefix to kilka pierwszych znakow surowego klucza, przechowywane jawnie
|
||||
-- WYLACZNIE do rozpoznania klucza w panelu ("ktory to z listy"). Sam prefiks
|
||||
-- nie wystarcza do uwierzytelnienia.
|
||||
--
|
||||
-- CYKL ZYCIA
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- active - wylaczenie odbiera dostep odwracalnie, bez kasowania sladu.
|
||||
-- last_used_at - kiedy klucz byl ostatnio uzyty (podglad, czy jeszcze zyje).
|
||||
-- Kasowanie usuwa wiersz na trwale: to odbiorca, ktorego juz nie obslugujemy.
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id bigserial PRIMARY KEY,
|
||||
-- nazwa odbiorcy, zeby wiedziec, komu klucz wydano
|
||||
name varchar(120) NOT NULL,
|
||||
-- SHA-256 klucza jako 64 znaki hex, unikalny: jeden klucz = jeden wiersz
|
||||
key_hash varchar(64) NOT NULL,
|
||||
-- kilka pierwszych znakow klucza, jawnie, tylko do rozpoznania w panelu
|
||||
key_prefix varchar(16) NOT NULL,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
last_used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_api_keys_key_hash UNIQUE (key_hash)
|
||||
);
|
||||
|
||||
-- weryfikacja szuka po skrocie: indeks jest juz zapewniony przez UNIQUE wyzej,
|
||||
-- wiec osobnego nie dodajemy
|
||||
Reference in New Issue
Block a user