diff --git a/.well-known/nostr.json b/.well-known/nostr.json new file mode 100644 index 0000000..4b45474 --- /dev/null +++ b/.well-known/nostr.json @@ -0,0 +1,96 @@ +{ + "names": { + "GitCitadel": "846ebf79a0a8813274ec9727490621ad423f16a3e474d7fd66e6a98bfe4e39a4", + "ChipTuner": "036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58", + "ChipTunerDev": "011fb1f8cd1cacff53e2e0dc37a688c4dc4363a7c2d59fee16dd98d07601475a", + "buttercat1791": "70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9", + "finrod": "ce1bf9ad92164df227bfcab2813193c60eb4021d35bf4bbbc6fa24c560d0f3e9", + "silberengel": "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", + "liminal": "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06", + "TheBeave": "0689df5847a8d3376892da29622d7c0fdc1ef1958f4bc4471d90966aa1eca9f2", + "LibertyGal": "8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b", + "testerin": "573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc", + "laeserin": "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", + "gc-publishing": "3e1ad0f3a5d3c12245db7788546c43ade3d97c6e046c594f6017cd6cd4164690", + "gc-devops": "d05e968d46c61307fae4b18aa5bf1d863b8d14d2e3ba089ed20123deb974b98f", + "nusa" : "d475ce4b3977507130f42c7f86346ef936800f3ae74d5ecf8089280cdc1923e9", + "matt": "fea186c2a4678dbc437704eed2160846e8a781e5fb17056e9bb333840d5bdef2" + }, + "relays": { + "846ebf79a0a8813274ec9727490621ad423f16a3e474d7fd66e6a98bfe4e39a4": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "011fb1f8cd1cacff53e2e0dc37a688c4dc4363a7c2d59fee16dd98d07601475a": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "ce1bf9ad92164df227bfcab2813193c60eb4021d35bf4bbbc6fa24c560d0f3e9": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "0689df5847a8d3376892da29622d7c0fdc1ef1958f4bc4471d90966aa1eca9f2": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "3e1ad0f3a5d3c12245db7788546c43ade3d97c6e046c594f6017cd6cd4164690": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "d05e968d46c61307fae4b18aa5bf1d863b8d14d2e3ba089ed20123deb974b98f": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "fea186c2a4678dbc437704eed2160846e8a781e5fb17056e9bb333840d5bdef2": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ], + "d475ce4b3977507130f42c7f86346ef936800f3ae74d5ecf8089280cdc1923e9": [ + "wss://theforest.nostr1.com", + "wss://thecitadel.nostr1.com", + "wss://nostr.land" + ] + } +} \ No newline at end of file diff --git a/internal/asciidoc/processor.go b/internal/asciidoc/processor.go index 12374ba..2a8501f 100644 --- a/internal/asciidoc/processor.go +++ b/internal/asciidoc/processor.go @@ -34,7 +34,10 @@ func (p *Processor) Process(asciidocContent string) (string, error) { // Sanitize HTML to prevent XSS sanitized := p.sanitizeHTML(html) - return sanitized, nil + // Process links: make external links open in new tab, local links in same tab + processed := p.processLinks(sanitized) + + return processed, nil } // rewriteLinks rewrites wikilinks and nostr: links in AsciiDoc content @@ -185,3 +188,72 @@ func (p *Processor) sanitizeHTML(html string) string { return html } + +// processLinks processes HTML links to add target="_blank" to external links +// External links are those that start with http:// or https:// and don't point to the linkBaseURL domain +// Local links (including relative links and links to linkBaseURL) open in the same tab +func (p *Processor) processLinks(html string) string { + // Extract domain from linkBaseURL for comparison + linkBaseDomain := "" + if strings.HasPrefix(p.linkBaseURL, "http://") || strings.HasPrefix(p.linkBaseURL, "https://") { + // Extract domain (e.g., "alexandria.gitcitadel.eu" from "https://alexandria.gitcitadel.eu") + parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(p.linkBaseURL, "https://"), "http://"), "/") + if len(parts) > 0 { + linkBaseDomain = parts[0] + } + } + + // Regex to match tags with href attributes (more flexible pattern) + linkRegex := regexp.MustCompile(`]*?)href\s*=\s*["']([^"']+)["']([^>]*?)>`) + + html = linkRegex.ReplaceAllStringFunc(html, func(match string) string { + // Extract href value + hrefMatch := regexp.MustCompile(`href\s*=\s*["']([^"']+)["']`) + hrefSubmatch := hrefMatch.FindStringSubmatch(match) + if len(hrefSubmatch) < 2 { + return match // No href found, return as-is + } + href := hrefSubmatch[1] + + // Check if it's an external link (starts with http:// or https://) + isExternal := strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") + + if isExternal { + // Check if it's pointing to our own domain + if linkBaseDomain != "" && strings.Contains(href, linkBaseDomain) { + // Same domain - open in same tab (remove any existing target attribute) + targetRegex := regexp.MustCompile(`\s*target\s*=\s*["'][^"']*["']`) + match = targetRegex.ReplaceAllString(match, "") + return match + } + + // External link - add target="_blank" and rel="noopener noreferrer" if not already present + if !strings.Contains(match, `target=`) { + // Insert before the closing > + match = strings.TrimSuffix(match, ">") + if !strings.Contains(match, `rel=`) { + match += ` target="_blank" rel="noopener noreferrer">` + } else { + // Update existing rel attribute to include noopener if not present + relRegex := regexp.MustCompile(`rel\s*=\s*["']([^"']*)["']`) + match = relRegex.ReplaceAllStringFunc(match, func(relMatch string) string { + relValue := relRegex.FindStringSubmatch(relMatch)[1] + if !strings.Contains(relValue, "noopener") { + relValue += " noopener noreferrer" + } + return `rel="` + strings.TrimSpace(relValue) + `"` + }) + match += ` target="_blank">` + } + } + } else { + // Local/relative link - ensure it opens in same tab (remove target if present) + targetRegex := regexp.MustCompile(`\s*target\s*=\s*["'][^"']*["']`) + match = targetRegex.ReplaceAllString(match, "") + } + + return match + }) + + return html +} diff --git a/internal/generator/html.go b/internal/generator/html.go index 5ef3750..a6c53a1 100644 --- a/internal/generator/html.go +++ b/internal/generator/html.go @@ -33,6 +33,14 @@ func getTemplateFuncs() template.FuncMap { "shortenPubkey": func(pubkey string) string { return nostr.ShortenPubkey(pubkey) }, + "pubkeyToNpub": func(pubkey string) string { + npub, err := nostr.PubkeyToNpub(pubkey) + if err != nil { + // Fallback to hex if conversion fails + return pubkey + } + return npub + }, "truncate": func(s string, maxLen int) string { if len(s) <= maxLen { return s @@ -147,6 +155,7 @@ type ArticleItemInfo struct { // FeedItemInfo represents info about a feed item type FeedItemInfo struct { + EventID string Author string Content string Time string diff --git a/internal/nostr/feed.go b/internal/nostr/feed.go index 14f62c8..653c32f 100644 --- a/internal/nostr/feed.go +++ b/internal/nostr/feed.go @@ -40,29 +40,29 @@ 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 + // 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] + 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] + } } } - } - items = append(items, item) + items = append(items, item) } logger.WithFields(map[string]interface{}{ diff --git a/internal/server/server.go b/internal/server/server.go index 0002947..d626b0a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -105,6 +105,7 @@ func (s *Server) convertFeedItemsToInfo(items []cache.FeedItem) []generator.Feed 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"), diff --git a/server b/server index 018a3c0..71fa4ea 100755 Binary files a/server and b/server differ diff --git a/static/css/main.css b/static/css/main.css index 68fa38b..4967f38 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -766,10 +766,18 @@ article { } .feed-item { - padding: 1rem 0; + padding: 1.25rem 0; border-bottom: 1px solid var(--border-color); overflow-wrap: break-word; word-wrap: break-word; + transition: background-color 0.2s; +} + +.feed-item:hover { + background-color: var(--bg-primary); + margin: 0 -1.5rem; + padding: 1.25rem 1.5rem; + border-radius: 8px; } .feed-item:last-child { @@ -780,13 +788,13 @@ article { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 0.5rem; + margin-bottom: 0.75rem; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.75rem; } .feed-author { - font-weight: 600; + font-weight: 500; color: var(--text-primary); word-break: break-all; overflow-wrap: anywhere; @@ -794,9 +802,10 @@ article { } .feed-content { - color: var(--text-secondary); - margin-bottom: 0.5rem; - font-size: 0.9rem; + color: var(--text-primary); + margin-bottom: 0.75rem; + font-size: 0.95rem; + line-height: 1.6; overflow-wrap: break-word; word-wrap: break-word; } @@ -1118,6 +1127,14 @@ footer { padding: 1rem; } +.modal-options .btn, +.modal-options a.btn { + text-decoration: none; + display: flex; + align-items: center; + gap: 0.5rem; +} + .article-title { font-size: 2.5rem; margin: 0 0 0.5rem 0; @@ -1529,6 +1546,61 @@ textarea:focus-visible { margin-top: 2rem; } +/* Table of Contents */ +.table-of-contents { + margin-top: 2rem; + padding: 1.25rem; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + font-size: 0.9rem; +} + +.table-of-contents h2 { + font-size: 1.1rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.table-of-contents ul { + margin: 0; + padding-left: 1.5rem; + list-style: none; +} + +.table-of-contents ul ul { + margin-top: 0.25rem; + padding-left: 1.25rem; + border-left: 1px solid var(--border-color); +} + +.table-of-contents ul ul ul { + padding-left: 1rem; +} + +.table-of-contents li { + margin-bottom: 0.5rem; + line-height: 1.5; +} + +.table-of-contents li:last-child { + margin-bottom: 0; +} + +.table-of-contents a { + color: var(--link-color); + text-decoration: none; + word-break: break-word; + display: block; + padding: 0.25rem 0; +} + +.table-of-contents a:hover { + text-decoration: underline; + color: var(--link-hover-color); +} + .ebooks-page { max-width: 1200px; margin: 0 auto; @@ -1643,6 +1715,14 @@ textarea:focus-visible { border-radius: 4px; background: var(--bg-secondary); border: 1px solid var(--border-color); + text-decoration: none; + color: inherit; + transition: background-color 0.2s, border-color 0.2s; +} + +.user-badge:hover { + background: var(--bg-primary); + border-color: var(--link-color); } .user-badge-avatar { diff --git a/static/css/responsive.css b/static/css/responsive.css index ac314d6..c27560e 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -508,7 +508,7 @@ .table-of-contents { margin-top: 2rem; - padding: 1rem; + padding: 1.25rem; background: var(--bg-secondary); border-radius: 8px; border: 1px solid var(--border-color); @@ -517,25 +517,47 @@ .table-of-contents h2 { font-size: 1.1rem; - margin-bottom: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); } .table-of-contents ul { - margin-left: 1rem; + margin: 0; + padding-left: 1.5rem; + list-style: none; + } + + .table-of-contents ul ul { + margin-top: 0.25rem; + padding-left: 1.25rem; + border-left: 1px solid var(--border-color); + } + + .table-of-contents ul ul ul { + padding-left: 1rem; } .table-of-contents li { margin-bottom: 0.5rem; + line-height: 1.5; + } + + .table-of-contents li:last-child { + margin-bottom: 0; } .table-of-contents a { color: var(--link-color); text-decoration: none; word-break: break-word; + display: block; + padding: 0.25rem 0; } .table-of-contents a:hover { text-decoration: underline; + color: var(--link-hover-color); } /* Feed */ diff --git a/templates/components.html b/templates/components.html index 9331387..aecf2b4 100644 --- a/templates/components.html +++ b/templates/components.html @@ -11,7 +11,7 @@
{{.Content}}
{{else}} @@ -84,7 +84,9 @@ {{$pubkey := .Pubkey}} {{$profiles := .Profiles}} {{$profile := index $profiles $pubkey}} - +{{$npub := pubkeyToNpub $pubkey}} +{{$profileURL := printf "https://alexandria.gitcitadel.eu/events?id=%s" $npub}} + {{if and $profile $profile.Picture}} {{if $profile.DisplayName}}{{$profile.DisplayName}}{{else if $profile.Name}}{{$profile.Name}}{{else}}User{{end}} {{else}} @@ -102,7 +104,7 @@ {{if and $profile $profile.Name (not $profile.DisplayName)}} @{{$profile.Name}} {{end}} - + {{end}} {{/* Mobile Custom Dropdown Component - Shows title and profile pic diff --git a/templates/contact.html b/templates/contact.html index 5faae51..c3470c5 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -15,6 +15,11 @@ {{icon "github"}} GitHub: ShadowySupercode +
  • + + {{icon "user"}} GitCitadel on Alexandria + +
  • @@ -121,6 +126,45 @@ + + + + + + {{if .RepoAnnouncement}} {{end}} @@ -137,6 +181,13 @@ const modalCloseBtn = document.getElementById('modal-close-btn'); const loginBtn = document.getElementById('login-btn'); const anonymousBtn = document.getElementById('anonymous-btn'); + const successModal = document.getElementById('success-modal'); + const successModalCloseBtn = document.getElementById('success-modal-close-btn'); + const successModalCloseBtn2 = document.getElementById('success-modal-close-btn2'); + const failureModal = document.getElementById('failure-modal'); + const failureModalCloseBtn = document.getElementById('failure-modal-close-btn'); + const failureModalCloseBtn2 = document.getElementById('failure-modal-close-btn2'); + const failureMessage = document.getElementById('failure-message'); // Get contact relays from JSON script tag let contactRelays = []; @@ -183,6 +234,31 @@ modal.style.display = 'none'; } + function showSuccessModal() { + successModal.style.display = 'flex'; + } + + function hideSuccessModal() { + successModal.style.display = 'none'; + // Reset form + form.reset(); + submitBtn.disabled = false; + submitBtn.innerHTML = ' Submit'; + } + + function showFailureModal(errorMessage) { + if (errorMessage) { + failureMessage.textContent = errorMessage; + } + failureModal.style.display = 'flex'; + } + + function hideFailureModal() { + failureModal.style.display = 'none'; + submitBtn.disabled = false; + submitBtn.innerHTML = ' Submit'; + } + // Close modal handlers modalCloseBtn.addEventListener('click', hideModal); modal.addEventListener('click', function(e) { @@ -191,6 +267,22 @@ } }); + successModalCloseBtn.addEventListener('click', hideSuccessModal); + successModalCloseBtn2.addEventListener('click', hideSuccessModal); + successModal.addEventListener('click', function(e) { + if (e.target === successModal) { + hideSuccessModal(); + } + }); + + failureModalCloseBtn.addEventListener('click', hideFailureModal); + failureModalCloseBtn2.addEventListener('click', hideFailureModal); + failureModal.addEventListener('click', function(e) { + if (e.target === failureModal) { + hideFailureModal(); + } + }); + // Generate key pair for anonymous submission async function generateKeyPair() { const keyPair = NostrTools.generatePrivateKey(); @@ -218,14 +310,16 @@ 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; + // Show success modal submitBtn.innerHTML = ' Submit'; + showSuccessModal(); + } else { + // Show failure modal + const errorMsg = 'Failed to publish your message to any of the configured relays. ' + (result.error || 'Please try again later.'); + showFailureModal(errorMsg); } - } - + } + // Process submission with pubkey async function processSubmission(useExtension) { hideModal(); @@ -268,14 +362,14 @@ // Add 'a' tag for repository announcement if available if (repoAnnouncement) { - tags.push(['a', `30617:${repoAnnouncement.pubkey}:${repoAnnouncement.dTag}`]); - tags.push(['p', repoAnnouncement.pubkey]); - - if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) { - repoAnnouncement.maintainers.forEach(maintainer => { - tags.push(['p', maintainer]); - }); - } + tags.push(['a', `30617:${repoAnnouncement.pubkey}:${repoAnnouncement.dTag}`]); + tags.push(['p', repoAnnouncement.pubkey]); + + if (repoAnnouncement.maintainers && repoAnnouncement.maintainers.length > 0) { + repoAnnouncement.maintainers.forEach(maintainer => { + tags.push(['p', maintainer]); + }); + } } // Add required 'p' tags for contact recipients @@ -317,9 +411,7 @@ await submitEvent(signedEvent); } catch (error) { console.error('Error:', error); - showStatus('Error: ' + error.message, true); - submitBtn.disabled = false; - submitBtn.innerHTML = ' Submit'; + showFailureModal('Error: ' + error.message); } }