configurable url
This commit is contained in:
26
.env.development
Normal file
26
.env.development
Normal 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
|
||||||
24
.env.example
24
.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
|
DB_PASSWORD=secure_password_here
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
JWT_SECRET=secure_jwt_secret_here
|
JWT_SECRET=secure_jwt_secret_here
|
||||||
|
|
||||||
|
# Application Authentication
|
||||||
|
APP_PASSWORD=application_password_here
|
||||||
|
|
||||||
|
# Typesense Search Configuration
|
||||||
TYPESENSE_API_KEY=secure_api_key_here
|
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
30
.env.production
Normal 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
27
.env.staging
Normal 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
|
||||||
58
README.md
58
README.md
@@ -4,6 +4,24 @@ A self-hosted web application for storing, organizing, and reading short stories
|
|||||||
|
|
||||||
## Quick Start
|
## 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:
|
1. Copy environment variables:
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -16,7 +34,7 @@ cp .env.example .env
|
|||||||
docker-compose up -d
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -26,6 +44,44 @@ docker-compose up -d
|
|||||||
- **Search**: Typesense (Port 8108)
|
- **Search**: Typesense (Port 8108)
|
||||||
- **Proxy**: Nginx (Port 80)
|
- **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
|
## Development
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import java.util.List;
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@Value("${storycove.cors.allowed-origins:${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000}}")
|
@Value("${storycove.cors.allowed-origins}")
|
||||||
private String allowedOrigins;
|
private String allowedOrigins;
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -39,7 +37,6 @@ public class StoryController {
|
|||||||
private final StoryService storyService;
|
private final StoryService storyService;
|
||||||
private final AuthorService authorService;
|
private final AuthorService authorService;
|
||||||
private final SeriesService seriesService;
|
private final SeriesService seriesService;
|
||||||
private final TagService tagService;
|
|
||||||
private final HtmlSanitizationService sanitizationService;
|
private final HtmlSanitizationService sanitizationService;
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
private final TypesenseService typesenseService;
|
private final TypesenseService typesenseService;
|
||||||
@@ -47,14 +44,12 @@ public class StoryController {
|
|||||||
public StoryController(StoryService storyService,
|
public StoryController(StoryService storyService,
|
||||||
AuthorService authorService,
|
AuthorService authorService,
|
||||||
SeriesService seriesService,
|
SeriesService seriesService,
|
||||||
TagService tagService,
|
|
||||||
HtmlSanitizationService sanitizationService,
|
HtmlSanitizationService sanitizationService,
|
||||||
ImageService imageService,
|
ImageService imageService,
|
||||||
@Autowired(required = false) TypesenseService typesenseService) {
|
@Autowired(required = false) TypesenseService typesenseService) {
|
||||||
this.storyService = storyService;
|
this.storyService = storyService;
|
||||||
this.authorService = authorService;
|
this.authorService = authorService;
|
||||||
this.seriesService = seriesService;
|
this.seriesService = seriesService;
|
||||||
this.tagService = tagService;
|
|
||||||
this.sanitizationService = sanitizationService;
|
this.sanitizationService = sanitizationService;
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
this.typesenseService = typesenseService;
|
this.typesenseService = typesenseService;
|
||||||
@@ -88,7 +83,7 @@ public class StoryController {
|
|||||||
Story story = new Story();
|
Story story = new Story();
|
||||||
updateStoryFromRequest(story, request);
|
updateStoryFromRequest(story, request);
|
||||||
|
|
||||||
Story savedStory = storyService.create(story);
|
Story savedStory = storyService.createWithTagNames(story, request.getTagNames());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedStory));
|
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedStory));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,20 +320,7 @@ public class StoryController {
|
|||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tags
|
// Tags are handled in the service layer
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (request instanceof UpdateStoryRequest updateReq) {
|
} else if (request instanceof UpdateStoryRequest updateReq) {
|
||||||
if (updateReq.getTitle() != null) {
|
if (updateReq.getTitle() != null) {
|
||||||
story.setTitle(updateReq.getTitle());
|
story.setTitle(updateReq.getTitle());
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ public class AuthorService {
|
|||||||
if (updates.getAuthorRating() != null) {
|
if (updates.getAuthorRating() != null) {
|
||||||
existing.setAuthorRating(updates.getAuthorRating());
|
existing.setAuthorRating(updates.getAuthorRating());
|
||||||
}
|
}
|
||||||
if (updates.getUrls() != null && !updates.getUrls().isEmpty()) {
|
if (updates.getUrls() != null) {
|
||||||
existing.getUrls().clear();
|
existing.getUrls().clear();
|
||||||
existing.getUrls().addAll(updates.getUrls());
|
existing.getUrls().addAll(updates.getUrls());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,6 +316,36 @@ public class StoryService {
|
|||||||
return savedStory;
|
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) {
|
public Story update(UUID id, @Valid Story storyUpdates) {
|
||||||
Story existingStory = findById(id);
|
Story existingStory = findById(id);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
storycove:
|
storycove:
|
||||||
|
app:
|
||||||
|
public-url: ${STORYCOVE_PUBLIC_URL:http://localhost:6925}
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925}
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:default-secret-key}
|
secret: ${JWT_SECRET:default-secret-key}
|
||||||
expiration: 86400000 # 24 hours
|
expiration: 86400000 # 24 hours
|
||||||
|
|||||||
35
deploy.sh
Executable file
35
deploy.sh
Executable 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"
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=http://backend:8080/api
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-/api}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
- TYPESENSE_PORT=8108
|
- TYPESENSE_PORT=8108
|
||||||
- IMAGE_STORAGE_PATH=/app/images
|
- IMAGE_STORAGE_PATH=/app/images
|
||||||
- APP_PASSWORD=${APP_PASSWORD}
|
- 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:
|
volumes:
|
||||||
- images_data:/app/images
|
- images_data:/app/images
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
4
frontend/.env.development
Normal file
4
frontend/.env.development
Normal 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
4
frontend/.env.production
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Frontend Production Environment
|
||||||
|
# API URL should be relative in Docker setup
|
||||||
|
|
||||||
|
NEXT_PUBLIC_API_URL=/api
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import { authApi } from '../lib/api';
|
import { authApi } from '../lib/api';
|
||||||
|
import { preloadSanitizationConfig } from '../lib/sanitization';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isAuthenticated: boolean;
|
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();
|
checkAuth();
|
||||||
|
loadSanitizationConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (password: string) => {
|
const login = async (password: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user