diff --git a/README.md b/README.md index dd75766..6eb32c4 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,290 @@ # 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 + 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. Download nostr-tools bundle (for contact form): + +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 the example config: + +5. **Copy and configure:** ```bash cp config.yaml.example config.yaml ``` -6. 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 to configuration file (default: `config.yaml`) +- `--dev`: Enable development mode with verbose logging +- `--log-level `: Set log level (debug, info, warn, error) (default: info) + +## Routes & Endpoints + +### Public Pages + +- `/` - Landing page with feed sidebar +- `/wiki` - Wiki index page +- `/wiki/` - 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/` - 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 ## 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 diff --git a/internal/generator/html.go b/internal/generator/html.go index ec1908c..6282bd5 100644 --- a/internal/generator/html.go +++ b/internal/generator/html.go @@ -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 @@ -814,7 +815,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, diff --git a/internal/nostr/issues.go b/internal/nostr/issues.go index 90a52d2..d7dcd58 100644 --- a/internal/nostr/issues.go +++ b/internal/nostr/issues.go @@ -40,6 +40,11 @@ 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, @@ -53,6 +58,11 @@ func ParseRepoAnnouncement(event *nostr.Event, expectedKind int) (*RepoAnnouncem } } + // Validate that DTag is set + if repo.DTag == "" { + return nil, fmt.Errorf("repository announcement event missing d tag") + } + // Extract relays tag for _, tag := range event.Tags { if len(tag) > 0 && tag[0] == "relays" && len(tag) > 1 { diff --git a/internal/server/handlers.go b/internal/server/handlers.go index da86253..8fa253c 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -274,7 +274,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 +288,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 +312,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{}{ diff --git a/templates/contact.html b/templates/contact.html index 95e79ed..fbfc3c6 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -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 @@ // 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 @@ 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", "", "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 = ' 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 @@ let pubkey; let signFunction; + let userOutboxRelays = []; + if (useExtension) { // Login with browser extension if (!window.nostr) { @@ -344,6 +405,19 @@ submitBtn.innerHTML = ' 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 = ' Generating key...'; @@ -360,60 +434,72 @@ // Build event tags const tags = []; - // Add 'a' tag for repository announcement if available - if (repoAnnouncement && repoAnnouncement.pubkey && repoAnnouncement.dTag) { - 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::"] + // Note: Field names are capitalized (Pubkey, DTag) as they come from Go + const repoPubkey = repoAnnouncement?.Pubkey; + const repoDTag = repoAnnouncement?.DTag; + + 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(); - if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) { - repoAnnouncement.maintainers.forEach(maintainer => { - if (maintainer) { - tags.push(['p', maintainer]); + // 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 - // Store relays as a JSON string in the tag value - if (contactRelays && contactRelays.length > 0) { - tags.push(['relays', JSON.stringify(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 = ' 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); @@ -430,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; }