From d9211fa0d58246600f43c3de7be88ece250fc874 Mon Sep 17 00:00:00 2001 From: Arnas Udovic Date: Mon, 24 Mar 2025 21:05:50 +0200 Subject: [PATCH] gemini mentions --- dict.go | 60 ++++++++++++--- main.go | 133 +++++++++++++++++++++++++++++--- mention.go | 175 ++++++++++++++++++++++++++++++++++++++++++ templates/mention.gmi | 7 ++ templates/page.gmi | 16 ++++ 5 files changed, 368 insertions(+), 23 deletions(-) create mode 100644 mention.go create mode 100644 templates/mention.gmi diff --git a/dict.go b/dict.go index 4208f88..5e6f7de 100644 --- a/dict.go +++ b/dict.go @@ -1,24 +1,40 @@ package main +import ( + reflect "reflect" +) + type dict struct { - hostname string - languageEn string - searchStr string - newSentence string + hostname string + languageEn string + searchStr string + newSentence string + mentions string + newMention string + thanksForMention string + backToPage string } var EN = dict{ - hostname: "en.arns.lt", - languageEn: "English", - searchStr: "Input searching tag, created date or word in description", - newSentence: "New sentence", + hostname: "en.arns.lt", + languageEn: "English", + searchStr: "Input searching tag, created date or word in description", + newSentence: "New sentence", + mentions: "Mentions", + newMention: "New gemini mention", + thanksForMention: "Thanks for the mention", + backToPage: "Back to page", } var SGS = dict{ - hostname: "en.arns.lt", - languageEn: "Samogitian", - searchStr: "Ivesk ėiškuoma žīma, sokūrėma data arba žuodi ėš aprašīma", - newSentence: "Naujė ėšmintės", + hostname: "en.arns.lt", + languageEn: "Samogitian", + searchStr: "Ivesk ėiškuoma žīma, sokūrėma data arba žuodi ėš aprašīma", + newSentence: "Naujė ėšmintės", + mentions: "Pamėnavuojėmā", + newMention: "Naus gemini mėnavuojėms", + thanksForMention: "Diekou ož pamėnavuojėma", + backToPage: "Atgal i poslapi", } func Dict() dict { @@ -29,3 +45,23 @@ func Dict() dict { return dictMap[defaultLang] } + +func T(key string) string { + dict := map[string]string{} + + d := Dict() + r := reflect.ValueOf(&d).Elem() + rt := r.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + rv := reflect.ValueOf(&d) + value := reflect.Indirect(rv).FieldByName(field.Name) + dict[field.Name] = value.String() + } + + if value, ok := dict[key]; ok { + return value + } else { + return "" + } +} diff --git a/main.go b/main.go index b8aedb4..dbf20ab 100644 --- a/main.go +++ b/main.go @@ -163,6 +163,10 @@ func process(_ context.Context, w gemini.ResponseWriter, r *gemini.Request) { renderSearch(w, r, client) case regexp.MustCompile(`^/admin/sentence$`).MatchString(r.URL.Path): renderAdminSentence(w, r, client) + case regexp.MustCompile(`^/admin/mentions/review/[\d\w]+/[\d\w]+$`).MatchString(r.URL.Path): + adminReviewMention(w, r) + case regexp.MustCompile(`^/admin/mentions/remove/[\d\w]+/[\d\w]+$`).MatchString(r.URL.Path): + adminRemoveMention(w, r) case regexp.MustCompile(`^/t/?$`).MatchString(r.URL.Path): renderTags(w, client) case regexp.MustCompile(`^/f/?$`).MatchString(r.URL.Path): @@ -183,6 +187,8 @@ func process(_ context.Context, w gemini.ResponseWriter, r *gemini.Request) { downloadAttachment(w, r, client) case regexp.MustCompile(`^/r/.+`).MatchString(r.URL.Path): redirectAction(w, r, client) + case regexp.MustCompile(`^/mention/[\d\w]+`).MatchString(r.URL.Path): + processMention(w, r) default: w.WriteHeader(gemini.StatusNotFound, "Out of space") } @@ -254,12 +260,6 @@ func renderIndex(w gemini.ResponseWriter, client TreeManagerClient, r *gemini.Re db.Save("page_counter._"+defaultLang, "1") } - adminCert, found := db.Get("admin_cert") - if !found { - adminCert = "xxx" - db.Save("admin_cert", "xxx") - } - sentence := "" sentenceCount, _ := db.Length("sentences_" + defaultLang) if sentenceCount > 0 { @@ -276,7 +276,7 @@ func renderIndex(w gemini.ResponseWriter, client TreeManagerClient, r *gemini.Re "lastFiles": GetLastFiles(tree.Files), "lastRebuild": lastRebuild, "pageCounter": pageCounter, - "admin": adminCert == getCertFingerprint(r), + "admin": isAdmin(r), "sentence": sentence, }, ) @@ -406,8 +406,7 @@ func renderAdminSentence( return } - adminCert, found := db.Get("admin_cert") - if !found || adminCert != getCertFingerprint(r) { + if !isAdmin(r) { w.WriteHeader(gemini.StatusNotFound, "Out of space") return } @@ -418,7 +417,7 @@ func renderAdminSentence( return } - _, found = db.Get("sentences_" + defaultLang) + _, found := db.Get("sentences_" + defaultLang) if !found { db.CreateNode("sentences_" + defaultLang) _, _ = db.Get("sentences_" + defaultLang) @@ -434,6 +433,62 @@ func renderAdminSentence( w.WriteHeader(gemini.StatusPermanentRedirect, "/") } +func adminReviewMention( + w gemini.ResponseWriter, + r *gemini.Request, +) { + if !isAdmin(r) { + w.WriteHeader(gemini.StatusNotFound, "Out of space") + return + } + + q, err := gemini.QueryUnescape(r.URL.RawQuery) + if err != nil || q == "" { + w.WriteHeader(gemini.StatusInput, "Patvėrtink so 't'") + return + } + + urlParts := strings.Split(r.URL.Path, "/") + fmt.Println(urlParts) + pageId := urlParts[4] + id := urlParts[5] + + if q != "t" { + w.WriteHeader(gemini.StatusPermanentRedirect, "/r/"+pageId) + } + + MarkReviewed(pageId, id) + w.WriteHeader(gemini.StatusPermanentRedirect, "/r/"+pageId) +} + +func adminRemoveMention( + w gemini.ResponseWriter, + r *gemini.Request, +) { + if !isAdmin(r) { + w.WriteHeader(gemini.StatusNotFound, "Out of space") + return + } + + q, err := gemini.QueryUnescape(r.URL.RawQuery) + if err != nil || q == "" { + w.WriteHeader(gemini.StatusInput, "Patvėrtink so 't'") + return + } + + urlParts := strings.Split(r.URL.Path, "/") + fmt.Println(urlParts) + pageId := urlParts[4] + id := urlParts[5] + + if q != "t" { + w.WriteHeader(gemini.StatusPermanentRedirect, "/r/"+pageId) + } + + RemoveMention(pageId, id) + w.WriteHeader(gemini.StatusPermanentRedirect, "/r/"+pageId) +} + func renderSearch( w gemini.ResponseWriter, r *gemini.Request, @@ -547,7 +602,15 @@ func renderFile(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerCl w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile("templates/page.gmi")) page, err := tpl.Execute( - pongo2.Context{"lang": defaultLang, "file": file, "pageCounter": pageCounter}, + pongo2.Context{ + "lang": defaultLang, + "file": file, + "pageCounter": pageCounter, + "T": T, + "admin": isAdmin(r), + "mentions": GetMentionsForPage(id, STATUS_MENTION_REVIEWED), + "adminWaitingMentions": GetMentionsForPage(id, STATUS_MENTION_NOT_REVIEWED), + }, ) if err != nil { log.Fatalf("template failed: %v", err) @@ -561,6 +624,38 @@ func renderFile(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerCl } } +func processMention(w gemini.ResponseWriter, r *gemini.Request) { + urlParts := strings.Split(r.URL.Path, "/") + id := urlParts[2] + + q, err := gemini.QueryUnescape(r.URL.RawQuery) + if err != nil || q == "" { + w.WriteHeader(gemini.StatusInput, Dict().newMention) + return + } + + _, err = ProcessMention(q, id) + if err != nil { + fmt.Println(err) + w.WriteHeader(gemini.StatusCGIError, "Internal server error: unprocessable mention") + return + } + + w.SetMediaType("text/gemini") + tpl := pongo2.Must(pongo2.FromFile("templates/mention.gmi")) + page, err := tpl.Execute(pongo2.Context{"lang": defaultLang, "id": id, "q": q, "T": T}) + if err != nil { + log.Fatalf("template failed: %v", err) + return + } + + _, err = w.Write([]byte(page)) + if err != nil { + w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") + return + } +} + func renderAllFiles(w gemini.ResponseWriter, client TreeManagerClient) { path := "" tr := TreeRequest{Path: &path, Filter: getLangFilters()} @@ -649,3 +744,19 @@ func getCertFingerprint(r *gemini.Request) string { return hex.EncodeToString(sum[:]) } + +func isAdmin(r *gemini.Request) bool { + db, err := zordfsdb.InitDB("./db") + if err != nil { + log.Fatal(err) + return false + } + + adminCert, found := db.Get("admin_cert") + if !found { + adminCert = "xxx" + db.Save("admin_cert", "xxx") + } + + return getCertFingerprint(r) == adminCert +} diff --git a/mention.go b/mention.go new file mode 100644 index 0000000..abd640b --- /dev/null +++ b/mention.go @@ -0,0 +1,175 @@ +package main + +// Trying to implement gemini mentions from https://codeberg.org/bacardi55/gemini-mentions-rfc +// Thanks to @bacardi55 and his example https://git.sr.ht/~bacardi55/ggm (this is forked from there) + +import ( + "context" + "fmt" + "io" + "log" + "net/url" + "regexp" + "strings" + "time" + + "g.arns.lt/zordsdavini/zordfsdb" + "git.sr.ht/~adnano/go-gemini" +) + +const ( + STATUS_MENTION_NOT_REVIEWED = "0" + STATUS_MENTION_REVIEWED = "1" +) + +// Nota: Gemini servers might have max execution time for cgi scripts. +// Eg: Gemserv has a 5s maximum policy before killing the request. +const maxRequestTime = 4 + +func ProcessMention(remoteUrl string, pageId string) (bool, error) { + u, err := validateUrl(remoteUrl) + if err != nil { + return false, fmt.Errorf("[gemini-mentions] Url is not valid.") + } + + response, err := fetchGeminiPage(u) + if err != nil || response.Status.Class() != gemini.StatusSuccess { + return false, fmt.Errorf("[gemini-mentions] Error retrieving content from %s: %s", u, err) + } + + if respCert := response.TLS().PeerCertificates; len(respCert) > 0 && + time.Now().After(respCert[0].NotAfter) { + return false, fmt.Errorf( + "[gemini-mentions] Ignored url (invalid certificate for capsule): %s", + u, + ) + } + + // If all good, let's find gemini mentions link inside. + var content []byte + content, err = io.ReadAll(response.Body) + defer response.Body.Close() + + if err != nil { + return false, fmt.Errorf("[gemini-mentions] Couldn't retrieve the provided URL content") + } + links := findMentionLinks(string(content), hostname) + if len(links) < 1 { + return false, fmt.Errorf("[gemini-mentions] Recieved a link with no mention: %s", u) + } + + // log to db + db, err := zordfsdb.InitDB("./db") + if err != nil { + return false, fmt.Errorf("[gemini-mentions] no db: %s", err) + } + _, found := db.GetNode("mentions") + if !found { + db.CreateNode("mentions") + } + _, found = db.GetNode("mentions." + pageId) + if !found { + db.CreateNode("mentions." + pageId) + db.CreateNode("mentions." + pageId + ".mentions") + } + for _, link := range links { + id, err := db.AddObject("mentions." + pageId + ".mentions") + if err != nil { + return false, fmt.Errorf("[gemini-mentions] couldn't add object: %s", err) + } + db.Save("mentions."+pageId+".mentions."+id+".url", extractUrl(link)) + db.Save("mentions."+pageId+".mentions."+id+".reviewed", STATUS_MENTION_NOT_REVIEWED) + } + + // TODO: send notification to misfin + + return true, nil +} + +func validateUrl(remoteUrl string) (string, error) { + remote, e := url.QueryUnescape(remoteUrl) + if e != nil { + return "", fmt.Errorf("Provided URL is not a good URL: %s", e) + } + remote = strings.Replace(remote, "..", "", -1) + + u, err := url.Parse(remote) + + if err != nil { + return "", fmt.Errorf("Provided URL is not a good URL: %s", err) + } else if u.Scheme != "gemini" && u.Scheme != "" { + return "", fmt.Errorf("Only gemini url are supported for now.") + } else { + return "gemini://" + u.Host + u.Path, nil + } +} + +func fetchGeminiPage(remoteUrl string) (*gemini.Response, error) { + gemclient := &gemini.Client{} + ctx, _ := context.WithTimeout(context.Background(), time.Duration(maxRequestTime)*time.Second) + response, err := gemclient.Get(ctx, remoteUrl) + if err != nil { + return response, err + } + + if respCert := response.TLS().PeerCertificates; len(respCert) > 0 && + time.Now().After(respCert[0].NotAfter) { + return response, fmt.Errorf("Certificate is unvalid") + } + + return response, nil +} + +func findMentionLinks(content string, capsuleRootAddress string) []string { + exp := "(?im)^(=>)[ ]?gemini://" + capsuleRootAddress + "[^ ]+([ ](.*))?$" + re := regexp.MustCompile(exp) + return re.FindAllString(content, -1) +} + +func extractUrl(url string) string { + exp := "(?im)gemini://[^ ]+" + re := regexp.MustCompile(exp) + return re.FindString(url) +} + +func GetMentionsForPage(pageId string, reviewStatus string) map[string]string { + mentions := make(map[string]string) + db, err := zordfsdb.InitDB("./db") + if err != nil { + log.Fatal(err) + return mentions + } + + _, found := db.Get("mentions." + pageId + ".mentions") + if !found { + return mentions + } + + for _, id := range db.Keys("mentions." + pageId + ".mentions") { + reviewed, _ := db.Get("mentions." + pageId + ".mentions." + id + ".reviewed") + if reviewed == reviewStatus { + url, _ := db.Get("mentions." + pageId + ".mentions." + id + ".url") + mentions[id] = url + } + } + + return mentions +} + +func MarkReviewed(pageId string, id string) { + db, err := zordfsdb.InitDB("./db") + if err != nil { + log.Fatal(err) + } + + db.Save("mentions."+pageId+".mentions."+id+".reviewed", STATUS_MENTION_REVIEWED) +} + +func RemoveMention(pageId string, id string) { + db, err := zordfsdb.InitDB("./db") + if err != nil { + log.Fatal(err) + } + + db.Del("mentions." + pageId + ".mentions." + id) +} diff --git a/templates/mention.gmi b/templates/mention.gmi new file mode 100644 index 0000000..789fbb1 --- /dev/null +++ b/templates/mention.gmi @@ -0,0 +1,7 @@ +{% import "macros.tpl" home %} +# {{ T('newMention') }} + +{{ T('thanksForMention') }}: {{ q }} + +=> /r/{{id}} {{ T('backToPage') }} +{{ home(lang) }} diff --git a/templates/page.gmi b/templates/page.gmi index d774be5..ba5a903 100644 --- a/templates/page.gmi +++ b/templates/page.gmi @@ -14,8 +14,24 @@ Language: {{ file.File.Lang }} {{ category_url(file.File.CategoryPath, file.File.Category|last, 0) }} +### {{ T('mentions') }} +{% if mentions %} +{% for id, mention in mentions %}=> {{ mention}} +{% endfor %} {% endif %} +=> /mention/{{file.File.Id}} {{ T('newMention') }} + {{ home(lang) }} ``` Page counter: {{pageCounter}} ``` + +{% if admin and adminWaitingMentions %} +### Keravuotuojė sekcėjė +{% for id, mention in adminWaitingMentions %} +=> {{ mention }} +=> /admin/mentions/review/{{file.File.Id}}/{{id}} ✓ Patvėrtintė +=> /admin/mentions/remove/{{file.File.Id}}/{{id}} 🗙 Pašalintė + +{% endfor %} +{% endif %}