diff --git a/.gitignore b/.gitignore index cf332b9..58d2c72 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ yarn-error.log* .npm # Note: package-lock.json should be committed for reproducible builds +# Local gc-parser directory (the actual repo is at ../gc-parser) +gc-parser/ + # IDE and editor files .vscode/ .idea/ diff --git a/DOCKER.md b/DOCKER.md deleted file mode 100644 index c796253..0000000 --- a/DOCKER.md +++ /dev/null @@ -1,235 +0,0 @@ -# Docker Setup for GitCitadel Online - -This guide explains how to run GitCitadel Online using Docker. - -## Prerequisites - -- Docker (version 20.10 or later) -- Docker Compose (version 2.0 or later, optional but recommended) -- Network access (for downloading dependencies and connecting to Nostr relays) - -## Image Details - -The Docker image uses **Alpine Linux** for a smaller footprint (~50MB base image vs ~200MB+ for Debian). This works well because: - -- The Go binary is statically compiled (`CGO_ENABLED=0`), so no C library dependencies -- Node.js packages (`@asciidoctor/core`, `marked`) are pure JavaScript with no native bindings -- Alpine's musl libc is sufficient for our use case - -If you encounter any compatibility issues, you can modify the Dockerfile to use Debian-based images (`golang:1.22` and `node:20-slim`), though this will increase the image size. - -## Quick Start - -### Using Docker Compose (Recommended) - -1. **Create your configuration file:** - ```bash - cp config.yaml.example config.yaml - # Edit config.yaml with your Nostr indices, relay URLs, and settings - ``` - -2. **Build and run:** - ```bash - docker-compose up -d - ``` - -3. **View logs:** - ```bash - docker-compose logs -f - ``` - -4. **Stop the container:** - ```bash - docker-compose down - ``` - -### Using Docker directly - -1. **Build the image:** - ```bash - docker build -t gitcitadel-online . - ``` - -2. **Create config file:** - ```bash - cp config.yaml.example config.yaml - # Edit config.yaml with your settings - ``` - -3. **Run the container:** - ```bash - docker run -d \ - --name gitcitadel-online \ - -p 8080:8080 \ - -v $(pwd)/config.yaml:/app/config.yaml:ro \ - -v $(pwd)/cache:/app/cache \ - --restart unless-stopped \ - gitcitadel-online - ``` - -4. **View logs:** - ```bash - docker logs -f gitcitadel-online - ``` - -5. **Stop the container:** - ```bash - docker stop gitcitadel-online - docker rm gitcitadel-online - ``` - -## Configuration - -### Config File - -The `config.yaml` file must be mounted into the container. The default path is `/app/config.yaml`. - -You can override the config path using the `--config` flag: -```bash -docker run ... gitcitadel-online --config /path/to/config.yaml -``` - -### Port Mapping - -By default, the application runs on port 8080. You can change the host port mapping: -```bash -# Map to different host port -docker run -p 3000:8080 ... -``` - -Or update `docker-compose.yml`: -```yaml -ports: - - "3000:8080" -``` - -### Cache Persistence - -The cache directory (`cache/`) is persisted as a volume to maintain cached pages and media between container restarts. - -### Environment Variables - -You can pass environment variables, though most configuration should be in `config.yaml`: - -```bash -docker run -e LOG_LEVEL=debug ... -``` - -## Development Mode - -To run in development mode with verbose logging: - -```bash -docker run ... gitcitadel-online --dev -``` - -Or with docker-compose, override the command: -```yaml -command: ["--config", "/app/config.yaml", "--dev"] -``` - -## Health Check - -The container includes a health check that monitors the `/health` endpoint. You can check the health status: - -```bash -docker ps -# Look for "healthy" status - -# Or inspect directly -docker inspect --format='{{.State.Health.Status}}' gitcitadel-online -``` - -## Troubleshooting - -### Container won't start - -1. **Check logs:** - ```bash - docker logs gitcitadel-online - ``` - -2. **Verify config file:** - ```bash - docker exec gitcitadel-online cat /app/config.yaml - ``` - -3. **Check file permissions:** - The container runs as a non-root user (UID 1000). Ensure cache directory is writable: - ```bash - chmod -R 777 cache/ - ``` - -### Can't connect to Nostr relays - -- Ensure the container has network access -- Check firewall rules if running on a remote server -- Verify relay URLs in `config.yaml` are correct - -### Cache not persisting - -- Ensure the cache volume is properly mounted -- Check volume permissions -- Verify the cache directory exists and is writable - -## Building for Different Architectures - -The Dockerfile builds for `linux/amd64` by default. To build for other architectures: - -```bash -# For ARM64 (e.g., Raspberry Pi, Apple Silicon) -docker buildx build --platform linux/arm64 -t gitcitadel-online . - -# For multiple architectures -docker buildx build --platform linux/amd64,linux/arm64 -t gitcitadel-online . -``` - -## Production Deployment - -For production deployment: - -1. **Use a reverse proxy** (nginx, Traefik, Apache, etc.) in front of the container -2. **Set up SSL/TLS** certificates -3. **Configure proper logging** and monitoring -4. **Use secrets management** for sensitive configuration -5. **Set resource limits** in docker-compose.yml: - ```yaml - deploy: - resources: - limits: - cpus: '1' - memory: 512M - ``` - -## Apache Reverse Proxy Setup - -The `docker-compose.yml` is configured to expose the container on port **2323** for Apache reverse proxy integration. - -### Port Configuration - -The Docker container exposes port 2323 on the host, which maps to port 8080 inside the container. This matches Apache configurations that proxy to `127.0.0.1:2323`. - -If you need to use a different port, update `docker-compose.yml`: Change `"2323:8080"` to your desired port mapping. - -**Note:** For Plesk-managed Apache servers, configure the reverse proxy settings through the Plesk control panel. The Docker container is ready to accept connections on port 2323. - -## Updating - -To update to a new version: - -```bash -# Pull latest code -git pull - -# Rebuild and restart -docker-compose build -docker-compose up -d -``` - -Or with Docker directly: -```bash -docker build -t gitcitadel-online . -docker stop gitcitadel-online -docker rm gitcitadel-online -docker run ... # (same command as before) -``` diff --git a/Dockerfile b/Dockerfile index 6ab52e2..060123d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,10 @@ # Multi-stage build for GitCitadel Online -# Using Alpine Linux for smaller image size (~50MB vs ~200MB+ for Debian) -# Alpine works well here because: -# - Go binary is statically compiled (CGO_ENABLED=0) -# - Node.js packages are pure JavaScript (no native bindings) -# - No C library dependencies required - # Stage 1: Build Go application -FROM golang:1.22-alpine AS builder +FROM golang:1.22-alpine AS go-builder # Install build dependencies -RUN apk add --no-cache git +RUN apk add --no-cache git make -# Set working directory WORKDIR /build # Copy go mod files @@ -22,64 +15,79 @@ RUN go mod download COPY . . # Build the application -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags='-w -s' -o gitcitadel-online ./cmd/server +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gitcitadel-online ./cmd/server/main.go + +# Stage 2: Build and install gc-parser from local directory +# We copy the local gc-parser directory, build it, and install it to avoid git repository access issues +FROM node:18-alpine AS node-setup + +WORKDIR /app -# Stage 2: Runtime with Node.js for AsciiDoc processing -FROM node:20-alpine +# Copy package.json and the local gc-parser directory +COPY package.json package-lock.json* ./ +COPY gc-parser ./gc-parser -# Install runtime dependencies (wget for health check and nostr-tools download) -RUN apk add --no-cache ca-certificates tzdata wget +# Build gc-parser (install dependencies and compile TypeScript) +WORKDIR /app/gc-parser +RUN npm install && npm run build -# Set working directory +# Go back to /app and install gc-parser from local directory +# This will install gc-parser and @asciidoctor/core (as a dependency of gc-parser) WORKDIR /app +RUN npm install ./gc-parser + +# Verify gc-parser is installed and can be required +RUN node -e "require('gc-parser'); console.log('✓ gc-parser installed and verified')" || \ + (echo "Error: gc-parser verification failed" && exit 1) -# Install Node.js dependencies for AsciiDoc processing -# Note: gc-parser is referenced from ../gc-parser in package.json -# Before building, ensure gc-parser is built: cd ../gc-parser && npm install && npm run build -# Then npm install here will link to the built gc-parser -COPY package.json package-lock.json ./ -RUN npm ci --only=production - -# Copy gc-parser wrapper script -COPY scripts/ ./scripts/ -RUN chmod +x ./scripts/process-content.js - -# Copy built binary from builder -COPY --from=builder /build/gitcitadel-online /app/gitcitadel-online - -# Copy static files and templates -COPY static/ ./static/ -COPY templates/ ./templates/ - -# Download nostr-tools bundle if not present (for contact form) -RUN if [ ! -f ./static/js/nostr.bundle.js ]; then \ - mkdir -p ./static/js && \ - wget -O ./static/js/nostr.bundle.js https://unpkg.com/nostr-tools@latest/lib/nostr.bundle.js || \ - echo "Warning: Failed to download nostr-tools bundle"; \ +# Ensure gc-parser is a directory, not a symlink (for proper copying to final stage) +RUN if [ -L node_modules/gc-parser ]; then \ + echo "Warning: gc-parser is a symlink, copying as directory..."; \ + rm node_modules/gc-parser; \ + cp -r gc-parser node_modules/gc-parser; \ fi -# Copy example config (user should mount their own config.yaml) -COPY config.yaml.example ./config.yaml.example +# Stage 3: Final runtime image +FROM alpine:latest -# Copy entrypoint script -COPY docker-entrypoint.sh /app/docker-entrypoint.sh +# Install runtime dependencies +RUN apk add --no-cache \ + ca-certificates \ + wget \ + nodejs \ + npm \ + git \ + && rm -rf /var/cache/apk/* -# Create non-root user for security -# node:20-alpine already has a 'node' user with UID 1000 -# Change ownership of /app to node user -RUN chown -R node:node /app && \ - chmod +x /app/docker-entrypoint.sh +WORKDIR /app -# Switch to non-root user -USER node +# Copy Node.js dependencies from node-setup stage +COPY --from=node-setup /app/node_modules ./node_modules -# Expose port (default 8080, can be overridden via config) +# Copy built Go binary from go-builder stage +COPY --from=go-builder /build/gitcitadel-online . + +# Copy necessary files +COPY scripts ./scripts +COPY static ./static +COPY templates ./templates +COPY docker-entrypoint.sh ./ + +# Make entrypoint executable +RUN chmod +x docker-entrypoint.sh + +# Create cache directory +RUN mkdir -p cache/media && chmod 755 cache + +# Expose port EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Use entrypoint script +ENTRYPOINT ["./docker-entrypoint.sh"] -# Run the application via entrypoint script -ENTRYPOINT ["/app/docker-entrypoint.sh"] -CMD ["/app/gitcitadel-online", "--config", "/app/config.yaml"] +# Run the application +CMD ["./gitcitadel-online"] diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 1db487c..109e405 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -1,24 +1,39 @@ version: '3.8' +# Production Docker Compose configuration for GitCitadel Online +# Domain: https://gitcitadel.imwald.eu +# +# This configuration: +# - Installs gc-parser from git.imwald.eu (with GitHub fallback) +# - Sets up production networking and resource limits +# - Includes Traefik labels for reverse proxy (optional - remove if not using Traefik) +# - Configures health checks and automatic restarts + services: gitcitadel-online: - image: silberengel/gitcitadel-online:latest - container_name: gitcitadel-online + build: + context: . + dockerfile: Dockerfile + container_name: gitcitadel-online-prod restart: unless-stopped ports: # Expose port 2323 for Apache reverse proxy (maps to container port 8080) - "2323:8080" volumes: + # Mount config file (create from config.yaml.example) + - ./config.yaml:/app/config.yaml:ro # Persist cache directory - # Note: Ensure the host cache directory is writable by UID 1000 (node user) - # Run: sudo chown -R 1000:1000 ./cache (or use 777 permissions) - ./cache:/app/cache - # Optional: Mount config file to override defaults - # - ./config.yaml:/app/config.yaml:ro - # Optional environment variables (uncomment and set as needed): - # environment: - # - CONFIG_PATH=/app/config.yaml - # - LOG_LEVEL=info + # Ensure scripts directory is available + - ./scripts:/app/scripts:ro + environment: + # Production environment variables + - CONFIG_PATH=/app/config.yaml + - LOG_LEVEL=info + # Link base URL for production domain + - LINK_BASE_URL=https://gitcitadel.imwald.eu + networks: + - gitcitadel-network healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] interval: 30s @@ -28,5 +43,22 @@ services: deploy: resources: limits: - cpus: '1' - memory: 512M + cpus: '2' + memory: 1G + # Note: reservations are only supported in Docker Swarm mode + # For regular docker-compose, only limits are supported + # Traefik labels for reverse proxy (optional - remove if not using Traefik) + # If using nginx or another reverse proxy, remove these labels and configure + # your reverse proxy to forward requests to localhost:2323 + labels: + - "traefik.enable=true" + - "traefik.http.routers.gitcitadel.rule=Host(`gitcitadel.imwald.eu`)" + - "traefik.http.routers.gitcitadel.entrypoints=websecure" + - "traefik.http.routers.gitcitadel.tls.certresolver=letsencrypt" + - "traefik.http.services.gitcitadel.loadbalancer.server.port=8080" + - "traefik.docker.network=gitcitadel-network" + +networks: + gitcitadel-network: + driver: bridge + name: gitcitadel-network diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 828fcc9..105fe02 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -36,6 +36,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/ebooks", s.handleEBooks) mux.HandleFunc("/contact", s.handleContact) mux.HandleFunc("/feed", s.handleFeed) + mux.HandleFunc("/events", s.handleEvents) // Health and metrics mux.HandleFunc("/health", s.handleHealth) @@ -122,6 +123,152 @@ func (s *Server) handleFeed(w http.ResponseWriter, r *http.Request) { s.handleCachedPage("/feed")(w, r) } +// handleEvents handles the /events?d=... page to show events with a specific d-tag +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get d-tag from query parameter + dTag := r.URL.Query().Get("d") + if dTag == "" { + s.handle404(w, r) + return + } + + // Query events with this d-tag across multiple kinds + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Search across wiki, blog, and longform kinds + kinds := []int{nostr.KindWiki, nostr.KindBlog, nostr.KindLongform} + var allEvents []*gonostr.Event + + for _, kind := range kinds { + filter := gonostr.Filter{ + Kinds: []int{kind}, + Tags: map[string][]string{ + "d": {dTag}, + }, + Limit: 100, // Get up to 100 events per kind + } + + events, err := s.nostrClient.FetchEvents(ctx, filter) + if err != nil { + logger.WithFields(map[string]interface{}{ + "kind": kind, + "dtag": dTag, + "error": err, + }).Warn("Failed to fetch events for d-tag") + continue + } + + allEvents = append(allEvents, events...) + } + + if len(allEvents) == 0 { + // No events found - show 404 or empty page + html, err := s.htmlGenerator.GenerateErrorPage(http.StatusNotFound, []generator.FeedItemInfo{}) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(html)) + return + } + + // Fetch profiles for all events + pubkeys := make([]string, 0, len(allEvents)) + seenPubkeys := make(map[string]bool) + for _, event := range allEvents { + if !seenPubkeys[event.PubKey] { + pubkeys = append(pubkeys, event.PubKey) + seenPubkeys[event.PubKey] = true + } + } + + profiles := make(map[string]*nostr.Profile) + if len(pubkeys) > 0 { + profileCtx, profileCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer profileCancel() + + fetchedProfiles, err := s.nostrClient.FetchProfilesBatch(profileCtx, pubkeys) + if err == nil { + profiles = fetchedProfiles + } + } + + // Generate HTML page showing the events + // For now, we'll create a simple list - you can enhance this with a proper template + html := s.generateEventsPage(dTag, allEvents, profiles) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + +// generateEventsPage generates HTML for the events page +func (s *Server) generateEventsPage(dTag string, events []*gonostr.Event, profiles map[string]*nostr.Profile) string { + // Simple HTML generation - you can enhance this with a proper template + html := ` + + + + + Events: ` + dTag + ` - ` + s.siteURL + ` + + + +
+

Events with d-tag: ` + dTag + `

+
` + + for _, event := range events { + profile := profiles[event.PubKey] + authorName := event.PubKey[:16] + "..." + if profile != nil && profile.Name != "" { + authorName = profile.Name + } + + // Extract title from tags + title := dTag + for _, tag := range event.Tags { + if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 { + title = tag[1] + break + } + } + + // Determine URL based on kind + var url string + switch event.Kind { + case nostr.KindBlog: + url = "/blog#" + dTag + case nostr.KindLongform: + url = "/articles#" + dTag + default: + url = "/wiki/" + dTag + } + + html += ` +
+

` + title + `

+

Kind: ` + fmt.Sprintf("%d", event.Kind) + `

+

Author: ` + authorName + `

+

Created: ` + time.Unix(int64(event.CreatedAt), 0).Format("2006-01-02 15:04:05") + `

+
` + } + + html += ` +
+
+ +` + + return html +} + // handleContact handles the contact form (GET and POST) func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { diff --git a/package-lock.json b/package-lock.json index 55a1438..fd0b692 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,8 +5,7 @@ "packages": { "": { "dependencies": { - "@asciidoctor/core": "^3.0.4", - "marked": "^12.0.0" + "gc-parser": "git+https://git.imwald.eu/silberengel/gc-parser.git" } }, "node_modules/@asciidoctor/core": { @@ -57,6 +56,14 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/gc-parser": { + "version": "1.0.0", + "resolved": "git+https://git.imwald.eu/silberengel/gc-parser.git#80b70a87d09efada2d688ba77f5e35118593c1a1", + "license": "MIT", + "dependencies": { + "@asciidoctor/core": "^3.0.4" + } + }, "node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -94,22 +101,10 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/marked": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", - "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" diff --git a/package.json b/package.json index e525385..02f8298 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "gc-parser": "file:../gc-parser" + "gc-parser": "git+https://git.imwald.eu/silberengel/gc-parser.git" } }