Compare commits

..

5 Commits

Author SHA1 Message Date
Silberengel dd40297107 created docker container 4 weeks ago
Silberengel 07dbb55b55 refactor 4 weeks ago
Silberengel 4fe07e636a removed dead and redundant code 4 weeks ago
Silberengel c6b4ed44e4 bug-fixes 4 weeks ago
Silberengel 8c725244ac bug-fixes 4 weeks ago
  1. 47
      .dockerignore
  2. 223
      DOCKER.md
  3. 77
      Dockerfile
  4. 313
      README.md
  5. 2
      cmd/server/main.go
  6. 33
      docker-compose.yml
  7. 28
      internal/config/config.go
  8. 23
      internal/generator/html.go
  9. 70
      internal/generator/seo.go
  10. 53
      internal/nostr/client.go
  11. 53
      internal/nostr/ebooks.go
  12. 325
      internal/nostr/events.go
  13. 23
      internal/nostr/feed.go
  14. 33
      internal/nostr/issues.go
  15. 2
      internal/nostr/profile.go
  16. 137
      internal/nostr/wiki.go
  17. 142
      internal/server/handlers.go
  18. 20
      internal/server/server.go
  19. 57
      static/css/main.css
  20. 7164
      static/js/nostr.bundle.js
  21. 19
      templates/articles.html
  22. 2
      templates/base.html
  23. 25
      templates/blog.html
  24. 5
      templates/components.html
  25. 170
      templates/contact.html
  26. 2
      templates/ebooks.html
  27. 16
      templates/feed.html
  28. 6
      templates/landing.html
  29. 1
      templates/wiki.html

47
.dockerignore

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
# Git files
.git
.gitignore
.gitattributes
# Build artifacts
gitcitadel-online
server
*.exe
*.dll
*.so
*.dylib
# Test files
*_test.go
*.test
# Documentation
*.md
!README.md
LICENSE.md
# IDE files
.vscode
.idea
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Cache and temporary files
cache/
*.log
*.tmp
# Node modules (will be installed in container)
node_modules/
# Config file (should be mounted)
config.yaml
# Development files
.env
.env.local

223
DOCKER.md

@ -0,0 +1,223 @@ @@ -0,0 +1,223 @@
# 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, 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
```
## 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)
```

77
Dockerfile

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
# 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
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags='-w -s' -o gitcitadel-online ./cmd/server
# Stage 2: Runtime with Node.js for AsciiDoc processing
FROM node:20-alpine
# Install runtime dependencies (wget for health check and nostr-tools download)
RUN apk add --no-cache ca-certificates tzdata wget
# Set working directory
WORKDIR /app
# Install Node.js dependencies for AsciiDoc processing
COPY package.json package-lock.json ./
RUN npm ci --only=production
# 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
# Copy example config (user should mount their own config.yaml)
COPY config.yaml.example ./config.yaml.example
# Create cache directories
RUN mkdir -p cache/media
# Create non-root user for security
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose port (default 8080, can be overridden via config)
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
# Run the application
ENTRYPOINT ["/app/gitcitadel-online"]
CMD ["--config", "/app/config.yaml"]

313
README.md

@ -1,103 +1,312 @@ @@ -1,103 +1,312 @@
# GitCitadel Online
A server-generated website that fetches kind 30818 wiki events from Nostr relays, processes AsciiDoc content, and serves professional HTML pages with caching.
A server-generated static website that fetches content from Nostr relays, processes AsciiDoc articles, and serves professional HTML pages with intelligent caching. Built with Go and designed for decentralized content publishing.
## Features
- Fetches wiki content from Nostr relays (kind 30818 events)
- Processes AsciiDoc content to HTML
- Caches all pages for fast serving
- Background cache rewarming to keep content fresh
- Kind 1 feed integration in sidebar
- SEO optimized with structured data
- Responsive design with medium-dark theme
- WCAG AA/AAA compliant accessibility
- YAML configuration for easy index management
- **Wiki System**: Fetches and displays wiki articles (kind 30818) from Nostr relays
- **Blog & Articles**: Supports blog posts and longform articles (kind 30023) with full markdown/AsciiDoc processing
- **E-Books Library**: Displays e-books and publications (kind 30040) from Nostr
- **Feed Integration**: Real-time kind 1 feed integration in sidebar
- **Contact Form**: Nostr-based contact form with browser extension support and anonymous submission
- **AsciiDoc Processing**: Full AsciiDoc to HTML conversion with table of contents support
- **Intelligent Caching**: Multi-layer caching system with background rewarming
- **Media Caching**: Automatic caching of external images and media
- **SEO Optimized**: Structured data, sitemaps, and meta tags
- **Responsive Design**: Mobile-first responsive design with medium-dark theme
- **Accessibility**: WCAG AA/AAA compliant with proper ARIA labels and keyboard navigation
- **Content Security Policy**: Secure CSP headers for XSS protection
## Requirements
- Go 1.22+
- Node.js (for asciidoctor.js)
- @asciidoctor/core npm package
- Network access to Nostr relays
- **Go 1.22+** - For building and running the server
- **Node.js** - For AsciiDoc processing
- **@asciidoctor/core** - npm package for AsciiDoc conversion
- **Network access** - To connect to Nostr relays
## Installation
1. Clone the repository
2. Install Go dependencies:
1. **Clone the repository:**
```bash
git clone <repository-url>
cd gitcitadel-online
```
2. **Install Go dependencies:**
```bash
go mod tidy
```
3. Install Node.js dependencies:
3. **Install Node.js dependencies:**
```bash
npm install @asciidoctor/core
```
Or globally:
Or install globally:
```bash
npm install -g @asciidoctor/core
```
4. Copy the example config:
4. **Download nostr-tools bundle (for contact form):**
```bash
mkdir -p static/js
curl -L -o static/js/nostr.bundle.js https://unpkg.com/nostr-tools@latest/lib/nostr.bundle.js
```
Note: The nostr-tools library is hosted locally to avoid dependency on external CDNs.
5. **Copy and configure:**
```bash
cp config.yaml.example config.yaml
```
5. Edit `config.yaml` with your indices and settings
Edit `config.yaml` with your Nostr indices, relay URLs, and settings.
## Configuration
Edit `config.yaml` to set:
Edit `config.yaml` to configure:
### Required Settings
- `wiki_index`: naddr for your wiki index (kind 30040)
- `blog_index`: naddr for your blog index (kind 30040)
- Relay URLs
- Cache refresh intervals
- Server port
- SEO settings
- `repo_announcement`: naddr for repository announcement (for contact form)
- `relays.feeds`: Primary relay URL for fetching content
- `relays.profiles`: Comma-separated relay URLs for profile data
- `relays.contactform`: Comma-separated relay URLs for contact form submissions
### Optional Settings
- `link_base_url`: Base URL for external links (default: Alexandria)
- `cache.refresh_interval_minutes`: How often to refresh cached pages (default: 30)
- `feed.poll_interval_minutes`: How often to poll for new feed items (default: 5)
- `feed.max_events`: Maximum number of feed items to display (default: 30)
- `server.port`: HTTP server port (default: 8080)
- `server.enable_compression`: Enable gzip compression (default: true)
- `seo.site_name`: Site name for SEO
- `seo.site_url`: Canonical site URL
- `seo.default_image`: Default OpenGraph image path
### Example Configuration
```yaml
wiki_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyd8wumn8ghj7..."
blog_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyvhwumn8ghj7..."
repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqq9xw6t5vd5hgctyv4kqde47kt"
relays:
feeds: "wss://theforest.nostr1.com"
profiles: "wss://theforest.nostr1.com,wss://nostr.land"
contactform: "wss://thecitadel.nostr1.com,wss://relay.damus.io"
server:
port: 8080
enable_compression: true
seo:
site_name: "GitCitadel"
site_url: "https://gitcitadel.com"
```
## Running
### Development Mode
Run with verbose logging:
```bash
go run cmd/server/main.go
go run cmd/server/main.go --dev
```
Or build and run:
### Production Mode
Build and run:
```bash
go build -o gitcitadel-online cmd/server/main.go
./gitcitadel-online
```
Development mode with verbose logging:
```bash
go run cmd/server/main.go --dev
```
### Command Line Options
- `--config <path>`: Path to configuration file (default: `config.yaml`)
- `--dev`: Enable development mode with verbose logging
- `--log-level <level>`: Set log level (debug, info, warn, error) (default: info)
## Routes & Endpoints
### Public Pages
- `/` - Landing page with feed sidebar
- `/wiki` - Wiki index page
- `/wiki/<d-tag>` - Individual wiki article pages
- `/blog` - Blog index page with article navigation
- `/articles` - Longform articles index page
- `/ebooks` - E-books library with sortable table
- `/feed` - Feed page with relay information
- `/contact` - Contact form with Nostr integration
### Static Assets
- `/static/` - Static files (CSS, JavaScript, images, icons)
- `/cache/media/` - Cached external media files
- `/favicon.ico` - Site favicon
### API Endpoints
- `/api/contact` - POST endpoint for submitting contact form events (JSON)
### System Endpoints
- `/health` - Health check endpoint
- `/metrics` - Metrics endpoint (Prometheus format)
- `/sitemap.xml` - XML sitemap for search engines
- `/robots.txt` - Robots.txt file
## Project Structure
```
gitcitadel-online/
├── cmd/server/ # Main server application
├── cmd/
│ └── server/ # Main server application entry point
├── internal/
│ ├── nostr/ # Nostr client and event parsing
│ ├── asciidoc/ # AsciiDoc processing
│ ├── generator/ # HTML generation
│ ├── cache/ # Caching layer
│ ├── server/ # HTTP server
│ └── config/ # Configuration management
├── templates/ # HTML templates
├── static/ # Static assets (CSS, images)
└── config.yaml # Configuration file
│ ├── asciidoc/ # AsciiDoc processing with Node.js
│ ├── cache/ # Multi-layer caching system
│ │ ├── cache.go # Page cache
│ │ ├── feed_cache.go # Feed item cache
│ │ └── media_cache.go # Media file cache
│ ├── config/ # Configuration management
│ ├── generator/ # HTML generation and SEO
│ ├── logger/ # Structured logging
│ ├── nostr/ # Nostr client and event parsing
│ │ ├── client.go # Relay connection management
│ │ ├── wiki.go # Wiki event parsing
│ │ ├── profile.go # Profile metadata
│ │ ├── feed.go # Feed event parsing
│ │ ├── ebooks.go # E-book parsing
│ │ └── issues.go # Issue/contact form handling
│ └── server/ # HTTP server and handlers
├── static/ # Static assets
│ ├── css/ # Stylesheets
│ ├── icons/ # SVG icons
│ └── js/ # JavaScript libraries
├── templates/ # HTML templates
│ ├── base.html # Base template
│ ├── landing.html # Landing page
│ ├── wiki.html # Wiki pages
│ ├── blog.html # Blog pages
│ ├── articles.html # Article pages
│ ├── ebooks.html # E-books page
│ ├── feed.html # Feed page
│ ├── contact.html # Contact form
│ └── components.html # Reusable components
├── cache/ # Runtime cache directory
│ └── media/ # Cached media files
├── config.yaml # Configuration file (not in repo)
├── config.yaml.example # Example configuration
├── go.mod # Go module dependencies
├── package.json # Node.js dependencies
└── README.md # This file
```
## API
## Development
The server provides:
- `/` - Landing page
- `/wiki/<d-tag>` - Wiki article pages
- `/blog` - Blog index page
- `/static/` - Static assets
- `/health` - Health check endpoint
- `/metrics` - Metrics endpoint
- `/sitemap.xml` - Sitemap
- `/robots.txt` - Robots.txt
### Building
```bash
go build -o gitcitadel-online cmd/server/main.go
```
### Testing
The server uses a caching system that pre-generates all pages. On first run, pages will be generated and cached. Subsequent requests serve from cache until the refresh interval.
### Cache Management
- Pages are cached in memory for fast serving
- Cache rewarming runs in the background at configured intervals
- Media files are cached to disk in `cache/media/`
- Cache can be cleared by restarting the server
### Logging
Logs are structured and can be configured via:
- `--log-level` flag (debug, info, warn, error)
- `--dev` flag enables debug logging and verbose output
## Content Types Supported
### Wiki Articles (Kind 30818)
- AsciiDoc content processing
- Table of contents generation
- Cross-referencing support
- Syntax highlighting
### Blog Posts (Kind 30023)
- Markdown/AsciiDoc content
- Image support with caching
- Author profiles
- Timestamps and metadata
### E-Books (Kind 30040)
- Publication listings
- Author information
- Sortable table interface
- Links to Alexandria library
### Feed Items (Kind 1)
- Real-time note display
- Author badges with profiles
- Timestamp formatting
- Content rendering
## Contact Form
The contact form supports two submission methods:
1. **Browser Extension**: Users can sign with their Nostr browser extension (nos2x, Alby, etc.)
2. **Anonymous**: Server generates a temporary key pair for anonymous submissions
Both methods publish kind 1 events to configured relays with proper tags for issue tracking.
## Security
- Content Security Policy (CSP) headers prevent XSS attacks
- All external scripts are hosted locally
- Input validation on contact form
- Event signature verification for API submissions
- Secure relay connections (WSS)
## Performance
- Multi-layer caching (memory + disk)
- Background cache rewarming
- Gzip compression support
- Optimized static asset serving
- Efficient Nostr event parsing
## Docker Deployment
GitCitadel Online can be run using Docker for easy deployment on localhost or remote servers.
### Quick Start with Docker Compose
```bash
# 1. Create config file
cp config.yaml.example config.yaml
# Edit config.yaml with your settings
# 2. Build and run
docker-compose up -d
# 3. View logs
docker-compose logs -f
```
The application will be available at `http://localhost:8080`.
For detailed Docker instructions, see [DOCKER.md](DOCKER.md).
## License
MIT License - see LICENSE.md
MIT License - see LICENSE.md for details
## Contributing
Contributions are welcome! Please ensure:
- Code follows Go conventions
- Templates are accessible (WCAG AA/AAA)
- All routes are documented
- Configuration changes are backward compatible

2
cmd/server/main.go

@ -124,7 +124,7 @@ func main() { @@ -124,7 +124,7 @@ func main() {
rewarmer.Start(ctx)
// Initialize HTTP server
httpServer := server.NewServer(cfg.Server.Port, pageCache, feedCache, mediaCache, issueService, cfg.RepoAnnouncement, htmlGenerator, nostrClient)
httpServer := server.NewServer(cfg.Server.Port, pageCache, feedCache, mediaCache, issueService, cfg.RepoAnnouncement, htmlGenerator, nostrClient, cfg.SEO.SiteURL)
// Start server in goroutine
go func() {

33
docker-compose.yml

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
version: '3.8'
services:
gitcitadel-online:
build:
context: .
dockerfile: Dockerfile
container_name: gitcitadel-online
restart: unless-stopped
ports:
- "8080:8080"
volumes:
# Mount config file (create from config.yaml.example)
- ./config.yaml:/app/config.yaml:ro
# Persist cache directory
- ./cache:/app/cache
environment:
# Optional: override config path
# - CONFIG_PATH=/app/config.yaml
# Optional: set log level
# - LOG_LEVEL=info
networks:
- gitcitadel-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
gitcitadel-network:
driver: bridge

28
internal/config/config.go

@ -96,12 +96,12 @@ func LoadConfig(path string) (*Config, error) { @@ -96,12 +96,12 @@ func LoadConfig(path string) (*Config, error) {
return &config, nil
}
// GetProfilesRelays parses the comma-separated profiles relay string into a slice
func (c *Config) GetProfilesRelays() []string {
if c.Relays.Profiles == "" {
// parseRelayList parses a comma-separated relay string into a slice
func parseRelayList(relayStr string) []string {
if relayStr == "" {
return []string{}
}
relays := strings.Split(c.Relays.Profiles, ",")
relays := strings.Split(relayStr, ",")
result := make([]string, 0, len(relays))
for _, relay := range relays {
relay = strings.TrimSpace(relay)
@ -114,22 +114,14 @@ func (c *Config) GetProfilesRelays() []string { @@ -114,22 +114,14 @@ func (c *Config) GetProfilesRelays() []string {
return result
}
// GetProfilesRelays parses the comma-separated profiles relay string into a slice
func (c *Config) GetProfilesRelays() []string {
return parseRelayList(c.Relays.Profiles)
}
// GetContactFormRelays parses the comma-separated contact form relay string into a slice
func (c *Config) GetContactFormRelays() []string {
if c.Relays.ContactForm == "" {
return []string{}
}
relays := strings.Split(c.Relays.ContactForm, ",")
result := make([]string, 0, len(relays))
for _, relay := range relays {
relay = strings.TrimSpace(relay)
// Remove quotes if present
relay = strings.Trim(relay, "\"'")
if relay != "" {
result = append(result, relay)
}
}
return result
return parseRelayList(c.Relays.ContactForm)
}
// Validate validates the configuration

23
internal/generator/html.go

@ -20,12 +20,13 @@ import ( @@ -20,12 +20,13 @@ import (
func getTemplateFuncs() template.FuncMap {
return template.FuncMap{
"year": func() int { return time.Now().Year() },
"json": func(v interface{}) (string, error) {
"json": func(v interface{}) (template.HTML, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
// Return as template.HTML to prevent HTML escaping
return template.HTML(b), nil
},
"hasPrefix": func(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
@ -178,15 +179,6 @@ type EBookInfo struct { @@ -178,15 +179,6 @@ type EBookInfo struct {
TimeISO string // ISO time
}
// UserBadgeInfo represents user badge data for display
type UserBadgeInfo struct {
Pubkey string
Picture string
DisplayName string
Name string
ShortNpub string
}
// NewHTMLGenerator creates a new HTML generator
func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaultImage string, nostrClient *nostr.Client) (*HTMLGenerator, error) {
tmpl := template.New("base").Funcs(getTemplateFuncs())
@ -814,7 +806,8 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event @@ -814,7 +806,8 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
}
// Add repo announcement data for JavaScript
if repoAnnouncement != nil {
// Only include if Pubkey and DTag are both set (required fields)
if repoAnnouncement != nil && repoAnnouncement.Pubkey != "" && repoAnnouncement.DTag != "" {
templateData["RepoAnnouncement"] = map[string]interface{}{
"Pubkey": repoAnnouncement.Pubkey,
"DTag": repoAnnouncement.DTag,
@ -906,13 +899,13 @@ func (g *HTMLGenerator) GenerateFeedPage(feedItems []FeedItemInfo) (string, erro @@ -906,13 +899,13 @@ func (g *HTMLGenerator) GenerateFeedPage(feedItems []FeedItemInfo) (string, erro
ctx := context.Background()
profiles := g.fetchProfilesBatch(ctx, pubkeys)
description := "Recent notes from TheForest relay"
description := "Recent notes from The Forest relay"
if len(feedItems) > 0 {
description = fmt.Sprintf("Browse %d recent notes from TheForest relay", len(feedItems))
description = fmt.Sprintf("Browse %d recent notes from The Forest relay", len(feedItems))
}
data := PageData{
Title: "TheForest Feed",
Title: "The Forest Feed",
Description: description,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,

70
internal/generator/seo.go

@ -1,70 +0,0 @@ @@ -1,70 +0,0 @@
package generator
import (
"encoding/json"
)
// GenerateStructuredData generates JSON-LD structured data
func GenerateStructuredData(siteName, siteURL, pageType, title, description, url string) string {
var data map[string]interface{}
switch pageType {
case "article":
data = map[string]interface{}{
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"url": url,
"publisher": map[string]interface{}{
"@type": "Organization",
"name": siteName,
},
}
case "website":
data = map[string]interface{}{
"@context": "https://schema.org",
"@type": "WebSite",
"name": siteName,
"url": siteURL,
}
default:
data = map[string]interface{}{
"@context": "https://schema.org",
"@type": "WebPage",
"name": title,
"url": url,
}
}
jsonData, _ := json.Marshal(data)
return string(jsonData)
}
// GenerateBreadcrumbStructuredData generates breadcrumb structured data
func GenerateBreadcrumbStructuredData(items []BreadcrumbItem, siteURL string) string {
breadcrumbList := make([]map[string]interface{}, len(items))
for i, item := range items {
breadcrumbList[i] = map[string]interface{}{
"@type": "ListItem",
"position": i + 1,
"name": item.Name,
"item": siteURL + item.URL,
}
}
data := map[string]interface{}{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumbList,
}
jsonData, _ := json.Marshal(data)
return string(jsonData)
}
// BreadcrumbItem represents a breadcrumb item
type BreadcrumbItem struct {
Name string
URL string
}

53
internal/nostr/client.go

@ -3,7 +3,7 @@ package nostr @@ -3,7 +3,7 @@ package nostr
import (
"context"
"fmt"
"sync"
"sort"
"time"
"gitcitadel-online/internal/logger"
@ -15,11 +15,9 @@ import ( @@ -15,11 +15,9 @@ import (
type Client struct {
pool *nostr.SimplePool
relays []string
feedsRelay string // Relay for feeds
profileRelays []string // Relays for profiles
contactRelays []string // Relays for contact form
mu sync.RWMutex
ctx context.Context
feedsRelay string // Relay for feeds
profileRelays []string // Relays for profiles
contactRelays []string // Relays for contact form
requestSem chan struct{} // Semaphore to limit concurrent requests
maxConcurrent int // Maximum concurrent requests
}
@ -45,7 +43,6 @@ func NewClient(feedsRelay string, profileRelays []string, contactRelays []string @@ -45,7 +43,6 @@ func NewClient(feedsRelay string, profileRelays []string, contactRelays []string
feedsRelay: feedsRelay,
profileRelays: profileRelays,
contactRelays: contactRelays,
ctx: ctx,
requestSem: make(chan struct{}, maxConcurrent),
maxConcurrent: maxConcurrent,
}
@ -197,23 +194,6 @@ func (c *Client) FetchEventsBatchFromRelays(ctx context.Context, filters []nostr @@ -197,23 +194,6 @@ func (c *Client) FetchEventsBatchFromRelays(ctx context.Context, filters []nostr
return events, nil
}
// FetchEventByID fetches an event by its ID
func (c *Client) FetchEventByID(ctx context.Context, eventID string) (*nostr.Event, error) {
filter := nostr.Filter{
IDs: []string{eventID},
}
return c.FetchEvent(ctx, filter)
}
// FetchEventsByKind fetches events of a specific kind
func (c *Client) FetchEventsByKind(ctx context.Context, kind int, limit int) ([]*nostr.Event, error) {
filter := nostr.Filter{
Kinds: []int{kind},
Limit: limit,
}
return c.FetchEvents(ctx, filter)
}
// Close closes all relay connections in the pool
func (c *Client) Close() {
// SimplePool manages connections, but we can close individual relays if needed
@ -305,17 +285,16 @@ func (c *Client) FetchDeletionEvents(ctx context.Context, authors []string) (map @@ -305,17 +285,16 @@ func (c *Client) FetchDeletionEvents(ctx context.Context, authors []string) (map
}
// Parse deletion events - extract event IDs from "e" tags
// According to NIP-09, "e" tags have format ["e", "event_id", ...]
// Only the first element (event_id) should be used, not additional elements
deletedEventIDs := make(map[string]*nostr.Event)
for _, deletionEvent := range deletionEvents {
// Kind 5 events have "e" tags with the event IDs they're deleting
for _, tag := range deletionEvent.Tags {
if len(tag) > 0 && tag[0] == "e" && len(tag) > 1 {
eventID := tag[1]
// Keep the newest deletion event if multiple deletions exist
existing, exists := deletedEventIDs[eventID]
if !exists || deletionEvent.CreatedAt > existing.CreatedAt {
deletedEventIDs[eventID] = deletionEvent
}
for _, eventID := range getETagValues(deletionEvent.Tags) {
// Keep the newest deletion event if multiple deletions exist
existing, exists := deletedEventIDs[eventID]
if !exists || deletionEvent.CreatedAt > existing.CreatedAt {
deletedEventIDs[eventID] = deletionEvent
}
}
}
@ -579,13 +558,9 @@ func (c *Client) ProcessEventsWithCache( @@ -579,13 +558,9 @@ func (c *Client) ProcessEventsWithCache(
allEvents = FilterDeletedEvents(allEvents, deletedEventIDs)
// Step 6: Sort newest-first (by created_at descending)
for i := 0; i < len(allEvents)-1; i++ {
for j := i + 1; j < len(allEvents); j++ {
if allEvents[i].CreatedAt < allEvents[j].CreatedAt {
allEvents[i], allEvents[j] = allEvents[j], allEvents[i]
}
}
}
sort.Slice(allEvents, func(i, j int) bool {
return allEvents[i].CreatedAt > allEvents[j].CreatedAt
})
logger.WithFields(map[string]interface{}{
"after_deletion": len(allEvents),

53
internal/nostr/ebooks.go

@ -108,57 +108,18 @@ func (es *EBooksService) FetchTopLevelIndexEvents(ctx context.Context) ([]EBookI @@ -108,57 +108,18 @@ func (es *EBooksService) FetchTopLevelIndexEvents(ctx context.Context) ([]EBookI
CreatedAt: int64(event.CreatedAt),
}
// Extract d tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
ebook.DTag = tag[1]
break
}
}
// Extract title
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
ebook.Title = tag[1]
break
}
}
// Extract common tags using utilities
ebook.DTag = getDTag(event.Tags)
ebook.Title = getTitle(event.Tags)
if ebook.Title == "" {
ebook.Title = ebook.DTag // Fallback to d tag
}
ebook.Summary = getSummary(event.Tags)
ebook.Type = getTagValue(event.Tags, "type")
ebook.Image = getImage(event.Tags)
// Extract summary
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
ebook.Summary = tag[1]
break
}
}
// Extract type
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "type" && len(tag) > 1 {
ebook.Type = tag[1]
break
}
}
// Extract image
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "image" && len(tag) > 1 {
ebook.Image = tag[1]
break
}
}
// Build naddr - create proper bech32-encoded naddr
// Extract relay hints from event tags if available
var relays []string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "relays" {
relays = append(relays, tag[1:]...)
}
}
relays := getAllTagValues(event.Tags, "relays")
// If no relays in tags, use the relay we fetched from
if len(relays) == 0 {
relays = []string{es.relayURL}

325
internal/nostr/events.go

@ -7,6 +7,97 @@ import ( @@ -7,6 +7,97 @@ import (
"github.com/nbd-wtf/go-nostr"
)
// Tag utilities for extracting common tags from events
// getTagValue extracts the first value for a given tag name
func getTagValue(tags nostr.Tags, tagName string) string {
for _, tag := range tags {
if len(tag) > 0 && tag[0] == tagName && len(tag) > 1 {
return tag[1]
}
}
return ""
}
// getAllTagValues extracts all values for a given tag name
// For tags that may have multiple values (like "relays", "maintainers")
func getAllTagValues(tags nostr.Tags, tagName string) []string {
var values []string
for _, tag := range tags {
if len(tag) > 0 && tag[0] == tagName && len(tag) > 1 {
values = append(values, tag[1:]...)
}
}
return values
}
// getETagValues extracts event IDs from "e" tags
// According to NIP-09, "e" tags have format ["e", "event_id", ...]
// Only the first element (event_id) should be extracted, not additional elements like relay URLs or markers
func getETagValues(tags nostr.Tags) []string {
var eventIDs []string
for _, tag := range tags {
if len(tag) > 0 && tag[0] == "e" && len(tag) > 1 {
// Only take the first element (event_id), ignore additional elements
eventIDs = append(eventIDs, tag[1])
}
}
return eventIDs
}
// getDTag extracts the d tag from an event
func getDTag(tags nostr.Tags) string {
return getTagValue(tags, "d")
}
// getTitle extracts the title tag from an event
func getTitle(tags nostr.Tags) string {
return getTagValue(tags, "title")
}
// getSummary extracts the summary tag from an event
func getSummary(tags nostr.Tags) string {
return getTagValue(tags, "summary")
}
// getImage extracts the image tag from an event
func getImage(tags nostr.Tags) string {
return getTagValue(tags, "image")
}
// ContentEvent represents a generic content event with common fields
type ContentEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
Image string
}
// parseContentEvent parses common content event fields (used by wiki, blog, longform)
func parseContentEvent(event *nostr.Event, expectedKind int) (*ContentEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
ce := &ContentEvent{
Event: event,
Content: event.Content,
DTag: getDTag(event.Tags),
Title: getTitle(event.Tags),
Summary: getSummary(event.Tags),
Image: getImage(event.Tags),
}
// Fallback title to d tag if not set
if ce.Title == "" {
ce.Title = ce.DTag
}
return ce, nil
}
// IndexEvent represents a kind 30040 publication index event (NKBIP-01)
type IndexEvent struct {
Event *nostr.Event
@ -37,26 +128,15 @@ func ParseIndexEvent(event *nostr.Event, expectedKind int) (*IndexEvent, error) @@ -37,26 +128,15 @@ func ParseIndexEvent(event *nostr.Event, expectedKind int) (*IndexEvent, error)
}
index := &IndexEvent{
Event: event,
Author: event.PubKey,
}
// Extract d tag
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
}
}
index.DTag = dTag
// Extract title
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
index.Title = tag[1]
break
}
Event: event,
Author: event.PubKey,
DTag: getDTag(event.Tags),
Title: getTitle(event.Tags),
Summary: getSummary(event.Tags),
Image: getImage(event.Tags),
Type: getTagValue(event.Tags, "type"),
Version: getTagValue(event.Tags, "version"),
AutoUpdate: getTagValue(event.Tags, "auto-update"),
}
// Extract 'a' tags (index items)
@ -86,224 +166,49 @@ func ParseIndexEvent(event *nostr.Event, expectedKind int) (*IndexEvent, error) @@ -86,224 +166,49 @@ func ParseIndexEvent(event *nostr.Event, expectedKind int) (*IndexEvent, error)
}
}
// Extract auto-update tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "auto-update" && len(tag) > 1 {
index.AutoUpdate = tag[1]
break
}
}
// Extract type tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "type" && len(tag) > 1 {
index.Type = tag[1]
break
}
}
// Extract version tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "version" && len(tag) > 1 {
index.Version = tag[1]
break
}
}
// Extract summary tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
index.Summary = tag[1]
break
}
}
// Extract image tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "image" && len(tag) > 1 {
index.Image = tag[1]
break
}
}
return index, nil
}
// WikiEvent represents a kind 30818 wiki event (NIP-54)
type WikiEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
Image string
*ContentEvent
}
// ParseWikiEvent parses a wiki event according to NIP-54
func ParseWikiEvent(event *nostr.Event, expectedKind int) (*WikiEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
ce, err := parseContentEvent(event, expectedKind)
if err != nil {
return nil, err
}
wiki := &WikiEvent{
Event: event,
Content: event.Content,
}
// Extract d tag (normalized identifier)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
wiki.DTag = tag[1]
break
}
}
// Extract title tag (optional, falls back to d tag)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
wiki.Title = tag[1]
break
}
}
if wiki.Title == "" {
wiki.Title = wiki.DTag
}
// Extract summary tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
wiki.Summary = tag[1]
break
}
}
// Extract image tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "image" && len(tag) > 1 {
wiki.Image = tag[1]
break
}
}
return wiki, nil
return &WikiEvent{ContentEvent: ce}, nil
}
// BlogEvent represents a kind 30041 blog event
type BlogEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
Image string
*ContentEvent
}
// ParseBlogEvent parses a blog event
func ParseBlogEvent(event *nostr.Event, expectedKind int) (*BlogEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
blog := &BlogEvent{
Event: event,
Content: event.Content,
}
// Extract d tag (normalized identifier)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
blog.DTag = tag[1]
break
}
}
// Extract title tag (optional, falls back to d tag)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
blog.Title = tag[1]
break
}
}
if blog.Title == "" {
blog.Title = blog.DTag
ce, err := parseContentEvent(event, expectedKind)
if err != nil {
return nil, err
}
// Extract summary tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
blog.Summary = tag[1]
break
}
}
// Extract image tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "image" && len(tag) > 1 {
blog.Image = tag[1]
break
}
}
return blog, nil
return &BlogEvent{ContentEvent: ce}, nil
}
// LongformEvent represents a kind 30023 longform article event
type LongformEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
Image string
*ContentEvent
}
// ParseLongformEvent parses a longform article event
func ParseLongformEvent(event *nostr.Event, expectedKind int) (*LongformEvent, error) {
if event.Kind != expectedKind {
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
ce, err := parseContentEvent(event, expectedKind)
if err != nil {
return nil, err
}
longform := &LongformEvent{
Event: event,
Content: event.Content,
}
// Extract d tag (normalized identifier)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
longform.DTag = tag[1]
break
}
}
// Extract title tag (optional, falls back to d tag)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
longform.Title = tag[1]
break
}
}
if longform.Title == "" {
longform.Title = longform.DTag
}
// Extract summary tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
longform.Summary = tag[1]
break
}
}
// Extract image tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "image" && len(tag) > 1 {
longform.Image = tag[1]
break
}
}
return longform, nil
return &LongformEvent{ContentEvent: ce}, nil
}
// NormalizeDTag normalizes a d tag according to NIP-54 rules

23
internal/nostr/feed.go

@ -40,29 +40,20 @@ func (fs *FeedService) FetchFeedItems(ctx context.Context, feedRelay string, max @@ -40,29 +40,20 @@ func (fs *FeedService) FetchFeedItems(ctx context.Context, feedRelay string, max
// Convert events to feed items
items := make([]FeedItem, 0, len(result.Events))
for _, event := range result.Events {
item := FeedItem{
item := FeedItem{
EventID: event.ID,
Author: event.PubKey,
Content: event.Content,
Time: time.Unix(int64(event.CreatedAt), 0),
Link: fmt.Sprintf("https://alexandria.gitcitadel.eu/events?id=nevent1%s", event.ID),
}
}
// Extract title, summary, and image tags
for _, tag := range event.Tags {
if len(tag) > 0 && len(tag) > 1 {
switch tag[0] {
case "title":
item.Title = tag[1]
case "summary":
item.Summary = tag[1]
case "image":
item.Image = tag[1]
}
}
}
// Extract title, summary, and image tags
item.Title = getTitle(event.Tags)
item.Summary = getSummary(event.Tags)
item.Image = getImage(event.Tags)
items = append(items, item)
items = append(items, item)
}
logger.WithFields(map[string]interface{}{

33
internal/nostr/issues.go

@ -40,32 +40,27 @@ func ParseRepoAnnouncement(event *nostr.Event, expectedKind int) (*RepoAnnouncem @@ -40,32 +40,27 @@ func ParseRepoAnnouncement(event *nostr.Event, expectedKind int) (*RepoAnnouncem
return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind)
}
// Validate that PubKey is set
if event.PubKey == "" {
return nil, fmt.Errorf("repository announcement event missing pubkey")
}
repo := &RepoAnnouncement{
Event: event,
Pubkey: event.PubKey,
}
// Extract d tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
repo.DTag = tag[1]
break
}
}
repo.DTag = getDTag(event.Tags)
// Extract relays tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "relays" && len(tag) > 1 {
repo.Relays = append(repo.Relays, tag[1:]...)
}
// Validate that DTag is set
if repo.DTag == "" {
return nil, fmt.Errorf("repository announcement event missing d tag")
}
// Extract maintainers tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "maintainers" && len(tag) > 1 {
repo.Maintainers = append(repo.Maintainers, tag[1:]...)
}
}
// Extract relays and maintainers tags
repo.Relays = getAllTagValues(event.Tags, "relays")
repo.Maintainers = getAllTagValues(event.Tags, "maintainers")
return repo, nil
}
@ -185,6 +180,8 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA @@ -185,6 +180,8 @@ func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoA
}
err = relay.Publish(ctx, *event)
// Note: SimplePool manages connections, but we close here for explicit cleanup
// Closing after publish attempt ensures cleanup regardless of success/failure
relay.Close()
if err != nil {
lastErr = err
@ -243,6 +240,8 @@ func (s *IssueService) PublishSignedIssue(ctx context.Context, signedEvent *nost @@ -243,6 +240,8 @@ func (s *IssueService) PublishSignedIssue(ctx context.Context, signedEvent *nost
}
err = relay.Publish(ctx, *signedEvent)
// Note: SimplePool manages connections, but we close here for explicit cleanup
// Closing after publish attempt ensures cleanup regardless of success/failure
relay.Close()
if err != nil {
lastErr = err

2
internal/nostr/profile.go

@ -118,7 +118,7 @@ func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[ @@ -118,7 +118,7 @@ func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[
"pubkeys": len(uniquePubkeys),
}).Debug("Batch fetching profiles")
// Fetch all profile events from fallback relays only (not theforest)
// Fetch all profile events from fallback relays only (not The Forest)
profileRelays := c.GetProfileRelays()
if len(profileRelays) == 0 {
// Fallback: if no profile relays configured, use all relays

137
internal/nostr/wiki.go

@ -57,7 +57,7 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In @@ -57,7 +57,7 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In
filter := naddr.ToFilter()
logFilter(filter, fmt.Sprintf("wiki index (kind %d)", ws.indexKind))
// Fetch the event from theforest only (primary relay)
// Fetch the event from The Forest only (primary relay)
primaryRelay := ws.client.GetPrimaryRelay()
if primaryRelay == "" {
return nil, fmt.Errorf("primary relay not configured")
@ -76,25 +76,24 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In @@ -76,25 +76,24 @@ func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*In
return index, nil
}
// FetchWikiEvents fetches all wiki events referenced in an index
// Uses ProcessEventsWithCache for the initial fetch, then filters by index items
func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ([]*WikiEvent, error) {
// fetchIndexEventsByKind is a helper that fetches events of a specific kind from an index
// Returns a map of kind:pubkey:dtag -> event for matching
func (ws *WikiService) fetchIndexEventsByKind(ctx context.Context, index *IndexEvent, targetKind int) (map[string]*nostr.Event, error) {
// Build a map of expected items (kind:pubkey:dtag) for fast lookup
expectedItems := make(map[string]IndexItem)
for _, item := range index.Items {
if item.Kind == ws.wikiKind {
if item.Kind == targetKind {
key := fmt.Sprintf("%d:%s:%s", item.Kind, item.Pubkey, item.DTag)
expectedItems[key] = item
}
}
if len(expectedItems) == 0 {
return []*WikiEvent{}, nil
return make(map[string]*nostr.Event), nil
}
// Use ProcessEventsWithCache to fetch events of this kind
// Use a high display limit (1000) to ensure we get all events referenced in the index
// This means we'll fetch 2000 events, which should be enough for most cases
displayLimit := 1000
primaryRelay := ws.client.GetPrimaryRelay()
if primaryRelay == "" {
@ -102,37 +101,21 @@ func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ( @@ -102,37 +101,21 @@ func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) (
}
logger.WithFields(map[string]interface{}{
"kind": ws.wikiKind,
"kind": targetKind,
"items": len(expectedItems),
"index_event_id": index.Event.ID,
}).Debug("Fetching wiki events using ProcessEventsWithCache with index")
}).Debug("Fetching events using ProcessEventsWithCache with index")
// Use standard process with index event ID: fetch index, query only referenced events, merge cache, deduplicate, filter deletions, sort, limit, fetch profiles
result, err := ws.client.ProcessEventsWithCache(ctx, ws.wikiKind, displayLimit, make(map[string]*nostr.Event), primaryRelay, index.Event.ID, ws.indexKind)
// Use standard process with index event ID
result, err := ws.client.ProcessEventsWithCache(ctx, targetKind, displayLimit, make(map[string]*nostr.Event), primaryRelay, index.Event.ID, ws.indexKind)
if err != nil {
logger.WithField("error", err).Warn("Failed to fetch wiki events using ProcessEventsWithCache")
return nil, err
return nil, fmt.Errorf("failed to fetch events using ProcessEventsWithCache: %w", err)
}
allEvents := result.Events
logger.WithFields(map[string]interface{}{
"fetched": len(allEvents),
"expected": len(expectedItems),
}).Debug("Fetched wiki events using ProcessEventsWithCache with index")
// Build event map by kind:pubkey:dtag for matching
eventMap := make(map[string]*nostr.Event)
for _, event := range allEvents {
// Extract d-tag from event
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
}
}
for _, event := range result.Events {
dTag := getDTag(event.Tags)
if dTag == "" {
continue // Skip events without d-tag
}
@ -145,6 +128,24 @@ func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ( @@ -145,6 +128,24 @@ func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) (
}
}
logger.WithFields(map[string]interface{}{
"fetched": len(result.Events),
"matched": len(eventMap),
"expected": len(expectedItems),
"kind": targetKind,
}).Debug("Fetched and matched events from index")
return eventMap, nil
}
// FetchWikiEvents fetches all wiki events referenced in an index
// Uses ProcessEventsWithCache for the initial fetch, then filters by index items
func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ([]*WikiEvent, error) {
eventMap, err := ws.fetchIndexEventsByKind(ctx, index, ws.wikiKind)
if err != nil {
return nil, err
}
// Convert matched events to wiki events, preserving order from index.Items
var wikiEvents []*WikiEvent
for _, item := range index.Items {
@ -166,11 +167,6 @@ func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ( @@ -166,11 +167,6 @@ func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) (
wikiEvents = append(wikiEvents, wiki)
}
logger.WithFields(map[string]interface{}{
"matched": len(wikiEvents),
"expected": len(expectedItems),
}).Debug("Matched wiki events")
if len(wikiEvents) == 0 && len(index.Items) > 0 {
logger.WithField("items", len(index.Items)).Warn("No wiki events matched from fetched events")
}
@ -218,72 +214,11 @@ func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent, @@ -218,72 +214,11 @@ func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent,
return nil, fmt.Errorf("unsupported event kind: %d (only %v are supported)", targetKind, ws.articleKinds)
}
// Build a map of expected items (kind:pubkey:dtag) for fast lookup
expectedItems := make(map[string]IndexItem)
for _, item := range index.Items {
if item.Kind == targetKind {
key := fmt.Sprintf("%d:%s:%s", item.Kind, item.Pubkey, item.DTag)
expectedItems[key] = item
}
}
if len(expectedItems) == 0 {
return []*nostr.Event{}, nil
}
// Use ProcessEventsWithCache to fetch events of this kind
// Use a high display limit (1000) to ensure we get all events referenced in the index
// This means we'll fetch 2000 events, which should be enough for most cases
displayLimit := 1000
primaryRelay := ws.client.GetPrimaryRelay()
if primaryRelay == "" {
return nil, fmt.Errorf("primary relay not configured")
}
logger.WithFields(map[string]interface{}{
"kind": targetKind,
"items": len(expectedItems),
"index_event_id": index.Event.ID,
}).Debug("Fetching events using ProcessEventsWithCache with index")
// Use standard process with index event ID: fetch index, query only referenced events, merge cache, deduplicate, filter deletions, sort, limit, fetch profiles
result, err := ws.client.ProcessEventsWithCache(ctx, targetKind, displayLimit, make(map[string]*nostr.Event), primaryRelay, index.Event.ID, ws.indexKind)
eventMap, err := ws.fetchIndexEventsByKind(ctx, index, targetKind)
if err != nil {
logger.WithField("error", err).Warn("Failed to fetch events using ProcessEventsWithCache")
return nil, err
}
allEvents := result.Events
logger.WithFields(map[string]interface{}{
"fetched": len(allEvents),
"expected": len(expectedItems),
}).Debug("Fetched events using ProcessEventsWithCache with index")
// Build event map by kind:pubkey:dtag for matching
eventMap := make(map[string]*nostr.Event)
for _, event := range allEvents {
// Extract d-tag from event
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
}
}
if dTag == "" {
continue // Skip events without d-tag
}
key := fmt.Sprintf("%d:%s:%s", event.Kind, event.PubKey, dTag)
// Keep the newest version if we have multiple
existing, exists := eventMap[key]
if !exists || event.CreatedAt > existing.CreatedAt {
eventMap[key] = event
}
}
// Convert to result slice, preserving order from index.Items
events := make([]*nostr.Event, 0, len(eventMap))
for _, item := range index.Items {
@ -298,12 +233,6 @@ func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent, @@ -298,12 +233,6 @@ func (ws *WikiService) FetchIndexEvents(ctx context.Context, index *IndexEvent,
events = append(events, event)
}
logger.WithFields(map[string]interface{}{
"matched": len(events),
"expected": len(expectedItems),
"kind": targetKind,
}).Debug("Matched index events")
if len(events) == 0 && len(index.Items) > 0 {
logger.WithFields(map[string]interface{}{
"kind": targetKind,
@ -326,7 +255,7 @@ func (ws *WikiService) FetchWikiEventByDTag(ctx context.Context, pubkey, dTag st @@ -326,7 +255,7 @@ func (ws *WikiService) FetchWikiEventByDTag(ctx context.Context, pubkey, dTag st
}
logFilter(filter, fmt.Sprintf("wiki by d-tag %s", dTag))
// Fetch from theforest only (primary relay)
// Fetch from The Forest only (primary relay)
primaryRelay := ws.client.GetPrimaryRelay()
if primaryRelay == "" {
return nil, fmt.Errorf("primary relay not configured")

142
internal/server/handlers.go

@ -90,48 +90,36 @@ func (s *Server) handleWiki(w http.ResponseWriter, r *http.Request) { @@ -90,48 +90,36 @@ func (s *Server) handleWiki(w http.ResponseWriter, r *http.Request) {
s.servePage(w, r, page)
}
// handleBlog handles the blog page
func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/blog")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
// handleCachedPage handles pages that are served from cache
func (s *Server) handleCachedPage(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get(path)
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
}
}
s.servePage(w, r, page)
// handleBlog handles the blog page
func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) {
s.handleCachedPage("/blog")(w, r)
}
// handleArticles handles the articles page
func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/articles")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
s.handleCachedPage("/articles")(w, r)
}
// handleEBooks handles the e-books listing page
func (s *Server) handleEBooks(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/ebooks")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
s.handleCachedPage("/ebooks")(w, r)
}
// handleFeed handles the Feed page
func (s *Server) handleFeed(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/feed")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
s.handleCachedPage("/feed")(w, r)
}
// handleContact handles the contact form (GET and POST)
@ -274,7 +262,8 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) { @@ -274,7 +262,8 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) {
// Parse JSON request
var req struct {
Event *gonostr.Event `json:"event"`
Event *gonostr.Event `json:"event"`
AdditionalRelays []string `json:"additionalRelays,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -287,9 +276,9 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) { @@ -287,9 +276,9 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) {
return
}
// Validate event kind (should be kind 1 for contact messages)
if req.Event.Kind != 1 {
http.Error(w, fmt.Sprintf("Invalid event kind: expected 1, got %d", req.Event.Kind), http.StatusBadRequest)
// Validate event kind (should be kind 1621 for issues per NIP-34)
if req.Event.Kind != 1621 {
http.Error(w, fmt.Sprintf("Invalid event kind: expected 1621, got %d", req.Event.Kind), http.StatusBadRequest)
return
}
@ -311,10 +300,28 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) { @@ -311,10 +300,28 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) {
// Get contact relays
contactRelays := s.nostrClient.GetContactRelays()
// Publish to contact relays
// Combine contact relays with user's outbox relays (if provided)
allRelays := make(map[string]bool)
for _, relay := range contactRelays {
allRelays[relay] = true
}
// Add user's outbox relays (from their kind 10002 relay list)
for _, relay := range req.AdditionalRelays {
if relay != "" {
allRelays[relay] = true
}
}
// Convert map to slice
relaysToPublish := make([]string, 0, len(allRelays))
for relay := range allRelays {
relaysToPublish = append(relaysToPublish, relay)
}
// Publish to all relays (contact relays + user outbox relays)
var lastErr error
var published bool
for _, relayURL := range contactRelays {
for _, relayURL := range relaysToPublish {
relay, err := s.nostrClient.ConnectToRelay(ctx, relayURL)
if err != nil {
logger.WithFields(map[string]interface{}{
@ -326,8 +333,8 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) { @@ -326,8 +333,8 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) {
}
err = relay.Publish(ctx, *req.Event)
// Note: SimplePool manages connections, but we close here for explicit cleanup
relay.Close()
if err != nil {
logger.WithFields(map[string]interface{}{
"relay": relayURL,
@ -389,11 +396,24 @@ func (s *Server) handleMediaCache(w http.ResponseWriter, r *http.Request) { @@ -389,11 +396,24 @@ func (s *Server) handleMediaCache(w http.ResponseWriter, r *http.Request) {
// handleHealth handles health check requests
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
// Check if cache has pages
if s.cache.Size() == 0 {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Not ready"))
w.Write([]byte("Not ready - cache empty"))
return
}
// Check relay connectivity with timeout
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := s.nostrClient.HealthCheck(ctx, 5*time.Second); err != nil {
logger.WithField("error", err).Warn("Health check: relay connectivity check failed")
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Not ready - relay connectivity check failed"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
@ -407,11 +427,51 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { @@ -407,11 +427,51 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
// handleSitemap handles sitemap requests
func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) {
// TODO: Generate sitemap from cache
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
// Get all cached page paths
paths := s.cache.GetAllPaths()
if len(paths) == 0 {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>`))
return
}
// Convert cached pages to sitemap URLs
sitemapURLs := make([]generator.SitemapURL, 0, len(paths))
for _, path := range paths {
page, exists := s.cache.Get(path)
if !exists {
continue
}
// Determine priority and change frequency based on path
priority := 0.5
changeFreq := "daily"
if path == "/" {
priority = 1.0
changeFreq = "hourly"
} else if path == "/wiki" || path == "/blog" || path == "/articles" || path == "/ebooks" {
priority = 0.8
changeFreq = "daily"
} else if strings.HasPrefix(path, "/wiki/") {
priority = 0.7
changeFreq = "weekly"
}
sitemapURLs = append(sitemapURLs, generator.SitemapURL{
Path: path,
LastMod: page.LastUpdated,
ChangeFreq: changeFreq,
Priority: priority,
})
}
// Generate sitemap XML
sitemapXML := generator.GenerateSitemap(sitemapURLs, s.siteURL)
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(sitemapXML))
}
// handleRobots handles robots.txt requests
@ -468,8 +528,8 @@ func (s *Server) middleware(next http.Handler) http.Handler { @@ -468,8 +528,8 @@ func (s *Server) middleware(next http.Handler) http.Handler {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// CSP header - allow unpkg.com for Lucide icons
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;")
// CSP header - allow unpkg.com for Lucide icons and jsdelivr.net for nostr-tools
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;")
// Log request (only in debug mode to reduce noise)
start := time.Now()

20
internal/server/server.go

@ -28,6 +28,7 @@ type Server struct { @@ -28,6 +28,7 @@ type Server struct {
repoAnnouncement string
htmlGenerator HTMLGeneratorInterface
nostrClient *nostr.Client
siteURL string
}
// IssueServiceInterface defines the interface for issue service
@ -44,7 +45,7 @@ type HTMLGeneratorInterface interface { @@ -44,7 +45,7 @@ type HTMLGeneratorInterface interface {
}
// NewServer creates a new HTTP server
func NewServer(port int, pageCache *cache.Cache, feedCache *cache.FeedCache, mediaCache *cache.MediaCache, issueService IssueServiceInterface, repoAnnouncement string, htmlGenerator HTMLGeneratorInterface, nostrClient *nostr.Client) *Server {
func NewServer(port int, pageCache *cache.Cache, feedCache *cache.FeedCache, mediaCache *cache.MediaCache, issueService IssueServiceInterface, repoAnnouncement string, htmlGenerator HTMLGeneratorInterface, nostrClient *nostr.Client, siteURL string) *Server {
s := &Server{
cache: pageCache,
feedCache: feedCache,
@ -54,6 +55,7 @@ func NewServer(port int, pageCache *cache.Cache, feedCache *cache.FeedCache, med @@ -54,6 +55,7 @@ func NewServer(port int, pageCache *cache.Cache, feedCache *cache.FeedCache, med
repoAnnouncement: repoAnnouncement,
htmlGenerator: htmlGenerator,
nostrClient: nostrClient,
siteURL: siteURL,
}
mux := http.NewServeMux()
@ -99,19 +101,3 @@ func (s *Server) WaitForShutdown() { @@ -99,19 +101,3 @@ func (s *Server) WaitForShutdown() {
logger.Info("Server exited")
}
// convertFeedItemsToInfo converts cache.FeedItem to generator.FeedItemInfo
func (s *Server) convertFeedItemsToInfo(items []cache.FeedItem) []generator.FeedItemInfo {
feedItems := make([]generator.FeedItemInfo, 0, len(items))
for _, item := range items {
feedItems = append(feedItems, generator.FeedItemInfo{
EventID: item.EventID,
Author: item.Author,
Content: item.Content,
Time: item.Time.Format("2006-01-02 15:04:05"),
TimeISO: item.Time.Format(time.RFC3339),
Link: item.Link,
})
}
return feedItems
}

57
static/css/main.css

@ -138,6 +138,9 @@ header { @@ -138,6 +138,9 @@ header {
gap: 0.5rem;
padding: 0.5rem 0;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.nav-menu a:hover {
@ -229,6 +232,9 @@ header { @@ -229,6 +232,9 @@ header {
color: var(--text-primary);
text-decoration: none;
transition: background 0.2s, color 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.dropdown-menu a:hover {
@ -291,6 +297,9 @@ header { @@ -291,6 +297,9 @@ header {
text-decoration: none;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.wiki-menu a:hover {
@ -511,7 +520,7 @@ a:focus { @@ -511,7 +520,7 @@ a:focus {
position: relative;
background-image: url('/static/GitCitadel_PFP.png');
background-size: cover;
background-position: center right;
background-position: center center;
background-repeat: no-repeat;
padding: 3rem 2rem;
border-radius: 12px;
@ -530,7 +539,7 @@ a:focus { @@ -530,7 +539,7 @@ a:focus {
right: 0;
bottom: 0;
background: linear-gradient(
to right,
to bottom,
rgba(45, 45, 45, 0.95) 0%,
rgba(45, 45, 45, 0.85) 30%,
rgba(124, 158, 255, 0.4) 60%,
@ -1018,6 +1027,7 @@ footer { @@ -1018,6 +1027,7 @@ footer {
.article-menu li {
margin-bottom: 0.5rem;
display: block;
}
.article-link {
@ -1040,10 +1050,15 @@ footer { @@ -1040,10 +1050,15 @@ footer {
}
.article-link-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-size: 1em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.article-link-meta {
@ -1089,6 +1104,23 @@ footer { @@ -1089,6 +1104,23 @@ footer {
border-bottom: 1px solid var(--border-color);
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-top: 1rem;
align-items: center;
font-size: 0.95em;
}
.article-meta .article-date,
.article-meta .article-author {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
}
.article-image {
margin: 1.5rem 0;
width: 100%;
@ -1736,6 +1768,17 @@ textarea:focus-visible { @@ -1736,6 +1768,17 @@ textarea:focus-visible {
background-color: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
table-layout: fixed;
}
.ebooks-table th:nth-child(1),
.ebooks-table td:nth-child(1) {
width: 66.67%;
}
.ebooks-table th:nth-child(2),
.ebooks-table td:nth-child(2) {
width: 33.33%;
}
.ebooks-table thead {
@ -1801,6 +1844,16 @@ textarea:focus-visible { @@ -1801,6 +1844,16 @@ textarea:focus-visible {
font-size: 0.9em;
}
.btn-icon-only {
padding: 0.5rem;
aspect-ratio: 1;
justify-content: center;
}
.btn-icon-only .icon-inline {
margin-right: 0;
}
.ebooks-table td .text-center {
text-align: center;
color: var(--text-secondary);

7164
static/js/nostr.bundle.js

File diff suppressed because it is too large Load Diff

19
templates/articles.html

@ -22,14 +22,6 @@ @@ -22,14 +22,6 @@
<li>
<a href="#" class="article-link" data-dtag="{{$item.DTag}}" data-index="{{$index}}"{{if eq $index 0}} data-active="true"{{end}}>
<div class="article-link-title"><span class="icon-inline">{{icon "file-text"}}</span> {{$item.Title}}</div>
{{if $item.Time}}
<div class="article-link-meta">
<span class="article-date"><span class="icon-inline">{{icon "clock"}}</span> {{$item.Time}}</span>
{{if $item.Author}}
<span class="article-author">{{template "user-badge-simple" (dict "Pubkey" $item.Author "Profiles" $.Profiles)}}</span>
{{end}}
</div>
{{end}}
</a>
</li>
{{end}}
@ -43,7 +35,16 @@ @@ -43,7 +35,16 @@
<article class="blog-article{{if eq $index 0}} active{{end}}" data-dtag="{{$item.DTag}}" id="article-{{$item.DTag}}">
<header class="article-header">
<h1 class="article-title">{{$item.Title}}</h1>
<p class="article-subtitle">Longform article</p>
{{if or $item.Time $item.Author}}
<div class="article-meta">
{{if $item.Time}}
<span class="article-date"><span class="icon-inline">{{icon "clock"}}</span> {{$item.Time}}</span>
{{end}}
{{if $item.Author}}
<span class="article-author"><span class="icon-inline">{{icon "user"}}</span> {{template "user-badge-simple" (dict "Pubkey" $item.Author "Profiles" $.Profiles)}}</span>
{{end}}
</div>
{{end}}
</header>
{{if and $item.Image (ne $item.Image "")}}
<div class="article-image">

2
templates/base.html

@ -58,7 +58,7 @@ @@ -58,7 +58,7 @@
</ul>
</li>
<li class="dropdown">
<a href="#" class="dropdown-toggle">TheForest Relay <span class="dropdown-arrow"></span></a>
<a href="#" class="dropdown-toggle">The Forest Relay <span class="dropdown-arrow"></span></a>
<ul class="dropdown-menu">
<li><a href="/feed"><span class="icon-inline">{{icon "rss"}}</span> Feed</a></li>
<li><a href="/articles"><span class="icon-inline">{{icon "file-text"}}</span> Articles</a></li>

25
templates/blog.html

@ -8,18 +8,12 @@ @@ -8,18 +8,12 @@
<!-- Left Sidebar -->
<aside class="blog-sidebar">
<div class="blog-header">
{{if .BlogIndexAuthor}}
<div class="blog-author-handle">{{template "user-badge-simple" (dict "Pubkey" .BlogIndexAuthor "Profiles" $.Profiles)}}</div>
{{end}}
{{if .BlogIndexImage}}
<div class="blog-image">
<img src="{{.BlogIndexImage}}" alt="{{.BlogIndexTitle}}" />
</div>
{{end}}
<h1 class="blog-title">{{if .BlogIndexTitle}}{{.BlogIndexTitle}}{{else}}Blog{{end}}</h1>
{{if .BlogIndexAuthor}}
<p class="blog-byline">by {{template "user-badge-simple" (dict "Pubkey" .BlogIndexAuthor "Profiles" $.Profiles)}}</p>
{{end}}
{{if .BlogIndexSummary}}
<p class="blog-description">{{.BlogIndexSummary}}</p>
{{end}}
@ -35,14 +29,6 @@ @@ -35,14 +29,6 @@
<li>
<a href="#" class="article-link" data-dtag="{{$item.DTag}}" data-index="{{$index}}"{{if eq $index 0}} data-active="true"{{end}}>
<div class="article-link-title"><span class="icon-inline">{{icon "file-text"}}</span> {{$item.Title}}</div>
{{if $item.Time}}
<div class="article-link-meta">
<span class="article-date"><span class="icon-inline">{{icon "clock"}}</span> {{$item.Time}}</span>
{{if $item.Author}}
<span class="article-author">{{template "user-badge-simple" (dict "Pubkey" $item.Author "Profiles" $.Profiles)}}</span>
{{end}}
</div>
{{end}}
</a>
</li>
{{end}}
@ -56,7 +42,16 @@ @@ -56,7 +42,16 @@
<article class="blog-article{{if eq $index 0}} active{{end}}" data-dtag="{{$item.DTag}}" id="article-{{$item.DTag}}">
<header class="article-header">
<h1 class="article-title">{{$item.Title}}</h1>
<p class="article-subtitle">This entry originally appeared in this blog.</p>
{{if or $item.Time $item.Author}}
<div class="article-meta">
{{if $item.Time}}
<span class="article-date"><span class="icon-inline">{{icon "clock"}}</span> {{$item.Time}}</span>
{{end}}
{{if $item.Author}}
<span class="article-author"><span class="icon-inline">{{icon "user"}}</span> {{template "user-badge-simple" (dict "Pubkey" $item.Author "Profiles" $.Profiles)}}</span>
{{end}}
</div>
{{end}}
</header>
{{if and $item.Image (ne $item.Image "")}}
<div class="article-image">

5
templates/components.html

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
{{/* Feed Component - Reusable feed sidebar */}}
{{define "feed"}}
<div class="feed-container">
<h3><span class="icon-inline">{{icon "rss"}}</span> Recent Notes <a href="https://aitherboard.imwald.eu/feed/relay/theforest.nostr1.com" target="_blank" rel="noopener noreferrer" class="feed-link-header"><span class="icon-inline">{{icon "external-link"}}</span> View Full Feed</a></h3>
<h3><span class="icon-inline">{{icon "rss"}}</span> Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<article class="feed-item">
@ -10,9 +10,6 @@ @@ -10,9 +10,6 @@
<time class="feed-time" datetime="{{.TimeISO}}"><span class="icon-inline">{{icon "clock"}}</span> {{.Time}}</time>
</header>
<div class="feed-content">{{.Content}}</div>
<footer class="feed-footer">
<a href="https://aitherboard.imwald.eu/event/{{.EventID}}" class="feed-link" target="_blank" rel="noopener noreferrer"><span class="icon-inline">{{icon "external-link"}}</span> View on Aitherboard</a>
</footer>
</article>
{{else}}
<p class="feed-empty"><span class="icon-inline">{{icon "inbox"}}</span> No recent notes available.</p>

170
templates/contact.html

@ -171,7 +171,7 @@ @@ -171,7 +171,7 @@
<script type="application/json" id="contact-relays-data">{{json .ContactRelays}}</script>
<script src="https://cdn.jsdelivr.net/npm/nostr-tools@1.18.0/lib/nostr.bundle.js"></script>
<script src="/static/js/nostr.bundle.js"></script>
<script>
(function() {
const form = document.getElementById('contact-form');
@ -207,10 +207,46 @@ @@ -207,10 +207,46 @@
const repoDataEl = document.getElementById('repo-announcement-data');
if (repoDataEl) {
try {
repoAnnouncement = JSON.parse(repoDataEl.textContent);
// Trim whitespace before parsing
let jsonText = repoDataEl.textContent.trim();
let parsed = JSON.parse(jsonText);
// Handle double-encoded JSON (if the result is still a string, parse again)
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
// Ensure all required fields are present and valid
const pubkey = parsed?.Pubkey;
const dTag = parsed?.DTag;
const hasPubkey = pubkey && typeof pubkey === 'string' && pubkey.trim() !== '';
const hasDTag = dTag && typeof dTag === 'string' && dTag.trim() !== '';
if (hasPubkey && hasDTag) {
repoAnnouncement = parsed;
console.log('Repo announcement loaded successfully:', {
dTag: dTag,
pubkey: pubkey.substring(0, 16) + '...',
maintainersCount: parsed.Maintainers ? parsed.Maintainers.length : 0,
relaysCount: parsed.Relays ? parsed.Relays.length : 0
});
} else {
console.error('Repo announcement data incomplete:', {
hasPubkey,
hasDTag,
pubkeyType: typeof pubkey,
pubkeyValue: pubkey,
dTagType: typeof dTag,
dTagValue: dTag,
fullObject: parsed
});
}
} catch (e) {
console.error('Failed to parse repo announcement data:', e);
console.error('Failed to parse repo announcement data:', e, 'Raw content:', repoDataEl.textContent);
}
} else {
console.warn('Repo announcement data element not found');
}
// Store form data for submission
@ -285,9 +321,9 @@ @@ -285,9 +321,9 @@
// Generate key pair for anonymous submission
async function generateKeyPair() {
const keyPair = NostrTools.generatePrivateKey();
const pubkey = NostrTools.getPublicKey(keyPair);
return { privateKey: keyPair, pubkey: pubkey };
const secretKey = NostrTools.generateSecretKey();
const pubkey = NostrTools.getPublicKey(secretKey);
return { privateKey: secretKey, pubkey: pubkey };
}
// Sign event with private key
@ -295,16 +331,39 @@ @@ -295,16 +331,39 @@
return NostrTools.finalizeEvent(event, privateKey);
}
// Extract outbox (write) relays from user's relay list (kind 10002)
function extractOutboxRelays(relayListEvent) {
const outboxRelays = [];
if (relayListEvent && relayListEvent.tags) {
for (const tag of relayListEvent.tags) {
// Format: ["r", "<relay-url>", "write"] for outbox relays
if (tag[0] === 'r' && tag.length >= 3 && tag[2] === 'write') {
const relayUrl = tag[1];
if (relayUrl && !outboxRelays.includes(relayUrl)) {
outboxRelays.push(relayUrl);
}
}
}
}
return outboxRelays;
}
// Submit event
async function submitEvent(signedEvent) {
async function submitEvent(signedEvent, additionalRelays = []) {
submitBtn.innerHTML = '<span class="icon-inline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg></span> Publishing...';
// Include additional relays (outbox relays from user's relay list)
const eventWithRelays = {
...signedEvent,
additionalRelays: additionalRelays
};
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ event: signedEvent })
body: JSON.stringify({ event: eventWithRelays })
});
const result = await response.json();
@ -332,6 +391,8 @@ @@ -332,6 +391,8 @@
let pubkey;
let signFunction;
let userOutboxRelays = [];
if (useExtension) {
// Login with browser extension
if (!window.nostr) {
@ -344,6 +405,19 @@ @@ -344,6 +405,19 @@
submitBtn.innerHTML = '<span class="icon-inline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg></span> Signing...';
pubkey = await window.nostr.getPublicKey();
signFunction = (event) => window.nostr.signEvent(event);
// Fetch user's relay list (kind 10002) to get outbox relays
if (window.nostr.getRelays) {
try {
const relayListEvent = await window.nostr.getRelays();
if (relayListEvent) {
userOutboxRelays = extractOutboxRelays(relayListEvent);
}
} catch (e) {
console.warn('Failed to fetch user relay list:', e);
// Continue without user relay list - not critical
}
}
} else {
// Anonymous submission - generate key pair
submitBtn.innerHTML = '<span class="icon-inline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg></span> Generating key...';
@ -360,55 +434,72 @@ @@ -360,55 +434,72 @@
// Build event tags
const tags = [];
// Add 'a' tag for repository announcement if available
if (repoAnnouncement) {
tags.push(['a', `30617:${repoAnnouncement.pubkey}:${repoAnnouncement.dTag}`]);
tags.push(['p', repoAnnouncement.pubkey]);
// Add 'a' tag for repository announcement if available (required for NIP-34 issues)
// Format: ["a", "30617:<pubkey>:<d-tag>"]
// Note: Field names are capitalized (Pubkey, DTag) as they come from Go
const repoPubkey = repoAnnouncement?.Pubkey;
const repoDTag = repoAnnouncement?.DTag;
if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) {
repoAnnouncement.maintainers.forEach(maintainer => {
tags.push(['p', maintainer]);
if (repoAnnouncement && repoPubkey && typeof repoPubkey === 'string' && repoPubkey.trim() !== '' &&
repoDTag && typeof repoDTag === 'string' && repoDTag.trim() !== '') {
tags.push(['a', `30617:${repoPubkey.trim()}:${repoDTag.trim()}`]);
// Collect unique pubkeys for 'p' tags (owner + maintainers, deduplicated)
const uniquePubkeys = new Set();
// Add repository owner (required for NIP-34 issues)
uniquePubkeys.add(repoPubkey.trim());
// Add maintainers (deduplicated - owner won't be added twice)
if (repoAnnouncement.Maintainers && Array.isArray(repoAnnouncement.Maintainers)) {
repoAnnouncement.Maintainers.forEach(maintainer => {
if (maintainer && typeof maintainer === 'string' && maintainer.trim() !== '') {
uniquePubkeys.add(maintainer.trim());
}
});
}
// Add all unique pubkeys as 'p' tags
uniquePubkeys.forEach(pk => {
tags.push(['p', pk]);
});
}
} else {
// This should not happen if validation passed, but log for debugging
console.error('Repo announcement data missing or incomplete in tag building:', JSON.stringify(repoAnnouncement));
showFailureModal('Repository configuration is invalid. Please refresh the page and try again.');
return;
}
// Add required 'p' tags for contact recipients
tags.push(['p', '846ebf79a0a8813274ec9727490621ad423f16a3e474d7fd66e6a98bfe4e39a4']);
tags.push(['p', 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1']);
// Add client tag
tags.push(['client', 'gitcitadel.com']);
// Add subject tag
// Add subject tag (required for NIP-34 issues)
if (subject) {
tags.push(['subject', subject]);
}
// Add label tags
// Add label tags (t tags for issue labels per NIP-34)
labels.forEach(label => {
if (label) {
tags.push(['t', label]);
}
});
// Add contact relays tag
tags.push(['relays', ...contactRelays]);
// Add client tag
tags.push(['client', 'GitCitadel.com']);
// Create unsigned event (kind 1 for contact messages)
// Create unsigned event (kind 1621 for issues per NIP-34)
const unsignedEvent = {
kind: 1,
kind: 1621,
pubkey: pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: `Subject: ${subject}\n\n${content}`
content: content // Just the content, subject is in tags
};
// Sign the event
submitBtn.innerHTML = '<span class="icon-inline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg></span> Signing...';
const signedEvent = await signFunction(unsignedEvent);
// Submit event
await submitEvent(signedEvent);
// Submit event with user's outbox relays
await submitEvent(signedEvent, userOutboxRelays);
} catch (error) {
console.error('Error:', error);
showFailureModal('Error: ' + error.message);
@ -425,8 +516,23 @@ @@ -425,8 +516,23 @@
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Validate repo announcement data
if (!repoAnnouncement) {
showStatus('Repository configuration not available. Please try again later.', true);
console.error('Repo announcement is null or undefined');
return;
}
// Additional validation (should already be validated during parsing, but double-check)
if (!repoAnnouncement.Pubkey || typeof repoAnnouncement.Pubkey !== 'string' || repoAnnouncement.Pubkey.trim() === '') {
showStatus('Repository configuration incomplete: missing pubkey. Please try again later.', true);
console.error('Repo announcement missing Pubkey:', JSON.stringify(repoAnnouncement));
return;
}
if (!repoAnnouncement.DTag || typeof repoAnnouncement.DTag !== 'string' || repoAnnouncement.DTag.trim() === '') {
showStatus('Repository configuration incomplete: missing d-tag. Please try again later.', true);
console.error('Repo announcement missing DTag:', JSON.stringify(repoAnnouncement));
return;
}

2
templates/ebooks.html

@ -25,7 +25,7 @@ @@ -25,7 +25,7 @@
<strong>{{.Title}}</strong>
{{if .Summary}}<br><small class="text-muted">{{.Summary}}</small>{{end}}
<div style="margin-top: 0.75rem;">
<a href="https://alexandria.gitcitadel.eu/publication/naddr/{{.Naddr}}" target="_blank" rel="noopener noreferrer" class="btn btn-sm"><span class="icon-inline">{{icon "external-link"}}</span> View on Alexandria</a>
<a href="https://alexandria.gitcitadel.eu/publication/naddr/{{.Naddr}}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-icon-only" title="View on Alexandria"><span class="icon-inline">{{icon "external-link"}}</span></a>
</div>
</td>
<td>{{template "user-badge-simple" (dict "Pubkey" .Author "Profiles" $.Profiles)}}</td>

16
templates/feed.html

@ -1,19 +1,19 @@ @@ -1,19 +1,19 @@
{{define "content"}}
<article class="feed-page">
<header class="page-header">
<h1><span class="icon-inline">{{icon "rss"}}</span> TheForest Feed</h1>
<p class="page-summary">Recent notes from TheForest relay</p>
<h1><span class="icon-inline">{{icon "rss"}}</span> The Forest Feed</h1>
<p class="page-summary">Recent notes from The Forest relay</p>
</header>
<div class="feed-about-blurb">
<h2>About TheForest Relay</h2>
<p>TheForest is a Nostr relay operated by GitCitadel. It provides a reliable, fast, and open relay service for the Nostr protocol.</p>
<h2>About The Forest 🌲 Relay</h2>
<p>The Forest is a Nostr relay operated by GitCitadel. It provides a reliable, fast, and open relay service for the Nostr protocol.</p>
<ul>
<li><strong>Relay URL: </strong> <a href="https://theforest.nostr1.com" target="_blank" rel="noopener noreferrer"><code>wss://theforest.nostr1.com</code></a></li>
<li><strong>Status: </strong> Online and operational</li>
<li><strong>Features: </strong> Supports all standard Nostr event kinds</li>
<li><strong>Relay URL : </strong> <a href="https://theforest.nostr1.com" target="_blank" rel="noopener noreferrer"><code>wss://theforest.nostr1.com</code></a></li>
<li><strong>Status : </strong> Online and operational</li>
<li><strong>Features : </strong> Supports all standard Nostr event kinds</li>
</ul>
<p>TheForest relay hosts a variety of content, including longform markdown articles (kind 30023), e-books and structured publications (kind 30040), and short-form notes (kind 1).</p>
<p>The Forest relay hosts a variety of content, including longform markdown articles (kind 30023), e-books and structured publications (kind 30040), and short-form notes (kind 1).</p>
</div>
<div class="feed-container">

6
templates/landing.html

@ -87,20 +87,20 @@ @@ -87,20 +87,20 @@
</div>
</div>
<div class="feature-card-content">
<p>Longform markdown articles from the TheForest relay.</p>
<p>Longform markdown articles from the The Forest relay.</p>
<a href="/articles" class="btn"><span class="icon-inline">{{icon "arrow-right"}}</span> View Articles</a>
</div>
</div>
<div class="feature-card">
<h3><span class="icon-inline">{{icon "rss"}}</span> Feed</h3>
<p>Browse recent notes and updates from TheForest relay.</p>
<p>Browse recent notes and updates from The Forest relay.</p>
<a href="/feed" class="btn"><span class="icon-inline">{{icon "arrow-right"}}</span> View Feed</a>
</div>
<div class="feature-card">
<h3><span class="icon-inline">{{icon "book"}}</span> E-Books</h3>
<p>Discover and download e-books from the decentralized #Alexandria library.</p>
<p>Discover and download e-books from the decentralized Alexandria library.</p>
<a href="/ebooks" class="btn"><span class="icon-inline">{{icon "arrow-right"}}</span> View E-Books</a>
</div>

1
templates/wiki.html

@ -14,7 +14,6 @@ @@ -14,7 +14,6 @@
<a href="/wiki/{{.DTag}}" class="wiki-link"><span class="icon-inline">{{icon "file-text"}}</span> {{.Title}}</a>
{{end}}
</div>
<p class="wiki-index-note wiki-sidebar-note"><span class="icon-inline">{{icon "arrow-right"}}</span> On larger screens, you can also select an article from the sidebar.</p>
{{else}}
<p><span class="icon-inline">{{icon "inbox"}}</span> No wiki articles available yet.</p>
{{end}}

Loading…
Cancel
Save