Browse Source

finished contact form with client and p-tags and browser extension log-in or anonymous submission

master
Silberengel 4 weeks ago
parent
commit
7ad6268f0b
  1. 14
      cmd/server/main.go
  2. 7
      config.yaml.example
  3. 61
      internal/config/config.go
  4. 3
      internal/generator/html.go
  5. 55
      internal/nostr/client.go
  6. 62
      internal/server/handlers.go
  7. BIN
      server
  8. 79
      static/css/main.css
  9. 24
      static/css/responsive.css
  10. 217
      templates/contact.html
  11. 2
      templates/feed.html

14
cmd/server/main.go

@ -51,7 +51,9 @@ func main() {
} }
// Initialize Nostr client // Initialize Nostr client
nostrClient := nostr.NewClient(cfg.Relays.Primary, cfg.Relays.Fallback, cfg.Relays.AdditionalFallback) profileRelays := cfg.GetProfilesRelays()
contactRelays := cfg.GetContactFormRelays()
nostrClient := nostr.NewClient(cfg.Relays.Feeds, profileRelays, contactRelays)
ctx := context.Background() ctx := context.Background()
if err := nostrClient.Connect(ctx); err != nil { if err := nostrClient.Connect(ctx); err != nil {
logger.Warnf("Failed to connect to relays: %v", err) logger.Warnf("Failed to connect to relays: %v", err)
@ -60,10 +62,15 @@ func main() {
// Initialize services // Initialize services
// Use standard Nostr kind constants // Use standard Nostr kind constants
articleKinds := nostr.SupportedArticleKinds() articleKinds := nostr.SupportedArticleKinds()
wikiService := nostr.NewWikiService(nostrClient, articleKinds, nostr.KindWiki, cfg.Relays.AdditionalFallback, nostr.KindIndex, nostr.KindBlog, nostr.KindLongform) // Use first profile relay for wiki service (fallback)
wikiRelay := cfg.Relays.Feeds
if len(profileRelays) > 0 {
wikiRelay = profileRelays[0]
}
wikiService := nostr.NewWikiService(nostrClient, articleKinds, nostr.KindWiki, wikiRelay, nostr.KindIndex, nostr.KindBlog, nostr.KindLongform)
feedService := nostr.NewFeedService(nostrClient, nostr.KindNote) feedService := nostr.NewFeedService(nostrClient, nostr.KindNote)
issueService := nostr.NewIssueService(nostrClient, nostr.KindIssue, nostr.KindRepoAnnouncement) issueService := nostr.NewIssueService(nostrClient, nostr.KindIssue, nostr.KindRepoAnnouncement)
ebooksService := nostr.NewEBooksService(nostrClient, nostr.KindIndex, "wss://theforest.nostr1.com") ebooksService := nostr.NewEBooksService(nostrClient, nostr.KindIndex, cfg.Relays.Feeds)
// Initialize HTML generator // Initialize HTML generator
htmlGenerator, err := generator.NewHTMLGenerator( htmlGenerator, err := generator.NewHTMLGenerator(
@ -98,7 +105,6 @@ func main() {
logger.Info("Generating initial landing page...") logger.Info("Generating initial landing page...")
initialLandingHTML, err := htmlGenerator.GenerateLandingPage( initialLandingHTML, err := htmlGenerator.GenerateLandingPage(
[]generator.WikiPageInfo{}, []generator.WikiPageInfo{},
[]generator.FeedItemInfo{},
nil, // newestBlogItem nil, // newestBlogItem
nil, // newestArticleItem nil, // newestArticleItem
[]generator.ArticleItemInfo{}, // allArticleItems []generator.ArticleItemInfo{}, // allArticleItems

7
config.yaml.example

@ -2,14 +2,13 @@ wiki_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8aj
blog_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyvhwumn8ghj7enjv4jkccte9eek7anzd96zu6r0wd6qzxmhwden5te0w35x2cmfw3skgetv9ehx7um5wgcjucm0d5q35amnwvaz7tm5dpjkvmmjv4ehgtnwdaehgu339e3k7mgpzpmhxue69uhkummnw3ezumrpdejqzyrhwden5te0dehhxarj9emkjmn9qythwumn8ghj7mn0wd68ytnndamxy6t59e5x7um5qyghwumn8ghj7mn0wd68yv339e3k7mgqy96xsefdva5hgcmfw3skgetv943xcmm89438jttnw3jkcmrp94mz6vggpn2pq" blog_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyvhwumn8ghj7enjv4jkccte9eek7anzd96zu6r0wd6qzxmhwden5te0w35x2cmfw3skgetv9ehx7um5wgcjucm0d5q35amnwvaz7tm5dpjkvmmjv4ehgtnwdaehgu339e3k7mgpzpmhxue69uhkummnw3ezumrpdejqzyrhwden5te0dehhxarj9emkjmn9qythwumn8ghj7mn0wd68ytnndamxy6t59e5x7um5qyghwumn8ghj7mn0wd68yv339e3k7mgqy96xsefdva5hgcmfw3skgetv943xcmm89438jttnw3jkcmrp94mz6vggpn2pq"
repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqq9xw6t5vd5hgctyv4kqde47kt" repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqq9xw6t5vd5hgctyv4kqde47kt"
relays: relays:
primary: "wss://theforest.nostr1.com" feeds: "wss://theforest.nostr1.com"
fallback: "wss://nostr.land" profiles: "wss://theforest.nostr1.com","wss://nostr.land","wss://thecitadel.nostr1.com"
additional_fallback: "wss://thecitadel.nostr1.com" contactform: "wss://thecitadel.nostr1.com","wss://relay.damus.io","wss://freelay.sovbit.host","wss://relay.primal.net",
link_base_url: "https://alexandria.gitcitadel.eu" link_base_url: "https://alexandria.gitcitadel.eu"
cache: cache:
refresh_interval_minutes: 30 refresh_interval_minutes: 30
feed: feed:
relay: "wss://theforest.nostr1.com"
poll_interval_minutes: 5 poll_interval_minutes: 5
max_events: 30 max_events: 30
server: server:

61
internal/config/config.go

@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -13,9 +14,9 @@ type Config struct {
BlogIndex string `yaml:"blog_index"` BlogIndex string `yaml:"blog_index"`
RepoAnnouncement string `yaml:"repo_announcement"` // naddr for kind 30617 repo announcement RepoAnnouncement string `yaml:"repo_announcement"` // naddr for kind 30617 repo announcement
Relays struct { Relays struct {
Primary string `yaml:"primary"` Feeds string `yaml:"feeds"` // Single relay for feeds
Fallback string `yaml:"fallback"` Profiles string `yaml:"profiles"` // Comma-separated list of relays for profiles and deletion events
AdditionalFallback string `yaml:"additional_fallback"` ContactForm string `yaml:"contactform"` // Comma-separated list of relays for contact form
} `yaml:"relays"` } `yaml:"relays"`
LinkBaseURL string `yaml:"link_base_url"` LinkBaseURL string `yaml:"link_base_url"`
Cache struct { Cache struct {
@ -50,14 +51,14 @@ func LoadConfig(path string) (*Config, error) {
} }
// Set defaults // Set defaults
if config.Relays.Primary == "" { if config.Relays.Feeds == "" {
config.Relays.Primary = "wss://theforest.nostr1.com" config.Relays.Feeds = "wss://theforest.nostr1.com"
} }
if config.Relays.Fallback == "" { if config.Relays.Profiles == "" {
config.Relays.Fallback = "wss://nostr.land" config.Relays.Profiles = "wss://theforest.nostr1.com,wss://nostr.land,wss://thecitadel.nostr1.com"
} }
if config.Relays.AdditionalFallback == "" { if config.Relays.ContactForm == "" {
config.Relays.AdditionalFallback = "wss://thecitadel.nostr1.com" config.Relays.ContactForm = "wss://thecitadel.nostr1.com,wss://relay.damus.io,wss://freelay.sovbit.host,wss://relay.primal.net"
} }
if config.LinkBaseURL == "" { if config.LinkBaseURL == "" {
config.LinkBaseURL = "https://alexandria.gitcitadel.eu" config.LinkBaseURL = "https://alexandria.gitcitadel.eu"
@ -66,7 +67,7 @@ func LoadConfig(path string) (*Config, error) {
config.Cache.RefreshIntervalMinutes = 30 config.Cache.RefreshIntervalMinutes = 30
} }
if config.Feed.Relay == "" { if config.Feed.Relay == "" {
config.Feed.Relay = "wss://theforest.nostr1.com" config.Feed.Relay = config.Relays.Feeds
} }
if config.Feed.PollIntervalMinutes == 0 { if config.Feed.PollIntervalMinutes == 0 {
config.Feed.PollIntervalMinutes = 5 config.Feed.PollIntervalMinutes = 5
@ -95,13 +96,49 @@ func LoadConfig(path string) (*Config, error) {
return &config, nil return &config, nil
} }
// GetProfilesRelays parses the comma-separated profiles relay string into a slice
func (c *Config) GetProfilesRelays() []string {
if c.Relays.Profiles == "" {
return []string{}
}
relays := strings.Split(c.Relays.Profiles, ",")
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
}
// 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
}
// Validate validates the configuration // Validate validates the configuration
func (c *Config) Validate() error { func (c *Config) Validate() error {
if c.WikiIndex == "" { if c.WikiIndex == "" {
return fmt.Errorf("wiki_index is required") return fmt.Errorf("wiki_index is required")
} }
if c.Relays.Primary == "" { if c.Relays.Feeds == "" {
return fmt.Errorf("relays.primary is required") return fmt.Errorf("relays.feeds is required")
} }
return nil return nil
} }

3
internal/generator/html.go

@ -780,6 +780,9 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
} }
} }
// Add contact relays for JavaScript
templateData["ContactRelays"] = g.nostrClient.GetContactRelays()
// Execute base.html which will use the blocks from contact.html // Execute base.html which will use the blocks from contact.html
var buf bytes.Buffer var buf bytes.Buffer
if err := renderTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil { if err := renderTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil {

55
internal/nostr/client.go

@ -15,34 +15,36 @@ import (
type Client struct { type Client struct {
pool *nostr.SimplePool pool *nostr.SimplePool
relays []string relays []string
feedsRelay string // Relay for feeds
profileRelays []string // Relays for profiles
contactRelays []string // Relays for contact form
mu sync.RWMutex mu sync.RWMutex
ctx context.Context ctx context.Context
requestSem chan struct{} // Semaphore to limit concurrent requests requestSem chan struct{} // Semaphore to limit concurrent requests
maxConcurrent int // Maximum concurrent requests maxConcurrent int // Maximum concurrent requests
} }
// NewClient creates a new Nostr client with primary, fallback, and additional fallback relays // NewClient creates a new Nostr client with relay configuration
// Uses go-nostr's SimplePool for connection management and parallel queries // Uses go-nostr's SimplePool for connection management and parallel queries
// Limits concurrent requests to prevent overwhelming relays (default: 5 concurrent requests) // Limits concurrent requests to prevent overwhelming relays (default: 5 concurrent requests)
func NewClient(primaryRelay, fallbackRelay, additionalFallback string) *Client { func NewClient(feedsRelay string, profileRelays []string, contactRelays []string) *Client {
ctx := context.Background() ctx := context.Background()
pool := nostr.NewSimplePool(ctx) pool := nostr.NewSimplePool(ctx)
// Build relay list: feeds relay first, then profile relays
relays := []string{} relays := []string{}
if primaryRelay != "" { if feedsRelay != "" {
relays = append(relays, primaryRelay) relays = append(relays, feedsRelay)
}
if fallbackRelay != "" {
relays = append(relays, fallbackRelay)
}
if additionalFallback != "" {
relays = append(relays, additionalFallback)
} }
relays = append(relays, profileRelays...)
maxConcurrent := 5 // Limit to 5 concurrent requests to avoid overwhelming relays maxConcurrent := 5 // Limit to 5 concurrent requests to avoid overwhelming relays
return &Client{ return &Client{
pool: pool, pool: pool,
relays: relays, relays: relays,
feedsRelay: feedsRelay,
profileRelays: profileRelays,
contactRelays: contactRelays,
ctx: ctx, ctx: ctx,
requestSem: make(chan struct{}, maxConcurrent), requestSem: make(chan struct{}, maxConcurrent),
maxConcurrent: maxConcurrent, maxConcurrent: maxConcurrent,
@ -223,23 +225,19 @@ func (c *Client) GetRelays() []string {
return c.relays return c.relays
} }
// GetPrimaryRelay returns the primary relay (theforest) for main event fetching // GetPrimaryRelay returns the feeds relay for main event fetching
func (c *Client) GetPrimaryRelay() string { func (c *Client) GetPrimaryRelay() string {
if len(c.relays) > 0 { return c.feedsRelay
return c.relays[0]
}
return ""
} }
// GetProfileRelays returns fallback relays for profile fetching (excludes primary/theforest) // GetProfileRelays returns relays for profile fetching
func (c *Client) GetProfileRelays() []string { func (c *Client) GetProfileRelays() []string {
profileRelays := []string{} return c.profileRelays
// Skip the first relay (primary/theforest) and use fallback relays
if len(c.relays) > 1 {
profileRelays = append(profileRelays, c.relays[1:]...)
} }
// If no fallback relays, return empty (shouldn't happen, but handle gracefully)
return profileRelays // GetContactRelays returns the relays for contact form submissions
func (c *Client) GetContactRelays() []string {
return c.contactRelays
} }
// GetPool returns the underlying SimplePool (for services that need direct access) // GetPool returns the underlying SimplePool (for services that need direct access)
@ -284,10 +282,10 @@ func (c *Client) FetchDeletionEvents(ctx context.Context, authors []string) (map
return make(map[string]*nostr.Event), nil return make(map[string]*nostr.Event), nil
} }
// Fetch kind 5 deletion events from theforest only (primary relay) // Fetch kind 5 deletion events from profile relays
primaryRelay := c.GetPrimaryRelay() profileRelays := c.GetProfileRelays()
if primaryRelay == "" { if len(profileRelays) == 0 {
return nil, fmt.Errorf("primary relay not configured") return nil, fmt.Errorf("profile relays not configured")
} }
filter := nostr.Filter{ filter := nostr.Filter{
@ -298,9 +296,10 @@ func (c *Client) FetchDeletionEvents(ctx context.Context, authors []string) (map
logger.WithFields(map[string]interface{}{ logger.WithFields(map[string]interface{}{
"authors": len(uniqueAuthors), "authors": len(uniqueAuthors),
}).Debug("Fetching deletion events") "relays": len(profileRelays),
}).Debug("Fetching deletion events from profile relays")
deletionEvents, err := c.FetchEventsFromRelays(ctx, filter, []string{primaryRelay}) deletionEvents, err := c.FetchEventsFromRelays(ctx, filter, profileRelays)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch deletion events: %w", err) return nil, fmt.Errorf("failed to fetch deletion events: %w", err)
} }

62
internal/server/handlers.go

@ -287,25 +287,73 @@ func (s *Server) handleContactAPI(w http.ResponseWriter, r *http.Request) {
return return
} }
// Validate event kind (will be validated again in PublishSignedIssue) // Validate event kind (should be kind 1 for contact messages)
// Note: issueKind is stored in issueService, validation happens there if req.Event.Kind != 1 {
http.Error(w, fmt.Sprintf("Invalid event kind: expected 1, got %d", req.Event.Kind), http.StatusBadRequest)
return
}
// Verify the event signature
valid, err := req.Event.CheckSignature()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to check signature: %v", err), http.StatusBadRequest)
return
}
if !valid {
http.Error(w, "Invalid event signature", http.StatusBadRequest)
return
}
// Publish the signed event // Publish the signed event to contact relays
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
eventID, err := s.issueService.PublishSignedIssue(ctx, req.Event) // Get contact relays
contactRelays := s.nostrClient.GetContactRelays()
// Publish to contact relays
var lastErr error
var published bool
for _, relayURL := range contactRelays {
relay, err := s.nostrClient.ConnectToRelay(ctx, relayURL)
if err != nil { if err != nil {
logger.Errorf("Failed to publish signed issue: %v", err) logger.WithFields(map[string]interface{}{
"relay": relayURL,
"error": err,
}).Warn("Failed to connect to contact relay")
lastErr = err
continue
}
err = relay.Publish(ctx, *req.Event)
relay.Close()
if err != nil {
logger.WithFields(map[string]interface{}{
"relay": relayURL,
"error": err,
}).Warn("Failed to publish to contact relay")
lastErr = err
} else {
published = true
logger.WithFields(map[string]interface{}{
"relay": relayURL,
"event_id": req.Event.ID,
}).Info("Published contact event to relay")
}
}
if !published {
logger.Errorf("Failed to publish contact event to any relay: %v", lastErr)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error": "Failed to publish issue: %s"}`, err.Error()) fmt.Fprintf(w, `{"error": "Failed to publish to any relay: %s"}`, lastErr.Error())
return return
} }
// Return success response // Return success response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"success": true, "event_id": "%s"}`, eventID) fmt.Fprintf(w, `{"success": true, "event_id": "%s"}`, req.Event.ID)
} }
// handleStatic serves static files // handleStatic serves static files

BIN
server

Binary file not shown.

79
static/css/main.css

@ -1039,6 +1039,85 @@ footer {
display: block; display: block;
} }
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.modal-close {
background: none;
border: none;
font-size: 2rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem;
}
.modal-body p {
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.modal-options {
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-options .btn {
width: 100%;
justify-content: center;
padding: 1rem;
}
.article-title { .article-title {
font-size: 2.5rem; font-size: 2.5rem;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;

24
static/css/responsive.css

@ -764,6 +764,30 @@
.landing-page .feed-section { .landing-page .feed-section {
margin: 1.5rem 0; margin: 1.5rem 0;
} }
/* Modal */
.modal-content {
width: 95%;
max-width: 95%;
margin: 1rem;
}
.modal-header {
padding: 1rem;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
}
.modal-options .btn {
padding: 0.875rem;
font-size: 0.95rem;
}
} }
/* Tablet styles (768px - 1024px) */ /* Tablet styles (768px - 1024px) */

217
templates/contact.html

@ -100,15 +100,56 @@
</div> </div>
</form> </form>
<!-- Contact Submission Modal -->
<div id="contact-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>Choose Submission Method</h2>
<button type="button" class="modal-close" id="modal-close-btn" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>How would you like to submit your message?</p>
<div class="modal-options">
<button type="button" id="login-btn" class="btn btn-primary">
<span class="icon-inline">{{icon "key"}}</span> Login with Browser Extension
</button>
<button type="button" id="anonymous-btn" class="btn btn-secondary">
<span class="icon-inline">{{icon "user-x"}}</span> Submit Anonymously
</button>
</div>
</div>
</div>
</div>
{{if .RepoAnnouncement}} {{if .RepoAnnouncement}}
<script type="application/json" id="repo-announcement-data">{{json .RepoAnnouncement}}</script> <script type="application/json" id="repo-announcement-data">{{json .RepoAnnouncement}}</script>
{{end}} {{end}}
<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> <script>
(function() { (function() {
const form = document.getElementById('contact-form'); const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
const statusDiv = document.getElementById('nostr-status'); const statusDiv = document.getElementById('nostr-status');
const modal = document.getElementById('contact-modal');
const modalCloseBtn = document.getElementById('modal-close-btn');
const loginBtn = document.getElementById('login-btn');
const anonymousBtn = document.getElementById('anonymous-btn');
// Get contact relays from JSON script tag
let contactRelays = [];
const contactRelaysEl = document.getElementById('contact-relays-data');
if (contactRelaysEl) {
try {
contactRelays = JSON.parse(contactRelaysEl.textContent);
} catch (e) {
console.error('Failed to parse contact relays data:', e);
// Fallback to empty array
contactRelays = [];
}
}
// Get repo announcement data from JSON script tag // Get repo announcement data from JSON script tag
let repoAnnouncement = null; let repoAnnouncement = null;
@ -121,6 +162,9 @@
} }
} }
// Store form data for submission
let pendingFormData = null;
function showStatus(message, isError) { function showStatus(message, isError) {
statusDiv.textContent = message; statusDiv.textContent = message;
statusDiv.className = 'alert ' + (isError ? 'alert-error' : 'alert-success'); statusDiv.className = 'alert ' + (isError ? 'alert-error' : 'alert-success');
@ -131,37 +175,90 @@
statusDiv.style.display = 'none'; statusDiv.style.display = 'none';
} }
form.addEventListener('submit', async function(e) { function showModal() {
e.preventDefault(); modal.style.display = 'flex';
}
// Check if Nostr extension is available function hideModal() {
if (!window.nostr) { modal.style.display = 'none';
showStatus('Nostr extension not found. Please install a Nostr browser extension (e.g., nos2x, Alby) to submit issues.', true);
return;
} }
if (!repoAnnouncement) { // Close modal handlers
showStatus('Repository configuration not available. Please try again later.', true); modalCloseBtn.addEventListener('click', hideModal);
return; modal.addEventListener('click', function(e) {
if (e.target === modal) {
hideModal();
} }
});
const subject = document.getElementById('subject').value.trim(); // Generate key pair for anonymous submission
const content = document.getElementById('content').value.trim(); async function generateKeyPair() {
const labelsStr = document.getElementById('labels').value.trim(); const keyPair = NostrTools.generatePrivateKey();
const pubkey = NostrTools.getPublicKey(keyPair);
return { privateKey: keyPair, pubkey: pubkey };
}
if (!subject || !content) { // Sign event with private key
showStatus('Subject and message are required.', true); function signEvent(event, privateKey) {
return; return NostrTools.finalizeEvent(event, privateKey);
}
// Submit event
async function submitEvent(signedEvent) {
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...';
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ event: signedEvent })
});
const result = await response.json();
if (response.ok && result.success) {
window.location.href = '/contact?success=true&event_id=' + result.event_id;
} else {
showStatus('Failed to publish: ' + (result.error || 'Unknown error'), true);
submitBtn.disabled = false;
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="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span> Submit';
}
} }
// Disable submit button // Process submission with pubkey
async function processSubmission(useExtension) {
hideModal();
submitBtn.disabled = true; submitBtn.disabled = true;
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...';
hideStatus(); hideStatus();
const { subject, content, labelsStr } = pendingFormData;
try { try {
// Get user's public key let pubkey;
const pubkey = await window.nostr.getPublicKey(); let signFunction;
if (useExtension) {
// Login with browser extension
if (!window.nostr) {
showStatus('Nostr extension not found. Please install a Nostr browser extension (e.g., nos2x, Alby).', true);
submitBtn.disabled = false;
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="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span> Submit';
return;
}
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);
} 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...';
const keyPair = await generateKeyPair();
pubkey = keyPair.pubkey;
signFunction = (event) => {
return signEvent(event, keyPair.privateKey);
};
}
// Parse labels // Parse labels
const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()).filter(l => l) : []; const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()).filter(l => l) : [];
@ -169,18 +266,24 @@
// Build event tags // Build event tags
const tags = []; const tags = [];
// Add 'a' tag for repository announcement // Add 'a' tag for repository announcement if available
if (repoAnnouncement) {
tags.push(['a', `30617:${repoAnnouncement.pubkey}:${repoAnnouncement.dTag}`]); tags.push(['a', `30617:${repoAnnouncement.pubkey}:${repoAnnouncement.dTag}`]);
// Add 'p' tag for repository owner
tags.push(['p', repoAnnouncement.pubkey]); tags.push(['p', repoAnnouncement.pubkey]);
// Add maintainers as 'p' tags
if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) { if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) {
repoAnnouncement.maintainers.forEach(maintainer => { repoAnnouncement.maintainers.forEach(maintainer => {
tags.push(['p', maintainer]); tags.push(['p', maintainer]);
}); });
} }
}
// 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
if (subject) { if (subject) {
@ -194,49 +297,61 @@
} }
}); });
// Add relays tag if available // Add contact relays tag
if (repoAnnouncement.relays && repoAnnouncement.relays.length > 0) { tags.push(['relays', ...contactRelays]);
tags.push(['relays', ...repoAnnouncement.relays]);
}
// Create unsigned event // Create unsigned event (kind 1 for contact messages)
const unsignedEvent = { const unsignedEvent = {
kind: 1621, kind: 1,
pubkey: pubkey, pubkey: pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: tags, tags: tags,
content: content content: `Subject: ${subject}\n\n${content}`
}; };
// Sign the event // 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> Publishing...'; 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 window.nostr.signEvent(unsignedEvent); const signedEvent = await signFunction(unsignedEvent);
// Send to API
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ event: signedEvent })
});
const result = await response.json();
if (response.ok && result.success) { // Submit event
// Redirect to success page await submitEvent(signedEvent);
window.location.href = '/contact?success=true&event_id=' + result.event_id;
} else {
showStatus('Failed to publish issue: ' + (result.error || 'Unknown error'), true);
submitBtn.disabled = false;
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="m21.854 2.147-10.94 10.939"/></svg></span> Submit';
}
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
showStatus('Error: ' + error.message, true); showStatus('Error: ' + error.message, true);
submitBtn.disabled = false; submitBtn.disabled = false;
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="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span> Submit'; 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="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span> Submit';
} }
}
// Login button handler
loginBtn.addEventListener('click', () => processSubmission(true));
// Anonymous button handler
anonymousBtn.addEventListener('click', () => processSubmission(false));
// Form submit handler
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (!repoAnnouncement) {
showStatus('Repository configuration not available. Please try again later.', true);
return;
}
const subject = document.getElementById('subject').value.trim();
const content = document.getElementById('content').value.trim();
const labelsStr = document.getElementById('labels').value.trim();
if (!subject || !content) {
showStatus('Subject and message are required.', true);
return;
}
// Store form data
pendingFormData = { subject, content, labelsStr };
// Show modal
showModal();
}); });
})(); })();
</script> </script>

2
templates/feed.html

@ -9,7 +9,7 @@
<h2>About TheForest Relay</h2> <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> <p>TheForest is a Nostr relay operated by GitCitadel. It provides a reliable, fast, and open relay service for the Nostr protocol.</p>
<ul> <ul>
<li><strong>Relay URL:</strong> <code>wss://theforest.nostr1.com</code></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>Status:</strong> Online and operational</li>
<li><strong>Features:</strong> Supports all standard Nostr event kinds</li> <li><strong>Features:</strong> Supports all standard Nostr event kinds</li>
</ul> </ul>

Loading…
Cancel
Save