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/version" "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, "join": strings.Join, "version": version.Is, "newVersion": version.UpgradeAvailableString}).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") 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, } 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, "

scrape queued

") }) 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, "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, ) }) 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 }