549 lines
14 KiB
Go
549 lines
14 KiB
Go
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)
|
|
case regexp.MustCompile(`^/r/.+`).MatchString(r.URL.Path):
|
|
redirectAction(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
|
|
}
|
|
}
|
|
|
|
func redirectAction(w gemini.ResponseWriter, r *gemini.Request, client TreeManagerClient) {
|
|
urlParts := strings.Split(r.URL.Path, "/")
|
|
id := urlParts[1]
|
|
|
|
fr := FileRequest{Id: id}
|
|
file, err := client.GetFile(context.Background(), &fr)
|
|
if err != nil {
|
|
log.Fatalf("client.GetFile failed: %v", err)
|
|
w.WriteHeader(gemini.StatusTemporaryFailure, "Internal server error")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(
|
|
gemini.StatusPermanentRedirect,
|
|
fmt.Sprintf(
|
|
"/f/%s/%s/%s",
|
|
strings.Join(file.File.Category, "/"),
|
|
file.File.Id,
|
|
file.File.GmiName(),
|
|
),
|
|
)
|
|
}
|