Maskinlesbart format. Kan importeres tilbake.
+ +diff --git a/go.mod b/go.mod index 8b04c00..336ed5f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( github.com/google/uuid v1.6.0 + github.com/gorilla/feeds v1.2.0 golang.org/x/crypto v0.49.0 modernc.org/sqlite v1.48.0 ) diff --git a/go.sum b/go.sum index 22a6f85..b1e95be 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,22 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= +github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= diff --git a/internal/handler/feed.go b/internal/handler/feed.go new file mode 100644 index 0000000..58b17c8 --- /dev/null +++ b/internal/handler/feed.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handler + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/gorilla/feeds" + + "kode.naiv.no/olemd/favoritter/internal/model" + "kode.naiv.no/olemd/favoritter/internal/store" +) + +const feedPageSize = 50 + +// handleFeedGlobal generates an Atom feed of all public faves. +func (h *Handler) handleFeedGlobal(w http.ResponseWriter, r *http.Request) { + baseURL := h.deps.Config.BaseURL(r.Host) + + faves, _, err := h.deps.Faves.ListPublic(feedPageSize, 0) + if err != nil { + slog.Error("feed: list public error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := h.deps.Faves.LoadTags(faves); err != nil { + slog.Error("feed: load tags error", "error", err) + } + + settings, _ := h.deps.Settings.Get() + siteName := "Favoritter" + if settings != nil { + siteName = settings.SiteName + } + + feed := &feeds.Feed{ + Title: siteName + " — Siste favoritter", + Link: &feeds.Link{Href: baseURL}, + Description: "Siste offentlige favoritter", + Updated: time.Now(), + } + + feed.Items = favesToFeedItems(faves, baseURL) + h.writeAtom(w, feed) +} + +// handleFeedUser generates an Atom feed for a user's public faves. +func (h *Handler) handleFeedUser(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + baseURL := h.deps.Config.BaseURL(r.Host) + + user, err := h.deps.Users.GetByUsername(username) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + http.NotFound(w, r) + return + } + slog.Error("feed: get user error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if user.Disabled || user.ProfileVisibility == "limited" { + http.NotFound(w, r) + return + } + + faves, _, err := h.deps.Faves.ListPublicByUser(user.ID, feedPageSize, 0) + if err != nil { + slog.Error("feed: list user faves error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := h.deps.Faves.LoadTags(faves); err != nil { + slog.Error("feed: load tags error", "error", err) + } + + feed := &feeds.Feed{ + Title: user.DisplayNameOrUsername() + " sine favoritter", + Link: &feeds.Link{Href: baseURL + "/u/" + user.Username}, + Updated: time.Now(), + } + + feed.Items = favesToFeedItems(faves, baseURL) + h.writeAtom(w, feed) +} + +// handleFeedTag generates an Atom feed for a tag's public faves. +func (h *Handler) handleFeedTag(w http.ResponseWriter, r *http.Request) { + tagName := r.PathValue("name") + baseURL := h.deps.Config.BaseURL(r.Host) + + faves, _, err := h.deps.Faves.ListByTag(tagName, feedPageSize, 0) + if err != nil { + slog.Error("feed: list by tag error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := h.deps.Faves.LoadTags(faves); err != nil { + slog.Error("feed: load tags error", "error", err) + } + + feed := &feeds.Feed{ + Title: "Favoritter med merkelapp: " + tagName, + Link: &feeds.Link{Href: baseURL + "/tags/" + tagName}, + Updated: time.Now(), + } + + feed.Items = favesToFeedItems(faves, baseURL) + h.writeAtom(w, feed) +} + +func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item { + items := make([]*feeds.Item, 0, len(faves)) + for _, f := range faves { + item := &feeds.Item{ + Title: f.Description, + Link: &feeds.Link{Href: baseURL + "/faves/" + itoa(f.ID)}, + Author: &feeds.Author{Name: f.DisplayName}, + Created: f.CreatedAt, + Updated: f.UpdatedAt, + } + + if f.URL != "" { + item.Content = `
` + } + + if f.ImagePath != "" { + item.Enclosure = &feeds.Enclosure{ + Url: baseURL + "/uploads/" + f.ImagePath, + Type: "image/jpeg", + } + } + + items = append(items, item) + } + return items +} + +func (h *Handler) writeAtom(w http.ResponseWriter, feed *feeds.Feed) { + atom, err := feed.ToAtom() + if err != nil { + slog.Error("feed: generate atom error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") + w.Write([]byte(atom)) +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index b81e20e..d805e35 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -112,6 +112,18 @@ func (h *Handler) Routes() *http.ServeMux { mux.Handle("POST /settings/avatar", requireLogin(http.HandlerFunc(h.handleAvatarPost))) mux.Handle("POST /settings/password", requireLogin(http.HandlerFunc(h.handleSettingsPasswordPost))) + // Feeds (public, no auth required). + mux.HandleFunc("GET /feed.xml", h.handleFeedGlobal) + mux.HandleFunc("GET /u/{username}/feed.xml", h.handleFeedUser) + mux.HandleFunc("GET /tags/{name}/feed.xml", h.handleFeedTag) + + // Import/Export (authenticated). + mux.Handle("GET /export", requireLogin(http.HandlerFunc(h.handleExportPage))) + mux.Handle("GET /export/json", requireLogin(http.HandlerFunc(h.handleExportJSON))) + mux.Handle("GET /export/csv", requireLogin(http.HandlerFunc(h.handleExportCSV))) + mux.Handle("GET /import", requireLogin(http.HandlerFunc(h.handleImportPage))) + mux.Handle("POST /import", requireLogin(http.HandlerFunc(h.handleImportPost))) + // Admin panel (requires admin role). admin := func(hf http.HandlerFunc) http.Handler { return requireLogin(middleware.RequireAdmin(http.HandlerFunc(hf))) diff --git a/internal/handler/import_export.go b/internal/handler/import_export.go new file mode 100644 index 0000000..3f7abb9 --- /dev/null +++ b/internal/handler/import_export.go @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handler + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "kode.naiv.no/olemd/favoritter/internal/middleware" + "kode.naiv.no/olemd/favoritter/internal/render" +) + +// ExportFave is the JSON representation for export/import. +type ExportFave struct { + Description string `json:"description"` + URL string `json:"url,omitempty"` + Privacy string `json:"privacy"` + Tags []string `json:"tags,omitempty"` + CreatedAt string `json:"created_at"` +} + +// handleExportPage shows the export options page. +func (h *Handler) handleExportPage(w http.ResponseWriter, r *http.Request) { + h.deps.Renderer.Page(w, r, "export", render.PageData{ + Title: "Eksporter favoritter", + }) +} + +// handleExportJSON exports the user's faves as JSON. +func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) + if err != nil { + slog.Error("export: list faves error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := h.deps.Faves.LoadTags(faves); err != nil { + slog.Error("export: load tags error", "error", err) + } + + export := make([]ExportFave, len(faves)) + for i, f := range faves { + tags := make([]string, len(f.Tags)) + for j, t := range f.Tags { + tags[j] = t.Name + } + export[i] = ExportFave{ + Description: f.Description, + URL: f.URL, + Privacy: f.Privacy, + Tags: tags, + CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=favoritter.json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(export) +} + +// handleExportCSV exports the user's faves as CSV. +func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0) + if err != nil { + slog.Error("export: list faves error", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := h.deps.Faves.LoadTags(faves); err != nil { + slog.Error("export: load tags error", "error", err) + } + + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=favoritter.csv") + + cw := csv.NewWriter(w) + cw.Write([]string{"description", "url", "privacy", "tags", "created_at"}) + + for _, f := range faves { + tags := make([]string, len(f.Tags)) + for j, t := range f.Tags { + tags[j] = t.Name + } + cw.Write([]string{ + f.Description, + f.URL, + f.Privacy, + strings.Join(tags, ","), + f.CreatedAt.Format("2006-01-02T15:04:05Z"), + }) + } + cw.Flush() +} + +// handleImportPage shows the import page. +func (h *Handler) handleImportPage(w http.ResponseWriter, r *http.Request) { + h.deps.Renderer.Page(w, r, "import", render.PageData{ + Title: "Importer favoritter", + }) +} + +// handleImportPost processes an uploaded JSON or CSV file. +func (h *Handler) handleImportPost(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil { + h.flash(w, r, "import", "Filen er for stor.", "error", nil) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + h.flash(w, r, "import", "Velg en fil å importere.", "error", nil) + return + } + defer file.Close() + + var faves []ExportFave + filename := strings.ToLower(header.Filename) + + switch { + case strings.HasSuffix(filename, ".json"): + faves, err = parseImportJSON(file) + case strings.HasSuffix(filename, ".csv"): + faves, err = parseImportCSV(file) + default: + h.flash(w, r, "import", "Ugyldig filformat. Bruk JSON eller CSV.", "error", nil) + return + } + + if err != nil { + slog.Error("import parse error", "error", err) + h.flash(w, r, "import", "Kunne ikke lese filen. Sjekk formatet.", "error", nil) + return + } + + imported := 0 + for _, ef := range faves { + if ef.Description == "" { + continue + } + privacy := ef.Privacy + if privacy != "public" && privacy != "private" { + privacy = user.DefaultFavePrivacy + } + + fave, err := h.deps.Faves.Create(user.ID, ef.Description, ef.URL, "", privacy) + if err != nil { + slog.Error("import: create fave error", "error", err) + continue + } + + if len(ef.Tags) > 0 { + if err := h.deps.Tags.SetFaveTags(fave.ID, ef.Tags); err != nil { + slog.Error("import: set tags error", "error", err) + } + } + imported++ + } + + h.flash(w, r, "import", + fmt.Sprintf("Importert %d av %d favoritter.", imported, len(faves)), + "success", nil) +} + +func parseImportJSON(r io.Reader) ([]ExportFave, error) { + var faves []ExportFave + if err := json.NewDecoder(r).Decode(&faves); err != nil { + return nil, err + } + return faves, nil +} + +func parseImportCSV(r io.Reader) ([]ExportFave, error) { + cr := csv.NewReader(r) + records, err := cr.ReadAll() + if err != nil { + return nil, err + } + + if len(records) < 2 { + return nil, nil + } + + // Find column indices from header row. + header := records[0] + colMap := make(map[string]int) + for i, col := range header { + colMap[strings.ToLower(strings.TrimSpace(col))] = i + } + + var faves []ExportFave + for _, row := range records[1:] { + f := ExportFave{} + if idx, ok := colMap["description"]; ok && idx < len(row) { + f.Description = row[idx] + } + if idx, ok := colMap["url"]; ok && idx < len(row) { + f.URL = row[idx] + } + if idx, ok := colMap["privacy"]; ok && idx < len(row) { + f.Privacy = row[idx] + } + if idx, ok := colMap["tags"]; ok && idx < len(row) && row[idx] != "" { + f.Tags = strings.Split(row[idx], ",") + for i := range f.Tags { + f.Tags[i] = strings.TrimSpace(f.Tags[i]) + } + } + if idx, ok := colMap["created_at"]; ok && idx < len(row) { + f.CreatedAt = row[idx] + } + faves = append(faves, f) + } + return faves, nil +} diff --git a/web/templates/pages/export.html b/web/templates/pages/export.html new file mode 100644 index 0000000..a2b30e1 --- /dev/null +++ b/web/templates/pages/export.html @@ -0,0 +1,21 @@ +{{define "content"}} +Last ned alle favorittene dine i ønsket format.
+ +Maskinlesbart format. Kan importeres tilbake.
+ +Åpnes i regneark. Kan importeres tilbake.
+ +Last opp en JSON- eller CSV-fil med favoritter. Bilder støttes ikke ved import.
+ + +[
+ {
+ "description": "Blade Runner 2049",
+ "url": "https://example.com",
+ "privacy": "public",
+ "tags": ["film", "sci-fi"]
+ }
+]
+ description,url,privacy,tags
+Blade Runner 2049,https://example.com,public,"film,sci-fi"
+