479 lines
12 KiB
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
|
|
}
|