shiori/internal/webserver/handler-api.go
Monirzadeh 4a5564d60b
feat: store created and modified time separately on database for bookmarks (#896)
* sqlite migrate script

* create time just when bookmark added and modified update if change happen

* show added and modified time in footer instead of header

* add bun.lockb that missing

* add migrate for postgres

* add pg support of created time

* change modifed to modifed_at and create to created_at in sqlite

* change modifed to modifed_at and create to created_at in postgre

* add created_at to mariadb

* fix migration file names

* better variable name and more clear code for add modified time if created and modified is not in same day

* add unittest

* add unittest to sure filters work as expected

* index for created_at and modified_at

* build new styles.css

* update swagger documents

* make styles

* change Created and Modified to CreatedAt and ModifiedAt

* fix missing Modified

* fix typo

* missing Modified

* fix typo

* make swagger

* run tests parallel

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>

* remove t.Parallel()

* remove dayjs dependency and combine two function

* better unittest name

* fix typo

* diffrnt footer style for login and content page

* use class instead of id

* back parallel

* change duplicate url

* remvoe run Parallel

* make styles

---------

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>
2024-06-26 20:17:51 +02:00

573 lines
14 KiB
Go

package webserver
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"path"
fp "path/filepath"
"strconv"
"strings"
"github.com/go-shiori/shiori/internal/core"
"github.com/go-shiori/shiori/internal/database"
"github.com/go-shiori/shiori/internal/dependencies"
"github.com/go-shiori/shiori/internal/model"
"github.com/julienschmidt/httprouter"
"golang.org/x/crypto/bcrypt"
)
func downloadBookmarkContent(deps *dependencies.Dependencies, book *model.BookmarkDTO, dataDir string, request *http.Request, keepTitle, keepExcerpt bool) (*model.BookmarkDTO, error) {
content, contentType, err := core.DownloadBookmark(book.URL)
if err != nil {
return nil, fmt.Errorf("error downloading url: %s", err)
}
processRequest := core.ProcessRequest{
DataDir: dataDir,
Bookmark: *book,
Content: content,
ContentType: contentType,
KeepTitle: keepTitle,
KeepExcerpt: keepExcerpt,
}
result, isFatalErr, err := core.ProcessBookmark(deps, processRequest)
content.Close()
if err != nil && isFatalErr {
return nil, fmt.Errorf("failed to process: %v", err)
}
return &result, err
}
// ApiLogout is handler for POST /api/logout
func (h *Handler) ApiLogout(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Get session ID
sessionID := h.GetSessionID(r)
if sessionID != "" {
h.SessionCache.Delete(sessionID)
}
fmt.Fprint(w, 1)
}
// ApiGetBookmarks is handler for GET /api/bookmarks
func (h *Handler) ApiGetBookmarks(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Get URL queries
keyword := r.URL.Query().Get("keyword")
strPage := r.URL.Query().Get("page")
strTags := r.URL.Query().Get("tags")
strExcludedTags := r.URL.Query().Get("exclude")
tags := strings.Split(strTags, ",")
if len(tags) == 1 && tags[0] == "" {
tags = []string{}
}
excludedTags := strings.Split(strExcludedTags, ",")
if len(excludedTags) == 1 && excludedTags[0] == "" {
excludedTags = []string{}
}
page, _ := strconv.Atoi(strPage)
if page < 1 {
page = 1
}
// Prepare filter for database
searchOptions := database.GetBookmarksOptions{
Tags: tags,
ExcludedTags: excludedTags,
Keyword: keyword,
Limit: 30,
Offset: (page - 1) * 30,
OrderMethod: database.ByLastAdded,
}
// Calculate max page
nBookmarks, err := h.DB.GetBookmarksCount(ctx, searchOptions)
checkError(err)
maxPage := int(math.Ceil(float64(nBookmarks) / 30))
// Fetch all matching bookmarks
bookmarks, err := h.DB.GetBookmarks(ctx, searchOptions)
checkError(err)
// Get image URL for each bookmark, and check if it has archive
for i := range bookmarks {
strID := strconv.Itoa(bookmarks[i].ID)
imgPath := fp.Join(h.DataDir, "thumb", strID)
archivePath := fp.Join(h.DataDir, "archive", strID)
ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
if FileExists(imgPath) {
bookmarks[i].ImageURL = path.Join(h.RootPath, "bookmark", strID, "thumb")
}
if FileExists(archivePath) {
bookmarks[i].HasArchive = true
}
if FileExists(ebookPath) {
bookmarks[i].HasEbook = true
}
}
// Return JSON response
resp := map[string]interface{}{
"page": page,
"maxPage": maxPage,
"bookmarks": bookmarks,
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&resp)
checkError(err)
}
// ApiGetTags is handler for GET /api/tags
func (h *Handler) ApiGetTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Fetch all tags
tags, err := h.DB.GetTags(ctx)
checkError(err)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&tags)
checkError(err)
}
// ApiRenameTag is handler for PUT /api/tag
func (h *Handler) ApiRenameTag(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
tag := model.Tag{}
err = json.NewDecoder(r.Body).Decode(&tag)
checkError(err)
// Update name
err = h.DB.RenameTag(ctx, tag.ID, tag.Name)
checkError(err)
fmt.Fprint(w, 1)
}
// Bookmark is the record for an URL.
type apiInsertBookmarkPayload struct {
URL string `json:"url"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Tags []model.Tag `json:"tags"`
CreateArchive bool `json:"create_archive"`
CreateEbook bool `json:"create_ebook"`
MakePublic int `json:"public"`
Async bool `json:"async"`
}
// newApiInsertBookmarkPayload
// Returns the payload struct with its defaults
func newAPIInsertBookmarkPayload() *apiInsertBookmarkPayload {
return &apiInsertBookmarkPayload{
CreateArchive: false,
CreateEbook: false,
Async: true,
}
}
// ApiInsertBookmark is handler for POST /api/bookmark
func (h *Handler) ApiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
payload := newAPIInsertBookmarkPayload()
err = json.NewDecoder(r.Body).Decode(&payload)
checkError(err)
book := &model.BookmarkDTO{
URL: payload.URL,
Title: payload.Title,
Excerpt: payload.Excerpt,
Tags: payload.Tags,
Public: payload.MakePublic,
CreateArchive: payload.CreateArchive,
CreateEbook: payload.CreateEbook,
}
// Clean up bookmark URL
book.URL, err = core.RemoveUTMParams(book.URL)
if err != nil {
panic(fmt.Errorf("failed to clean URL: %v", err))
}
userHasDefinedTitle := book.Title != ""
// Make sure bookmark's title not empty
if book.Title == "" {
book.Title = book.URL
}
// Save bookmark to database
results, err := h.DB.SaveBookmarks(ctx, true, *book)
if err != nil || len(results) == 0 {
panic(fmt.Errorf("failed to save bookmark: %v", err))
}
book = &results[0]
if payload.Async {
go func() {
bookmark, err := downloadBookmarkContent(h.dependencies, book, h.DataDir, r, userHasDefinedTitle, book.Excerpt != "")
if err != nil {
log.Printf("error downloading boorkmark: %s", err)
return
}
if _, err := h.DB.SaveBookmarks(context.Background(), false, *bookmark); err != nil {
log.Printf("failed to save bookmark: %s", err)
}
}()
} else {
// Workaround. Download content after saving the bookmark so we have the proper database
// id already set in the object regardless of the database engine.
book, err = downloadBookmarkContent(h.dependencies, book, h.DataDir, r, userHasDefinedTitle, book.Excerpt != "")
if err != nil {
log.Printf("error downloading boorkmark: %s", err)
} else if _, err := h.DB.SaveBookmarks(ctx, false, *book); err != nil {
log.Printf("failed to save bookmark: %s", err)
}
}
// Return the new bookmark
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(results[0])
checkError(err)
}
// ApiDeleteBookmarks is handler for DELETE /api/bookmark
func (h *Handler) ApiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
ids := []int{}
err = json.NewDecoder(r.Body).Decode(&ids)
checkError(err)
// Delete bookmarks
err = h.DB.DeleteBookmarks(ctx, ids...)
checkError(err)
// Delete thumbnail image and archives from local disk
for _, id := range ids {
strID := strconv.Itoa(id)
imgPath := fp.Join(h.DataDir, "thumb", strID)
archivePath := fp.Join(h.DataDir, "archive", strID)
ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
os.Remove(imgPath)
os.Remove(archivePath)
os.Remove(ebookPath)
}
fmt.Fprint(w, 1)
}
// ApiUpdateBookmark is handler for PUT /api/bookmarks
func (h *Handler) ApiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := model.BookmarkDTO{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Validate input
if request.Title == "" {
panic(fmt.Errorf("title must not empty"))
}
// Get existing bookmark from database
filter := database.GetBookmarksOptions{
IDs: []int{request.ID},
WithContent: true,
}
bookmarks, err := h.DB.GetBookmarks(ctx, filter)
checkError(err)
if len(bookmarks) == 0 {
panic(fmt.Errorf("no bookmark with matching ids"))
}
// Set new bookmark data
book := bookmarks[0]
book.URL = request.URL
book.Title = request.Title
book.Excerpt = request.Excerpt
book.Public = request.Public
// Clean up bookmark URL
book.URL, err = core.RemoveUTMParams(book.URL)
if err != nil {
panic(fmt.Errorf("failed to clean URL: %v", err))
}
// Set new tags
for i := range book.Tags {
book.Tags[i].Deleted = true
}
for _, newTag := range request.Tags {
for i, oldTag := range book.Tags {
if newTag.Name == oldTag.Name {
newTag.ID = oldTag.ID
book.Tags[i].Deleted = false
break
}
}
if newTag.ID == 0 {
book.Tags = append(book.Tags, newTag)
}
}
// Set bookmark modified
book.ModifiedAt = ""
// Update database
res, err := h.DB.SaveBookmarks(ctx, false, book)
checkError(err)
// Add thumbnail image to the saved bookmarks again
newBook := res[0]
newBook.ImageURL = request.ImageURL
newBook.HasArchive = request.HasArchive
// Return new saved result
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&newBook)
checkError(err)
}
// ApiUpdateBookmarkTags is handler for PUT /api/bookmarks/tags
func (h *Handler) ApiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := struct {
IDs []int `json:"ids"`
Tags []model.Tag `json:"tags"`
}{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Validate input
if len(request.IDs) == 0 || len(request.Tags) == 0 {
panic(fmt.Errorf("IDs and tags must not empty"))
}
// Get existing bookmark from database
filter := database.GetBookmarksOptions{
IDs: request.IDs,
WithContent: true,
}
bookmarks, err := h.DB.GetBookmarks(ctx, filter)
checkError(err)
if len(bookmarks) == 0 {
panic(fmt.Errorf("no bookmark with matching ids"))
}
// Set new tags
for i, book := range bookmarks {
for _, newTag := range request.Tags {
for _, oldTag := range book.Tags {
if newTag.Name == oldTag.Name {
newTag.ID = oldTag.ID
break
}
}
if newTag.ID == 0 {
book.Tags = append(book.Tags, newTag)
}
}
bookmarks[i] = book
}
// Update database
bookmarks, err = h.DB.SaveBookmarks(ctx, false, bookmarks...)
checkError(err)
// Get image URL for each bookmark
for i := range bookmarks {
strID := strconv.Itoa(bookmarks[i].ID)
imgPath := fp.Join(h.DataDir, "thumb", strID)
imgURL := path.Join(h.RootPath, "bookmark", strID, "thumb")
if FileExists(imgPath) {
bookmarks[i].ImageURL = imgURL
}
}
// Return new saved result
err = json.NewEncoder(w).Encode(&bookmarks)
checkError(err)
}
// ApiGetAccounts is handler for GET /api/accounts
func (h *Handler) ApiGetAccounts(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Get list of usernames from database
accounts, err := h.DB.GetAccounts(ctx, database.GetAccountsOptions{})
checkError(err)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&accounts)
checkError(err)
}
// ApiInsertAccount is handler for POST /api/accounts
func (h *Handler) ApiInsertAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
var account model.Account
err = json.NewDecoder(r.Body).Decode(&account)
checkError(err)
// Save account to database
err = h.DB.SaveAccount(ctx, account)
checkError(err)
fmt.Fprint(w, 1)
}
// ApiUpdateAccount is handler for PUT /api/accounts
func (h *Handler) ApiUpdateAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := struct {
Username string `json:"username"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
Owner bool `json:"owner"`
}{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Get existing account data from database
account, exist, err := h.DB.GetAccount(ctx, request.Username)
checkError(err)
if !exist {
panic(fmt.Errorf("username doesn't exist"))
}
// Compare old password with database
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(request.OldPassword))
if err != nil {
panic(fmt.Errorf("old password doesn't match"))
}
// Save new password to database
account.Password = request.NewPassword
account.Owner = request.Owner
err = h.DB.SaveAccount(ctx, account)
checkError(err)
// Delete user's sessions
if val, found := h.UserCache.Get(request.Username); found {
userSessions := val.([]string)
for _, session := range userSessions {
h.SessionCache.Delete(session)
}
h.UserCache.Delete(request.Username)
}
fmt.Fprint(w, 1)
}
// ApiDeleteAccount is handler for DELETE /api/accounts
func (h *Handler) ApiDeleteAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
usernames := []string{}
err = json.NewDecoder(r.Body).Decode(&usernames)
checkError(err)
// Delete accounts
err = h.DB.DeleteAccounts(ctx, usernames...)
checkError(err)
// Delete user's sessions
var userSessions []string
for _, username := range usernames {
if val, found := h.UserCache.Get(username); found {
userSessions = val.([]string)
for _, session := range userSessions {
h.SessionCache.Delete(session)
}
h.UserCache.Delete(username)
}
}
fmt.Fprint(w, 1)
}