Expose safe public app settings via dedicated endpoint

This commit is contained in:
aditya.chandel
2025-08-14 18:18:59 -06:00
committed by Aditya Chandel
parent f5e58080ea
commit 7cea853756
9 changed files with 159 additions and 62 deletions
@@ -47,7 +47,7 @@ public class SecurityConfig {
private static final String[] COMMON_PUBLIC_ENDPOINTS = {
"/ws/**",
"/api/v1/auth/**",
"/api/v1/settings",
"/api/v1/public-settings",
"/api/v1/setup/**",
"/api/v1/books/*/cover",
"/api/v1/books/*/backup-cover",
@@ -0,0 +1,23 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.settings.PublicAppSetting;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/public-settings")
@RequiredArgsConstructor
public class PublicAppSettingController {
private final AppSettingService appSettingService;
@GetMapping
public List<PublicAppSetting> getPublicSettings() {
return appSettingService.getPublicSettings();
}
}
@@ -4,35 +4,37 @@ import lombok.Getter;
@Getter
public enum AppSettingKey {
QUICK_BOOK_MATCH("quick_book_match", true),
OIDC_PROVIDER_DETAILS("oidc_provider_details", true),
OIDC_AUTO_PROVISION_DETAILS("oidc_auto_provision_details", true),
SIDEBAR_LIBRARY_SORTING("sidebar_library_sorting", true),
SIDEBAR_SHELF_SORTING("sidebar_shelf_sorting", true),
METADATA_PROVIDER_SETTINGS("metadata_provider_settings", true),
METADATA_MATCH_WEIGHTS("metadata_match_weights", true),
METADATA_PERSISTENCE_SETTINGS("metadata_persistence_settings", true),
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true),
QUICK_BOOK_MATCH("quick_book_match", true, false),
OIDC_PROVIDER_DETAILS("oidc_provider_details", true, true),
OIDC_AUTO_PROVISION_DETAILS("oidc_auto_provision_details", true, false),
SIDEBAR_LIBRARY_SORTING("sidebar_library_sorting", true, false),
SIDEBAR_SHELF_SORTING("sidebar_shelf_sorting", true, false),
METADATA_PROVIDER_SETTINGS("metadata_provider_settings", true, false),
METADATA_MATCH_WEIGHTS("metadata_match_weights", true, false),
METADATA_PERSISTENCE_SETTINGS("metadata_persistence_settings", true, false),
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true, false),
AUTO_BOOK_SEARCH("auto_book_search", false),
COVER_IMAGE_RESOLUTION("cover_image_resolution", false),
SIMILAR_BOOK_RECOMMENDATION("similar_book_recommendation", false),
UPLOAD_FILE_PATTERN("upload_file_pattern", false),
MOVE_FILE_PATTERN("move_file_pattern", false),
OPDS_SERVER_ENABLED("opds_server_enabled", false),
OIDC_ENABLED("oidc_enabled", false),
CBX_CACHE_SIZE_IN_MB("cbx_cache_size_in_mb", false),
PDF_CACHE_SIZE_IN_MB("pdf_cache_size_in_mb", false),
BOOK_DELETION_ENABLED("book_deletion_enabled", false),
METADATA_DOWNLOAD_ON_BOOKDROP("metadata_download_on_bookdrop", false),
MAX_FILE_UPLOAD_SIZE_IN_MB("max_file_upload_size_in_mb", false);
AUTO_BOOK_SEARCH("auto_book_search", false, false),
COVER_IMAGE_RESOLUTION("cover_image_resolution", false, false),
SIMILAR_BOOK_RECOMMENDATION("similar_book_recommendation", false, false),
UPLOAD_FILE_PATTERN("upload_file_pattern", false, false),
MOVE_FILE_PATTERN("move_file_pattern", false, false),
OPDS_SERVER_ENABLED("opds_server_enabled", false, false),
OIDC_ENABLED("oidc_enabled", false, true),
CBX_CACHE_SIZE_IN_MB("cbx_cache_size_in_mb", false, false),
PDF_CACHE_SIZE_IN_MB("pdf_cache_size_in_mb", false, false),
BOOK_DELETION_ENABLED("book_deletion_enabled", false, false),
METADATA_DOWNLOAD_ON_BOOKDROP("metadata_download_on_bookdrop", false, false),
MAX_FILE_UPLOAD_SIZE_IN_MB("max_file_upload_size_in_mb", false, false);
private final String dbKey;
private final boolean isJson;
private final boolean isPublic;
AppSettingKey(String dbKey, boolean isJson) {
AppSettingKey(String dbKey, boolean isJson, boolean isPublic) {
this.dbKey = dbKey;
this.isJson = isJson;
this.isPublic = isPublic;
}
@Override
@@ -0,0 +1,11 @@
package com.adityachandel.booklore.model.dto.settings;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class PublicAppSetting {
private final String key;
private final Object value;
}
@@ -9,6 +9,8 @@ import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@@ -49,6 +51,16 @@ public class AppSettingService {
refreshCache();
}
public List<PublicAppSetting> getPublicSettings() {
return Arrays.stream(AppSettingKey.values())
.filter(AppSettingKey::isPublic)
.map(key -> new PublicAppSetting(
key.toString(),
settingPersistenceHelper.getOrCreateSetting(key, null)
))
.toList();
}
private void refreshCache() {
lock.lock();
try {
+7 -10
View File
@@ -1,24 +1,21 @@
import {inject} from '@angular/core';
import {Router} from '@angular/router';
import {OAuthEvent, OAuthService} from 'angular-oauth2-oidc';
import {AppSettingsService} from './core/service/app-settings.service';
import {OAuthService} from 'angular-oauth2-oidc';
import {AuthService, websocketInitializer} from './core/service/auth.service';
import {filter} from 'rxjs/operators';
import {AuthInitializationService} from './auth-initialization-service';
import {PublicAppSettingService} from './public-app-settings.service';
export function initializeAuthFactory() {
return () => {
const oauthService = inject(OAuthService);
const appSettingsService = inject(AppSettingsService);
const publicAppSettingService = inject(PublicAppSettingService);
const authService = inject(AuthService);
const router = inject(Router);
const authInitService = inject(AuthInitializationService);
return new Promise<void>((resolve) => {
const sub = appSettingsService.appSettings$.subscribe(settings => {
if (settings) {
if (settings.oidcEnabled && settings.oidcProviderDetails) {
const details = settings.oidcProviderDetails;
const sub = publicAppSettingService.publicAppSettings$.subscribe(publicSettings => {
if (publicSettings) {
if (publicSettings.oidcEnabled && publicSettings.oidcProviderDetails) {
const details = publicSettings.oidcProviderDetails;
oauthService.configure({
issuer: details.issuerUri,
@@ -11,6 +11,7 @@ import {AppSettingsService} from '../../service/app-settings.service';
import {AppSettings} from '../../model/app-settings.model';
import {Observable} from 'rxjs';
import {filter, take} from 'rxjs/operators';
import {PublicAppSettings, PublicAppSettingService} from '../../../public-app-settings.service';
@Component({
selector: 'app-login',
@@ -33,20 +34,20 @@ export class LoginComponent implements OnInit {
private authService = inject(AuthService);
private oAuthService = inject(OAuthService);
private appSettingsService = inject(AppSettingsService);
private appSettingsService = inject(PublicAppSettingService);
private router = inject(Router);
appSettings$: Observable<AppSettings | null> = this.appSettingsService.appSettings$;
publicAppSettings$: Observable<PublicAppSettings | null> = this.appSettingsService.publicAppSettings$;
ngOnInit(): void {
this.appSettings$
this.publicAppSettings$
.pipe(
filter(settings => settings != null),
take(1)
)
.subscribe(settings => {
this.oidcEnabled = settings!.oidcEnabled;
this.oidcName = settings!.oidcProviderDetails?.providerName || 'OIDC';
.subscribe(publicSettings => {
this.oidcEnabled = publicSettings!.oidcEnabled;
this.oidcName = publicSettings!.oidcProviderDetails?.providerName || 'OIDC';
});
}
@@ -1,44 +1,53 @@
import {Injectable} from '@angular/core';
import {inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject, Observable, of} from 'rxjs';
import {AppSettings} from '../model/app-settings.model';
import {API_CONFIG} from '../../config/api-config';
import {catchError, map} from 'rxjs/operators';
import {catchError, finalize, shareReplay, tap} from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
@Injectable({providedIn: 'root'})
export class AppSettingsService {
private http = inject(HttpClient);
private readonly apiUrl = `${API_CONFIG.BASE_URL}/api/v1/settings`;
private loading$: Observable<AppSettings> | null = null;
private appSettingsSubject = new BehaviorSubject<AppSettings | null>(null);
appSettings$ = this.appSettingsSubject.asObservable();
constructor(private http: HttpClient) {
this.loadAppSettings();
}
loadAppSettings(): void {
this.http.get<AppSettings>(this.apiUrl).subscribe({
next: (settings: AppSettings) => {
this.appSettingsSubject.next(settings);
},
error: (error) => {
console.error('Error loading app settings:', error);
this.appSettingsSubject.next(null);
appSettings$ = this.appSettingsSubject.asObservable().pipe(
tap(state => {
if (!state && !this.loading$) {
this.loading$ = this.fetchAppSettings().pipe(
shareReplay(1),
finalize(() => (this.loading$ = null))
);
this.loading$.subscribe();
}
});
})
);
private fetchAppSettings(): Observable<AppSettings> {
return this.http.get<AppSettings>(this.apiUrl).pipe(
tap(settings => this.appSettingsSubject.next(settings)),
catchError(err => {
console.error('Error loading app settings:', err);
this.appSettingsSubject.next(null);
throw err;
})
);
}
saveSettings(settings: { key: string, newValue: any }[]): Observable<void> {
saveSettings(settings: { key: string; newValue: any }[]): Observable<void> {
const payload = settings.map(setting => ({
name: setting.key,
value: setting.newValue
}));
return this.http.put<void>(this.apiUrl, payload).pipe(
map(() => {
this.loadAppSettings();
tap(() => {
this.loading$ = this.fetchAppSettings().pipe(
shareReplay(1),
finalize(() => (this.loading$ = null))
);
this.loading$.subscribe();
}),
catchError(err => {
console.error('Error saving settings:', err);
@@ -0,0 +1,42 @@
import {inject, Injectable} from '@angular/core';
import {API_CONFIG} from './config/api-config';
import {BehaviorSubject, Observable} from 'rxjs';
import {HttpClient} from '@angular/common/http';
import {OidcProviderDetails} from './core/model/app-settings.model';
import {catchError, finalize, shareReplay, tap} from 'rxjs/operators';
export interface PublicAppSettings {
oidcEnabled: boolean;
oidcProviderDetails: OidcProviderDetails;
}
@Injectable({providedIn: 'root'})
export class PublicAppSettingService {
private http = inject(HttpClient);
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/public-settings`;
private loading$: Observable<PublicAppSettings> | null = null;
private publicAppSettingsSubject = new BehaviorSubject<PublicAppSettings | null>(null);
publicAppSettings$ = this.publicAppSettingsSubject.asObservable().pipe(
tap(state => {
if (!state && !this.loading$) {
this.loading$ = this.fetchPublicSettings().pipe(
shareReplay(1),
finalize(() => (this.loading$ = null))
);
this.loading$.subscribe();
}
})
);
private fetchPublicSettings(): Observable<PublicAppSettings> {
return this.http.get<PublicAppSettings>(this.url).pipe(
tap(settings => this.publicAppSettingsSubject.next(settings)),
catchError(err => {
console.error('Failed to fetch public settings', err);
throw err;
})
);
}
}