various improvements and performance enhancements

This commit is contained in:
Stefan Hardegger
2025-08-12 14:55:51 +02:00
parent 75c207970d
commit c46108c317
11 changed files with 380 additions and 32 deletions

View File

@@ -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()

View File

@@ -90,6 +90,13 @@ public class StoryController {
return ResponseEntity.ok(convertToDto(story));
}
@GetMapping("/{id}/read")
public ResponseEntity<StoryReadingDto> getStoryForReading(@PathVariable UUID id) {
logger.info("Getting story {} for reading", id);
Story story = storyService.findById(id);
return ResponseEntity.ok(convertToReadingDto(story));
}
@PostMapping
public ResponseEntity<StoryDto> 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());

View File

@@ -8,6 +8,7 @@ public class HtmlSanitizationConfigDto {
private Map<String, List<String>> allowedAttributes;
private List<String> allowedCssProperties;
private Map<String, List<String>> removedAttributes;
private Map<String, Map<String, List<String>>> allowedProtocols;
private String description;
public HtmlSanitizationConfigDto() {}
@@ -44,6 +45,14 @@ public class HtmlSanitizationConfigDto {
this.removedAttributes = removedAttributes;
}
public Map<String, Map<String, List<String>>> getAllowedProtocols() {
return allowedProtocols;
}
public void setAllowedProtocols(Map<String, Map<String, List<String>>> allowedProtocols) {
this.allowedProtocols = allowedProtocols;
}
public String getDescription() {
return description;
}

View File

@@ -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;

View File

@@ -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<TagDto> 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<TagDto> getTags() {
return tags;
}
public void setTags(List<TagDto> 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;
}
}

View File

@@ -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<Collection> 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;
}
}

View File

@@ -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<String, Map<String, List<String>>> tagEntry : config.getAllowedProtocols().entrySet()) {
String tag = tagEntry.getKey();
Map<String, List<String>> attributeProtocols = tagEntry.getValue();
if (attributeProtocols != null) {
for (Map.Entry<String, List<String>> attrEntry : attributeProtocols.entrySet()) {
String attribute = attrEntry.getKey();
List<String> 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<String, List<String>> entry : config.getRemovedAttributes().entrySet()) {
String tag = entry.getKey();

View File

@@ -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")

View File

@@ -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
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

View File

@@ -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."
}

View File

@@ -5,7 +5,8 @@ interface SanitizationConfig {
allowedTags: string[];
allowedAttributes: Record<string, string[]>;
allowedCssProperties: string[];
removedAttributes: Record<string, string[]>;
removedAttributes?: Record<string, string[]>;
allowedProtocols?: Record<string, Record<string, string[]>>;
description: string;
}
@@ -95,8 +96,10 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
'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<string, string[]> = { ...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,