From c46108c317d6adaaae8b0184dc12aa029d440e17 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Tue, 12 Aug 2025 14:55:51 +0200 Subject: [PATCH] various improvements and performance enhancements --- .../com/storycove/config/SecurityConfig.java | 15 ++ .../storycove/controller/StoryController.java | 45 +++- .../dto/HtmlSanitizationConfigDto.java | 9 + .../main/java/com/storycove/dto/StoryDto.java | 9 +- .../com/storycove/dto/StoryReadingDto.java | 202 ++++++++++++++++++ .../storycove/service/CollectionService.java | 49 ++++- .../service/HtmlSanitizationService.java | 21 +- .../main/java/com/storycove/util/JwtUtil.java | 16 +- backend/src/main/resources/application.yml | 14 +- .../resources/html-sanitization-config.json | 8 +- frontend/src/lib/sanitization.ts | 24 ++- 11 files changed, 380 insertions(+), 32 deletions(-) create mode 100644 backend/src/main/java/com/storycove/dto/StoryReadingDto.java diff --git a/backend/src/main/java/com/storycove/config/SecurityConfig.java b/backend/src/main/java/com/storycove/config/SecurityConfig.java index 0365f01..e7826a2 100644 --- a/backend/src/main/java/com/storycove/config/SecurityConfig.java +++ b/backend/src/main/java/com/storycove/config/SecurityConfig.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -38,6 +39,20 @@ public class SecurityConfig { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers + .frameOptions().deny() + .contentTypeOptions().and() + .contentSecurityPolicy("default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: blob:; " + + "font-src 'self'; " + + "connect-src 'self'; " + + "media-src 'self'; " + + "object-src 'none'; " + + "frame-src 'none'; " + + "base-uri 'self'") + ) .authorizeHttpRequests(authz -> authz // Public endpoints .requestMatchers("/api/auth/**").permitAll() diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 7a6cb9a..d823f8f 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -90,6 +90,13 @@ public class StoryController { return ResponseEntity.ok(convertToDto(story)); } + @GetMapping("/{id}/read") + public ResponseEntity getStoryForReading(@PathVariable UUID id) { + logger.info("Getting story {} for reading", id); + Story story = storyService.findById(id); + return ResponseEntity.ok(convertToReadingDto(story)); + } + @PostMapping public ResponseEntity createStory(@Valid @RequestBody CreateStoryRequest request) { logger.info("Creating new story: {}", request.getTitle()); @@ -425,7 +432,43 @@ public class StoryController { dto.setSummary(story.getSummary()); dto.setDescription(story.getDescription()); dto.setContentHtml(story.getContentHtml()); - dto.setContentPlain(story.getContentPlain()); + dto.setSourceUrl(story.getSourceUrl()); + dto.setCoverPath(story.getCoverPath()); + dto.setWordCount(story.getWordCount()); + dto.setRating(story.getRating()); + dto.setVolume(story.getVolume()); + dto.setCreatedAt(story.getCreatedAt()); + dto.setUpdatedAt(story.getUpdatedAt()); + + // Reading progress fields + dto.setIsRead(story.getIsRead()); + dto.setReadingPosition(story.getReadingPosition()); + dto.setLastReadAt(story.getLastReadAt()); + + if (story.getAuthor() != null) { + dto.setAuthorId(story.getAuthor().getId()); + dto.setAuthorName(story.getAuthor().getName()); + } + + if (story.getSeries() != null) { + dto.setSeriesId(story.getSeries().getId()); + dto.setSeriesName(story.getSeries().getName()); + } + + dto.setTags(story.getTags().stream() + .map(this::convertTagToDto) + .collect(Collectors.toList())); + + return dto; + } + + private StoryReadingDto convertToReadingDto(Story story) { + StoryReadingDto dto = new StoryReadingDto(); + dto.setId(story.getId()); + dto.setTitle(story.getTitle()); + dto.setSummary(story.getSummary()); + dto.setDescription(story.getDescription()); + dto.setContentHtml(story.getContentHtml()); dto.setSourceUrl(story.getSourceUrl()); dto.setCoverPath(story.getCoverPath()); dto.setWordCount(story.getWordCount()); diff --git a/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java b/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java index dca10d9..a847991 100644 --- a/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java +++ b/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java @@ -8,6 +8,7 @@ public class HtmlSanitizationConfigDto { private Map> allowedAttributes; private List allowedCssProperties; private Map> removedAttributes; + private Map>> allowedProtocols; private String description; public HtmlSanitizationConfigDto() {} @@ -44,6 +45,14 @@ public class HtmlSanitizationConfigDto { this.removedAttributes = removedAttributes; } + public Map>> getAllowedProtocols() { + return allowedProtocols; + } + + public void setAllowedProtocols(Map>> allowedProtocols) { + this.allowedProtocols = allowedProtocols; + } + public String getDescription() { return description; } diff --git a/backend/src/main/java/com/storycove/dto/StoryDto.java b/backend/src/main/java/com/storycove/dto/StoryDto.java index 9ac01b1..013832b 100644 --- a/backend/src/main/java/com/storycove/dto/StoryDto.java +++ b/backend/src/main/java/com/storycove/dto/StoryDto.java @@ -21,7 +21,7 @@ public class StoryDto { private String description; private String contentHtml; - private String contentPlain; + // contentPlain removed for performance - use StoryReadingDto when content is needed private String sourceUrl; private String coverPath; private Integer wordCount; @@ -90,13 +90,6 @@ public class StoryDto { this.contentHtml = contentHtml; } - public String getContentPlain() { - return contentPlain; - } - - public void setContentPlain(String contentPlain) { - this.contentPlain = contentPlain; - } public String getSourceUrl() { return sourceUrl; diff --git a/backend/src/main/java/com/storycove/dto/StoryReadingDto.java b/backend/src/main/java/com/storycove/dto/StoryReadingDto.java new file mode 100644 index 0000000..6ab6f43 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/StoryReadingDto.java @@ -0,0 +1,202 @@ +package com.storycove.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Story DTO specifically for reading view. + * Contains contentHtml but excludes contentPlain for performance. + */ +public class StoryReadingDto { + + private UUID id; + private String title; + private String summary; + private String description; + private String contentHtml; // For reading - includes HTML + // contentPlain excluded for performance + private String sourceUrl; + private String coverPath; + private Integer wordCount; + private Integer rating; + private Integer volume; + + // Reading progress fields + private Boolean isRead; + private Integer readingPosition; + private LocalDateTime lastReadAt; + + // Related entities as simple references + private UUID authorId; + private String authorName; + private UUID seriesId; + private String seriesName; + private List tags; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public StoryReadingDto() {} + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getContentHtml() { + return contentHtml; + } + + public void setContentHtml(String contentHtml) { + this.contentHtml = contentHtml; + } + + public String getSourceUrl() { + return sourceUrl; + } + + public void setSourceUrl(String sourceUrl) { + this.sourceUrl = sourceUrl; + } + + public String getCoverPath() { + return coverPath; + } + + public void setCoverPath(String coverPath) { + this.coverPath = coverPath; + } + + public Integer getWordCount() { + return wordCount; + } + + public void setWordCount(Integer wordCount) { + this.wordCount = wordCount; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public Integer getVolume() { + return volume; + } + + public void setVolume(Integer volume) { + this.volume = volume; + } + + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } + + public Integer getReadingPosition() { + return readingPosition; + } + + public void setReadingPosition(Integer readingPosition) { + this.readingPosition = readingPosition; + } + + public LocalDateTime getLastReadAt() { + return lastReadAt; + } + + public void setLastReadAt(LocalDateTime lastReadAt) { + this.lastReadAt = lastReadAt; + } + + public UUID getAuthorId() { + return authorId; + } + + public void setAuthorId(UUID authorId) { + this.authorId = authorId; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public UUID getSeriesId() { + return seriesId; + } + + public void setSeriesId(UUID seriesId) { + this.seriesId = seriesId; + } + + public String getSeriesName() { + return seriesName; + } + + public void setSeriesName(String seriesName) { + this.seriesName = seriesName; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionService.java b/backend/src/main/java/com/storycove/service/CollectionService.java index ae2f384..e9e288a 100644 --- a/backend/src/main/java/com/storycove/service/CollectionService.java +++ b/backend/src/main/java/com/storycove/service/CollectionService.java @@ -1,6 +1,8 @@ package com.storycove.service; import com.storycove.dto.SearchResultDto; +import com.storycove.dto.StoryReadingDto; +import com.storycove.dto.TagDto; import com.storycove.entity.Collection; import com.storycove.entity.CollectionStory; import com.storycove.entity.Story; @@ -336,7 +338,7 @@ public class CollectionService { ); return Map.of( - "story", story, + "story", convertToReadingDto(story), "collection", collectionContext ); } @@ -430,4 +432,49 @@ public class CollectionService { public List findAllForIndexing() { return collectionRepository.findAllActiveCollections(); } + + private StoryReadingDto convertToReadingDto(Story story) { + StoryReadingDto dto = new StoryReadingDto(); + dto.setId(story.getId()); + dto.setTitle(story.getTitle()); + dto.setSummary(story.getSummary()); + dto.setDescription(story.getDescription()); + dto.setContentHtml(story.getContentHtml()); + dto.setSourceUrl(story.getSourceUrl()); + dto.setCoverPath(story.getCoverPath()); + dto.setWordCount(story.getWordCount()); + dto.setRating(story.getRating()); + dto.setVolume(story.getVolume()); + dto.setCreatedAt(story.getCreatedAt()); + dto.setUpdatedAt(story.getUpdatedAt()); + + // Reading progress fields + dto.setIsRead(story.getIsRead()); + dto.setReadingPosition(story.getReadingPosition()); + dto.setLastReadAt(story.getLastReadAt()); + + if (story.getAuthor() != null) { + dto.setAuthorId(story.getAuthor().getId()); + dto.setAuthorName(story.getAuthor().getName()); + } + + if (story.getSeries() != null) { + dto.setSeriesId(story.getSeries().getId()); + dto.setSeriesName(story.getSeries().getName()); + } + + dto.setTags(story.getTags().stream() + .map(this::convertTagToDto) + .collect(Collectors.toList())); + + return dto; + } + + private TagDto convertTagToDto(Tag tag) { + TagDto dto = new TagDto(); + dto.setId(tag.getId()); + dto.setName(tag.getName()); + dto.setStoryCount(tag.getStories().size()); + return dto; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java b/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java index e381bb8..2a5c227 100644 --- a/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java +++ b/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java @@ -83,7 +83,26 @@ public class HtmlSanitizationService { } } - // Remove specific attributes (like href from links for security) + // Configure allowed protocols for specific attributes (e.g., href) + if (config.getAllowedProtocols() != null) { + for (Map.Entry>> tagEntry : config.getAllowedProtocols().entrySet()) { + String tag = tagEntry.getKey(); + Map> attributeProtocols = tagEntry.getValue(); + + if (attributeProtocols != null) { + for (Map.Entry> attrEntry : attributeProtocols.entrySet()) { + String attribute = attrEntry.getKey(); + List protocols = attrEntry.getValue(); + + if (protocols != null) { + allowlist.addProtocols(tag, attribute, protocols.toArray(new String[0])); + } + } + } + } + } + + // Remove specific attributes if needed (deprecated in favor of protocol control) if (config.getRemovedAttributes() != null) { for (Map.Entry> entry : config.getRemovedAttributes().entrySet()) { String tag = entry.getKey(); diff --git a/backend/src/main/java/com/storycove/util/JwtUtil.java b/backend/src/main/java/com/storycove/util/JwtUtil.java index c908671..a8e3b08 100644 --- a/backend/src/main/java/com/storycove/util/JwtUtil.java +++ b/backend/src/main/java/com/storycove/util/JwtUtil.java @@ -1,9 +1,10 @@ package com.storycove.util; +import com.storycove.config.SecurityProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; @@ -12,19 +13,20 @@ import java.util.Date; @Component public class JwtUtil { - @Value("${storycove.jwt.secret}") - private String secret; + private final SecurityProperties securityProperties; - @Value("${storycove.jwt.expiration:86400000}") // 24 hours default - private Long expiration; + @Autowired + public JwtUtil(SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(secret.getBytes()); + return Keys.hmacShaKeyFor(securityProperties.getJwt().getSecret().getBytes()); } public String generateToken() { Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); + Date expiryDate = new Date(now.getTime() + securityProperties.getJwt().getExpiration()); return Jwts.builder() .subject("user") diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8f94d19..ad385a1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -16,8 +16,8 @@ spring: servlet: multipart: - max-file-size: 250MB - max-request-size: 250MB + max-file-size: 10MB # Reduced for security (was 250MB) + max-request-size: 15MB # Slightly higher to account for form data server: port: 8080 @@ -28,10 +28,10 @@ storycove: cors: allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925} jwt: - secret: ${JWT_SECRET:default-secret-key} + secret: ${JWT_SECRET} # REQUIRED: Must be at least 32 characters, no default for security expiration: 86400000 # 24 hours auth: - password: ${APP_PASSWORD:admin} + password: ${APP_PASSWORD} # REQUIRED: No default password for security typesense: api-key: ${TYPESENSE_API_KEY:xyz} host: ${TYPESENSE_HOST:localhost} @@ -43,5 +43,7 @@ storycove: logging: level: - com.storycove: DEBUG - org.springframework.security: DEBUG \ No newline at end of file + com.storycove: ${LOG_LEVEL:INFO} # Use INFO for production, DEBUG for development + org.springframework.security: WARN # Reduce security logging + org.springframework.web: WARN + org.hibernate.SQL: ${SQL_LOG_LEVEL:WARN} # Control SQL logging separately \ No newline at end of file diff --git a/backend/src/main/resources/html-sanitization-config.json b/backend/src/main/resources/html-sanitization-config.json index ffb01c0..5dad4e9 100644 --- a/backend/src/main/resources/html-sanitization-config.json +++ b/backend/src/main/resources/html-sanitization-config.json @@ -17,7 +17,7 @@ "h4": ["class", "style"], "h5": ["class", "style"], "h6": ["class", "style"], - "a": ["class"], + "a": ["class", "href", "title"], "table": ["class", "style"], "th": ["class", "style", "colspan", "rowspan"], "td": ["class", "style", "colspan", "rowspan"], @@ -38,8 +38,10 @@ "font-weight", "font-style", "text-align", "text-decoration", "margin", "padding", "text-indent", "line-height" ], - "removedAttributes": { - "a": ["href", "target"] + "allowedProtocols": { + "a": { + "href": ["http", "https", "#", "/"] + } }, "description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency." } \ No newline at end of file diff --git a/frontend/src/lib/sanitization.ts b/frontend/src/lib/sanitization.ts index a96de2c..3f33229 100644 --- a/frontend/src/lib/sanitization.ts +++ b/frontend/src/lib/sanitization.ts @@ -5,7 +5,8 @@ interface SanitizationConfig { allowedTags: string[]; allowedAttributes: Record; allowedCssProperties: string[]; - removedAttributes: Record; + removedAttributes?: Record; + allowedProtocols?: Record>; description: string; } @@ -95,8 +96,10 @@ async function fetchSanitizationConfig(): Promise { 'font-style', 'text-align', 'text-decoration', 'margin', 'padding', 'text-indent', 'line-height' ], - removedAttributes: { - 'a': ['href', 'target'] + allowedProtocols: { + 'a': { + 'href': ['http', 'https', '#', '/'] + } }, description: 'Fallback sanitization configuration' }; @@ -114,10 +117,10 @@ function createDOMPurifyConfig(config: SanitizationConfig) { const allowedTags = config.allowedTags; const allowedAttributes: Record = { ...config.allowedAttributes }; - // Remove attributes that should be stripped (like href from links) + // Remove attributes that should be stripped (deprecated, keeping for backward compatibility) if (config.removedAttributes) { Object.keys(config.removedAttributes).forEach(tag => { - const attributesToRemove = config.removedAttributes[tag]; + const attributesToRemove = config.removedAttributes![tag]; if (allowedAttributes[tag]) { allowedAttributes[tag] = allowedAttributes[tag].filter( attr => !attributesToRemove.includes(attr) @@ -132,9 +135,20 @@ function createDOMPurifyConfig(config: SanitizationConfig) { const flattenedAttributes = Object.values(allowedAttributes).flat(); const uniqueAttributes = Array.from(new Set(flattenedAttributes)); + // Configure allowed protocols for URL validation + const allowedSchemes: string[] = []; + if (config.allowedProtocols) { + Object.values(config.allowedProtocols).forEach(attributeProtocols => { + Object.values(attributeProtocols).forEach(protocols => { + allowedSchemes.push(...protocols); + }); + }); + } + const domPurifyConfig: DOMPurify.Config = { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: uniqueAttributes, + ALLOWED_URI_REGEXP: /^(?:(?:https?|#|\/):?\/?)[\w.\-#/?=&%]+$/i, ALLOW_UNKNOWN_PROTOCOLS: false, SANITIZE_DOM: true, KEEP_CONTENT: true,