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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
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.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -38,6 +39,20 @@ public class SecurityConfig {
|
|||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.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
|
.authorizeHttpRequests(authz -> authz
|
||||||
// Public endpoints
|
// Public endpoints
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ public class StoryController {
|
|||||||
return ResponseEntity.ok(convertToDto(story));
|
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
|
@PostMapping
|
||||||
public ResponseEntity<StoryDto> createStory(@Valid @RequestBody CreateStoryRequest request) {
|
public ResponseEntity<StoryDto> createStory(@Valid @RequestBody CreateStoryRequest request) {
|
||||||
logger.info("Creating new story: {}", request.getTitle());
|
logger.info("Creating new story: {}", request.getTitle());
|
||||||
@@ -425,7 +432,43 @@ public class StoryController {
|
|||||||
dto.setSummary(story.getSummary());
|
dto.setSummary(story.getSummary());
|
||||||
dto.setDescription(story.getDescription());
|
dto.setDescription(story.getDescription());
|
||||||
dto.setContentHtml(story.getContentHtml());
|
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.setSourceUrl(story.getSourceUrl());
|
||||||
dto.setCoverPath(story.getCoverPath());
|
dto.setCoverPath(story.getCoverPath());
|
||||||
dto.setWordCount(story.getWordCount());
|
dto.setWordCount(story.getWordCount());
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class HtmlSanitizationConfigDto {
|
|||||||
private Map<String, List<String>> allowedAttributes;
|
private Map<String, List<String>> allowedAttributes;
|
||||||
private List<String> allowedCssProperties;
|
private List<String> allowedCssProperties;
|
||||||
private Map<String, List<String>> removedAttributes;
|
private Map<String, List<String>> removedAttributes;
|
||||||
|
private Map<String, Map<String, List<String>>> allowedProtocols;
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
public HtmlSanitizationConfigDto() {}
|
public HtmlSanitizationConfigDto() {}
|
||||||
@@ -44,6 +45,14 @@ public class HtmlSanitizationConfigDto {
|
|||||||
this.removedAttributes = removedAttributes;
|
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() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class StoryDto {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
private String contentHtml;
|
private String contentHtml;
|
||||||
private String contentPlain;
|
// contentPlain removed for performance - use StoryReadingDto when content is needed
|
||||||
private String sourceUrl;
|
private String sourceUrl;
|
||||||
private String coverPath;
|
private String coverPath;
|
||||||
private Integer wordCount;
|
private Integer wordCount;
|
||||||
@@ -90,13 +90,6 @@ public class StoryDto {
|
|||||||
this.contentHtml = contentHtml;
|
this.contentHtml = contentHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getContentPlain() {
|
|
||||||
return contentPlain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setContentPlain(String contentPlain) {
|
|
||||||
this.contentPlain = contentPlain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getSourceUrl() {
|
public String getSourceUrl() {
|
||||||
return sourceUrl;
|
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;
|
package com.storycove.service;
|
||||||
|
|
||||||
import com.storycove.dto.SearchResultDto;
|
import com.storycove.dto.SearchResultDto;
|
||||||
|
import com.storycove.dto.StoryReadingDto;
|
||||||
|
import com.storycove.dto.TagDto;
|
||||||
import com.storycove.entity.Collection;
|
import com.storycove.entity.Collection;
|
||||||
import com.storycove.entity.CollectionStory;
|
import com.storycove.entity.CollectionStory;
|
||||||
import com.storycove.entity.Story;
|
import com.storycove.entity.Story;
|
||||||
@@ -336,7 +338,7 @@ public class CollectionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"story", story,
|
"story", convertToReadingDto(story),
|
||||||
"collection", collectionContext
|
"collection", collectionContext
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -430,4 +432,49 @@ public class CollectionService {
|
|||||||
public List<Collection> findAllForIndexing() {
|
public List<Collection> findAllForIndexing() {
|
||||||
return collectionRepository.findAllActiveCollections();
|
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) {
|
if (config.getRemovedAttributes() != null) {
|
||||||
for (Map.Entry<String, List<String>> entry : config.getRemovedAttributes().entrySet()) {
|
for (Map.Entry<String, List<String>> entry : config.getRemovedAttributes().entrySet()) {
|
||||||
String tag = entry.getKey();
|
String tag = entry.getKey();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.storycove.util;
|
package com.storycove.util;
|
||||||
|
|
||||||
|
import com.storycove.config.SecurityProperties;
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import io.jsonwebtoken.security.Keys;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
@@ -12,19 +13,20 @@ import java.util.Date;
|
|||||||
@Component
|
@Component
|
||||||
public class JwtUtil {
|
public class JwtUtil {
|
||||||
|
|
||||||
@Value("${storycove.jwt.secret}")
|
private final SecurityProperties securityProperties;
|
||||||
private String secret;
|
|
||||||
|
|
||||||
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
|
@Autowired
|
||||||
private Long expiration;
|
public JwtUtil(SecurityProperties securityProperties) {
|
||||||
|
this.securityProperties = securityProperties;
|
||||||
|
}
|
||||||
|
|
||||||
private SecretKey getSigningKey() {
|
private SecretKey getSigningKey() {
|
||||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
return Keys.hmacShaKeyFor(securityProperties.getJwt().getSecret().getBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String generateToken() {
|
public String generateToken() {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + expiration);
|
Date expiryDate = new Date(now.getTime() + securityProperties.getJwt().getExpiration());
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.subject("user")
|
.subject("user")
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ spring:
|
|||||||
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 250MB
|
max-file-size: 10MB # Reduced for security (was 250MB)
|
||||||
max-request-size: 250MB
|
max-request-size: 15MB # Slightly higher to account for form data
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
@@ -28,10 +28,10 @@ storycove:
|
|||||||
cors:
|
cors:
|
||||||
allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925}
|
allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925}
|
||||||
jwt:
|
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
|
expiration: 86400000 # 24 hours
|
||||||
auth:
|
auth:
|
||||||
password: ${APP_PASSWORD:admin}
|
password: ${APP_PASSWORD} # REQUIRED: No default password for security
|
||||||
typesense:
|
typesense:
|
||||||
api-key: ${TYPESENSE_API_KEY:xyz}
|
api-key: ${TYPESENSE_API_KEY:xyz}
|
||||||
host: ${TYPESENSE_HOST:localhost}
|
host: ${TYPESENSE_HOST:localhost}
|
||||||
@@ -43,5 +43,7 @@ storycove:
|
|||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.storycove: DEBUG
|
com.storycove: ${LOG_LEVEL:INFO} # Use INFO for production, DEBUG for development
|
||||||
org.springframework.security: DEBUG
|
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"],
|
"h4": ["class", "style"],
|
||||||
"h5": ["class", "style"],
|
"h5": ["class", "style"],
|
||||||
"h6": ["class", "style"],
|
"h6": ["class", "style"],
|
||||||
"a": ["class"],
|
"a": ["class", "href", "title"],
|
||||||
"table": ["class", "style"],
|
"table": ["class", "style"],
|
||||||
"th": ["class", "style", "colspan", "rowspan"],
|
"th": ["class", "style", "colspan", "rowspan"],
|
||||||
"td": ["class", "style", "colspan", "rowspan"],
|
"td": ["class", "style", "colspan", "rowspan"],
|
||||||
@@ -38,8 +38,10 @@
|
|||||||
"font-weight", "font-style", "text-align", "text-decoration", "margin",
|
"font-weight", "font-style", "text-align", "text-decoration", "margin",
|
||||||
"padding", "text-indent", "line-height"
|
"padding", "text-indent", "line-height"
|
||||||
],
|
],
|
||||||
"removedAttributes": {
|
"allowedProtocols": {
|
||||||
"a": ["href", "target"]
|
"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."
|
"description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency."
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,8 @@ interface SanitizationConfig {
|
|||||||
allowedTags: string[];
|
allowedTags: string[];
|
||||||
allowedAttributes: Record<string, string[]>;
|
allowedAttributes: Record<string, string[]>;
|
||||||
allowedCssProperties: string[];
|
allowedCssProperties: string[];
|
||||||
removedAttributes: Record<string, string[]>;
|
removedAttributes?: Record<string, string[]>;
|
||||||
|
allowedProtocols?: Record<string, Record<string, string[]>>;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +96,10 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
|||||||
'font-style', 'text-align', 'text-decoration', 'margin',
|
'font-style', 'text-align', 'text-decoration', 'margin',
|
||||||
'padding', 'text-indent', 'line-height'
|
'padding', 'text-indent', 'line-height'
|
||||||
],
|
],
|
||||||
removedAttributes: {
|
allowedProtocols: {
|
||||||
'a': ['href', 'target']
|
'a': {
|
||||||
|
'href': ['http', 'https', '#', '/']
|
||||||
|
}
|
||||||
},
|
},
|
||||||
description: 'Fallback sanitization configuration'
|
description: 'Fallback sanitization configuration'
|
||||||
};
|
};
|
||||||
@@ -114,10 +117,10 @@ function createDOMPurifyConfig(config: SanitizationConfig) {
|
|||||||
const allowedTags = config.allowedTags;
|
const allowedTags = config.allowedTags;
|
||||||
const allowedAttributes: Record<string, string[]> = { ...config.allowedAttributes };
|
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) {
|
if (config.removedAttributes) {
|
||||||
Object.keys(config.removedAttributes).forEach(tag => {
|
Object.keys(config.removedAttributes).forEach(tag => {
|
||||||
const attributesToRemove = config.removedAttributes[tag];
|
const attributesToRemove = config.removedAttributes![tag];
|
||||||
if (allowedAttributes[tag]) {
|
if (allowedAttributes[tag]) {
|
||||||
allowedAttributes[tag] = allowedAttributes[tag].filter(
|
allowedAttributes[tag] = allowedAttributes[tag].filter(
|
||||||
attr => !attributesToRemove.includes(attr)
|
attr => !attributesToRemove.includes(attr)
|
||||||
@@ -132,9 +135,20 @@ function createDOMPurifyConfig(config: SanitizationConfig) {
|
|||||||
const flattenedAttributes = Object.values(allowedAttributes).flat();
|
const flattenedAttributes = Object.values(allowedAttributes).flat();
|
||||||
const uniqueAttributes = Array.from(new Set(flattenedAttributes));
|
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 = {
|
const domPurifyConfig: DOMPurify.Config = {
|
||||||
ALLOWED_TAGS: allowedTags,
|
ALLOWED_TAGS: allowedTags,
|
||||||
ALLOWED_ATTR: uniqueAttributes,
|
ALLOWED_ATTR: uniqueAttributes,
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|#|\/):?\/?)[\w.\-#/?=&%]+$/i,
|
||||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
SANITIZE_DOM: true,
|
SANITIZE_DOM: true,
|
||||||
KEEP_CONTENT: true,
|
KEEP_CONTENT: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user