various improvements and performance enhancements
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
202
backend/src/main/java/com/storycove/dto/StoryReadingDto.java
Normal file
202
backend/src/main/java/com/storycove/dto/StoryReadingDto.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user