From 77ad643eaceef00708d96e7e9ceeec7d45f2a70e Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 24 Jul 2025 08:03:56 +0200 Subject: [PATCH] configurable url --- .env.development | 26 +++++++++ .env.example | 24 +++++++- .env.production | 30 ++++++++++ .env.staging | 27 +++++++++ README.md | 58 ++++++++++++++++++- .../com/storycove/config/SecurityConfig.java | 2 +- .../storycove/controller/StoryController.java | 22 +------ .../com/storycove/service/AuthorService.java | 2 +- .../com/storycove/service/StoryService.java | 30 ++++++++++ backend/src/main/resources/application.yml | 4 ++ deploy.sh | 35 +++++++++++ docker-compose.yml | 4 +- frontend/.env.development | 4 ++ frontend/.env.production | 4 ++ frontend/src/contexts/AuthContext.tsx | 11 ++++ 15 files changed, 257 insertions(+), 26 deletions(-) create mode 100644 .env.development create mode 100644 .env.production create mode 100644 .env.staging create mode 100755 deploy.sh create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..5eae090 --- /dev/null +++ b/.env.development @@ -0,0 +1,26 @@ +# Development Environment Configuration +# For local development with Docker + +# Application URLs and CORS Configuration +STORYCOVE_PUBLIC_URL=http://localhost:6925 +STORYCOVE_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:6925 + +# Database Configuration +DB_PASSWORD=dev_password + +# JWT Configuration +JWT_SECRET=dev-jwt-secret-key-for-development-only + +# Application Authentication +APP_PASSWORD=admin + +# Typesense Search Configuration +TYPESENSE_API_KEY=dev_api_key +TYPESENSE_ENABLED=true +TYPESENSE_REINDEX_INTERVAL=3600000 + +# Image Storage +IMAGE_STORAGE_PATH=/app/images + +# Frontend API URL (for local development) +NEXT_PUBLIC_API_URL=http://localhost:8080/api \ No newline at end of file diff --git a/.env.example b/.env.example index a3ddc2d..e1c05f3 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,26 @@ +# StoryCove Environment Configuration +# Copy this file to .env and configure for your environment + +# Application URLs and CORS Configuration +STORYCOVE_PUBLIC_URL=http://localhost:6925 +STORYCOVE_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:6925 + +# Database Configuration DB_PASSWORD=secure_password_here + +# JWT Configuration JWT_SECRET=secure_jwt_secret_here + +# Application Authentication +APP_PASSWORD=application_password_here + +# Typesense Search Configuration TYPESENSE_API_KEY=secure_api_key_here -APP_PASSWORD=application_password_here \ No newline at end of file +TYPESENSE_ENABLED=true +TYPESENSE_REINDEX_INTERVAL=3600000 + +# Image Storage +IMAGE_STORAGE_PATH=/app/images + +# Frontend API URL (for development only - not used in Docker) +NEXT_PUBLIC_API_URL=http://localhost:8080/api \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..f23b74f --- /dev/null +++ b/.env.production @@ -0,0 +1,30 @@ +# Production Environment Configuration +# Set these values in your production environment + +# Application URLs and CORS Configuration +# Replace with your actual production URL +STORYCOVE_PUBLIC_URL=https://storycove.hardegger.io +STORYCOVE_CORS_ALLOWED_ORIGINS=https://storycove.hardegger.io + +# Database Configuration +# Use a strong password in production +DB_PASSWORD=REPLACE_WITH_SECURE_PASSWORD + +# JWT Configuration +# Use a strong, random secret in production +JWT_SECRET=REPLACE_WITH_SECURE_JWT_SECRET_MINIMUM_32_CHARS + +# Application Authentication +# Use a strong password in production +APP_PASSWORD=REPLACE_WITH_SECURE_APP_PASSWORD + +# Typesense Search Configuration +TYPESENSE_API_KEY=REPLACE_WITH_SECURE_TYPESENSE_API_KEY +TYPESENSE_ENABLED=true +TYPESENSE_REINDEX_INTERVAL=3600000 + +# Image Storage +IMAGE_STORAGE_PATH=/app/images + +# Frontend API URL (not used in Docker, but can be set for external deployments) +NEXT_PUBLIC_API_URL=/api \ No newline at end of file diff --git a/.env.staging b/.env.staging new file mode 100644 index 0000000..2358a0c --- /dev/null +++ b/.env.staging @@ -0,0 +1,27 @@ +# Staging Environment Configuration +# For testing before production deployment + +# Application URLs and CORS Configuration +# Replace with your actual staging URL +STORYCOVE_PUBLIC_URL=https://staging.yourdomain.com +STORYCOVE_CORS_ALLOWED_ORIGINS=https://staging.yourdomain.com + +# Database Configuration +DB_PASSWORD=staging_secure_password + +# JWT Configuration +JWT_SECRET=staging-jwt-secret-key-for-testing-32-chars-min + +# Application Authentication +APP_PASSWORD=staging_admin_password + +# Typesense Search Configuration +TYPESENSE_API_KEY=staging_typesense_api_key +TYPESENSE_ENABLED=true +TYPESENSE_REINDEX_INTERVAL=3600000 + +# Image Storage +IMAGE_STORAGE_PATH=/app/images + +# Frontend API URL +NEXT_PUBLIC_API_URL=/api \ No newline at end of file diff --git a/README.md b/README.md index 70ba340..4c4729a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,24 @@ A self-hosted web application for storing, organizing, and reading short stories ## Quick Start +### Option 1: Using Environment-Specific Deployment (Recommended) + +1. Deploy for your environment: +```bash +# For development +./deploy.sh development + +# For staging +./deploy.sh staging + +# For production (edit .env.production first with your values) +./deploy.sh production +``` + +2. Edit the environment file (`.env.development`, `.env.staging`, or `.env.production`) with your specific configuration + +### Option 2: Manual Configuration + 1. Copy environment variables: ```bash cp .env.example .env @@ -16,7 +34,7 @@ cp .env.example .env docker-compose up -d ``` -4. Access the application at http://localhost +4. Access the application at the URL specified in your environment configuration (default: http://localhost:6925) ## Architecture @@ -26,6 +44,44 @@ docker-compose up -d - **Search**: Typesense (Port 8108) - **Proxy**: Nginx (Port 80) +## Environment Configuration + +StoryCove supports different deployment environments with specific configuration files: + +### Environment Files + +- `.env.development` - Local development with Docker +- `.env.staging` - Staging/testing environment +- `.env.production` - Production deployment +- `.env.example` - Template with all available options + +### Key Environment Variables + +- `STORYCOVE_PUBLIC_URL` - The public URL where your app is accessible +- `STORYCOVE_CORS_ALLOWED_ORIGINS` - Comma-separated list of allowed CORS origins +- `DB_PASSWORD` - PostgreSQL database password +- `JWT_SECRET` - Secret key for JWT token signing (minimum 32 characters) +- `APP_PASSWORD` - Application login password +- `TYPESENSE_API_KEY` - API key for Typesense search service +- `NEXT_PUBLIC_API_URL` - Frontend API URL (for development only) + +### Deployment Examples + +For a production deployment at `https://stories.example.com`: +```bash +# Edit .env.production +STORYCOVE_PUBLIC_URL=https://stories.example.com +STORYCOVE_CORS_ALLOWED_ORIGINS=https://stories.example.com + +# Deploy +./deploy.sh production +``` + +For local development: +```bash +./deploy.sh development +``` + ## Development ### Frontend Development diff --git a/backend/src/main/java/com/storycove/config/SecurityConfig.java b/backend/src/main/java/com/storycove/config/SecurityConfig.java index 66444de..ac113ab 100644 --- a/backend/src/main/java/com/storycove/config/SecurityConfig.java +++ b/backend/src/main/java/com/storycove/config/SecurityConfig.java @@ -23,7 +23,7 @@ import java.util.List; @EnableWebSecurity public class SecurityConfig { - @Value("${storycove.cors.allowed-origins:${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000}}") + @Value("${storycove.cors.allowed-origins}") private String allowedOrigins; private final JwtAuthenticationFilter jwtAuthenticationFilter; diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index f908537..fe00ed9 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -23,10 +23,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -39,7 +37,6 @@ public class StoryController { private final StoryService storyService; private final AuthorService authorService; private final SeriesService seriesService; - private final TagService tagService; private final HtmlSanitizationService sanitizationService; private final ImageService imageService; private final TypesenseService typesenseService; @@ -47,14 +44,12 @@ public class StoryController { public StoryController(StoryService storyService, AuthorService authorService, SeriesService seriesService, - TagService tagService, HtmlSanitizationService sanitizationService, ImageService imageService, @Autowired(required = false) TypesenseService typesenseService) { this.storyService = storyService; this.authorService = authorService; this.seriesService = seriesService; - this.tagService = tagService; this.sanitizationService = sanitizationService; this.imageService = imageService; this.typesenseService = typesenseService; @@ -88,7 +83,7 @@ public class StoryController { Story story = new Story(); updateStoryFromRequest(story, request); - Story savedStory = storyService.create(story); + Story savedStory = storyService.createWithTagNames(story, request.getTagNames()); return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedStory)); } @@ -325,20 +320,7 @@ public class StoryController { story.setSeries(series); } - // Handle tags - if (createReq.getTagNames() != null && !createReq.getTagNames().isEmpty()) { - for (String tagName : createReq.getTagNames()) { - if (tagName != null && !tagName.trim().isEmpty()) { - Tag tag = tagService.findByNameOptional(tagName.trim().toLowerCase()) - .orElseGet(() -> { - Tag newTag = new Tag(); - newTag.setName(tagName.trim().toLowerCase()); - return tagService.create(newTag); - }); - story.addTag(tag); - } - } - } + // Tags are handled in the service layer } else if (request instanceof UpdateStoryRequest updateReq) { if (updateReq.getTitle() != null) { story.setTitle(updateReq.getTitle()); diff --git a/backend/src/main/java/com/storycove/service/AuthorService.java b/backend/src/main/java/com/storycove/service/AuthorService.java index 748f231..a6632d0 100644 --- a/backend/src/main/java/com/storycove/service/AuthorService.java +++ b/backend/src/main/java/com/storycove/service/AuthorService.java @@ -338,7 +338,7 @@ public class AuthorService { if (updates.getAuthorRating() != null) { existing.setAuthorRating(updates.getAuthorRating()); } - if (updates.getUrls() != null && !updates.getUrls().isEmpty()) { + if (updates.getUrls() != null) { existing.getUrls().clear(); existing.getUrls().addAll(updates.getUrls()); } diff --git a/backend/src/main/java/com/storycove/service/StoryService.java b/backend/src/main/java/com/storycove/service/StoryService.java index d3d4097..14bce04 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -316,6 +316,36 @@ public class StoryService { return savedStory; } + public Story createWithTagNames(@Valid Story story, java.util.List tagNames) { + validateStoryForCreate(story); + + // Set up relationships + if (story.getAuthor() != null && story.getAuthor().getId() != null) { + Author author = authorService.findById(story.getAuthor().getId()); + story.setAuthor(author); + } + + if (story.getSeries() != null && story.getSeries().getId() != null) { + Series series = seriesService.findById(story.getSeries().getId()); + story.setSeries(series); + validateSeriesVolume(series, story.getVolume()); + } + + Story savedStory = storyRepository.save(story); + + // Handle tags by names + if (tagNames != null && !tagNames.isEmpty()) { + updateStoryTagsByNames(savedStory, tagNames); + } + + // Index in Typesense (if available) + if (typesenseService != null) { + typesenseService.indexStory(savedStory); + } + + return savedStory; + } + public Story update(UUID id, @Valid Story storyUpdates) { Story existingStory = findById(id); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c3da118..9cc201d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -23,6 +23,10 @@ server: port: 8080 storycove: + app: + public-url: ${STORYCOVE_PUBLIC_URL:http://localhost:6925} + cors: + allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925} jwt: secret: ${JWT_SECRET:default-secret-key} expiration: 86400000 # 24 hours diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..47d16ef --- /dev/null +++ b/deploy.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# StoryCove Deployment Script +# Usage: ./deploy.sh [environment] +# Environments: development, staging, production + +set -e + +ENVIRONMENT=${1:-development} +ENV_FILE=".env.${ENVIRONMENT}" + +echo "Deploying StoryCove for ${ENVIRONMENT} environment..." + +# Check if environment file exists +if [ ! -f "$ENV_FILE" ]; then + echo "Error: Environment file $ENV_FILE not found." + echo "Available environments: development, staging, production" + exit 1 +fi + +# Copy environment file to .env +cp "$ENV_FILE" .env +echo "Using environment configuration from $ENV_FILE" + +# Build and start services +echo "Building and starting Docker services..." +docker-compose down +docker-compose build --no-cache +docker-compose up -d + +echo "Deployment complete!" +echo "StoryCove is running at: $(grep STORYCOVE_PUBLIC_URL $ENV_FILE | cut -d'=' -f2)" +echo "" +echo "To view logs: docker-compose logs -f" +echo "To stop: docker-compose down" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9406977..19d1458 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: frontend: build: ./frontend environment: - - NEXT_PUBLIC_API_URL=http://backend:8080/api + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-/api} depends_on: - backend networks: @@ -39,7 +39,7 @@ services: - TYPESENSE_PORT=8108 - IMAGE_STORAGE_PATH=/app/images - APP_PASSWORD=${APP_PASSWORD} - - STORYCOVE_CORS_ALLOWED_ORIGINS=https://storycove.sharyavin.synology.me,http://localhost:3000 + - STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925} volumes: - images_data:/app/images depends_on: diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..140456a --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,4 @@ +# Frontend Development Environment +# For local development outside of Docker + +NEXT_PUBLIC_API_URL=http://localhost:8080/api \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..8a9972b --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,4 @@ +# Frontend Production Environment +# API URL should be relative in Docker setup + +NEXT_PUBLIC_API_URL=/api \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index d6b6600..f38ae51 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { authApi } from '../lib/api'; +import { preloadSanitizationConfig } from '../lib/sanitization'; interface AuthContextType { isAuthenticated: boolean; @@ -30,7 +31,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }; + // Preload sanitization config for content formatting + const loadSanitizationConfig = async () => { + try { + await preloadSanitizationConfig(); + } catch (error) { + console.error('Failed to preload sanitization config:', error); + } + }; + checkAuth(); + loadSanitizationConfig(); }, []); const login = async (password: string) => {