gemini mentions
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Arnas Udovic 2025-03-24 21:05:50 +02:00
parent bd279a8808
commit d9211fa0d5
5 changed files with 368 additions and 23 deletions

60
dict.go
View file

@ -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 ""
}
}

133
main.go
View file

@ -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
}

175
mention.go Normal file
View file

@ -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)
}

7
templates/mention.gmi Normal file
View file

@ -0,0 +1,7 @@
{% import "macros.tpl" home %}
# {{ T('newMention') }}
{{ T('thanksForMention') }}: {{ q }}
=> /r/{{id}} {{ T('backToPage') }}
{{ home(lang) }}

View file

@ -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 %}