Browse Source

fix build

master
Silberengel 2 weeks ago
parent
commit
befe7db0bd
  1. 3
      .gitignore
  2. 235
      DOCKER.md
  3. 116
      Dockerfile
  4. 56
      docker-compose-prod.yml
  5. 147
      internal/server/handlers.go
  6. 29
      package-lock.json
  7. 2
      package.json

3
.gitignore vendored

@ -45,6 +45,9 @@ yarn-error.log*
.npm .npm
# Note: package-lock.json should be committed for reproducible builds # 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 # IDE and editor files
.vscode/ .vscode/
.idea/ .idea/

235
DOCKER.md

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

116
Dockerfile

@ -1,17 +1,10 @@
# Multi-stage build for GitCitadel Online # 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 # Stage 1: Build Go application
FROM golang:1.22-alpine AS builder FROM golang:1.22-alpine AS go-builder
# Install build dependencies # Install build dependencies
RUN apk add --no-cache git RUN apk add --no-cache git make
# Set working directory
WORKDIR /build WORKDIR /build
# Copy go mod files # Copy go mod files
@ -22,64 +15,79 @@ RUN go mod download
COPY . . COPY . .
# Build the application # 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 # Copy package.json and the local gc-parser directory
FROM node:20-alpine COPY package.json package-lock.json* ./
COPY gc-parser ./gc-parser
# Install runtime dependencies (wget for health check and nostr-tools download) # Build gc-parser (install dependencies and compile TypeScript)
RUN apk add --no-cache ca-certificates tzdata wget 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 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 # Ensure gc-parser is a directory, not a symlink (for proper copying to final stage)
# Note: gc-parser is referenced from ../gc-parser in package.json RUN if [ -L node_modules/gc-parser ]; then \
# Before building, ensure gc-parser is built: cd ../gc-parser && npm install && npm run build echo "Warning: gc-parser is a symlink, copying as directory..."; \
# Then npm install here will link to the built gc-parser rm node_modules/gc-parser; \
COPY package.json package-lock.json ./ cp -r gc-parser node_modules/gc-parser; \
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"; \
fi fi
# Copy example config (user should mount their own config.yaml) # Stage 3: Final runtime image
COPY config.yaml.example ./config.yaml.example FROM alpine:latest
# Copy entrypoint script # Install runtime dependencies
COPY docker-entrypoint.sh /app/docker-entrypoint.sh RUN apk add --no-cache \
ca-certificates \
wget \
nodejs \
npm \
git \
&& rm -rf /var/cache/apk/*
# Create non-root user for security WORKDIR /app
# 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
# Switch to non-root user # Copy Node.js dependencies from node-setup stage
USER node 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 EXPOSE 8080
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ 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 # Run the application
ENTRYPOINT ["/app/docker-entrypoint.sh"] CMD ["./gitcitadel-online"]
CMD ["/app/gitcitadel-online", "--config", "/app/config.yaml"]

56
docker-compose-prod.yml

@ -1,24 +1,39 @@
version: '3.8' 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: services:
gitcitadel-online: gitcitadel-online:
image: silberengel/gitcitadel-online:latest build:
container_name: gitcitadel-online context: .
dockerfile: Dockerfile
container_name: gitcitadel-online-prod
restart: unless-stopped restart: unless-stopped
ports: ports:
# Expose port 2323 for Apache reverse proxy (maps to container port 8080) # Expose port 2323 for Apache reverse proxy (maps to container port 8080)
- "2323:8080" - "2323:8080"
volumes: volumes:
# Mount config file (create from config.yaml.example)
- ./config.yaml:/app/config.yaml:ro
# Persist cache directory # 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 - ./cache:/app/cache
# Optional: Mount config file to override defaults # Ensure scripts directory is available
# - ./config.yaml:/app/config.yaml:ro - ./scripts:/app/scripts:ro
# Optional environment variables (uncomment and set as needed): environment:
# environment: # Production environment variables
# - CONFIG_PATH=/app/config.yaml - CONFIG_PATH=/app/config.yaml
# - LOG_LEVEL=info - LOG_LEVEL=info
# Link base URL for production domain
- LINK_BASE_URL=https://gitcitadel.imwald.eu
networks:
- gitcitadel-network
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s interval: 30s
@ -28,5 +43,22 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: '1' cpus: '2'
memory: 512M 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

147
internal/server/handlers.go

@ -36,6 +36,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/ebooks", s.handleEBooks) mux.HandleFunc("/ebooks", s.handleEBooks)
mux.HandleFunc("/contact", s.handleContact) mux.HandleFunc("/contact", s.handleContact)
mux.HandleFunc("/feed", s.handleFeed) mux.HandleFunc("/feed", s.handleFeed)
mux.HandleFunc("/events", s.handleEvents)
// Health and metrics // Health and metrics
mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/health", s.handleHealth)
@ -122,6 +123,152 @@ func (s *Server) handleFeed(w http.ResponseWriter, r *http.Request) {
s.handleCachedPage("/feed")(w, r) 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 := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Events: ` + dTag + ` - ` + s.siteURL + `</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<h1>Events with d-tag: ` + dTag + `</h1>
<div class="events-list">`
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 += `
<div class="event-card">
<h2><a href="` + url + `">` + title + `</a></h2>
<p>Kind: ` + fmt.Sprintf("%d", event.Kind) + `</p>
<p>Author: ` + authorName + `</p>
<p>Created: ` + time.Unix(int64(event.CreatedAt), 0).Format("2006-01-02 15:04:05") + `</p>
</div>`
}
html += `
</div>
</div>
</body>
</html>`
return html
}
// handleContact handles the contact form (GET and POST) // handleContact handles the contact form (GET and POST)
func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) { func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {

29
package-lock.json generated

@ -5,8 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "gc-parser": "git+https://git.imwald.eu/silberengel/gc-parser.git"
"marked": "^12.0.0"
} }
}, },
"node_modules/@asciidoctor/core": { "node_modules/@asciidoctor/core": {
@ -57,6 +56,14 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC" "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": { "node_modules/glob": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@ -94,22 +101,10 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"

2
package.json

@ -1,5 +1,5 @@
{ {
"dependencies": { "dependencies": {
"gc-parser": "file:../gc-parser" "gc-parser": "git+https://git.imwald.eu/silberengel/gc-parser.git"
} }
} }

Loading…
Cancel
Save