linkwallet/web/web.go

479 lines
12 KiB
Go

package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/tardisx/linkwallet/db"
"github.com/tardisx/linkwallet/entity"
"github.com/tardisx/linkwallet/meta"
"github.com/tardisx/linkwallet/version"
"github.com/gomarkdown/markdown"
"github.com/hako/durafmt"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFiles embed.FS
//go:embed templates/*
var templateFiles embed.FS
// Server represents a SCUD web server.
// The SCUD web service can serve 2 different kinda of responses. The first is basic static
// vendor-provided files (called "assetFiles" here). An arbitrary number of them can be placed
// in assets/ and served up via a path prefix of /assets. They do not need individual routes
// to be specified.
// The second is htmx responses fragments. We never automatically serve templates (ie no mapping
// from template name to a URL route), there will always be a specific route or routes which
// use one or more templates to return a response.
type Server struct {
engine *gin.Engine
templ *template.Template
bmm *db.BookmarkManager
}
type ColumnInfo struct {
Name string
Param string
Sorted string
Class string
}
func (c ColumnInfo) URLString() string {
if c.Sorted == "asc" {
return "-" + c.Param
}
return c.Param
}
func (c ColumnInfo) TitleArrow() string {
if c.Sorted == "asc" {
return "↑"
} else if c.Sorted == "desc" {
return "↓"
}
return ""
}
// Create creates a new web server instance and sets up routing.
func Create(bmm *db.BookmarkManager, cmm *db.ConfigManager) *Server {
// setup routes for the static assets (vendor includes)
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("problem with assetFS: %s", err)
}
// templ := template.Must(template.New("").Funcs(template.FuncMap{"dict": dictHelper}).ParseFS(templateFiles, "templates/*.html"))
templ := template.Must(template.New("").Funcs(
template.FuncMap{
"nicetime": niceTime,
"niceURL": niceURL,
"niceSizeMB": func(s int) string { return fmt.Sprintf("%.1f", float32(s)/1024/1024) },
"join": strings.Join,
"version": func() *version.Info { return &version.VersionInfo },
"meminfo": meta.MemInfo,
"markdown": func(s string) template.HTML { return template.HTML(string(markdown.ToHTML([]byte(s), nil, nil))) },
}).ParseFS(templateFiles, "templates/*.html"))
config, err := cmm.LoadConfig()
if err != nil {
log.Fatalf("could not start server - failed to load config: %s", err)
}
r := gin.Default()
server := &Server{
engine: r,
templ: templ,
bmm: bmm,
}
r.Use(headersByURI())
r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedExtensions([]string{".pdf", ".mp4"})))
r.SetHTMLTemplate(templ)
r.StaticFS("/assets", http.FS(staticFS))
r.GET("/", func(c *gin.Context) {
meta := gin.H{"page": "root", "config": config}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.GET("/manage", func(c *gin.Context) {
allBookmarks, _ := bmm.ListBookmarks()
meta := gin.H{"page": "manage", "config": config, "bookmarks": allBookmarks}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.POST("/manage/results", func(c *gin.Context) {
query := c.PostForm("query")
tags := []string{}
sort := c.Query("sort")
if c.PostForm("tags_hidden") != "" {
tags = strings.Split(c.PostForm("tags_hidden"), "|")
}
allBookmarks, _ := bmm.Search(db.SearchOptions{Query: query, Tags: tags, Sort: sort})
meta := gin.H{"config": config, "bookmarks": allBookmarks}
colTitle := &ColumnInfo{Name: "Title/URL", Param: "title"}
colCreated := &ColumnInfo{Name: "Created", Param: "created", Class: "show-for-large"}
colScraped := &ColumnInfo{Name: "Scraped", Param: "scraped", Class: "show-for-large"}
if sort == "title" {
colTitle.Sorted = "asc"
}
if sort == "-title" {
colTitle.Sorted = "desc"
}
if sort == "scraped" {
colScraped.Sorted = "asc"
}
if sort == "-scraped" {
colScraped.Sorted = "desc"
}
if sort == "created" {
colCreated.Sorted = "asc"
}
if sort == "-created" {
colCreated.Sorted = "desc"
}
cols := gin.H{
"title": colTitle,
"created": colCreated,
"scraped": colScraped,
}
meta["column"] = cols
c.HTML(http.StatusOK,
"manage_results.html", meta,
)
})
r.GET("/config", func(c *gin.Context) {
meta := gin.H{"page": "config", "config": config}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.POST("/config", func(c *gin.Context) {
config.BaseURL = c.PostForm("baseurl")
config.BaseURL = strings.TrimRight(config.BaseURL, "/")
cmm.SaveConfig(&config)
meta := gin.H{"config": config}
c.HTML(http.StatusOK, "config_form.html", meta)
})
r.POST("/search", func(c *gin.Context) {
query := c.PostForm("query")
// no query, return an empty response
if len(query) == 0 {
c.Status(http.StatusNoContent)
c.Writer.Write([]byte{})
return
}
sr, err := bmm.Search(db.SearchOptions{Query: query})
data := gin.H{
"results": sr,
"error": err,
}
c.HTML(http.StatusOK,
"search_results.html", data,
)
})
r.POST("/add", func(c *gin.Context) {
url := c.PostForm("url")
tags := []string{}
if c.PostForm("tags_hidden") != "" {
tags = strings.Split(c.PostForm("tags_hidden"), "|")
}
bm := entity.Bookmark{
ID: 0,
URL: url,
Tags: tags,
}
err := bmm.AddBookmark(&bm)
data := gin.H{
"bm": bm,
"error": err,
}
if err != nil {
data["url"] = url
data["tags"] = tags
data["tags_hidden"] = c.PostForm("tags_hidden")
}
c.HTML(http.StatusOK, "add_url_form.html", data)
})
r.POST("/add_bulk", func(c *gin.Context) {
urls := c.PostForm("urls")
urlsSplit := strings.Split(urls, "\n")
urlsTrimmed := make([]string, 0, 0)
for _, url := range urlsSplit {
urlsTrimmed = append(urlsTrimmed, strings.TrimSpace(url))
}
totalErrors := make([]string, 0, 0)
added := 0
for _, url := range urlsTrimmed {
if url != "" {
bm := entity.Bookmark{
ID: 0,
URL: url,
}
err := bmm.AddBookmark(&bm)
if err != nil {
totalErrors = append(totalErrors, fmt.Sprintf("url: %s (%s)", url, err.Error()))
} else {
added++
}
}
}
data := gin.H{
"added": added,
"errors": totalErrors,
}
c.HTML(http.StatusOK, "add_url_form_bulk.html", data)
})
r.GET("/bulk_add", func(c *gin.Context) {
c.HTML(http.StatusOK, "add_url_form_bulk.html", nil)
})
r.POST("/tags", func(c *gin.Context) {
newTag := c.PostForm("tag") // new tag
oldTags := strings.Split(c.PostForm("tags_hidden"), "|")
remove := c.Query("remove")
if remove != "" {
log.Printf("removing %s", remove)
trimmedTags := []string{}
for _, k := range oldTags {
if k != remove {
trimmedTags = append(trimmedTags, k)
}
}
oldTags = trimmedTags
}
tags := append(oldTags, newTag)
tags = cleanupTags(tags)
tagsHidden := strings.Join(tags, "|")
data := gin.H{"tags": tags, "tags_hidden": tagsHidden}
c.HTML(http.StatusOK, "tags_widget.html", data)
})
r.GET("/single_add", func(c *gin.Context) {
c.HTML(http.StatusOK, "add_url_form.html", nil)
})
// XXX this should properly replace the button
r.POST("/scrape/:id", func(c *gin.Context) {
id := c.Params.ByName("id")
idNum, _ := strconv.ParseInt(id, 10, 32)
bm := bmm.LoadBookmarkByID(uint64(idNum))
bmm.QueueScrape(&bm)
c.String(http.StatusOK, "<p>scrape queued</p>")
})
r.GET("/export", func(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/plain")
c.Writer.Header().Set("Content-Disposition", "attachment; filename=\"bookmarks.txt\"")
err := bmm.ExportBookmarks(c.Writer)
// this is a bit late, but we already added headers, so at least log it.
if err != nil {
log.Printf("got error when exporting: %s", err)
}
})
r.GET("/bookmarklet", func(c *gin.Context) {
url := c.Query("url")
meta := gin.H{"page": "bookmarklet_click", "config": config, "url": url}
// check if they just clicked it from the actual app
if strings.Index(url, config.BaseURL) == 0 {
meta["clicked"] = true
}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.GET("/edit/:id", func(c *gin.Context) {
bookmarkIDstring := c.Param("id")
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
if ok != nil {
c.String(http.StatusBadRequest, "bad id")
return
}
bookmark := bmm.LoadBookmarkByID(bookmarkID)
meta := gin.H{"page": "edit", "bookmark": bookmark, "tw": gin.H{"tags": bookmark.Tags, "tags_hidden": strings.Join(bookmark.Tags, "|")}}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.POST("/edit/:id", func(c *gin.Context) {
bookmarkIDstring := c.Param("id")
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
if ok != nil {
c.String(http.StatusBadRequest, "bad id")
return
}
bookmark := bmm.LoadBookmarkByID(bookmarkID)
// update title and override title
overrideTitle := c.PostForm("override_title")
if overrideTitle != "" {
title := c.PostForm("title")
bookmark.Info.Title = title
bookmark.PreserveTitle = true
} else {
bookmark.PreserveTitle = false
}
// freshen tags
if c.PostForm("tags_hidden") == "" {
// empty
bookmark.Tags = []string{}
} else {
bookmark.Tags = strings.Split(c.PostForm("tags_hidden"), "|")
}
bmm.SaveBookmark(&bookmark)
bmm.UpdateIndexForBookmark(&bookmark) // because title may have changed
meta := gin.H{"page": "edit", "bookmark": bookmark, "saved": true, "tw": gin.H{"tags": bookmark.Tags, "tags_hidden": strings.Join(bookmark.Tags, "|")}}
c.HTML(http.StatusOK,
"edit_form.html", meta,
)
})
r.DELETE("/edit/:id", func(c *gin.Context) {
bookmarkIDstring := c.Param("id")
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
if ok != nil {
c.String(http.StatusBadRequest, "bad id")
return
}
bookmark := bmm.LoadBookmarkByID(bookmarkID)
err := bmm.DeleteBookmark(&bookmark)
if err != nil {
panic(err)
}
c.HTML(http.StatusOK,
"edit_form_deleted.html", nil,
)
})
r.GET("/info", func(c *gin.Context) {
dbStats, err := bmm.Stats()
if err != nil {
panic("could not load stats for info page")
}
meta := gin.H{"page": "info", "stats": dbStats, "config": config}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
return server
}
// headersByURI sets the headers for some special cases, set a custom long cache time for
// static resources.
func headersByURI() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.String(), "/assets/") {
c.Header("Cache-Control", "max-age=86400")
c.Header("Expires", time.Now().Add(time.Hour*24*1).Format("Mon 2 Jan 2006 15:04:05 MST"))
}
}
}
// Start starts the web server, blocking forever.
func (s *Server) Start() {
s.engine.Run()
}
func cleanupTags(tags []string) []string {
keys := make(map[string]struct{})
for _, k := range tags {
if k != "" && k != "|" {
for _, subKey := range strings.Split(k, ",") {
subKey := strings.Trim(subKey, " ")
keys[strings.ToLower(subKey)] = struct{}{}
}
}
}
out := []string{}
for k := range keys {
out = append(out, k)
}
sort.Strings(out)
return out
}
type timeVariations struct {
HumanDuration string
}
func niceTime(t time.Time) timeVariations {
u := "y:y,w:w,d:d,h:h,m:m,s:s,ms:ms,us:us"
units, err := durafmt.DefaultUnitsCoder.Decode(u)
if err != nil {
panic(err)
}
ago := durafmt.Parse(time.Since(t)).LimitFirstN(1).Format(units)
ago = strings.ReplaceAll(ago, " ", "")
return timeVariations{HumanDuration: ago}
}
func niceURL(url string) string {
if len(url) > 50 {
return url[0:50] + " ..."
}
return url
}