configurable url

This commit is contained in:
Stefan Hardegger
2025-07-24 08:03:56 +02:00
parent 4bbc14d165
commit 77ad643eac
15 changed files with 257 additions and 26 deletions

26
.env.development Normal file
View File

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

View File

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

30
.env.production Normal file
View File

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

27
.env.staging Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -316,6 +316,36 @@ public class StoryService {
return savedStory;
}
public Story createWithTagNames(@Valid Story story, java.util.List<String> 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);

View File

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

35
deploy.sh Executable file
View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# Frontend Development Environment
# For local development outside of Docker
NEXT_PUBLIC_API_URL=http://localhost:8080/api

4
frontend/.env.production Normal file
View File

@@ -0,0 +1,4 @@
# Frontend Production Environment
# API URL should be relative in Docker setup
NEXT_PUBLIC_API_URL=/api

View File

@@ -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) => {