package main import ( "context" "flag" "fmt" "io" "log" "mime" "path" "regexp" "strings" "time" "g.arns.lt/zordsdavini/zordfsdb" "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini/certificate" "github.com/flosch/pongo2/v6" "github.com/gorilla/feeds" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) var ( hostname string port string fileSrvHost string fileSrvPort string certificatePath string defaultLang string supportedLang string key = "WgiPGpt5wM6SVEWo5iqI" ) func init() { flag.StringVar(&hostname, "hostname", "", "capsule hostname") flag.StringVar(&port, "port", "1965", "capsule port") flag.StringVar(&certificatePath, "certificatePath", "", "capsule certificate path") flag.StringVar(&fileSrvHost, "fileSrvHost", "", "file push server host") flag.StringVar(&fileSrvPort, "fileSrvPort", "", "file push server port") flag.StringVar(&defaultLang, "defaultLang", "", "default language, ex. sgs") flag.StringVar( &supportedLang, "supportedLang", "", "other supported languages separated by |, ex., prg|lt", ) flag.StringVar(&key, "key", "", "secret key to communicate admin") flag.Parse() } func main() { certificates := &certificate.Store{} certificates.Register(hostname) if err := certificates.Load(certificatePath); err != nil { panic(err) } mux := &gemini.Mux{} mux.HandleFunc("/favicon.txt", processFavicon) mux.HandleFunc("/rebuild", processRebuild) // security // feed mux.HandleFunc("/", process) server := &gemini.Server{ Addr: ":" + port, Handler: mux, ReadTimeout: 30 * time.Second, WriteTimeout: 1 * time.Minute, GetCertificate: certificates.Get, } ctx := context.Background() if err := server.ListenAndServe(ctx); err != nil { log.Fatal(err) } } func processRebuild(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) { db, err := zordfsdb.InitDB("./db") if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") log.Fatal(err) return } db.Now("last_rebuild") log.Println("REBUILD") q, err := gemini.QueryUnescape(r.URL.RawQuery) if err != nil || q != key { w.WriteHeader(gemini.StatusServerUnavailable, "Don't wake up the dragons :-*") } conn, err := grpc.Dial( fileSrvHost+":"+fileSrvPort, grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") log.Fatal(err) return } defer conn.Close() client := NewTreeManagerClient(conn) ts := TreeSecret{Key: key} rsp, err := client.RebuildTree(context.Background(), &ts) if rsp.Success { _, err = w.Write([]byte(":-)")) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") } } else { w.WriteHeader(gemini.StatusNotFound, "Out of space") } log.Println("REBUILD: ", rsp.Success) } func processFavicon(_ context.Context, w gemini.ResponseWriter, _ *gemini.Request) { w.SetMediaType("text/plain") _, err := w.Write([]byte("\U0001F31B")) if err != nil { w.WriteHeader(gemini.StatusNotFound, "Out of space") } } func process(_ context.Context, w gemini.ResponseWriter, r *gemini.Request) { log.Println("-> " + r.URL.Path) db, err := zordfsdb.InitDB("./db") if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") log.Fatal(err) return } db.Inc("page_counter." + defaultLang) conn, err := grpc.Dial( fileSrvHost+":"+fileSrvPort, grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") log.Fatal(err) return } defer conn.Close() client := NewTreeManagerClient(conn) switch { case "/" == r.URL.Path: renderIndex(w, client) case regexp.MustCompile(`^/atom.xml$`).MatchString(r.URL.Path): renderFeed(w, r, client) case regexp.MustCompile(`^/a/?$`).MatchString(r.URL.Path): renderAbout(w) case regexp.MustCompile(`^/s/?$`).MatchString(r.URL.Path): renderSearch(w, r, client) case regexp.MustCompile(`^/f/?$`).MatchString(r.URL.Path): renderAllFiles(w, client) case regexp.MustCompile(`^/f/([\p{L}\d_+.]+/)+[\d\w]+/index.gmi$`).MatchString(r.URL.Path): urlParts := strings.Split(r.URL.Path, "/") w.WriteHeader( gemini.StatusPermanentRedirect, strings.Join(urlParts[:(len(urlParts)-2)], "/"), ) case regexp.MustCompile(`^/f/([\p{L}\d_+.]+/)+[\d\w]+/[\p{L}\d_+.]+.gmi$`).MatchString(r.URL.Path): renderFile(w, r, client) case regexp.MustCompile(`^/f/[\p{L}\d_+.]+(/[\p{L}\d_+.]+)*/?$`).MatchString(r.URL.Path): renderCategory(w, r, client) case regexp.MustCompile(`^/t/[\p{L}\d_+.]+/?$`).MatchString(r.URL.Path): renderTag(w, r, client) case regexp.MustCompile(`^/__a/`).MatchString(r.URL.Path): downloadAttachment(w, r, client) default: w.WriteHeader(gemini.StatusNotFound, "Out of space") } } func downloadAttachment(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient) { urlParts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") filePath := urlParts[len(urlParts)-1] ar := AttachmentRequest{Path: filePath} response, err := client.DownloadAttachment(context.Background(), &ar) if err != nil { log.Fatalf("client.DownloadAttachment failed: %v", err) w.WriteHeader(gemini.StatusNotFound, "We lost it!") return } w.SetMediaType(mime.TypeByExtension(path.Ext(filePath))) for { chunkResponse, err := response.Recv() if err == io.EOF { log.Println("received all chunks") break } if err != nil { log.Println("err receiving chunk:", err) break } _, err = w.Write(chunkResponse.Chunk) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } } } func getLangFilters() []*TreeRequest_Filter { filters := []*TreeRequest_Filter{{Key: "lang", Value: defaultLang}} for _, l := range strings.Split(supportedLang, "|") { filters = append(filters, &TreeRequest_Filter{Key: "lang", Value: l}) } return filters } func renderIndex(w gemini.ResponseWriter, client TreeManagerClient) { db, err := zordfsdb.InitDB("./db") if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") log.Fatal(err) return } path := "" tr := TreeRequest{Path: &path, Filter: getLangFilters()} tree, err := client.GetSummery(context.Background(), &tr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } lastRebuild, found := db.Get("last_rebuild") if !found { } pageCounter, found := db.Get("page_counter." + defaultLang) if !found { pageCounter = "1" db.Save("page_counter."+defaultLang, "1") } w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile(fmt.Sprintf("templates/%s/index.gmi", defaultLang))) page, err := tpl.Execute( pongo2.Context{ "tree": tree, "lang": defaultLang, "lastFiles": GetLastFiles(tree.Files), "lastRebuild": lastRebuild, "pageCounter": pageCounter, }, ) 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 renderFeed(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient) { path := "" tr := TreeRequest{Path: &path, Filter: getLangFilters()} tree, err := client.GetSummery(context.Background(), &tr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } w.SetMediaType("application/atom+xml") now := time.Now() feed := &feeds.Feed{ Title: "\U0001F31B Arna alkierios [" + defaultLang + "]", Link: &feeds.Link{Href: "gemini://" + Dict().hostname}, Description: "personal gemini capsule by Arns Udovič [" + Dict().languageEn + " version]", Author: &feeds.Author{Name: "Arns Udovič", Email: "zordsdavini@arns.lt"}, Created: now, } for _, file := range GetLastFiles(tree.Files) { created, err := time.Parse("2006-01-02", file.Created) if err != nil { created = time.Now() } feed.Add( &feeds.Item{ Title: file.Description, Link: &feeds.Link{Href: fmt.Sprintf("gemini://%s/f%s/%s/%s", r.URL.Host, file.CategoryPath(), file.Id, file.GmiName(), )}, Description: fmt.Sprintf("%s %s [%s]", file.Description, file.CategoryPath(), strings.Join(file.Tags, ","), ), Author: &feeds.Author{Name: "Arns Udovič", Email: "zordsdavini@arns.lt"}, Created: created, }, ) } atom, err := feed.ToAtom() if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } _, err = w.Write([]byte(atom)) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } } func renderAbout(w gemini.ResponseWriter) { w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile(fmt.Sprintf("templates/%s/about.gmi", defaultLang))) page, err := tpl.Execute(pongo2.Context{}) 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") } } func renderSearch( w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient, ) { q, err := gemini.QueryUnescape(r.URL.RawQuery) if err != nil || q == "" { w.WriteHeader(gemini.StatusInput, Dict().searchStr) return } descFilter := TreeRequest_Filter{Key: "description", Value: q} createdFilter := TreeRequest_Filter{Key: "created", Value: q} tagFilter := TreeRequest_Filter{Key: "tag", Value: q} filters := []*TreeRequest_Filter{&descFilter, &createdFilter, &tagFilter} path := "" tr := TreeRequest{Path: &path, Filter: filters} tree, err := client.GetSummery(context.Background(), &tr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile("templates/search.gmi")) page, err := tpl.Execute(pongo2.Context{"lang": defaultLang, "tree": tree, "q": q}) 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 renderCategory( w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient, ) { urlParts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") path := "/" + strings.Join(urlParts[1:], "/") tr := TreeRequest{Path: &path, Filter: []*TreeRequest_Filter{}} tree, err := client.GetSummery(context.Background(), &tr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } indexFile, err := tree.GetIndexFile() var file *FileContent if err == nil { fr := FileRequest{Id: indexFile.Id} file, err = client.GetFile(context.Background(), &fr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } } w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile("templates/category.gmi")) page, err := tpl.Execute( pongo2.Context{"lang": defaultLang, "tree": tree, "path": path, "indexFile": file}, ) 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 renderFile(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient) { urlParts := strings.Split(r.URL.Path, "/") id := urlParts[len(urlParts)-2] fr := FileRequest{Id: id} file, err := client.GetFile(context.Background(), &fr) if err != nil { log.Fatalf("client.GetSummery failed: %v", err) w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } db, err := zordfsdb.InitDB("./db") if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") log.Fatal(err) return } db.Inc("page_counter." + id) pageCounter, found := db.Get("page_counter." + id) if !found { pageCounter = "1" db.Save("page_counter."+id, "1") } w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile("templates/page.gmi")) page, err := tpl.Execute( pongo2.Context{"lang": defaultLang, "file": file, "pageCounter": pageCounter}, ) 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()} tree, err := client.GetSummery(context.Background(), &tr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile("templates/all_texts.gmi")) page, err := tpl.Execute(pongo2.Context{"lang": defaultLang, "tree": tree}) 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 renderTag(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient) { urlParts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") tag := urlParts[len(urlParts)-1] tagFilter := TreeRequest_Filter{Key: "tag", Value: tag} filters := []*TreeRequest_Filter{&tagFilter} path := "" tr := TreeRequest{Path: &path, Filter: filters} tree, err := client.GetSummery(context.Background(), &tr) if err != nil { w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error") return } w.SetMediaType("text/gemini") tpl := pongo2.Must(pongo2.FromFile("templates/tag.gmi")) page, err := tpl.Execute(pongo2.Context{"lang": defaultLang, "tree": tree, "tag": tag}) 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 } }