Move internal packages to an internal folder
For reference: https://go.dev/doc/go1.4#internalpackages
39
internal/ui/about.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
func (h *handler) showAboutPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("version", version.Version)
|
||||
view.Set("commit", version.Commit)
|
||||
view.Set("build_date", version.BuildDate)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("globalConfigOptions", config.Opts.SortedOptions(true))
|
||||
view.Set("postgres_version", h.store.DatabaseVersion())
|
||||
view.Set("go_version", runtime.Version())
|
||||
|
||||
html.OK(w, r, view.Render("about"))
|
||||
}
|
33
internal/ui/api_key_create.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCreateAPIKeyPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
view.Set("form", &form.APIKeyForm{})
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("create_api_key"))
|
||||
}
|
38
internal/ui/api_key_list.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showAPIKeysPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeys, err := h.store.APIKeys(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
view.Set("apiKeys", apiKeys)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("api_keys"))
|
||||
}
|
23
internal/ui/api_key_remove.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
)
|
||||
|
||||
func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
keyID := request.RouteInt64Param(r, "keyID")
|
||||
err := h.store.RemoveAPIKey(request.UserID(r), keyID)
|
||||
if err != nil {
|
||||
logger.Error("[UI:RemoveAPIKey] %v", err)
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "apiKeys"))
|
||||
}
|
57
internal/ui/api_key_save.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeyForm := form.NewAPIKeyForm(r)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", apiKeyForm)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
if err := apiKeyForm.Validate(); err != nil {
|
||||
view.Set("errorMessage", err.Error())
|
||||
html.OK(w, r, view.Render("create_api_key"))
|
||||
return
|
||||
}
|
||||
|
||||
if h.store.APIKeyExists(user.ID, apiKeyForm.Description) {
|
||||
view.Set("errorMessage", "error.api_key_already_exists")
|
||||
html.OK(w, r, view.Render("create_api_key"))
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := model.NewAPIKey(user.ID, apiKeyForm.Description)
|
||||
if err = h.store.CreateAPIKey(apiKey); err != nil {
|
||||
logger.Error("[UI:SaveAPIKey] %v", err)
|
||||
view.Set("errorMessage", "error.unable_to_create_api_key")
|
||||
html.OK(w, r, view.Render("create_api_key"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "apiKeys"))
|
||||
}
|
57
internal/ui/bookmark_entries.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStarred(true)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
view.Set("total", count)
|
||||
view.Set("entries", entries)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "starred"), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "starred")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("bookmark_entries"))
|
||||
}
|
30
internal/ui/category_create.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCreateCategoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("create_category"))
|
||||
}
|
54
internal/ui/category_edit.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showEditCategoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
categoryForm := form.CategoryForm{
|
||||
Title: category.Title,
|
||||
HideGlobally: "",
|
||||
}
|
||||
if category.HideGlobally {
|
||||
categoryForm.HideGlobally = "checked"
|
||||
}
|
||||
|
||||
view.Set("form", categoryForm)
|
||||
view.Set("category", category)
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("edit_category"))
|
||||
}
|
70
internal/ui/category_entries.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("category", category)
|
||||
view.Set("total", count)
|
||||
view.Set("entries", entries)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "categoryEntries", "categoryID", category.ID), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("showOnlyUnreadEntries", true)
|
||||
|
||||
html.OK(w, r, view.Render("category_entries"))
|
||||
}
|
70
internal/ui/category_entries_all.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("category", category)
|
||||
view.Set("total", count)
|
||||
view.Set("entries", entries)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "categoryEntriesAll", "categoryID", category.ID), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("showOnlyUnreadEntries", false)
|
||||
|
||||
html.OK(w, r, view.Render("category_entries"))
|
||||
}
|
51
internal/ui/category_feeds.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCategoryFeedsPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := h.store.FeedsByCategoryWithCounters(user.ID, categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("category", category)
|
||||
view.Set("feeds", feeds)
|
||||
view.Set("total", len(feeds))
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("category_feeds"))
|
||||
}
|
38
internal/ui/category_list.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCategoryListPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categories, err := h.store.CategoriesWithFeedCount(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("categories", categories)
|
||||
view.Set("total", len(categories))
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("categories"))
|
||||
}
|
36
internal/ui/category_mark_as_read.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "categories"))
|
||||
}
|
39
internal/ui/category_refresh.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func (h *handler) refreshCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := h.refreshCategory(w, r)
|
||||
html.Redirect(w, r, route.Path(h.router, "categoryEntries", "categoryID", categoryID))
|
||||
}
|
||||
|
||||
func (h *handler) refreshCategoryFeedsPage(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := h.refreshCategory(w, r)
|
||||
html.Redirect(w, r, route.Path(h.router, "categoryFeeds", "categoryID", categoryID))
|
||||
}
|
||||
|
||||
func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64 {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
jobs, err := h.store.NewCategoryBatch(userID, categoryID, h.store.CountFeeds(userID))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
go func() {
|
||||
h.pool.Push(jobs)
|
||||
}()
|
||||
|
||||
return categoryID
|
||||
}
|
39
internal/ui/category_remove.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveCategory(user.ID, category.ID); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "categories"))
|
||||
}
|
53
internal/ui/category_save.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) saveCategory(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUser, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryForm := form.NewCategoryForm(r)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", categoryForm)
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", loggedUser)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
|
||||
|
||||
categoryRequest := &model.CategoryRequest{Title: categoryForm.Title}
|
||||
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, loggedUser.ID, categoryRequest); validationErr != nil {
|
||||
view.Set("errorMessage", validationErr.TranslationKey)
|
||||
html.OK(w, r, view.Render("create_category"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = h.store.CreateCategory(loggedUser.ID, categoryRequest); err != nil {
|
||||
logger.Error("[UI:SaveCategory] %v", err)
|
||||
view.Set("errorMessage", "error.unable_to_create_category")
|
||||
html.OK(w, r, view.Render("create_category"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "categories"))
|
||||
}
|
70
internal/ui/category_update.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUser, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
categoryForm := form.NewCategoryForm(r)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", categoryForm)
|
||||
view.Set("category", category)
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", loggedUser)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
|
||||
|
||||
categoryRequest := &model.CategoryRequest{
|
||||
Title: categoryForm.Title,
|
||||
HideGlobally: categoryForm.HideGlobally,
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, loggedUser.ID, category.ID, categoryRequest); validationErr != nil {
|
||||
view.Set("errorMessage", validationErr.TranslationKey)
|
||||
html.OK(w, r, view.Render("create_category"))
|
||||
return
|
||||
}
|
||||
|
||||
categoryRequest.Patch(category)
|
||||
if err := h.store.UpdateCategory(category); err != nil {
|
||||
logger.Error("[UI:UpdateCategory] %v", err)
|
||||
view.Set("errorMessage", "error.unable_to_update_category")
|
||||
html.OK(w, r, view.Render("edit_category"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "categoryFeeds", "categoryID", categoryID))
|
||||
}
|
83
internal/ui/entry_bookmark.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showStarredEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
|
||||
entryPaginationBuilder.WithStarred()
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "starredEntry", "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "starredEntry", "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("menu", "starred")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
86
internal/ui/entry_category.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCategoryEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
|
||||
entryPaginationBuilder.WithCategoryID(categoryID)
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "categoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "categoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
53
internal/ui/entry_enclosure_save_position.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
json2 "encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
)
|
||||
|
||||
func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type enclosurePositionSaveRequest struct {
|
||||
Progression int64 `json:"progression"`
|
||||
}
|
||||
|
||||
var postData enclosurePositionSaveRequest
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json2.Unmarshal(body, &postData)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
enclosure.MediaProgression = postData.Progression
|
||||
|
||||
err = h.store.UpdateEnclosure(enclosure)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, map[string]string{"message": "saved"})
|
||||
}
|
86
internal/ui/entry_feed.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showFeedEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
|
||||
entryPaginationBuilder.WithFeedID(feedID)
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "feedEntry", "feedID", feedID, "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "feedEntry", "feedID", feedID, "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
73
internal/ui/entry_read.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showReadEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, "changed_at", "desc")
|
||||
entryPaginationBuilder.WithStatus(model.EntryStatusRead)
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "readEntry", "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "readEntry", "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("menu", "history")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
43
internal/ui/entry_save.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Integration(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
integration.SendEntry(entry, settings)
|
||||
}()
|
||||
|
||||
json.Created(w, r, map[string]string{"message": "saved"})
|
||||
}
|
70
internal/ui/entry_scraper.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/proxy"
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(entry.UserID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
}
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
}
|
||||
|
||||
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
|
||||
feedBuilder.WithFeedID(entry.FeedID)
|
||||
feed, err := feedBuilder.GetFeed()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateEntryContent(entry); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
}
|
||||
|
||||
readingTime := locale.NewPrinter(user.Language).Plural("entry.estimated_reading_time", entry.ReadingTime, entry.ReadingTime)
|
||||
|
||||
json.OK(w, r, map[string]string{"content": proxy.ProxyRewriter(h.router, entry.Content), "reading_time": readingTime})
|
||||
}
|
86
internal/ui/entry_search.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
searchQuery := request.QueryStringParam(r, "q", "")
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithSearchQuery(searchQuery)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
|
||||
entryPaginationBuilder.WithSearchQuery(searchQuery)
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "searchEntry", "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "searchEntry", "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("searchQuery", searchQuery)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("menu", "search")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
21
internal/ui/entry_toggle_bookmark.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
)
|
||||
|
||||
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, "OK")
|
||||
}
|
98
internal/ui/entry_unread.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.Redirect(w, r, route.Path(h.router, "unread"))
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure we always get the pagination in unread mode even if the page is refreshed.
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusUnread)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
|
||||
entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
|
||||
entryPaginationBuilder.WithGloballyVisible()
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "unreadEntry", "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "unreadEntry", "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
if user.MarkReadOnView {
|
||||
entry.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
// Restore entry read status if needed after fetching the pagination.
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("menu", "unread")
|
||||
view.Set("user", user)
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
// Fetching the counter here avoid to be off by one.
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
35
internal/ui/entry_update_status.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) updateEntriesStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.store.SetEntriesStatusCount(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, count)
|
||||
}
|
79
internal/ui/feed_edit.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
feed, err := h.store.FeedByID(user.ID, feedID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
categories, err := h.store.Categories(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedForm := form.FeedForm{
|
||||
SiteURL: feed.SiteURL,
|
||||
FeedURL: feed.FeedURL,
|
||||
Title: feed.Title,
|
||||
ScraperRules: feed.ScraperRules,
|
||||
RewriteRules: feed.RewriteRules,
|
||||
BlocklistRules: feed.BlocklistRules,
|
||||
KeeplistRules: feed.KeeplistRules,
|
||||
UrlRewriteRules: feed.UrlRewriteRules,
|
||||
Crawler: feed.Crawler,
|
||||
UserAgent: feed.UserAgent,
|
||||
Cookie: feed.Cookie,
|
||||
CategoryID: feed.Category.ID,
|
||||
Username: feed.Username,
|
||||
Password: feed.Password,
|
||||
IgnoreHTTPCache: feed.IgnoreHTTPCache,
|
||||
AllowSelfSignedCertificates: feed.AllowSelfSignedCertificates,
|
||||
FetchViaProxy: feed.FetchViaProxy,
|
||||
Disabled: feed.Disabled,
|
||||
NoMediaPlayer: feed.NoMediaPlayer,
|
||||
HideGlobally: feed.HideGlobally,
|
||||
CategoryHidden: feed.Category.HideGlobally,
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", feedForm)
|
||||
view.Set("categories", categories)
|
||||
view.Set("feed", feed)
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent())
|
||||
view.Set("hasProxyConfigured", config.Opts.HasHTTPClientProxyConfigured())
|
||||
|
||||
html.OK(w, r, view.Render("edit_feed"))
|
||||
}
|
70
internal/ui/feed_entries.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
feed, err := h.store.FeedByID(user.ID, feedID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithFeedID(feed.ID)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("feed", feed)
|
||||
view.Set("entries", entries)
|
||||
view.Set("total", count)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "feedEntries", "feedID", feed.ID), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("showOnlyUnreadEntries", true)
|
||||
|
||||
html.OK(w, r, view.Render("feed_entries"))
|
||||
}
|
70
internal/ui/feed_entries_all.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
feed, err := h.store.FeedByID(user.ID, feedID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithFeedID(feed.ID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("feed", feed)
|
||||
view.Set("entries", entries)
|
||||
view.Set("total", count)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "feedEntriesAll", "feedID", feed.ID), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("showOnlyUnreadEntries", false)
|
||||
|
||||
html.OK(w, r, view.Render("feed_entries"))
|
||||
}
|
35
internal/ui/feed_icon.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
)
|
||||
|
||||
func (h *handler) showIcon(w http.ResponseWriter, r *http.Request) {
|
||||
iconID := request.RouteInt64Param(r, "iconID")
|
||||
icon, err := h.store.IconByID(iconID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response.New(w, r).WithCaching(icon.Hash, 72*time.Hour, func(b *response.Builder) {
|
||||
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
b.WithHeader("Content-Type", icon.MimeType)
|
||||
b.WithBody(icon.Content)
|
||||
b.WithoutCompression()
|
||||
b.Write()
|
||||
})
|
||||
}
|
38
internal/ui/feed_list.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showFeedsPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := h.store.FeedsWithCounters(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("feeds", feeds)
|
||||
view.Set("total", len(feeds))
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("feeds"))
|
||||
}
|
36
internal/ui/feed_mark_as_read.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
|
||||
feed, err := h.store.FeedByID(userID, feedID)
|
||||
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.store.MarkFeedAsRead(userID, feedID, feed.CheckedAt); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feeds"))
|
||||
}
|
39
internal/ui/feed_refresh.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
feedHandler "miniflux.app/v2/internal/reader/handler"
|
||||
)
|
||||
|
||||
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
forceRefresh := request.QueryBoolParam(r, "forceRefresh", false)
|
||||
if err := feedHandler.RefreshFeed(h.store, request.UserID(r), feedID, forceRefresh); err != nil {
|
||||
logger.Error("[UI:RefreshFeed] %v", err)
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feedEntries", "feedID", feedID))
|
||||
}
|
||||
|
||||
func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
jobs, err := h.store.NewUserBatch(userID, h.store.CountFeeds(userID))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
h.pool.Push(jobs)
|
||||
}()
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feeds"))
|
||||
}
|
28
internal/ui/feed_remove.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !h.store.FeedExists(request.UserID(r), feedID) {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveFeed(request.UserID(r), feedID); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feeds"))
|
||||
}
|
84
internal/ui/feed_update.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUser, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
feed, err := h.store.FeedByID(loggedUser.ID, feedID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
categories, err := h.store.Categories(loggedUser.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedForm := form.NewFeedForm(r)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", feedForm)
|
||||
view.Set("categories", categories)
|
||||
view.Set("feed", feed)
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", loggedUser)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
|
||||
view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent())
|
||||
|
||||
feedModificationRequest := &model.FeedModificationRequest{
|
||||
FeedURL: model.OptionalString(feedForm.FeedURL),
|
||||
SiteURL: model.OptionalString(feedForm.SiteURL),
|
||||
Title: model.OptionalString(feedForm.Title),
|
||||
CategoryID: model.OptionalInt64(feedForm.CategoryID),
|
||||
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
|
||||
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
|
||||
UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feedModificationRequest); validationErr != nil {
|
||||
view.Set("errorMessage", validationErr.TranslationKey)
|
||||
html.OK(w, r, view.Render("edit_feed"))
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.UpdateFeed(feedForm.Merge(feed))
|
||||
if err != nil {
|
||||
logger.Error("[UI:UpdateFeed] %v", err)
|
||||
view.Set("errorMessage", "error.unable_to_update_feed")
|
||||
html.OK(w, r, view.Render("edit_feed"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feedEntries", "feedID", feed.ID))
|
||||
}
|
31
internal/ui/form/api_key.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/errors"
|
||||
)
|
||||
|
||||
// APIKeyForm represents the API Key form.
|
||||
type APIKeyForm struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
// Validate makes sure the form values are valid.
|
||||
func (a APIKeyForm) Validate() error {
|
||||
if a.Description == "" {
|
||||
return errors.NewLocalizedError("error.fields_mandatory")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAPIKeyForm returns a new APIKeyForm.
|
||||
func NewAPIKeyForm(r *http.Request) *APIKeyForm {
|
||||
return &APIKeyForm{
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
}
|
33
internal/ui/form/auth.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/errors"
|
||||
)
|
||||
|
||||
// AuthForm represents the authentication form.
|
||||
type AuthForm struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Validate makes sure the form values are valid.
|
||||
func (a AuthForm) Validate() error {
|
||||
if a.Username == "" || a.Password == "" {
|
||||
return errors.NewLocalizedError("error.fields_mandatory")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAuthForm returns a new AuthForm.
|
||||
func NewAuthForm(r *http.Request) *AuthForm {
|
||||
return &AuthForm{
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
}
|
||||
}
|
22
internal/ui/form/category.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CategoryForm represents a feed form in the UI
|
||||
type CategoryForm struct {
|
||||
Title string
|
||||
HideGlobally string
|
||||
}
|
||||
|
||||
// NewCategoryForm returns a new CategoryForm.
|
||||
func NewCategoryForm(r *http.Request) *CategoryForm {
|
||||
return &CategoryForm{
|
||||
Title: r.FormValue("title"),
|
||||
HideGlobally: r.FormValue("hide_globally"),
|
||||
}
|
||||
}
|
93
internal/ui/form/feed.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// FeedForm represents a feed form in the UI
|
||||
type FeedForm struct {
|
||||
FeedURL string
|
||||
SiteURL string
|
||||
Title string
|
||||
ScraperRules string
|
||||
RewriteRules string
|
||||
BlocklistRules string
|
||||
KeeplistRules string
|
||||
UrlRewriteRules string
|
||||
Crawler bool
|
||||
UserAgent string
|
||||
Cookie string
|
||||
CategoryID int64
|
||||
Username string
|
||||
Password string
|
||||
IgnoreHTTPCache bool
|
||||
AllowSelfSignedCertificates bool
|
||||
FetchViaProxy bool
|
||||
Disabled bool
|
||||
NoMediaPlayer bool
|
||||
HideGlobally bool
|
||||
CategoryHidden bool // Category has "hide_globally"
|
||||
}
|
||||
|
||||
// Merge updates the fields of the given feed.
|
||||
func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
|
||||
feed.Category.ID = f.CategoryID
|
||||
feed.Title = f.Title
|
||||
feed.SiteURL = f.SiteURL
|
||||
feed.FeedURL = f.FeedURL
|
||||
feed.ScraperRules = f.ScraperRules
|
||||
feed.RewriteRules = f.RewriteRules
|
||||
feed.BlocklistRules = f.BlocklistRules
|
||||
feed.KeeplistRules = f.KeeplistRules
|
||||
feed.UrlRewriteRules = f.UrlRewriteRules
|
||||
feed.Crawler = f.Crawler
|
||||
feed.UserAgent = f.UserAgent
|
||||
feed.Cookie = f.Cookie
|
||||
feed.ParsingErrorCount = 0
|
||||
feed.ParsingErrorMsg = ""
|
||||
feed.Username = f.Username
|
||||
feed.Password = f.Password
|
||||
feed.IgnoreHTTPCache = f.IgnoreHTTPCache
|
||||
feed.AllowSelfSignedCertificates = f.AllowSelfSignedCertificates
|
||||
feed.FetchViaProxy = f.FetchViaProxy
|
||||
feed.Disabled = f.Disabled
|
||||
feed.NoMediaPlayer = f.NoMediaPlayer
|
||||
feed.HideGlobally = f.HideGlobally
|
||||
return feed
|
||||
}
|
||||
|
||||
// NewFeedForm parses the HTTP request and returns a FeedForm
|
||||
func NewFeedForm(r *http.Request) *FeedForm {
|
||||
categoryID, err := strconv.Atoi(r.FormValue("category_id"))
|
||||
if err != nil {
|
||||
categoryID = 0
|
||||
}
|
||||
return &FeedForm{
|
||||
FeedURL: r.FormValue("feed_url"),
|
||||
SiteURL: r.FormValue("site_url"),
|
||||
Title: r.FormValue("title"),
|
||||
ScraperRules: r.FormValue("scraper_rules"),
|
||||
UserAgent: r.FormValue("user_agent"),
|
||||
Cookie: r.FormValue("cookie"),
|
||||
RewriteRules: r.FormValue("rewrite_rules"),
|
||||
BlocklistRules: r.FormValue("blocklist_rules"),
|
||||
KeeplistRules: r.FormValue("keeplist_rules"),
|
||||
UrlRewriteRules: r.FormValue("urlrewrite_rules"),
|
||||
Crawler: r.FormValue("crawler") == "1",
|
||||
CategoryID: int64(categoryID),
|
||||
Username: r.FormValue("feed_username"),
|
||||
Password: r.FormValue("feed_password"),
|
||||
IgnoreHTTPCache: r.FormValue("ignore_http_cache") == "1",
|
||||
AllowSelfSignedCertificates: r.FormValue("allow_self_signed_certificates") == "1",
|
||||
FetchViaProxy: r.FormValue("fetch_via_proxy") == "1",
|
||||
Disabled: r.FormValue("disabled") == "1",
|
||||
NoMediaPlayer: r.FormValue("no_media_player") == "1",
|
||||
HideGlobally: r.FormValue("hide_globally") == "1",
|
||||
}
|
||||
}
|
175
internal/ui/form/integration.go
Normal file
|
@ -0,0 +1,175 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// IntegrationForm represents user integration settings form.
|
||||
type IntegrationForm struct {
|
||||
PinboardEnabled bool
|
||||
PinboardToken string
|
||||
PinboardTags string
|
||||
PinboardMarkAsUnread bool
|
||||
InstapaperEnabled bool
|
||||
InstapaperUsername string
|
||||
InstapaperPassword string
|
||||
FeverEnabled bool
|
||||
FeverUsername string
|
||||
FeverPassword string
|
||||
GoogleReaderEnabled bool
|
||||
GoogleReaderUsername string
|
||||
GoogleReaderPassword string
|
||||
WallabagEnabled bool
|
||||
WallabagOnlyURL bool
|
||||
WallabagURL string
|
||||
WallabagClientID string
|
||||
WallabagClientSecret string
|
||||
WallabagUsername string
|
||||
WallabagPassword string
|
||||
NotionEnabled bool
|
||||
NotionPageID string
|
||||
NotionToken string
|
||||
NunuxKeeperEnabled bool
|
||||
NunuxKeeperURL string
|
||||
NunuxKeeperAPIKey string
|
||||
EspialEnabled bool
|
||||
EspialURL string
|
||||
EspialAPIKey string
|
||||
EspialTags string
|
||||
ReadwiseEnabled bool
|
||||
ReadwiseAPIKey string
|
||||
PocketEnabled bool
|
||||
PocketAccessToken string
|
||||
PocketConsumerKey string
|
||||
TelegramBotEnabled bool
|
||||
TelegramBotToken string
|
||||
TelegramBotChatID string
|
||||
LinkdingEnabled bool
|
||||
LinkdingURL string
|
||||
LinkdingAPIKey string
|
||||
LinkdingTags string
|
||||
LinkdingMarkAsUnread bool
|
||||
MatrixBotEnabled bool
|
||||
MatrixBotUser string
|
||||
MatrixBotPassword string
|
||||
MatrixBotURL string
|
||||
MatrixBotChatID string
|
||||
AppriseEnabled bool
|
||||
AppriseURL string
|
||||
AppriseServicesURL string
|
||||
}
|
||||
|
||||
// Merge copy form values to the model.
|
||||
func (i IntegrationForm) Merge(integration *model.Integration) {
|
||||
integration.PinboardEnabled = i.PinboardEnabled
|
||||
integration.PinboardToken = i.PinboardToken
|
||||
integration.PinboardTags = i.PinboardTags
|
||||
integration.PinboardMarkAsUnread = i.PinboardMarkAsUnread
|
||||
integration.InstapaperEnabled = i.InstapaperEnabled
|
||||
integration.InstapaperUsername = i.InstapaperUsername
|
||||
integration.InstapaperPassword = i.InstapaperPassword
|
||||
integration.FeverEnabled = i.FeverEnabled
|
||||
integration.FeverUsername = i.FeverUsername
|
||||
integration.GoogleReaderEnabled = i.GoogleReaderEnabled
|
||||
integration.GoogleReaderUsername = i.GoogleReaderUsername
|
||||
integration.WallabagEnabled = i.WallabagEnabled
|
||||
integration.WallabagOnlyURL = i.WallabagOnlyURL
|
||||
integration.WallabagURL = i.WallabagURL
|
||||
integration.WallabagClientID = i.WallabagClientID
|
||||
integration.WallabagClientSecret = i.WallabagClientSecret
|
||||
integration.WallabagUsername = i.WallabagUsername
|
||||
integration.WallabagPassword = i.WallabagPassword
|
||||
integration.NotionEnabled = i.NotionEnabled
|
||||
integration.NotionPageID = i.NotionPageID
|
||||
integration.NotionToken = i.NotionToken
|
||||
integration.NunuxKeeperEnabled = i.NunuxKeeperEnabled
|
||||
integration.NunuxKeeperURL = i.NunuxKeeperURL
|
||||
integration.NunuxKeeperAPIKey = i.NunuxKeeperAPIKey
|
||||
integration.EspialEnabled = i.EspialEnabled
|
||||
integration.EspialURL = i.EspialURL
|
||||
integration.EspialAPIKey = i.EspialAPIKey
|
||||
integration.EspialTags = i.EspialTags
|
||||
integration.ReadwiseEnabled = i.ReadwiseEnabled
|
||||
integration.ReadwiseAPIKey = i.ReadwiseAPIKey
|
||||
integration.PocketEnabled = i.PocketEnabled
|
||||
integration.PocketAccessToken = i.PocketAccessToken
|
||||
integration.PocketConsumerKey = i.PocketConsumerKey
|
||||
integration.TelegramBotEnabled = i.TelegramBotEnabled
|
||||
integration.TelegramBotToken = i.TelegramBotToken
|
||||
integration.TelegramBotChatID = i.TelegramBotChatID
|
||||
integration.LinkdingEnabled = i.LinkdingEnabled
|
||||
integration.LinkdingURL = i.LinkdingURL
|
||||
integration.LinkdingAPIKey = i.LinkdingAPIKey
|
||||
integration.LinkdingTags = i.LinkdingTags
|
||||
integration.LinkdingMarkAsUnread = i.LinkdingMarkAsUnread
|
||||
integration.MatrixBotEnabled = i.MatrixBotEnabled
|
||||
integration.MatrixBotUser = i.MatrixBotUser
|
||||
integration.MatrixBotPassword = i.MatrixBotPassword
|
||||
integration.MatrixBotURL = i.MatrixBotURL
|
||||
integration.MatrixBotChatID = i.MatrixBotChatID
|
||||
integration.AppriseEnabled = i.AppriseEnabled
|
||||
integration.AppriseServicesURL = i.AppriseServicesURL
|
||||
integration.AppriseURL = i.AppriseURL
|
||||
}
|
||||
|
||||
// NewIntegrationForm returns a new IntegrationForm.
|
||||
func NewIntegrationForm(r *http.Request) *IntegrationForm {
|
||||
return &IntegrationForm{
|
||||
PinboardEnabled: r.FormValue("pinboard_enabled") == "1",
|
||||
PinboardToken: r.FormValue("pinboard_token"),
|
||||
PinboardTags: r.FormValue("pinboard_tags"),
|
||||
PinboardMarkAsUnread: r.FormValue("pinboard_mark_as_unread") == "1",
|
||||
InstapaperEnabled: r.FormValue("instapaper_enabled") == "1",
|
||||
InstapaperUsername: r.FormValue("instapaper_username"),
|
||||
InstapaperPassword: r.FormValue("instapaper_password"),
|
||||
FeverEnabled: r.FormValue("fever_enabled") == "1",
|
||||
FeverUsername: r.FormValue("fever_username"),
|
||||
FeverPassword: r.FormValue("fever_password"),
|
||||
GoogleReaderEnabled: r.FormValue("googlereader_enabled") == "1",
|
||||
GoogleReaderUsername: r.FormValue("googlereader_username"),
|
||||
GoogleReaderPassword: r.FormValue("googlereader_password"),
|
||||
WallabagEnabled: r.FormValue("wallabag_enabled") == "1",
|
||||
WallabagOnlyURL: r.FormValue("wallabag_only_url") == "1",
|
||||
WallabagURL: r.FormValue("wallabag_url"),
|
||||
WallabagClientID: r.FormValue("wallabag_client_id"),
|
||||
WallabagClientSecret: r.FormValue("wallabag_client_secret"),
|
||||
WallabagUsername: r.FormValue("wallabag_username"),
|
||||
WallabagPassword: r.FormValue("wallabag_password"),
|
||||
NotionEnabled: r.FormValue("notion_enabled") == "1",
|
||||
NotionPageID: r.FormValue("notion_page_id"),
|
||||
NotionToken: r.FormValue("notion_token"),
|
||||
NunuxKeeperEnabled: r.FormValue("nunux_keeper_enabled") == "1",
|
||||
NunuxKeeperURL: r.FormValue("nunux_keeper_url"),
|
||||
NunuxKeeperAPIKey: r.FormValue("nunux_keeper_api_key"),
|
||||
EspialEnabled: r.FormValue("espial_enabled") == "1",
|
||||
EspialURL: r.FormValue("espial_url"),
|
||||
EspialAPIKey: r.FormValue("espial_api_key"),
|
||||
EspialTags: r.FormValue("espial_tags"),
|
||||
ReadwiseEnabled: r.FormValue("readwise_enabled") == "1",
|
||||
ReadwiseAPIKey: r.FormValue("readwise_api_key"),
|
||||
PocketEnabled: r.FormValue("pocket_enabled") == "1",
|
||||
PocketAccessToken: r.FormValue("pocket_access_token"),
|
||||
PocketConsumerKey: r.FormValue("pocket_consumer_key"),
|
||||
TelegramBotEnabled: r.FormValue("telegram_bot_enabled") == "1",
|
||||
TelegramBotToken: r.FormValue("telegram_bot_token"),
|
||||
TelegramBotChatID: r.FormValue("telegram_bot_chat_id"),
|
||||
LinkdingEnabled: r.FormValue("linkding_enabled") == "1",
|
||||
LinkdingURL: r.FormValue("linkding_url"),
|
||||
LinkdingAPIKey: r.FormValue("linkding_api_key"),
|
||||
LinkdingTags: r.FormValue("linkding_tags"),
|
||||
LinkdingMarkAsUnread: r.FormValue("linkding_mark_as_unread") == "1",
|
||||
MatrixBotEnabled: r.FormValue("matrix_bot_enabled") == "1",
|
||||
MatrixBotUser: r.FormValue("matrix_bot_user"),
|
||||
MatrixBotPassword: r.FormValue("matrix_bot_password"),
|
||||
MatrixBotURL: r.FormValue("matrix_bot_url"),
|
||||
MatrixBotChatID: r.FormValue("matrix_bot_chat_id"),
|
||||
AppriseEnabled: r.FormValue("apprise_enabled") == "1",
|
||||
AppriseURL: r.FormValue("apprise_url"),
|
||||
AppriseServicesURL: r.FormValue("apprise_services_url"),
|
||||
}
|
||||
}
|
126
internal/ui/form/settings.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/v2/internal/errors"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// SettingsForm represents the settings form.
|
||||
type SettingsForm struct {
|
||||
Username string
|
||||
Password string
|
||||
Confirmation string
|
||||
Theme string
|
||||
Language string
|
||||
Timezone string
|
||||
EntryDirection string
|
||||
EntryOrder string
|
||||
EntriesPerPage int
|
||||
KeyboardShortcuts bool
|
||||
ShowReadingTime bool
|
||||
CustomCSS string
|
||||
EntrySwipe bool
|
||||
GestureNav string
|
||||
DisplayMode string
|
||||
DefaultReadingSpeed int
|
||||
CJKReadingSpeed int
|
||||
DefaultHomePage string
|
||||
CategoriesSortingOrder string
|
||||
MarkReadOnView bool
|
||||
}
|
||||
|
||||
// Merge updates the fields of the given user.
|
||||
func (s *SettingsForm) Merge(user *model.User) *model.User {
|
||||
user.Username = s.Username
|
||||
user.Theme = s.Theme
|
||||
user.Language = s.Language
|
||||
user.Timezone = s.Timezone
|
||||
user.EntryDirection = s.EntryDirection
|
||||
user.EntryOrder = s.EntryOrder
|
||||
user.EntriesPerPage = s.EntriesPerPage
|
||||
user.KeyboardShortcuts = s.KeyboardShortcuts
|
||||
user.ShowReadingTime = s.ShowReadingTime
|
||||
user.Stylesheet = s.CustomCSS
|
||||
user.EntrySwipe = s.EntrySwipe
|
||||
user.GestureNav = s.GestureNav
|
||||
user.DisplayMode = s.DisplayMode
|
||||
user.CJKReadingSpeed = s.CJKReadingSpeed
|
||||
user.DefaultReadingSpeed = s.DefaultReadingSpeed
|
||||
user.DefaultHomePage = s.DefaultHomePage
|
||||
user.CategoriesSortingOrder = s.CategoriesSortingOrder
|
||||
user.MarkReadOnView = s.MarkReadOnView
|
||||
|
||||
if s.Password != "" {
|
||||
user.Password = s.Password
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// Validate makes sure the form values are valid.
|
||||
func (s *SettingsForm) Validate() error {
|
||||
if s.Username == "" || s.Theme == "" || s.Language == "" || s.Timezone == "" || s.EntryDirection == "" || s.DisplayMode == "" || s.DefaultHomePage == "" {
|
||||
return errors.NewLocalizedError("error.settings_mandatory_fields")
|
||||
}
|
||||
|
||||
if s.CJKReadingSpeed <= 0 || s.DefaultReadingSpeed <= 0 {
|
||||
return errors.NewLocalizedError("error.settings_reading_speed_is_positive")
|
||||
}
|
||||
|
||||
if s.Confirmation == "" {
|
||||
// Firefox insists on auto-completing the password field.
|
||||
// If the confirmation field is blank, the user probably
|
||||
// didn't intend to change their password.
|
||||
s.Password = ""
|
||||
} else if s.Password != "" {
|
||||
if s.Password != s.Confirmation {
|
||||
return errors.NewLocalizedError("error.different_passwords")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSettingsForm returns a new SettingsForm.
|
||||
func NewSettingsForm(r *http.Request) *SettingsForm {
|
||||
entriesPerPage, err := strconv.ParseInt(r.FormValue("entries_per_page"), 10, 0)
|
||||
if err != nil {
|
||||
entriesPerPage = 0
|
||||
}
|
||||
defaultReadingSpeed, err := strconv.ParseInt(r.FormValue("default_reading_speed"), 10, 0)
|
||||
if err != nil {
|
||||
defaultReadingSpeed = 0
|
||||
}
|
||||
cjkReadingSpeed, err := strconv.ParseInt(r.FormValue("cjk_reading_speed"), 10, 0)
|
||||
if err != nil {
|
||||
cjkReadingSpeed = 0
|
||||
}
|
||||
return &SettingsForm{
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
Confirmation: r.FormValue("confirmation"),
|
||||
Theme: r.FormValue("theme"),
|
||||
Language: r.FormValue("language"),
|
||||
Timezone: r.FormValue("timezone"),
|
||||
EntryDirection: r.FormValue("entry_direction"),
|
||||
EntryOrder: r.FormValue("entry_order"),
|
||||
EntriesPerPage: int(entriesPerPage),
|
||||
KeyboardShortcuts: r.FormValue("keyboard_shortcuts") == "1",
|
||||
ShowReadingTime: r.FormValue("show_reading_time") == "1",
|
||||
CustomCSS: r.FormValue("custom_css"),
|
||||
EntrySwipe: r.FormValue("entry_swipe") == "1",
|
||||
GestureNav: r.FormValue("gesture_nav"),
|
||||
DisplayMode: r.FormValue("display_mode"),
|
||||
DefaultReadingSpeed: int(defaultReadingSpeed),
|
||||
CJKReadingSpeed: int(cjkReadingSpeed),
|
||||
DefaultHomePage: r.FormValue("default_home_page"),
|
||||
CategoriesSortingOrder: r.FormValue("categories_sorting_order"),
|
||||
MarkReadOnView: r.FormValue("mark_read_on_view") == "1",
|
||||
}
|
||||
}
|
81
internal/ui/form/settings_test.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValid(t *testing.T) {
|
||||
settings := &SettingsForm{
|
||||
Username: "user",
|
||||
Password: "hunter2",
|
||||
Confirmation: "hunter2",
|
||||
Theme: "default",
|
||||
Language: "en_US",
|
||||
Timezone: "UTC",
|
||||
EntryDirection: "asc",
|
||||
EntriesPerPage: 50,
|
||||
DisplayMode: "standalone",
|
||||
GestureNav: "tap",
|
||||
DefaultReadingSpeed: 35,
|
||||
CJKReadingSpeed: 25,
|
||||
DefaultHomePage: "unread",
|
||||
}
|
||||
|
||||
err := settings.Validate()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmationEmpty(t *testing.T) {
|
||||
settings := &SettingsForm{
|
||||
Username: "user",
|
||||
Password: "hunter2",
|
||||
Confirmation: "",
|
||||
Theme: "default",
|
||||
Language: "en_US",
|
||||
Timezone: "UTC",
|
||||
EntryDirection: "asc",
|
||||
EntriesPerPage: 50,
|
||||
DisplayMode: "standalone",
|
||||
GestureNav: "tap",
|
||||
DefaultReadingSpeed: 35,
|
||||
CJKReadingSpeed: 25,
|
||||
DefaultHomePage: "unread",
|
||||
}
|
||||
|
||||
err := settings.Validate()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if settings.Password != "" {
|
||||
t.Error("Password should have been cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmationIncorrect(t *testing.T) {
|
||||
settings := &SettingsForm{
|
||||
Username: "user",
|
||||
Password: "hunter2",
|
||||
Confirmation: "unter2",
|
||||
Theme: "default",
|
||||
Language: "en_US",
|
||||
Timezone: "UTC",
|
||||
EntryDirection: "asc",
|
||||
EntriesPerPage: 50,
|
||||
DisplayMode: "standalone",
|
||||
GestureNav: "tap",
|
||||
DefaultReadingSpeed: 35,
|
||||
CJKReadingSpeed: 25,
|
||||
DefaultHomePage: "unread",
|
||||
}
|
||||
|
||||
err := settings.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should return an error")
|
||||
}
|
||||
}
|
80
internal/ui/form/subscription.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/v2/internal/errors"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
// SubscriptionForm represents the subscription form.
|
||||
type SubscriptionForm struct {
|
||||
URL string
|
||||
CategoryID int64
|
||||
Crawler bool
|
||||
FetchViaProxy bool
|
||||
AllowSelfSignedCertificates bool
|
||||
UserAgent string
|
||||
Cookie string
|
||||
Username string
|
||||
Password string
|
||||
ScraperRules string
|
||||
RewriteRules string
|
||||
BlocklistRules string
|
||||
KeeplistRules string
|
||||
UrlRewriteRules string
|
||||
}
|
||||
|
||||
// Validate makes sure the form values are valid.
|
||||
func (s *SubscriptionForm) Validate() error {
|
||||
if s.URL == "" || s.CategoryID == 0 {
|
||||
return errors.NewLocalizedError("error.feed_mandatory_fields")
|
||||
}
|
||||
|
||||
if !validator.IsValidURL(s.URL) {
|
||||
return errors.NewLocalizedError("error.invalid_feed_url")
|
||||
}
|
||||
|
||||
if !validator.IsValidRegex(s.BlocklistRules) {
|
||||
return errors.NewLocalizedError("error.feed_invalid_blocklist_rule")
|
||||
}
|
||||
|
||||
if !validator.IsValidRegex(s.KeeplistRules) {
|
||||
return errors.NewLocalizedError("error.feed_invalid_keeplist_rule")
|
||||
}
|
||||
|
||||
if !validator.IsValidRegex(s.UrlRewriteRules) {
|
||||
return errors.NewLocalizedError("error.feed_invalid_urlrewrite_rule")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSubscriptionForm returns a new SubscriptionForm.
|
||||
func NewSubscriptionForm(r *http.Request) *SubscriptionForm {
|
||||
categoryID, err := strconv.Atoi(r.FormValue("category_id"))
|
||||
if err != nil {
|
||||
categoryID = 0
|
||||
}
|
||||
|
||||
return &SubscriptionForm{
|
||||
URL: r.FormValue("url"),
|
||||
CategoryID: int64(categoryID),
|
||||
Crawler: r.FormValue("crawler") == "1",
|
||||
AllowSelfSignedCertificates: r.FormValue("allow_self_signed_certificates") == "1",
|
||||
FetchViaProxy: r.FormValue("fetch_via_proxy") == "1",
|
||||
UserAgent: r.FormValue("user_agent"),
|
||||
Cookie: r.FormValue("cookie"),
|
||||
Username: r.FormValue("feed_username"),
|
||||
Password: r.FormValue("feed_password"),
|
||||
ScraperRules: r.FormValue("scraper_rules"),
|
||||
RewriteRules: r.FormValue("rewrite_rules"),
|
||||
BlocklistRules: r.FormValue("blocklist_rules"),
|
||||
KeeplistRules: r.FormValue("keeplist_rules"),
|
||||
UrlRewriteRules: r.FormValue("urlrewrite_rules"),
|
||||
}
|
||||
}
|
73
internal/ui/form/user.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/errors"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// UserForm represents the user form.
|
||||
type UserForm struct {
|
||||
Username string
|
||||
Password string
|
||||
Confirmation string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// ValidateCreation validates user creation.
|
||||
func (u UserForm) ValidateCreation() error {
|
||||
if u.Username == "" || u.Password == "" || u.Confirmation == "" {
|
||||
return errors.NewLocalizedError("error.fields_mandatory")
|
||||
}
|
||||
|
||||
if u.Password != u.Confirmation {
|
||||
return errors.NewLocalizedError("error.different_passwords")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateModification validates user modification.
|
||||
func (u UserForm) ValidateModification() error {
|
||||
if u.Username == "" {
|
||||
return errors.NewLocalizedError("error.user_mandatory_fields")
|
||||
}
|
||||
|
||||
if u.Password != "" {
|
||||
if u.Password != u.Confirmation {
|
||||
return errors.NewLocalizedError("error.different_passwords")
|
||||
}
|
||||
|
||||
if len(u.Password) < 6 {
|
||||
return errors.NewLocalizedError("error.password_min_length")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge updates the fields of the given user.
|
||||
func (u UserForm) Merge(user *model.User) *model.User {
|
||||
user.Username = u.Username
|
||||
user.IsAdmin = u.IsAdmin
|
||||
|
||||
if u.Password != "" {
|
||||
user.Password = u.Password
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// NewUserForm returns a new UserForm.
|
||||
func NewUserForm(r *http.Request) *UserForm {
|
||||
return &UserForm{
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
Confirmation: r.FormValue("confirmation"),
|
||||
IsAdmin: r.FormValue("is_admin") == "1",
|
||||
}
|
||||
}
|
19
internal/ui/handler.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/template"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
router *mux.Router
|
||||
store *storage.Storage
|
||||
tpl *template.Engine
|
||||
pool *worker.Pool
|
||||
}
|
56
internal/ui/history_entries.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
builder.WithSorting("changed_at", "DESC")
|
||||
builder.WithSorting("published_at", "DESC")
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entries", entries)
|
||||
view.Set("total", count)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "history"), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "history")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("history_entries"))
|
||||
}
|
21
internal/ui/history_flush.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
)
|
||||
|
||||
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.store.FlushHistory(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, "OK")
|
||||
}
|
84
internal/ui/integration_pocket.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
)
|
||||
|
||||
func (h *handler) pocketAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
printer := locale.NewPrinter(request.UserLanguage(r))
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
integration, err := h.store.Integration(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
connector := pocket.NewConnector(config.Opts.PocketConsumerKey(integration.PocketConsumerKey))
|
||||
redirectURL := config.Opts.BaseURL() + route.Path(h.router, "pocketCallback")
|
||||
requestToken, err := connector.RequestToken(redirectURL)
|
||||
if err != nil {
|
||||
logger.Error("[Pocket:Authorize] %v", err)
|
||||
sess.NewFlashErrorMessage(printer.Printf("error.pocket_request_token"))
|
||||
html.Redirect(w, r, route.Path(h.router, "integrations"))
|
||||
return
|
||||
}
|
||||
|
||||
sess.SetPocketRequestToken(requestToken)
|
||||
html.Redirect(w, r, connector.AuthorizationURL(requestToken, redirectURL))
|
||||
}
|
||||
|
||||
func (h *handler) pocketCallback(w http.ResponseWriter, r *http.Request) {
|
||||
printer := locale.NewPrinter(request.UserLanguage(r))
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
integration, err := h.store.Integration(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
connector := pocket.NewConnector(config.Opts.PocketConsumerKey(integration.PocketConsumerKey))
|
||||
accessToken, err := connector.AccessToken(request.PocketRequestToken(r))
|
||||
if err != nil {
|
||||
logger.Error("[Pocket:Callback] %v", err)
|
||||
sess.NewFlashErrorMessage(printer.Printf("error.pocket_access_token"))
|
||||
html.Redirect(w, r, route.Path(h.router, "integrations"))
|
||||
return
|
||||
}
|
||||
|
||||
sess.SetPocketRequestToken("")
|
||||
integration.PocketAccessToken = accessToken
|
||||
|
||||
err = h.store.UpdateIntegration(integration)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess.NewFlashMessage(printer.Printf("alert.pocket_linked"))
|
||||
html.Redirect(w, r, route.Path(h.router, "integrations"))
|
||||
}
|
92
internal/ui/integration_show.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
integration, err := h.store.Integration(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
integrationForm := form.IntegrationForm{
|
||||
PinboardEnabled: integration.PinboardEnabled,
|
||||
PinboardToken: integration.PinboardToken,
|
||||
PinboardTags: integration.PinboardTags,
|
||||
PinboardMarkAsUnread: integration.PinboardMarkAsUnread,
|
||||
InstapaperEnabled: integration.InstapaperEnabled,
|
||||
InstapaperUsername: integration.InstapaperUsername,
|
||||
InstapaperPassword: integration.InstapaperPassword,
|
||||
FeverEnabled: integration.FeverEnabled,
|
||||
FeverUsername: integration.FeverUsername,
|
||||
GoogleReaderEnabled: integration.GoogleReaderEnabled,
|
||||
GoogleReaderUsername: integration.GoogleReaderUsername,
|
||||
WallabagEnabled: integration.WallabagEnabled,
|
||||
WallabagOnlyURL: integration.WallabagOnlyURL,
|
||||
WallabagURL: integration.WallabagURL,
|
||||
WallabagClientID: integration.WallabagClientID,
|
||||
WallabagClientSecret: integration.WallabagClientSecret,
|
||||
WallabagUsername: integration.WallabagUsername,
|
||||
WallabagPassword: integration.WallabagPassword,
|
||||
NotionEnabled: integration.NotionEnabled,
|
||||
NotionPageID: integration.NotionPageID,
|
||||
NotionToken: integration.NotionToken,
|
||||
NunuxKeeperEnabled: integration.NunuxKeeperEnabled,
|
||||
NunuxKeeperURL: integration.NunuxKeeperURL,
|
||||
NunuxKeeperAPIKey: integration.NunuxKeeperAPIKey,
|
||||
EspialEnabled: integration.EspialEnabled,
|
||||
EspialURL: integration.EspialURL,
|
||||
EspialAPIKey: integration.EspialAPIKey,
|
||||
EspialTags: integration.EspialTags,
|
||||
ReadwiseEnabled: integration.ReadwiseEnabled,
|
||||
ReadwiseAPIKey: integration.ReadwiseAPIKey,
|
||||
PocketEnabled: integration.PocketEnabled,
|
||||
PocketAccessToken: integration.PocketAccessToken,
|
||||
PocketConsumerKey: integration.PocketConsumerKey,
|
||||
TelegramBotEnabled: integration.TelegramBotEnabled,
|
||||
TelegramBotToken: integration.TelegramBotToken,
|
||||
TelegramBotChatID: integration.TelegramBotChatID,
|
||||
LinkdingEnabled: integration.LinkdingEnabled,
|
||||
LinkdingURL: integration.LinkdingURL,
|
||||
LinkdingAPIKey: integration.LinkdingAPIKey,
|
||||
LinkdingTags: integration.LinkdingTags,
|
||||
LinkdingMarkAsUnread: integration.LinkdingMarkAsUnread,
|
||||
MatrixBotEnabled: integration.MatrixBotEnabled,
|
||||
MatrixBotUser: integration.MatrixBotUser,
|
||||
MatrixBotPassword: integration.MatrixBotPassword,
|
||||
MatrixBotURL: integration.MatrixBotURL,
|
||||
MatrixBotChatID: integration.MatrixBotChatID,
|
||||
AppriseEnabled: integration.AppriseEnabled,
|
||||
AppriseURL: integration.AppriseURL,
|
||||
AppriseServicesURL: integration.AppriseServicesURL,
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", integrationForm)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasPocketConsumerKeyConfigured", config.Opts.PocketConsumerKey("") != "")
|
||||
|
||||
html.OK(w, r, view.Render("integrations"))
|
||||
}
|
78
internal/ui/integration_update.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
)
|
||||
|
||||
func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
printer := locale.NewPrinter(request.UserLanguage(r))
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
integration, err := h.store.Integration(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
integrationForm := form.NewIntegrationForm(r)
|
||||
integrationForm.Merge(integration)
|
||||
|
||||
if integration.FeverUsername != "" && h.store.HasDuplicateFeverUsername(user.ID, integration.FeverUsername) {
|
||||
sess.NewFlashErrorMessage(printer.Printf("error.duplicate_fever_username"))
|
||||
html.Redirect(w, r, route.Path(h.router, "integrations"))
|
||||
return
|
||||
}
|
||||
|
||||
if integration.FeverEnabled {
|
||||
if integrationForm.FeverPassword != "" {
|
||||
integration.FeverToken = fmt.Sprintf("%x", md5.Sum([]byte(integration.FeverUsername+":"+integrationForm.FeverPassword)))
|
||||
}
|
||||
} else {
|
||||
integration.FeverToken = ""
|
||||
}
|
||||
|
||||
if integration.GoogleReaderUsername != "" && h.store.HasDuplicateGoogleReaderUsername(user.ID, integration.GoogleReaderUsername) {
|
||||
sess.NewFlashErrorMessage(printer.Printf("error.duplicate_googlereader_username"))
|
||||
html.Redirect(w, r, route.Path(h.router, "integrations"))
|
||||
return
|
||||
}
|
||||
|
||||
if integration.GoogleReaderEnabled {
|
||||
if integrationForm.GoogleReaderPassword != "" {
|
||||
integration.GoogleReaderPassword, err = crypto.HashPassword(integrationForm.GoogleReaderPassword)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
integration.GoogleReaderPassword = ""
|
||||
}
|
||||
|
||||
err = h.store.UpdateIntegration(integration)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess.NewFlashMessage(printer.Printf("alert.prefs_saved"))
|
||||
html.Redirect(w, r, route.Path(h.router, "integrations"))
|
||||
}
|
67
internal/ui/login_check.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/cookie"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
authForm := form.NewAuthForm(r)
|
||||
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("errorMessage", "error.bad_credentials")
|
||||
view.Set("form", authForm)
|
||||
|
||||
if err := authForm.Validate(); err != nil {
|
||||
logger.Error("[UI:CheckLogin] %v", err)
|
||||
html.OK(w, r, view.Render("login"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.CheckPassword(authForm.Username, authForm.Password); err != nil {
|
||||
logger.Error("[UI:CheckLogin] [ClientIP=%s] %v", clientIP, err)
|
||||
html.OK(w, r, view.Render("login"))
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken, userID, err := h.store.CreateUserSessionFromUsername(authForm.Username, r.UserAgent(), clientIP)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[UI:CheckLogin] username=%s just logged in", authForm.Username)
|
||||
h.store.SetLastLogin(userID)
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess.SetLanguage(user.Language)
|
||||
sess.SetTheme(user.Theme)
|
||||
|
||||
http.SetCookie(w, cookie.New(
|
||||
cookie.CookieUserSessionID,
|
||||
sessionToken,
|
||||
config.Opts.HTTPS,
|
||||
config.Opts.BasePath(),
|
||||
))
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, user.DefaultHomePage))
|
||||
}
|
31
internal/ui/login_show.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if request.IsAuthenticated(r) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, user.DefaultHomePage))
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
html.OK(w, r, view.Render("login"))
|
||||
}
|
40
internal/ui/logout.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/cookie"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
)
|
||||
|
||||
func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess.SetLanguage(user.Language)
|
||||
sess.SetTheme(user.Theme)
|
||||
|
||||
if err := h.store.RemoveUserSessionByToken(user.ID, request.UserSessionToken(r)); err != nil {
|
||||
logger.Error("[UI:Logout] %v", err)
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie.Expired(
|
||||
cookie.CookieUserSessionID,
|
||||
config.Opts.HTTPS,
|
||||
config.Opts.BasePath(),
|
||||
))
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
}
|
227
internal/ui/middleware.go
Normal file
|
@ -0,0 +1,227 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/cookie"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type middleware struct {
|
||||
router *mux.Router
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func newMiddleware(router *mux.Router, store *storage.Storage) *middleware {
|
||||
return &middleware{router, store}
|
||||
}
|
||||
|
||||
func (m *middleware) handleUserSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := m.getUserSessionFromCookie(r)
|
||||
|
||||
if session == nil {
|
||||
if m.isPublicRoute(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
} else {
|
||||
logger.Debug("[UI:UserSession] Session not found, redirect to login page")
|
||||
html.Redirect(w, r, route.Path(m.router, "login"))
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[UI:UserSession] %s", session)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, session.UserID)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
ctx = context.WithValue(ctx, request.UserSessionTokenContextKey, session.Token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) handleAppSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
session := m.getAppSessionValueFromCookie(r)
|
||||
|
||||
if session == nil {
|
||||
if request.IsAuthenticated(r) {
|
||||
userID := request.UserID(r)
|
||||
logger.Debug("[UI:AppSession] Cookie expired but user #%d is logged: creating a new session", userID)
|
||||
session, err = m.store.CreateAppSessionWithUserPrefs(userID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[UI:AppSession] Session not found, creating a new one")
|
||||
session, err = m.store.CreateAppSession()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS, config.Opts.BasePath()))
|
||||
} else {
|
||||
logger.Debug("[UI:AppSession] %s", session)
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
formValue := r.FormValue("csrf")
|
||||
headerValue := r.Header.Get("X-Csrf-Token")
|
||||
|
||||
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
|
||||
logger.Error(`[UI:AppSession] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue)
|
||||
|
||||
if mux.CurrentRoute(r).GetName() == "checkLogin" {
|
||||
html.Redirect(w, r, route.Path(m.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
html.BadRequest(w, r, errors.New("Invalid or missing CSRF"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.SessionIDContextKey, session.ID)
|
||||
ctx = context.WithValue(ctx, request.CSRFContextKey, session.Data.CSRF)
|
||||
ctx = context.WithValue(ctx, request.OAuth2StateContextKey, session.Data.OAuth2State)
|
||||
ctx = context.WithValue(ctx, request.FlashMessageContextKey, session.Data.FlashMessage)
|
||||
ctx = context.WithValue(ctx, request.FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
|
||||
ctx = context.WithValue(ctx, request.UserLanguageContextKey, session.Data.Language)
|
||||
ctx = context.WithValue(ctx, request.UserThemeContextKey, session.Data.Theme)
|
||||
ctx = context.WithValue(ctx, request.PocketRequestTokenContextKey, session.Data.PocketRequestToken)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) getAppSessionValueFromCookie(r *http.Request) *model.Session {
|
||||
cookieValue := request.CookieValue(r, cookie.CookieAppSessionID)
|
||||
if cookieValue == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := m.store.AppSession(cookieValue)
|
||||
if err != nil {
|
||||
logger.Error("[UI:AppSession] %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func (m *middleware) isPublicRoute(r *http.Request) bool {
|
||||
route := mux.CurrentRoute(r)
|
||||
switch route.GetName() {
|
||||
case "login",
|
||||
"checkLogin",
|
||||
"stylesheet",
|
||||
"javascript",
|
||||
"oauth2Redirect",
|
||||
"oauth2Callback",
|
||||
"appIcon",
|
||||
"favicon",
|
||||
"webManifest",
|
||||
"robots",
|
||||
"sharedEntry",
|
||||
"healthcheck",
|
||||
"offline",
|
||||
"proxy":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {
|
||||
cookieValue := request.CookieValue(r, cookie.CookieUserSessionID)
|
||||
if cookieValue == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := m.store.UserSessionByToken(cookieValue)
|
||||
if err != nil {
|
||||
logger.Error("[UI:UserSession] %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if request.IsAuthenticated(r) || config.Opts.AuthProxyHeader() == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.Header.Get(config.Opts.AuthProxyHeader())
|
||||
if username == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := request.ClientIP(r)
|
||||
logger.Info("[AuthProxy] [ClientIP=%s] Received authenticated requested for %q", clientIP, username)
|
||||
|
||||
user, err := m.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
logger.Error("[AuthProxy] [ClientIP=%s] %q doesn't exist", clientIP, username)
|
||||
|
||||
if !config.Opts.IsAuthProxyUserCreationAllowed() {
|
||||
html.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user, err = m.store.CreateUser(&model.UserCreationRequest{Username: username}); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sessionToken, _, err := m.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), clientIP)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[AuthProxy] [ClientIP=%s] username=%s just logged in", clientIP, user.Username)
|
||||
|
||||
m.store.SetLastLogin(user.ID)
|
||||
|
||||
sess := session.New(m.store, request.SessionID(r))
|
||||
sess.SetLanguage(user.Language)
|
||||
sess.SetTheme(user.Theme)
|
||||
|
||||
http.SetCookie(w, cookie.New(
|
||||
cookie.CookieUserSessionID,
|
||||
sessionToken,
|
||||
config.Opts.HTTPS,
|
||||
config.Opts.BasePath(),
|
||||
))
|
||||
|
||||
html.Redirect(w, r, route.Path(m.router, "unread"))
|
||||
})
|
||||
}
|
21
internal/ui/oauth2.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/oauth2"
|
||||
)
|
||||
|
||||
func getOAuth2Manager(ctx context.Context) *oauth2.Manager {
|
||||
return oauth2.NewManager(
|
||||
ctx,
|
||||
config.Opts.OAuth2ClientID(),
|
||||
config.Opts.OAuth2ClientSecret(),
|
||||
config.Opts.OAuth2RedirectURL(),
|
||||
config.Opts.OAuth2OidcDiscoveryEndpoint(),
|
||||
)
|
||||
}
|
135
internal/ui/oauth2_callback.go
Normal file
|
@ -0,0 +1,135 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/cookie"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
)
|
||||
|
||||
func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
printer := locale.NewPrinter(request.UserLanguage(r))
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
provider := request.RouteStringParam(r, "provider")
|
||||
if provider == "" {
|
||||
logger.Error("[OAuth2] Invalid or missing provider")
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
code := request.QueryStringParam(r, "code", "")
|
||||
if code == "" {
|
||||
logger.Error("[OAuth2] No code received on callback")
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
state := request.QueryStringParam(r, "state", "")
|
||||
if state == "" || state != request.OAuth2State(r) {
|
||||
logger.Error(`[OAuth2] Invalid state value: got "%s" instead of "%s"`, state, request.OAuth2State(r))
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
|
||||
if err != nil {
|
||||
logger.Error("[OAuth2] %v", err)
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := authProvider.GetProfile(r.Context(), code)
|
||||
if err != nil {
|
||||
logger.Error("[OAuth2] %v", err)
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[OAuth2] [ClientIP=%s] Successful auth for %s", clientIP, profile)
|
||||
|
||||
if request.IsAuthenticated(r) {
|
||||
loggedUser, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if h.store.AnotherUserWithFieldExists(loggedUser.ID, profile.Key, profile.ID) {
|
||||
logger.Error("[OAuth2] User #%d cannot be associated because it is already associated with another user", loggedUser.ID)
|
||||
sess.NewFlashErrorMessage(printer.Printf("error.duplicate_linked_account"))
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
return
|
||||
}
|
||||
|
||||
authProvider.PopulateUserWithProfileID(loggedUser, profile)
|
||||
if err := h.store.UpdateUser(loggedUser); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess.NewFlashMessage(printer.Printf("alert.account_linked"))
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByField(profile.Key, profile.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
if !config.Opts.IsOAuth2UserCreationAllowed() {
|
||||
html.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if h.store.UserExists(profile.Username) {
|
||||
html.BadRequest(w, r, errors.New(printer.Printf("error.user_already_exists")))
|
||||
return
|
||||
}
|
||||
|
||||
userCreationRequest := &model.UserCreationRequest{Username: profile.Username}
|
||||
authProvider.PopulateUserCreationWithProfileID(userCreationRequest, profile)
|
||||
|
||||
user, err = h.store.CreateUser(userCreationRequest)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), clientIP)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[OAuth2] [ClientIP=%s] username=%s (%s) just logged in", clientIP, user.Username, profile)
|
||||
|
||||
h.store.SetLastLogin(user.ID)
|
||||
sess.SetLanguage(user.Language)
|
||||
sess.SetTheme(user.Theme)
|
||||
|
||||
http.SetCookie(w, cookie.New(
|
||||
cookie.CookieUserSessionID,
|
||||
sessionToken,
|
||||
config.Opts.HTTPS,
|
||||
config.Opts.BasePath(),
|
||||
))
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "unread"))
|
||||
}
|
34
internal/ui/oauth2_redirect.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
)
|
||||
|
||||
func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
provider := request.RouteStringParam(r, "provider")
|
||||
if provider == "" {
|
||||
logger.Error("[OAuth2] Invalid or missing provider: %s", provider)
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
|
||||
if err != nil {
|
||||
logger.Error("[OAuth2] %v", err)
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, authProvider.GetRedirectURL(sess.NewOAuth2State()))
|
||||
}
|
60
internal/ui/oauth2_unlink.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
)
|
||||
|
||||
func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
|
||||
printer := locale.NewPrinter(request.UserLanguage(r))
|
||||
provider := request.RouteStringParam(r, "provider")
|
||||
if provider == "" {
|
||||
logger.Info("[OAuth2] Invalid or missing provider")
|
||||
html.Redirect(w, r, route.Path(h.router, "login"))
|
||||
return
|
||||
}
|
||||
|
||||
authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
|
||||
if err != nil {
|
||||
logger.Error("[OAuth2] %v", err)
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
hasPassword, err := h.store.HasPassword(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasPassword {
|
||||
sess.NewFlashErrorMessage(printer.Printf("error.unlink_account_without_password"))
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
return
|
||||
}
|
||||
|
||||
authProvider.UnsetUserProfileID(user)
|
||||
if err := h.store.UpdateUser(user); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess.NewFlashMessage(printer.Printf("alert.account_unlinked"))
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
}
|
19
internal/ui/offline.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showOfflinePage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
html.OK(w, r, view.Render("offline"))
|
||||
}
|
23
internal/ui/opml_export.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/response/xml"
|
||||
"miniflux.app/v2/internal/reader/opml"
|
||||
)
|
||||
|
||||
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
opml, err := opml.NewHandler(h.store).Export(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
xml.Attachment(w, r, "feeds.opml", opml)
|
||||
}
|
30
internal/ui/opml_import.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showImportPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("import"))
|
||||
}
|
105
internal/ui/opml_upload.go
Normal file
|
@ -0,0 +1,105 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/reader/opml"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
file, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
logger.Error("[UI:UploadOPML] %v", err)
|
||||
html.Redirect(w, r, route.Path(h.router, "import"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
logger.Debug(
|
||||
"[UI:UploadOPML] User #%d uploaded this file: %s (%d bytes)",
|
||||
user.ID,
|
||||
fileHeader.Filename,
|
||||
fileHeader.Size,
|
||||
)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
if fileHeader.Size == 0 {
|
||||
view.Set("errorMessage", "error.empty_file")
|
||||
html.OK(w, r, view.Render("import"))
|
||||
return
|
||||
}
|
||||
|
||||
if impErr := opml.NewHandler(h.store).Import(user.ID, file); impErr != nil {
|
||||
view.Set("errorMessage", impErr)
|
||||
html.OK(w, r, view.Render("import"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feeds"))
|
||||
}
|
||||
|
||||
func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
url := r.FormValue("url")
|
||||
if url == "" {
|
||||
html.Redirect(w, r, route.Path(h.router, "import"))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(
|
||||
"[UI:FetchOPML] User #%d fetching this URL: %s",
|
||||
user.ID,
|
||||
url,
|
||||
)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("menu", "feeds")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
clt := client.NewClientWithConfig(url, config.Opts)
|
||||
resp, err := clt.Get()
|
||||
if err != nil {
|
||||
view.Set("errorMessage", err)
|
||||
html.OK(w, r, view.Render("import"))
|
||||
return
|
||||
}
|
||||
|
||||
if impErr := opml.NewHandler(h.store).Import(user.ID, resp.Body); impErr != nil {
|
||||
view.Set("errorMessage", impErr)
|
||||
html.OK(w, r, view.Render("import"))
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "feeds"))
|
||||
}
|
42
internal/ui/pagination.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
type pagination struct {
|
||||
Route string
|
||||
Total int
|
||||
Offset int
|
||||
ItemsPerPage int
|
||||
ShowNext bool
|
||||
ShowPrev bool
|
||||
NextOffset int
|
||||
PrevOffset int
|
||||
SearchQuery string
|
||||
}
|
||||
|
||||
func getPagination(route string, total, offset, nbItemsPerPage int) pagination {
|
||||
nextOffset := 0
|
||||
prevOffset := 0
|
||||
showNext := (total - offset) > nbItemsPerPage
|
||||
showPrev := offset > 0
|
||||
|
||||
if showNext {
|
||||
nextOffset = offset + nbItemsPerPage
|
||||
}
|
||||
|
||||
if showPrev {
|
||||
prevOffset = offset - nbItemsPerPage
|
||||
}
|
||||
|
||||
return pagination{
|
||||
Route: route,
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
ItemsPerPage: nbItemsPerPage,
|
||||
ShowNext: showNext,
|
||||
NextOffset: nextOffset,
|
||||
ShowPrev: showPrev,
|
||||
PrevOffset: prevOffset,
|
||||
}
|
||||
}
|
118
internal/ui/proxy.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
)
|
||||
|
||||
func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// If we receive a "If-None-Match" header, we assume the media is already stored in browser cache.
|
||||
if r.Header.Get("If-None-Match") != "" {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
encodedDigest := request.RouteStringParam(r, "encodedDigest")
|
||||
encodedURL := request.RouteStringParam(r, "encodedURL")
|
||||
if encodedURL == "" {
|
||||
html.BadRequest(w, r, errors.New("No URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
decodedDigest, err := base64.URLEncoding.DecodeString(encodedDigest)
|
||||
if err != nil {
|
||||
html.BadRequest(w, r, errors.New("Unable to decode this Digest"))
|
||||
return
|
||||
}
|
||||
|
||||
decodedURL, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
html.BadRequest(w, r, errors.New("Unable to decode this URL"))
|
||||
return
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
|
||||
mac.Write(decodedURL)
|
||||
expectedMAC := mac.Sum(nil)
|
||||
|
||||
if !hmac.Equal(decodedDigest, expectedMAC) {
|
||||
html.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
mediaURL := string(decodedURL)
|
||||
logger.Debug(`[Proxy] Fetching %q`, mediaURL)
|
||||
|
||||
req, err := http.NewRequest("GET", mediaURL, nil)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Note: User-Agent HTTP header is omitted to avoid being blocked by bot protection mechanisms.
|
||||
req.Header.Add("Connection", "close")
|
||||
|
||||
forwardedRequestHeader := []string{"Range", "Accept", "Accept-Encoding"}
|
||||
for _, requestHeaderName := range forwardedRequestHeader {
|
||||
if r.Header.Get(requestHeaderName) != "" {
|
||||
req.Header.Add(requestHeaderName, r.Header.Get(requestHeaderName))
|
||||
}
|
||||
}
|
||||
|
||||
clt := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
IdleConnTimeout: time.Duration(config.Opts.ProxyHTTPClientTimeout()) * time.Second,
|
||||
},
|
||||
Timeout: time.Duration(config.Opts.ProxyHTTPClientTimeout()) * time.Second,
|
||||
}
|
||||
|
||||
resp, err := clt.Do(req)
|
||||
if err != nil {
|
||||
logger.Error(`[Proxy] Unable to initialize HTTP client: %v`, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
||||
logger.Error(`[Proxy] Status Code is %d for URL %q`, resp.StatusCode, mediaURL)
|
||||
html.RequestedRangeNotSatisfiable(w, r, resp.Header.Get("Content-Range"))
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
logger.Error(`[Proxy] Status Code is %d for URL %q`, resp.StatusCode, mediaURL)
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
etag := crypto.HashFromBytes(decodedURL)
|
||||
|
||||
response.New(w, r).WithCaching(etag, 72*time.Hour, func(b *response.Builder) {
|
||||
b.WithStatus(resp.StatusCode)
|
||||
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
b.WithHeader("Content-Type", resp.Header.Get("Content-Type"))
|
||||
forwardedResponseHeader := []string{"Content-Encoding", "Content-Type", "Content-Length", "Accept-Ranges", "Content-Range"}
|
||||
for _, responseHeaderName := range forwardedResponseHeader {
|
||||
if resp.Header.Get(responseHeaderName) != "" {
|
||||
b.WithHeader(responseHeaderName, resp.Header.Get(responseHeaderName))
|
||||
}
|
||||
}
|
||||
b.WithBody(resp.Body)
|
||||
b.WithoutCompression()
|
||||
b.Write()
|
||||
})
|
||||
}
|
60
internal/ui/search_entries.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showSearchEntriesPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
searchQuery := request.QueryStringParam(r, "q", "")
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithSearchQuery(searchQuery)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
pagination := getPagination(route.Path(h.router, "searchEntries"), count, offset, user.EntriesPerPage)
|
||||
pagination.SearchQuery = searchQuery
|
||||
|
||||
view.Set("searchQuery", searchQuery)
|
||||
view.Set("entries", entries)
|
||||
view.Set("total", count)
|
||||
view.Set("pagination", pagination)
|
||||
view.Set("menu", "search")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("search_entries"))
|
||||
}
|
68
internal/ui/session/session.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package session // import "miniflux.app/v2/internal/ui/session"
|
||||
|
||||
import (
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
// Session handles session data.
|
||||
type Session struct {
|
||||
store *storage.Storage
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// NewOAuth2State generates a new OAuth2 state and stores the value into the database.
|
||||
func (s *Session) NewOAuth2State() string {
|
||||
state := crypto.GenerateRandomString(32)
|
||||
s.store.UpdateAppSessionField(s.sessionID, "oauth2_state", state)
|
||||
return state
|
||||
}
|
||||
|
||||
// NewFlashMessage creates a new flash message.
|
||||
func (s *Session) NewFlashMessage(message string) {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "flash_message", message)
|
||||
}
|
||||
|
||||
// FlashMessage returns the current flash message if any.
|
||||
func (s *Session) FlashMessage(message string) string {
|
||||
if message != "" {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "flash_message", "")
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// NewFlashErrorMessage creates a new flash error message.
|
||||
func (s *Session) NewFlashErrorMessage(message string) {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "flash_error_message", message)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the last flash error message if any.
|
||||
func (s *Session) FlashErrorMessage(message string) string {
|
||||
if message != "" {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "flash_error_message", "")
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// SetLanguage updates the language field in session.
|
||||
func (s *Session) SetLanguage(language string) {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "language", language)
|
||||
}
|
||||
|
||||
// SetTheme updates the theme field in session.
|
||||
func (s *Session) SetTheme(theme string) {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "theme", theme)
|
||||
}
|
||||
|
||||
// SetPocketRequestToken updates Pocket Request Token.
|
||||
func (s *Session) SetPocketRequestToken(requestToken string) {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "pocket_request_token", requestToken)
|
||||
}
|
||||
|
||||
// New returns a new session handler.
|
||||
func New(store *storage.Storage, sessionID string) *Session {
|
||||
return &Session{store, sessionID}
|
||||
}
|
41
internal/ui/session_list.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showSessionsPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.store.UserSessions(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sessions.UseTimezone(user.Timezone)
|
||||
|
||||
view.Set("currentSessionToken", request.UserSessionToken(r))
|
||||
view.Set("sessions", sessions)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("sessions"))
|
||||
}
|
23
internal/ui/session_remove.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
)
|
||||
|
||||
func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := request.RouteInt64Param(r, "sessionID")
|
||||
err := h.store.RemoveUserSessionByID(request.UserID(r), sessionID)
|
||||
if err != nil {
|
||||
logger.Error("[UI:RemoveSession] %v", err)
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "sessions"))
|
||||
}
|
67
internal/ui/settings_show.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
settingsForm := form.SettingsForm{
|
||||
Username: user.Username,
|
||||
Theme: user.Theme,
|
||||
Language: user.Language,
|
||||
Timezone: user.Timezone,
|
||||
EntryDirection: user.EntryDirection,
|
||||
EntryOrder: user.EntryOrder,
|
||||
EntriesPerPage: user.EntriesPerPage,
|
||||
KeyboardShortcuts: user.KeyboardShortcuts,
|
||||
ShowReadingTime: user.ShowReadingTime,
|
||||
CustomCSS: user.Stylesheet,
|
||||
EntrySwipe: user.EntrySwipe,
|
||||
GestureNav: user.GestureNav,
|
||||
DisplayMode: user.DisplayMode,
|
||||
DefaultReadingSpeed: user.DefaultReadingSpeed,
|
||||
CJKReadingSpeed: user.CJKReadingSpeed,
|
||||
DefaultHomePage: user.DefaultHomePage,
|
||||
CategoriesSortingOrder: user.CategoriesSortingOrder,
|
||||
MarkReadOnView: user.MarkReadOnView,
|
||||
}
|
||||
|
||||
timezones, err := h.store.Timezones()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
view.Set("form", settingsForm)
|
||||
view.Set("themes", model.Themes())
|
||||
view.Set("languages", locale.AvailableLanguages())
|
||||
view.Set("timezones", timezones)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("default_home_pages", model.HomePages())
|
||||
view.Set("categories_sorting_options", model.CategoriesSortingOptions())
|
||||
|
||||
html.OK(w, r, view.Render("settings"))
|
||||
}
|
87
internal/ui/settings_update.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/logger"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
loggedUser, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
timezones, err := h.store.Timezones()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
settingsForm := form.NewSettingsForm(r)
|
||||
|
||||
view.Set("form", settingsForm)
|
||||
view.Set("themes", model.Themes())
|
||||
view.Set("languages", locale.AvailableLanguages())
|
||||
view.Set("timezones", timezones)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", loggedUser)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
|
||||
|
||||
if err := settingsForm.Validate(); err != nil {
|
||||
view.Set("errorMessage", err.Error())
|
||||
html.OK(w, r, view.Render("settings"))
|
||||
return
|
||||
}
|
||||
|
||||
userModificationRequest := &model.UserModificationRequest{
|
||||
Username: model.OptionalString(settingsForm.Username),
|
||||
Password: model.OptionalString(settingsForm.Password),
|
||||
Theme: model.OptionalString(settingsForm.Theme),
|
||||
Language: model.OptionalString(settingsForm.Language),
|
||||
Timezone: model.OptionalString(settingsForm.Timezone),
|
||||
EntryDirection: model.OptionalString(settingsForm.EntryDirection),
|
||||
EntriesPerPage: model.OptionalInt(settingsForm.EntriesPerPage),
|
||||
DisplayMode: model.OptionalString(settingsForm.DisplayMode),
|
||||
GestureNav: model.OptionalString(settingsForm.GestureNav),
|
||||
DefaultReadingSpeed: model.OptionalInt(settingsForm.DefaultReadingSpeed),
|
||||
CJKReadingSpeed: model.OptionalInt(settingsForm.CJKReadingSpeed),
|
||||
DefaultHomePage: model.OptionalString(settingsForm.DefaultHomePage),
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {
|
||||
view.Set("errorMessage", validationErr.TranslationKey)
|
||||
html.OK(w, r, view.Render("settings"))
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.UpdateUser(settingsForm.Merge(loggedUser))
|
||||
if err != nil {
|
||||
logger.Error("[UI:UpdateSettings] %v", err)
|
||||
view.Set("errorMessage", "error.unable_to_update_user")
|
||||
html.OK(w, r, view.Render("settings"))
|
||||
return
|
||||
}
|
||||
|
||||
sess.SetLanguage(loggedUser.Language)
|
||||
sess.SetTheme(loggedUser.Theme)
|
||||
sess.NewFlashMessage(locale.NewPrinter(request.UserLanguage(r)).Printf("alert.prefs_saved"))
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
}
|
66
internal/ui/share.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) createSharedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
shareCode, err := h.store.EntryShareCode(request.UserID(r), entryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "sharedEntry", "shareCode", shareCode))
|
||||
}
|
||||
|
||||
func (h *handler) unshareEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.UnshareEntry(request.UserID(r), entryID); err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "sharedEntries"))
|
||||
}
|
||||
|
||||
func (h *handler) sharedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
shareCode := request.RouteStringParam(r, "shareCode")
|
||||
if shareCode == "" {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
etag := shareCode
|
||||
response.New(w, r).WithCaching(etag, 72*time.Hour, func(b *response.Builder) {
|
||||
builder := storage.NewAnonymousQueryBuilder(h.store)
|
||||
builder.WithShareCode(shareCode)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil || entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
|
||||
b.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
b.WithBody(view.Render("entry"))
|
||||
b.Write()
|
||||
})
|
||||
}
|
49
internal/ui/shared_entries.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithShareCodeNotEmpty()
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entries", entries)
|
||||
view.Set("total", count)
|
||||
view.Set("menu", "history")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("shared_entries"))
|
||||
}
|
BIN
internal/ui/static/bin/favicon-16.png
Normal file
After Width: | Height: | Size: 276 B |
BIN
internal/ui/static/bin/favicon-32.png
Normal file
After Width: | Height: | Size: 369 B |
BIN
internal/ui/static/bin/favicon.ico
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
internal/ui/static/bin/icon-120.png
Normal file
After Width: | Height: | Size: 561 B |
BIN
internal/ui/static/bin/icon-128.png
Normal file
After Width: | Height: | Size: 619 B |
BIN
internal/ui/static/bin/icon-152.png
Normal file
After Width: | Height: | Size: 691 B |
BIN
internal/ui/static/bin/icon-167.png
Normal file
After Width: | Height: | Size: 773 B |
BIN
internal/ui/static/bin/icon-180.png
Normal file
After Width: | Height: | Size: 822 B |
BIN
internal/ui/static/bin/icon-192.png
Normal file
After Width: | Height: | Size: 881 B |
BIN
internal/ui/static/bin/icon-512.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
internal/ui/static/bin/maskable-icon-120.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
internal/ui/static/bin/maskable-icon-192.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
internal/ui/static/bin/maskable-icon-512.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
203
internal/ui/static/bin/sprite.svg
Normal file
|
@ -0,0 +1,203 @@
|
|||
<!--
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Paweł Kuna
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Source: https://github.com/tabler/tabler-icons
|
||||
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="icon-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9 12l2 2l4 -4" />
|
||||
</symbol>
|
||||
<symbol id="icon-unread" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M10 10l4 4m0 -4l-4 4" />
|
||||
</symbol>
|
||||
<symbol id="icon-star" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M12 17.75l-6.172 3.245 1.179-6.873-4.993-4.867 6.9-1.002L12 2l3.086 6.253 6.9 1.002-4.993 4.867 1.179 6.873z" />
|
||||
</symbol>
|
||||
<symbol id="icon-unstar" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path fill="currentColor" d="M12 17.75l-6.172 3.245 1.179-6.873-4.993-4.867 6.9-1.002L12 2l3.086 6.253 6.9 1.002-4.993 4.867 1.179 6.873z" />
|
||||
</symbol>
|
||||
<symbol id="icon-save" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"></path>
|
||||
<circle cx="12" cy="14" r="2"></circle>
|
||||
<polyline points="14 4 14 8 8 8 8 4"></polyline>
|
||||
</symbol>
|
||||
<symbol id="icon-scraper" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4" />
|
||||
<line x1="12" y1="13" x2="12" y2="22" />
|
||||
<polyline points="9 19 12 22 15 19" />
|
||||
</symbol>
|
||||
<symbol id="icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
|
||||
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
|
||||
</symbol>
|
||||
<symbol id="icon-comment" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M3 20l1.3 -3.9a9 8 0 1 1 3.4 2.9l-4.7 1" />
|
||||
<line x1="12" y1="12" x2="12" y2="12.01" />
|
||||
<line x1="8" y1="12" x2="8" y2="12.01" />
|
||||
<line x1="16" y1="12" x2="16" y2="12.01" />
|
||||
</symbol>
|
||||
<symbol id="icon-external-link" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M11 7h-5a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-5" />
|
||||
<line x1="10" y1="14" x2="20" y2="4" />
|
||||
<polyline points="15 4 20 4 20 9" />
|
||||
</symbol>
|
||||
<symbol id="icon-delete" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<line x1="4" y1="7" x2="20" y2="7" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
|
||||
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
|
||||
</symbol>
|
||||
<symbol id="icon-edit" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M9 7 h-3a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-3" />
|
||||
<path d="M9 15h3l8.5 -8.5a1.5 1.5 0 0 0 -3 -3l-8.5 8.5v3" />
|
||||
<line x1="16" y1="5" x2="19" y2="8" />
|
||||
</symbol>
|
||||
<symbol id="icon-feeds" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="5" cy="19" r="1"></circle>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"></path>
|
||||
<path d="M4 11a9 9 0 0 1 9 9"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-entries" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M16 6h3a1 1 0 0 1 1 1v11a2 2 0 0 1 -4 0v-13a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1v12a3 3 0 0 0 3 3h11" />
|
||||
<line x1="8" y1="8" x2="12" y2="8" />
|
||||
<line x1="8" y1="12" x2="12" y2="12" />
|
||||
<line x1="8" y1="16" x2="12" y2="16" />
|
||||
</symbol>
|
||||
<symbol id="icon-refresh" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -5v5h5" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 5v-5h-5" />
|
||||
</symbol>
|
||||
<symbol id="icon-home" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<polyline points="5 12 3 12 12 3 21 12 19 12"></polyline>
|
||||
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7"></path>
|
||||
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-mark-page-as-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 12l5 5l10 -10" />
|
||||
</symbol>
|
||||
<symbol id="icon-mark-all-as-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 12l5 5l10 -10" />
|
||||
<path d="M2 12l5 5m5 -5l5 -5" />
|
||||
</symbol>
|
||||
<symbol id="icon-show-all-entries" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<path d="M22 12c-2.667 4.667 -6 7 -10 7s-7.333 -2.333 -10 -7c2.667 -4.667 6 -7 10 -7s7.333 2.333 10 7"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-show-unread-entries" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<line x1="3" y1="3" x2="21" y2="21"></line>
|
||||
<path d="M10.584 10.587a2 2 0 0 0 2.828 2.83"></path>
|
||||
<path d="M9.363 5.365a9.466 9.466 0 0 1 2.637 -.365c4 0 7.333 2.333 10 7c-.778 1.361 -1.612 2.524 -2.503 3.488m-2.14 1.861c-1.631 1.1 -3.415 1.651 -5.357 1.651c-4 0 -7.333 -2.333 -10 -7c1.369 -2.395 2.913 -4.175 4.632 -5.341"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-add-category" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"></path>
|
||||
<line x1="12" y1="10" x2="12" y2="16"></line>
|
||||
<line x1="9" y1="13" x2="15" y2="13"></line>
|
||||
</symbol>
|
||||
<symbol id="icon-add-feed" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</symbol>
|
||||
<symbol id="icon-feed-import" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
|
||||
<path d="M5 13v-8a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-5.5m-9.5 -2h7m-3 -3l3 3l-3 3"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-feed-export" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
|
||||
<path d="M11.5 21h-4.5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v5m-5 6h7m-3 -3l3 3l-3 3"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-categories" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M9 4h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2"></path>
|
||||
<path d="M17 17v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-about" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
<polyline points="11 12 12 12 12 16 13 16"></polyline>
|
||||
</symbol>
|
||||
<symbol id="icon-users" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
<path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</symbol>
|
||||
<symbol id="icon-sessions" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<polyline points="12 8 12 12 14 14" />
|
||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||
</symbol>
|
||||
<symbol id="icon-api" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 15h-6.5a2.5 2.5 0 1 1 0 -5h.5"></path>
|
||||
<path d="M15 12v6.5a2.5 2.5 0 1 1 -5 0v-.5"></path>
|
||||
<path d="M12 9h6.5a2.5 2.5 0 1 1 0 5h-.5"></path>
|
||||
<path d="M9 12v-6.5a2.5 2.5 0 0 1 5 0v.5"></path>
|
||||
</symbol>
|
||||
<symbol id="icon-third-party-services" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M5.931 6.936l1.275 4.249m5.607 5.609l4.251 1.275"></path>
|
||||
<path d="M11.683 12.317l5.759 -5.759"></path>
|
||||
<circle cx="5.5" cy="5.5" r="1.5"></circle>
|
||||
<circle cx="18.5" cy="5.5" r="1.5"></circle>
|
||||
<circle cx="18.5" cy="18.5" r="1.5"></circle>
|
||||
<circle cx="8.5" cy="15.5" r="4.5"></circle>
|
||||
</symbol>
|
||||
</svg>
|
1110
internal/ui/static/css/common.css
Normal file
122
internal/ui/static/css/dark.css
Normal file
|
@ -0,0 +1,122 @@
|
|||
:root {
|
||||
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--body-color: #efefef;
|
||||
--body-background: #222;
|
||||
--hr-border-color: #555;
|
||||
--title-color: #aaa;
|
||||
--link-color: #aaa;
|
||||
--link-focus-color: #ddd;
|
||||
--link-hover-color: #ddd;
|
||||
--link-visited-color: #f083e4;
|
||||
|
||||
--header-list-border-color: #333;
|
||||
--header-link-color: #ddd;
|
||||
--header-link-focus-color: rgba(82, 168, 236, 0.85);
|
||||
--header-link-hover-color: rgba(82, 168, 236, 0.85);
|
||||
--header-active-link-color: #9b9494;
|
||||
|
||||
--page-header-title-color: #aaa;
|
||||
--page-header-title-border-color: #333;
|
||||
|
||||
--logo-color: #bbb;
|
||||
--logo-hover-color-span: #bbb;
|
||||
|
||||
--table-border-color: #555;
|
||||
--table-th-background: #333;
|
||||
--table-th-color: #aaa;
|
||||
--table-tr-hover-background-color: #333;
|
||||
--table-tr-hover-color: #aaa;
|
||||
|
||||
--button-primary-border-color: #444;
|
||||
--button-primary-background: #333;
|
||||
--button-primary-color: #efefef;
|
||||
--button-primary-focus-border-color: #888;
|
||||
--button-primary-focus-background: #555;
|
||||
|
||||
--input-border: 1px solid #555;
|
||||
--input-background: #333;
|
||||
--input-color: #ccc;
|
||||
--input-placeholder-color: #666;
|
||||
|
||||
--input-focus-color: #efefef;
|
||||
--input-focus-border-color: rgba(82, 168, 236, 0.8);
|
||||
--input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--alert-color: #efefef;
|
||||
--alert-background-color: #333;
|
||||
--alert-border-color: #444;
|
||||
|
||||
--alert-success-color: #efefef;
|
||||
--alert-success-background-color: #333;
|
||||
--alert-success-border-color: #444;
|
||||
|
||||
--alert-error-color: #efefef;
|
||||
--alert-error-background-color: #333;
|
||||
--alert-error-border-color: #444;
|
||||
|
||||
--alert-info-color: #efefef;
|
||||
--alert-info-background-color: #333;
|
||||
--alert-info-border-color: #444;
|
||||
|
||||
--panel-background: #333;
|
||||
--panel-border-color: #555;
|
||||
--panel-color: #9b9b9b;
|
||||
|
||||
--modal-background: #333;
|
||||
--modal-color: #efefef;
|
||||
--modal-box-shadow: 0 0 10px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--pagination-link-color: #aaa;
|
||||
--pagination-border-color: #333;
|
||||
|
||||
--category-color: #efefef;
|
||||
--category-background-color: #333;
|
||||
--category-border-color: #444;
|
||||
--category-link-color: #999;
|
||||
--category-link-hover-color: #aaa;
|
||||
|
||||
--item-border-color: #666;
|
||||
--item-padding: 4px;
|
||||
--item-title-link-font-weight: 400;
|
||||
|
||||
--item-status-read-title-link-color: #666;
|
||||
--item-status-read-title-focus-color: rgba(82, 168, 236, 0.6);
|
||||
|
||||
--item-meta-focus-color: #aaa;
|
||||
--item-meta-li-color: #ddd;
|
||||
|
||||
--current-item-border-width: 2px;
|
||||
--current-item-border-color: rgba(82, 168, 236, 0.8);
|
||||
--current-item-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--entry-header-border-color: #333;
|
||||
--entry-header-title-link-color: #bbb;
|
||||
--entry-content-color: #999;
|
||||
--entry-content-code-color: #fff;
|
||||
--entry-content-code-background: #555;
|
||||
--entry-content-code-border-color: #888;
|
||||
--entry-content-quote-color: #777;
|
||||
--entry-content-abbr-border-color: #777;
|
||||
--entry-enclosure-border-color: #333;
|
||||
|
||||
--parsing-error-color: #eee;
|
||||
--feed-parsing-error-background-color: #3a1515;
|
||||
--feed-parsing-error-border-style: solid;
|
||||
--feed-parsing-error-border-color: #562222;
|
||||
|
||||
--feed-has-unread-background-color: #1b1a1a;
|
||||
--feed-has-unread-border-style: solid;
|
||||
--feed-has-unread-border-color: rgb(33 57 76);
|
||||
|
||||
--category-has-unread-background-color: #1b1a1a;
|
||||
--category-has-unread-border-style: solid;
|
||||
--category-has-unread-border-color: rgb(33 57 76);
|
||||
|
||||
--keyboard-shortcuts-li-color: #9b9b9b;
|
||||
|
||||
--counter-color: #bbb;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
122
internal/ui/static/css/light.css
Normal file
|
@ -0,0 +1,122 @@
|
|||
:root {
|
||||
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--body-color: #333;
|
||||
--body-background: #fff;
|
||||
--hr-border-color: #ccc;
|
||||
--title-color: #333;
|
||||
--link-color: #3366CC;
|
||||
--link-focus-color: red;
|
||||
--link-hover-color: #333;
|
||||
--link-visited-color: purple;
|
||||
|
||||
--header-list-border-color: #ddd;
|
||||
--header-link-color: #444;
|
||||
--header-link-focus-color: #888;
|
||||
--header-link-hover-color: #888;
|
||||
--header-active-link-color: #444;
|
||||
|
||||
--page-header-title-color: #333;
|
||||
--page-header-title-border-color: #333;
|
||||
|
||||
--logo-color: #000;
|
||||
--logo-hover-color-span: #000;
|
||||
|
||||
--table-border-color: #ddd;
|
||||
--table-th-background: #fcfcfc;
|
||||
--table-th-color: #333;
|
||||
--table-tr-hover-background-color: #f9f9f9;
|
||||
--table-tr-hover-color: #333;
|
||||
|
||||
--button-primary-border-color: #3079ed;
|
||||
--button-primary-background: #4d90fe;
|
||||
--button-primary-color: #fff;
|
||||
--button-primary-focus-border-color: #2f5bb7;
|
||||
--button-primary-focus-background: #357ae8;
|
||||
|
||||
--input-border: 1px solid #ccc;
|
||||
--input-background: #fff;
|
||||
--input-color: #333;
|
||||
--input-placeholder-color: #d0d0d0;
|
||||
|
||||
--input-focus-color: #000;
|
||||
--input-focus-border-color: rgba(82, 168, 236, 0.8);
|
||||
--input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--alert-color: #c09853;
|
||||
--alert-background-color: #fcf8e3;
|
||||
--alert-border-color: #fbeed5;
|
||||
|
||||
--alert-success-color: #468847;
|
||||
--alert-success-background-color: #dff0d8;
|
||||
--alert-success-border-color: #d6e9c6;
|
||||
|
||||
--alert-error-color: #b94a48;
|
||||
--alert-error-background-color: #f2dede;
|
||||
--alert-error-border-color: #eed3d7;
|
||||
|
||||
--alert-info-color: #3a87ad;
|
||||
--alert-info-background-color: #d9edf7;
|
||||
--alert-info-border-color: #bce8f1;
|
||||
|
||||
--panel-background: #fcfcfc;
|
||||
--panel-border-color: #ddd;
|
||||
--panel-color: #333;
|
||||
|
||||
--modal-background: #f0f0f0;
|
||||
--modal-color: #333;
|
||||
--modal-box-shadow: 2px 0 5px 0 #ccc;
|
||||
|
||||
--pagination-link-color: #333;
|
||||
--pagination-border-color: #ddd;
|
||||
|
||||
--category-color: #333;
|
||||
--category-background-color: #fffcd7;
|
||||
--category-border-color: #d5d458;
|
||||
--category-link-color: #000;
|
||||
--category-link-hover-color: #000;
|
||||
|
||||
--item-border-color: #ddd;
|
||||
--item-padding: 5px;
|
||||
--item-title-link-font-weight: 600;
|
||||
|
||||
--item-status-read-title-link-color: #777;
|
||||
--item-status-read-title-focus-color: #777;
|
||||
|
||||
--item-meta-focus-color: #777;
|
||||
--item-meta-li-color: #aaa;
|
||||
|
||||
--current-item-border-width: 3px;
|
||||
--current-item-border-color: #bce;
|
||||
--current-item-box-shadow: none;
|
||||
|
||||
--entry-header-border-color: #ddd;
|
||||
--entry-header-title-link-color: #333;
|
||||
--entry-content-color: #555;
|
||||
--entry-content-code-color: #333;
|
||||
--entry-content-code-background: #f0f0f0;
|
||||
--entry-content-code-border-color: #ddd;
|
||||
--entry-content-quote-color: #666;
|
||||
--entry-content-abbr-border-color: #999;
|
||||
--entry-enclosure-border-color: #333;
|
||||
|
||||
--parsing-error-color: #333;
|
||||
--feed-parsing-error-background-color: #fcf8e3;
|
||||
--feed-parsing-error-border-style: solid;
|
||||
--feed-parsing-error-border-color: #f9e883;
|
||||
|
||||
--feed-has-unread-background-color: #dfd;
|
||||
--feed-has-unread-border-style: solid;
|
||||
--feed-has-unread-border-color: #bee6bc;
|
||||
|
||||
--category-has-unread-background-color: #dfd;
|
||||
--category-has-unread-border-style: solid;
|
||||
--category-has-unread-border-color: #bee6bc;
|
||||
|
||||
--keyboard-shortcuts-li-color: #333;
|
||||
|
||||
--counter-color: #666;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
5
internal/ui/static/css/sans_serif.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
:root {
|
||||
--entry-content-font-weight: 400;
|
||||
--entry-content-font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--entry-content-quote-font-family: var(--entry-content-font-family);
|
||||
}
|
5
internal/ui/static/css/serif.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
:root {
|
||||
--entry-content-font-weight: 300;
|
||||
--entry-content-font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
--entry-content-quote-font-family: var(--entry-content-font-family);
|
||||
}
|
244
internal/ui/static/css/system.css
Normal file
|
@ -0,0 +1,244 @@
|
|||
:root {
|
||||
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--body-color: #333;
|
||||
--body-background: #fff;
|
||||
--hr-border-color: #ccc;
|
||||
--title-color: #333;
|
||||
--link-color: #3366CC;
|
||||
--link-focus-color: red;
|
||||
--link-hover-color: #333;
|
||||
--link-visited-color: purple;
|
||||
|
||||
--header-list-border-color: #ddd;
|
||||
--header-link-color: #444;
|
||||
--header-link-focus-color: #888;
|
||||
--header-link-hover-color: #888;
|
||||
--header-active-link-color: #444;
|
||||
|
||||
--page-header-title-border-color: #333;
|
||||
|
||||
--logo-color: #000;
|
||||
--logo-hover-color-span: #000;
|
||||
|
||||
--table-border-color: #ddd;
|
||||
--table-th-background: #fcfcfc;
|
||||
--table-th-color: #333;
|
||||
--table-tr-hover-background-color: #f9f9f9;
|
||||
--table-tr-hover-color: #333;
|
||||
|
||||
--button-primary-border-color: #3079ed;
|
||||
--button-primary-background: #4d90fe;
|
||||
--button-primary-color: #fff;
|
||||
--button-primary-focus-border-color: #2f5bb7;
|
||||
--button-primary-focus-background: #357ae8;
|
||||
|
||||
--input-border: 1px solid #ccc;
|
||||
--input-background: #fff;
|
||||
--input-color: #333;
|
||||
--input-placeholder-color: #d0d0d0;
|
||||
|
||||
--input-focus-color: #000;
|
||||
--input-focus-border-color: rgba(82, 168, 236, 0.8);
|
||||
--input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--alert-color: #c09853;
|
||||
--alert-background-color: #fcf8e3;
|
||||
--alert-border-color: #fbeed5;
|
||||
|
||||
--alert-success-color: #468847;
|
||||
--alert-success-background-color: #dff0d8;
|
||||
--alert-success-border-color: #d6e9c6;
|
||||
|
||||
--alert-error-color: #b94a48;
|
||||
--alert-error-background-color: #f2dede;
|
||||
--alert-error-border-color: #eed3d7;
|
||||
|
||||
--alert-info-color: #3a87ad;
|
||||
--alert-info-background-color: #d9edf7;
|
||||
--alert-info-border-color: #bce8f1;
|
||||
|
||||
--panel-background: #fcfcfc;
|
||||
--panel-border-color: #ddd;
|
||||
--panel-color: #333;
|
||||
|
||||
--modal-background: #f0f0f0;
|
||||
--modal-color: #333;
|
||||
--modal-box-shadow: 2px 0 5px 0 #ccc;
|
||||
|
||||
--pagination-link-color: #333;
|
||||
--pagination-border-color: #ddd;
|
||||
|
||||
--category-color: #333;
|
||||
--category-background-color: #fffcd7;
|
||||
--category-border-color: #d5d458;
|
||||
--category-link-color: #000;
|
||||
--category-link-hover-color: #000;
|
||||
|
||||
--item-border-color: #ddd;
|
||||
--item-padding: 5px;
|
||||
--item-title-link-font-weight: 600;
|
||||
|
||||
--item-status-read-title-link-color: #777;
|
||||
--item-status-read-title-focus-color: #777;
|
||||
|
||||
--item-meta-focus-color: #777;
|
||||
--item-meta-li-color: #aaa;
|
||||
|
||||
--current-item-border-width: 3px;
|
||||
--current-item-border-color: #bce;
|
||||
--current-item-box-shadow: none;
|
||||
|
||||
--entry-header-border-color: #ddd;
|
||||
--entry-header-title-link-color: #333;
|
||||
--entry-content-color: #555;
|
||||
--entry-content-code-color: #333;
|
||||
--entry-content-code-background: #f0f0f0;
|
||||
--entry-content-code-border-color: #ddd;
|
||||
--entry-content-quote-color: #666;
|
||||
--entry-content-abbr-border-color: #999;
|
||||
--entry-enclosure-border-color: #333;
|
||||
|
||||
--parsing-error-color: #333;
|
||||
--feed-parsing-error-background-color: #fcf8e3;
|
||||
--feed-parsing-error-border-style: solid;
|
||||
--feed-parsing-error-border-color: #f9e883;
|
||||
|
||||
--feed-has-unread-background-color: #dfd;
|
||||
--feed-has-unread-border-style: solid;
|
||||
--feed-has-unread-border-color: #bee6bc;
|
||||
|
||||
--category-has-unread-background-color: #dfd;
|
||||
--category-has-unread-border-style: solid;
|
||||
--category-has-unread-border-color: #bee6bc;
|
||||
|
||||
--keyboard-shortcuts-li-color: #333;
|
||||
|
||||
--counter-color: #666;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--body-color: #efefef;
|
||||
--body-background: #222;
|
||||
--hr-border-color: #555;
|
||||
--title-color: #aaa;
|
||||
--link-color: #aaa;
|
||||
--link-focus-color: #ddd;
|
||||
--link-hover-color: #ddd;
|
||||
--link-visited-color: #f083e4;
|
||||
|
||||
--header-list-border-color: #333;
|
||||
--header-link-color: #ddd;
|
||||
--header-link-focus-color: rgba(82, 168, 236, 0.85);
|
||||
--header-link-hover-color: rgba(82, 168, 236, 0.85);
|
||||
--header-active-link-color: #9b9494;
|
||||
|
||||
--page-header-title-border-color: #333;
|
||||
|
||||
--logo-color: #bbb;
|
||||
--logo-hover-color-span: #bbb;
|
||||
|
||||
--table-border-color: #555;
|
||||
--table-th-background: #333;
|
||||
--table-th-color: #aaa;
|
||||
--table-tr-hover-background-color: #333;
|
||||
--table-tr-hover-color: #aaa;
|
||||
|
||||
--button-primary-border-color: #444;
|
||||
--button-primary-background: #333;
|
||||
--button-primary-color: #efefef;
|
||||
--button-primary-focus-border-color: #888;
|
||||
--button-primary-focus-background: #555;
|
||||
|
||||
--input-border: 1px solid #555;
|
||||
--input-background: #333;
|
||||
--input-color: #ccc;
|
||||
--input-placeholder-color: #666;
|
||||
|
||||
--input-focus-color: #efefef;
|
||||
--input-focus-border-color: rgba(82, 168, 236, 0.8);
|
||||
--input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--alert-color: #efefef;
|
||||
--alert-background-color: #333;
|
||||
--alert-border-color: #444;
|
||||
|
||||
--alert-success-color: #efefef;
|
||||
--alert-success-background-color: #333;
|
||||
--alert-success-border-color: #444;
|
||||
|
||||
--alert-error-color: #efefef;
|
||||
--alert-error-background-color: #333;
|
||||
--alert-error-border-color: #444;
|
||||
|
||||
--alert-info-color: #efefef;
|
||||
--alert-info-background-color: #333;
|
||||
--alert-info-border-color: #444;
|
||||
|
||||
--panel-background: #333;
|
||||
--panel-border-color: #555;
|
||||
--panel-color: #9b9b9b;
|
||||
|
||||
--modal-background: #333;
|
||||
--modal-color: #efefef;
|
||||
--modal-box-shadow: 0 0 10px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--pagination-link-color: #aaa;
|
||||
--pagination-border-color: #333;
|
||||
|
||||
--category-color: #efefef;
|
||||
--category-background-color: #333;
|
||||
--category-border-color: #444;
|
||||
--category-link-color: #999;
|
||||
--category-link-hover-color: #aaa;
|
||||
|
||||
--item-border-color: #666;
|
||||
--item-padding: 4px;
|
||||
--item-title-link-font-weight: 400;
|
||||
|
||||
--item-status-read-title-link-color: #666;
|
||||
--item-status-read-title-focus-color: rgba(82, 168, 236, 0.6);
|
||||
|
||||
--item-meta-focus-color: #aaa;
|
||||
--item-meta-li-color: #ddd;
|
||||
|
||||
--current-item-border-width: 2px;
|
||||
--current-item-border-color: rgba(82, 168, 236, 0.8);
|
||||
--current-item-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
|
||||
--entry-header-border-color: #333;
|
||||
--entry-header-title-link-color: #bbb;
|
||||
--entry-content-color: #999;
|
||||
--entry-content-code-color: #fff;
|
||||
--entry-content-code-background: #555;
|
||||
--entry-content-code-border-color: #888;
|
||||
--entry-content-quote-color: #777;
|
||||
--entry-content-abbr-border-color: #777;
|
||||
--entry-enclosure-border-color: #333;
|
||||
|
||||
--parsing-error-color: #eee;
|
||||
--feed-parsing-error-background-color: #3a1515;
|
||||
--feed-parsing-error-border-style: solid;
|
||||
--feed-parsing-error-border-color: #562222;
|
||||
|
||||
--feed-has-unread-background-color: #1b1a1a;
|
||||
--feed-has-unread-border-style: solid;
|
||||
--feed-has-unread-border-color: rgb(33 57 76);
|
||||
|
||||
--category-has-unread-background-color: #1b1a1a;
|
||||
--category-has-unread-border-style: solid;
|
||||
--category-has-unread-border-color: rgb(33 57 76);
|
||||
|
||||
--keyboard-shortcuts-li-color: #9b9b9b;
|
||||
|
||||
--counter-color: #bbb;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
3
internal/ui/static/js/.jshintrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"esversion": 8
|
||||
}
|
692
internal/ui/static/js/app.js
Normal file
|
@ -0,0 +1,692 @@
|
|||
// OnClick attaches a listener to the elements that match the selector.
|
||||
function onClick(selector, callback, noPreventDefault) {
|
||||
let elements = document.querySelectorAll(selector);
|
||||
elements.forEach((element) => {
|
||||
element.onclick = (event) => {
|
||||
if (!noPreventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
callback(event);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function onAuxClick(selector, callback, noPreventDefault) {
|
||||
let elements = document.querySelectorAll(selector);
|
||||
elements.forEach((element) => {
|
||||
element.onauxclick = (event) => {
|
||||
if (!noPreventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
callback(event);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Show and hide the main menu on mobile devices.
|
||||
function toggleMainMenu() {
|
||||
let menu = document.querySelector(".header nav ul");
|
||||
if (DomHelper.isVisible(menu)) {
|
||||
menu.style.display = "none";
|
||||
} else {
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
let searchElement = document.querySelector(".header .search");
|
||||
if (DomHelper.isVisible(searchElement)) {
|
||||
searchElement.style.display = "none";
|
||||
} else {
|
||||
searchElement.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click events for the main menu (<li> and <a>).
|
||||
function onClickMainMenuListItem(event) {
|
||||
let element = event.target;
|
||||
|
||||
if (element.tagName === "A") {
|
||||
window.location.href = element.getAttribute("href");
|
||||
} else {
|
||||
window.location.href = element.querySelector("a").getAttribute("href");
|
||||
}
|
||||
}
|
||||
|
||||
// Change the button label when the page is loading.
|
||||
function handleSubmitButtons() {
|
||||
let elements = document.querySelectorAll("form");
|
||||
elements.forEach((element) => {
|
||||
element.onsubmit = () => {
|
||||
let button = element.querySelector("button");
|
||||
|
||||
if (button) {
|
||||
button.innerHTML = button.dataset.labelLoading;
|
||||
button.disabled = true;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Set cursor focus to the search input.
|
||||
function setFocusToSearchInput(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let toggleSwitchElement = document.querySelector(".search-toggle-switch");
|
||||
if (toggleSwitchElement) {
|
||||
toggleSwitchElement.style.display = "none";
|
||||
}
|
||||
|
||||
let searchFormElement = document.querySelector(".search-form");
|
||||
if (searchFormElement) {
|
||||
searchFormElement.style.display = "block";
|
||||
}
|
||||
|
||||
let searchInputElement = document.getElementById("search-input");
|
||||
if (searchInputElement) {
|
||||
searchInputElement.focus();
|
||||
searchInputElement.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Show modal dialog with the list of keyboard shortcuts.
|
||||
function showKeyboardShortcuts() {
|
||||
let template = document.getElementById("keyboard-shortcuts");
|
||||
if (template !== null) {
|
||||
ModalHandler.open(template.content, "dialog-title");
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as read visible items of the current page.
|
||||
function markPageAsRead() {
|
||||
let items = DomHelper.getVisibleElements(".items .item");
|
||||
let entryIDs = [];
|
||||
|
||||
items.forEach((element) => {
|
||||
element.classList.add("item-status-read");
|
||||
entryIDs.push(parseInt(element.dataset.id, 10));
|
||||
});
|
||||
|
||||
if (entryIDs.length > 0) {
|
||||
updateEntriesStatus(entryIDs, "read", () => {
|
||||
// Make sure the Ajax request reach the server before we reload the page.
|
||||
|
||||
let element = document.querySelector("a[data-action=markPageAsRead]");
|
||||
let showOnlyUnread = false;
|
||||
if (element) {
|
||||
showOnlyUnread = element.dataset.showOnlyUnread || false;
|
||||
}
|
||||
|
||||
if (showOnlyUnread) {
|
||||
window.location.href = window.location.href;
|
||||
} else {
|
||||
goToPage("next", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle entry status changes from the list view and entry view.
|
||||
* Focus the next or the previous entry if it exists.
|
||||
* @param {string} item Item to focus: "previous" or "next".
|
||||
* @param {Element} element
|
||||
* @param {boolean} setToRead
|
||||
*/
|
||||
function handleEntryStatus(item, element, setToRead) {
|
||||
let toasting = !element;
|
||||
let currentEntry = findEntry(element);
|
||||
if (currentEntry) {
|
||||
if (!setToRead || currentEntry.querySelector("a[data-toggle-status]").dataset.value == "unread") {
|
||||
toggleEntryStatus(currentEntry, toasting);
|
||||
}
|
||||
if (isListView() && currentEntry.classList.contains('current-item')) {
|
||||
switch (item) {
|
||||
case "previous":
|
||||
goToListItem(-1);
|
||||
break;
|
||||
case "next":
|
||||
goToListItem(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Change the entry status to the opposite value.
|
||||
function toggleEntryStatus(element, toasting) {
|
||||
let entryID = parseInt(element.dataset.id, 10);
|
||||
let link = element.querySelector("a[data-toggle-status]");
|
||||
|
||||
let currentStatus = link.dataset.value;
|
||||
let newStatus = currentStatus === "read" ? "unread" : "read";
|
||||
|
||||
link.querySelector("span").innerHTML = link.dataset.labelLoading;
|
||||
updateEntriesStatus([entryID], newStatus, () => {
|
||||
let iconElement, label;
|
||||
|
||||
if (currentStatus === "read") {
|
||||
iconElement = document.querySelector("template#icon-read");
|
||||
label = link.dataset.labelRead;
|
||||
if (toasting) {
|
||||
showToast(link.dataset.toastUnread, iconElement);
|
||||
}
|
||||
} else {
|
||||
iconElement = document.querySelector("template#icon-unread");
|
||||
label = link.dataset.labelUnread;
|
||||
if (toasting) {
|
||||
showToast(link.dataset.toastRead, iconElement);
|
||||
}
|
||||
}
|
||||
|
||||
link.innerHTML = iconElement.innerHTML + '<span class="icon-label">' + label + '</span>';
|
||||
link.dataset.value = newStatus;
|
||||
|
||||
if (element.classList.contains("item-status-" + currentStatus)) {
|
||||
element.classList.remove("item-status-" + currentStatus);
|
||||
element.classList.add("item-status-" + newStatus);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mark a single entry as read.
|
||||
function markEntryAsRead(element) {
|
||||
if (element.classList.contains("item-status-unread")) {
|
||||
element.classList.remove("item-status-unread");
|
||||
element.classList.add("item-status-read");
|
||||
|
||||
let entryID = parseInt(element.dataset.id, 10);
|
||||
updateEntriesStatus([entryID], "read");
|
||||
}
|
||||
}
|
||||
|
||||
// Send the Ajax request to refresh all feeds in the background
|
||||
function handleRefreshAllFeeds() {
|
||||
let url = document.body.dataset.refreshAllFeedsUrl;
|
||||
let request = new RequestBuilder(url);
|
||||
|
||||
request.withCallback(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
request.withHttpMethod("GET");
|
||||
request.execute();
|
||||
}
|
||||
|
||||
// Send the Ajax request to change entries statuses.
|
||||
function updateEntriesStatus(entryIDs, status, callback) {
|
||||
let url = document.body.dataset.entriesStatusUrl;
|
||||
let request = new RequestBuilder(url);
|
||||
request.withBody({entry_ids: entryIDs, status: status});
|
||||
request.withCallback((resp) => {
|
||||
resp.json().then(count => {
|
||||
if (callback) {
|
||||
callback(resp);
|
||||
}
|
||||
|
||||
if (status === "read") {
|
||||
decrementUnreadCounter(count);
|
||||
} else {
|
||||
incrementUnreadCounter(count);
|
||||
}
|
||||
});
|
||||
});
|
||||
request.execute();
|
||||
}
|
||||
|
||||
// Handle save entry from list view and entry view.
|
||||
function handleSaveEntry(element) {
|
||||
let toasting = !element;
|
||||
let currentEntry = findEntry(element);
|
||||
if (currentEntry) {
|
||||
saveEntry(currentEntry.querySelector("a[data-save-entry]"), toasting);
|
||||
}
|
||||
}
|
||||
|
||||
// Send the Ajax request to save an entry.
|
||||
function saveEntry(element, toasting) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.dataset.completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousInnerHTML = element.innerHTML;
|
||||
element.innerHTML = '<span class="icon-label">' + element.dataset.labelLoading + '</span>';
|
||||
|
||||
let request = new RequestBuilder(element.dataset.saveUrl);
|
||||
request.withCallback(() => {
|
||||
element.innerHTML = previousInnerHTML;
|
||||
element.dataset.completed = true;
|
||||
if (toasting) {
|
||||
let iconElement = document.querySelector("template#icon-save");
|
||||
showToast(element.dataset.toastDone, iconElement);
|
||||
}
|
||||
});
|
||||
request.execute();
|
||||
}
|
||||
|
||||
// Handle bookmark from the list view and entry view.
|
||||
function handleBookmark(element) {
|
||||
let toasting = !element;
|
||||
let currentEntry = findEntry(element);
|
||||
if (currentEntry) {
|
||||
toggleBookmark(currentEntry, toasting);
|
||||
}
|
||||
}
|
||||
|
||||
// Send the Ajax request and change the icon when bookmarking an entry.
|
||||
function toggleBookmark(parentElement, toasting) {
|
||||
let element = parentElement.querySelector("a[data-toggle-bookmark]");
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.innerHTML = '<span class="icon-label">' + element.dataset.labelLoading + '</span>';
|
||||
|
||||
let request = new RequestBuilder(element.dataset.bookmarkUrl);
|
||||
request.withCallback(() => {
|
||||
|
||||
let currentStarStatus = element.dataset.value;
|
||||
let newStarStatus = currentStarStatus === "star" ? "unstar" : "star";
|
||||
|
||||
let iconElement, label;
|
||||
|
||||
if (currentStarStatus === "star") {
|
||||
iconElement = document.querySelector("template#icon-star");
|
||||
label = element.dataset.labelStar;
|
||||
if (toasting) {
|
||||
showToast(element.dataset.toastUnstar, iconElement);
|
||||
}
|
||||
} else {
|
||||
iconElement = document.querySelector("template#icon-unstar");
|
||||
label = element.dataset.labelUnstar;
|
||||
if (toasting) {
|
||||
showToast(element.dataset.toastStar, iconElement);
|
||||
}
|
||||
}
|
||||
|
||||
element.innerHTML = iconElement.innerHTML + '<span class="icon-label">' + label + '</span>';
|
||||
element.dataset.value = newStarStatus;
|
||||
});
|
||||
request.execute();
|
||||
}
|
||||
|
||||
// Send the Ajax request to download the original web page.
|
||||
function handleFetchOriginalContent() {
|
||||
if (isListView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let element = document.querySelector("a[data-fetch-content-entry]");
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousInnerHTML = element.innerHTML;
|
||||
element.innerHTML = '<span class="icon-label">' + element.dataset.labelLoading + '</span>';
|
||||
|
||||
let request = new RequestBuilder(element.dataset.fetchContentUrl);
|
||||
request.withCallback((response) => {
|
||||
element.innerHTML = previousInnerHTML;
|
||||
|
||||
response.json().then((data) => {
|
||||
if (data.hasOwnProperty("content") && data.hasOwnProperty("reading_time")) {
|
||||
document.querySelector(".entry-content").innerHTML = data.content;
|
||||
document.querySelector(".entry-reading-time").innerHTML = data.reading_time;
|
||||
}
|
||||
});
|
||||
});
|
||||
request.execute();
|
||||
}
|
||||
|
||||
function openOriginalLink(openLinkInCurrentTab) {
|
||||
let entryLink = document.querySelector(".entry h1 a");
|
||||
if (entryLink !== null) {
|
||||
if (openLinkInCurrentTab) {
|
||||
window.location.href = entryLink.getAttribute("href");
|
||||
} else {
|
||||
DomHelper.openNewTab(entryLink.getAttribute("href"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]");
|
||||
if (currentItemOriginalLink !== null) {
|
||||
DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));
|
||||
|
||||
let currentItem = document.querySelector(".current-item");
|
||||
// If we are not on the list of starred items, move to the next item
|
||||
if (document.location.href != document.querySelector('a[data-page=starred]').href) {
|
||||
goToListItem(1);
|
||||
}
|
||||
markEntryAsRead(currentItem);
|
||||
}
|
||||
}
|
||||
|
||||
function openCommentLink(openLinkInCurrentTab) {
|
||||
if (!isListView()) {
|
||||
let entryLink = document.querySelector("a[data-comments-link]");
|
||||
if (entryLink !== null) {
|
||||
if (openLinkInCurrentTab) {
|
||||
window.location.href = entryLink.getAttribute("href");
|
||||
} else {
|
||||
DomHelper.openNewTab(entryLink.getAttribute("href"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
let currentItemCommentsLink = document.querySelector(".current-item a[data-comments-link]");
|
||||
if (currentItemCommentsLink !== null) {
|
||||
DomHelper.openNewTab(currentItemCommentsLink.getAttribute("href"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedItem() {
|
||||
let currentItemLink = document.querySelector(".current-item .item-title a");
|
||||
if (currentItemLink !== null) {
|
||||
window.location.href = currentItemLink.getAttribute("href");
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribeFromFeed() {
|
||||
let unsubscribeLinks = document.querySelectorAll("[data-action=remove-feed]");
|
||||
if (unsubscribeLinks.length === 1) {
|
||||
let unsubscribeLink = unsubscribeLinks[0];
|
||||
|
||||
let request = new RequestBuilder(unsubscribeLink.dataset.url);
|
||||
request.withCallback(() => {
|
||||
if (unsubscribeLink.dataset.redirectUrl) {
|
||||
window.location.href = unsubscribeLink.dataset.redirectUrl;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
request.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} page Page to redirect to.
|
||||
* @param {boolean} fallbackSelf Refresh actual page if the page is not found.
|
||||
*/
|
||||
function goToPage(page, fallbackSelf) {
|
||||
let element = document.querySelector("a[data-page=" + page + "]");
|
||||
|
||||
if (element) {
|
||||
document.location.href = element.href;
|
||||
} else if (fallbackSelf) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
if (isListView()) {
|
||||
goToListItem(-1);
|
||||
} else {
|
||||
goToPage("previous");
|
||||
}
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
if (isListView()) {
|
||||
goToListItem(1);
|
||||
} else {
|
||||
goToPage("next");
|
||||
}
|
||||
}
|
||||
|
||||
function goToFeedOrFeeds() {
|
||||
if (isEntry()) {
|
||||
goToFeed();
|
||||
} else {
|
||||
goToPage('feeds');
|
||||
}
|
||||
}
|
||||
|
||||
function goToFeed() {
|
||||
if (isEntry()) {
|
||||
let feedAnchor = document.querySelector("span.entry-website a");
|
||||
if (feedAnchor !== null) {
|
||||
window.location.href = feedAnchor.href;
|
||||
}
|
||||
} else {
|
||||
let currentItemFeed = document.querySelector(".current-item a[data-feed-link]");
|
||||
if (currentItemFeed !== null) {
|
||||
window.location.href = currentItemFeed.getAttribute("href");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset How many items to jump for focus.
|
||||
*/
|
||||
function goToListItem(offset) {
|
||||
let items = DomHelper.getVisibleElements(".items .item");
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.querySelector(".current-item") === null) {
|
||||
items[0].classList.add("current-item");
|
||||
items[0].querySelector('.item-header a').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].classList.contains("current-item")) {
|
||||
items[i].classList.remove("current-item");
|
||||
|
||||
let item = items[(i + offset + items.length) % items.length];
|
||||
|
||||
item.classList.add("current-item");
|
||||
DomHelper.scrollPageTo(item);
|
||||
item.querySelector('.item-header a').focus();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCurrentItem() {
|
||||
let currentItem = document.querySelector(".current-item");
|
||||
if (currentItem !== null) {
|
||||
DomHelper.scrollPageTo(currentItem, true);
|
||||
}
|
||||
}
|
||||
|
||||
function decrementUnreadCounter(n) {
|
||||
updateUnreadCounterValue((current) => {
|
||||
return current - n;
|
||||
});
|
||||
}
|
||||
|
||||
function incrementUnreadCounter(n) {
|
||||
updateUnreadCounterValue((current) => {
|
||||
return current + n;
|
||||
});
|
||||
}
|
||||
|
||||
function updateUnreadCounterValue(callback) {
|
||||
let counterElements = document.querySelectorAll("span.unread-counter");
|
||||
counterElements.forEach((element) => {
|
||||
let oldValue = parseInt(element.textContent, 10);
|
||||
element.innerHTML = callback(oldValue);
|
||||
});
|
||||
|
||||
if (window.location.href.endsWith('/unread')) {
|
||||
let oldValue = parseInt(document.title.split('(')[1], 10);
|
||||
let newValue = callback(oldValue);
|
||||
|
||||
document.title = document.title.replace(
|
||||
/(.*?)\(\d+\)(.*?)/,
|
||||
function (match, prefix, suffix, offset, string) {
|
||||
return prefix + '(' + newValue + ')' + suffix;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isEntry() {
|
||||
return document.querySelector("section.entry") !== null;
|
||||
}
|
||||
|
||||
function isListView() {
|
||||
return document.querySelector(".items") !== null;
|
||||
}
|
||||
|
||||
function findEntry(element) {
|
||||
if (isListView()) {
|
||||
if (element) {
|
||||
return DomHelper.findParent(element, "item");
|
||||
} else {
|
||||
return document.querySelector(".current-item");
|
||||
}
|
||||
} else {
|
||||
return document.querySelector(".entry");
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirmationMessage(linkElement, callback) {
|
||||
if (linkElement.tagName != 'A') {
|
||||
linkElement = linkElement.parentNode;
|
||||
}
|
||||
|
||||
linkElement.style.display = "none";
|
||||
|
||||
let containerElement = linkElement.parentNode;
|
||||
let questionElement = document.createElement("span");
|
||||
|
||||
function createLoadingElement() {
|
||||
let loadingElement = document.createElement("span");
|
||||
loadingElement.className = "loading";
|
||||
loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));
|
||||
|
||||
questionElement.remove();
|
||||
containerElement.appendChild(loadingElement);
|
||||
}
|
||||
|
||||
let yesElement = document.createElement("a");
|
||||
yesElement.href = "#";
|
||||
yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));
|
||||
yesElement.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
createLoadingElement();
|
||||
|
||||
callback(linkElement.dataset.url, linkElement.dataset.redirectUrl);
|
||||
};
|
||||
|
||||
let noElement = document.createElement("a");
|
||||
noElement.href = "#";
|
||||
noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));
|
||||
noElement.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const noActionUrl = linkElement.dataset.noActionUrl;
|
||||
if (noActionUrl) {
|
||||
createLoadingElement();
|
||||
|
||||
callback(noActionUrl, linkElement.dataset.redirectUrl);
|
||||
} else {
|
||||
linkElement.style.display = "inline";
|
||||
questionElement.remove();
|
||||
}
|
||||
};
|
||||
|
||||
questionElement.className = "confirm";
|
||||
questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " "));
|
||||
questionElement.appendChild(yesElement);
|
||||
questionElement.appendChild(document.createTextNode(", "));
|
||||
questionElement.appendChild(noElement);
|
||||
|
||||
containerElement.appendChild(questionElement);
|
||||
}
|
||||
|
||||
function showToast(label, iconElement) {
|
||||
if (!label || !iconElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toastMsgElement = document.getElementById("toast-msg");
|
||||
if (toastMsgElement) {
|
||||
toastMsgElement.innerHTML = iconElement.innerHTML + '<span class="icon-label">' + label + '</span>';
|
||||
|
||||
const toastElementWrapper = document.getElementById("toast-wrapper");
|
||||
if (toastElementWrapper) {
|
||||
toastElementWrapper.classList.remove('toast-animate');
|
||||
setTimeout(function () {
|
||||
toastElementWrapper.classList.add('toast-animate');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate to the new subscription page. */
|
||||
function goToAddSubscription() {
|
||||
window.location.href = document.body.dataset.addSubscriptionUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* save player position to allow to resume playback later
|
||||
* @param {Element} playerElement
|
||||
*/
|
||||
function handlePlayerProgressionSave(playerElement) {
|
||||
const currentPositionInSeconds = Math.floor(playerElement.currentTime); // we do not need a precise value
|
||||
const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10);
|
||||
const recordInterval = 10;
|
||||
|
||||
// we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds
|
||||
if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) ||
|
||||
currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval)
|
||||
) {
|
||||
playerElement.dataset.lastPosition = currentPositionInSeconds.toString();
|
||||
let request = new RequestBuilder(playerElement.dataset.saveUrl);
|
||||
request.withBody({progression: currentPositionInSeconds});
|
||||
request.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* handle new share entires and already shared entries
|
||||
*/
|
||||
function handleShare() {
|
||||
let link = document.querySelector('a[data-share-status]');
|
||||
let title = document.querySelector("body > main > section > header > h1 > a");
|
||||
if (link.dataset.shareStatus === "shared") {
|
||||
checkShareAPI(title, link.href);
|
||||
}
|
||||
if (link.dataset.shareStatus === "share") {
|
||||
let request = new RequestBuilder(link.href);
|
||||
request.withCallback((r) => {
|
||||
checkShareAPI(title, r.url);
|
||||
});
|
||||
request.withHttpMethod("GET");
|
||||
request.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* wrapper for Web Share API
|
||||
*/
|
||||
function checkShareAPI(title, url) {
|
||||
if (!navigator.canShare) {
|
||||
console.error("Your browser doesn't support the Web Share API.");
|
||||
window.location = url;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
navigator.share({
|
||||
title: title,
|
||||
url: url
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
126
internal/ui/static/js/bootstrap.js
vendored
Normal file
|
@ -0,0 +1,126 @@
|
|||
document.addEventListener("DOMContentLoaded", function () {
|
||||
handleSubmitButtons();
|
||||
|
||||
if (!document.querySelector("body[data-disable-keyboard-shortcuts=true]")) {
|
||||
let keyboardHandler = new KeyboardHandler();
|
||||
keyboardHandler.on("g u", () => goToPage("unread"));
|
||||
keyboardHandler.on("g b", () => goToPage("starred"));
|
||||
keyboardHandler.on("g h", () => goToPage("history"));
|
||||
keyboardHandler.on("g f", () => goToFeedOrFeeds());
|
||||
keyboardHandler.on("g c", () => goToPage("categories"));
|
||||
keyboardHandler.on("g s", () => goToPage("settings"));
|
||||
keyboardHandler.on("ArrowLeft", () => goToPrevious());
|
||||
keyboardHandler.on("ArrowRight", () => goToNext());
|
||||
keyboardHandler.on("k", () => goToPrevious());
|
||||
keyboardHandler.on("p", () => goToPrevious());
|
||||
keyboardHandler.on("j", () => goToNext());
|
||||
keyboardHandler.on("n", () => goToNext());
|
||||
keyboardHandler.on("h", () => goToPage("previous"));
|
||||
keyboardHandler.on("l", () => goToPage("next"));
|
||||
keyboardHandler.on("z t", () => scrollToCurrentItem());
|
||||
keyboardHandler.on("o", () => openSelectedItem());
|
||||
keyboardHandler.on("v", () => openOriginalLink());
|
||||
keyboardHandler.on("V", () => openOriginalLink(true));
|
||||
keyboardHandler.on("c", () => openCommentLink());
|
||||
keyboardHandler.on("C", () => openCommentLink(true));
|
||||
keyboardHandler.on("m", () => handleEntryStatus("next"));
|
||||
keyboardHandler.on("M", () => handleEntryStatus("previous"));
|
||||
keyboardHandler.on("A", () => markPageAsRead());
|
||||
keyboardHandler.on("s", () => handleSaveEntry());
|
||||
keyboardHandler.on("d", () => handleFetchOriginalContent());
|
||||
keyboardHandler.on("f", () => handleBookmark());
|
||||
keyboardHandler.on("F", () => goToFeed());
|
||||
keyboardHandler.on("R", () => handleRefreshAllFeeds());
|
||||
keyboardHandler.on("?", () => showKeyboardShortcuts());
|
||||
keyboardHandler.on("+", () => goToAddSubscription());
|
||||
keyboardHandler.on("#", () => unsubscribeFromFeed());
|
||||
keyboardHandler.on("/", (e) => setFocusToSearchInput(e));
|
||||
keyboardHandler.on("a", () => {
|
||||
let enclosureElement = document.querySelector('.entry-enclosures');
|
||||
if (enclosureElement) {
|
||||
enclosureElement.toggleAttribute('open');
|
||||
}
|
||||
});
|
||||
keyboardHandler.on("Escape", () => ModalHandler.close());
|
||||
keyboardHandler.listen();
|
||||
}
|
||||
|
||||
let touchHandler = new TouchHandler();
|
||||
touchHandler.listen();
|
||||
|
||||
onClick("a[data-save-entry]", (event) => handleSaveEntry(event.target));
|
||||
onClick("a[data-toggle-bookmark]", (event) => handleBookmark(event.target));
|
||||
onClick("a[data-fetch-content-entry]", () => handleFetchOriginalContent());
|
||||
onClick("a[data-action=search]", (event) => setFocusToSearchInput(event));
|
||||
onClick("a[data-share-status]", () => handleShare());
|
||||
onClick("a[data-action=markPageAsRead]", (event) => handleConfirmationMessage(event.target, () => markPageAsRead()));
|
||||
onClick("a[data-toggle-status]", (event) => handleEntryStatus("next", event.target));
|
||||
|
||||
onClick("a[data-confirm]", (event) => handleConfirmationMessage(event.target, (url, redirectURL) => {
|
||||
let request = new RequestBuilder(url);
|
||||
|
||||
request.withCallback((response) => {
|
||||
if (redirectURL) {
|
||||
window.location.href = redirectURL;
|
||||
} else if (response && response.redirected && response.url) {
|
||||
window.location.href = response.url;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
request.execute();
|
||||
}));
|
||||
|
||||
onClick("a[data-original-link='true']", (event) => {
|
||||
handleEntryStatus("next", event.target, true);
|
||||
}, true);
|
||||
onAuxClick("a[data-original-link='true']", (event) => {
|
||||
if (event.button == 1) {
|
||||
handleEntryStatus("next", event.target, true);
|
||||
}
|
||||
}, true);
|
||||
|
||||
if (document.documentElement.clientWidth < 600) {
|
||||
onClick(".logo", () => toggleMainMenu());
|
||||
onClick(".header nav li", (event) => onClickMainMenuListItem(event));
|
||||
}
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
let scriptElement = document.getElementById("service-worker-script");
|
||||
if (scriptElement) {
|
||||
navigator.serviceWorker.register(scriptElement.src);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent Chrome 67 and earlier from automatically showing the prompt.
|
||||
e.preventDefault();
|
||||
|
||||
let deferredPrompt = e;
|
||||
const promptHomeScreen = document.getElementById('prompt-home-screen');
|
||||
if (promptHomeScreen) {
|
||||
promptHomeScreen.style.display = "block";
|
||||
|
||||
const btnAddToHomeScreen = document.getElementById('btn-add-to-home-screen');
|
||||
if (btnAddToHomeScreen) {
|
||||
btnAddToHomeScreen.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(() => {
|
||||
deferredPrompt = null;
|
||||
promptHomeScreen.style.display = "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// enclosure media player position save & resume
|
||||
const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]");
|
||||
elements.forEach((element) => {
|
||||
// we set the current time of media players
|
||||
if (element.dataset.lastPosition){ element.currentTime = element.dataset.lastPosition; }
|
||||
element.ontimeupdate = () => handlePlayerProgressionSave(element);
|
||||
});
|
||||
});
|
65
internal/ui/static/js/dom_helper.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
class DomHelper {
|
||||
static isVisible(element) {
|
||||
return element.offsetParent !== null;
|
||||
}
|
||||
|
||||
static openNewTab(url) {
|
||||
let win = window.open("");
|
||||
win.opener = null;
|
||||
win.location = url;
|
||||
win.focus();
|
||||
}
|
||||
|
||||
static scrollPageTo(element, evenIfOnScreen) {
|
||||
let windowScrollPosition = window.pageYOffset;
|
||||
let windowHeight = document.documentElement.clientHeight;
|
||||
let viewportPosition = windowScrollPosition + windowHeight;
|
||||
let itemBottomPosition = element.offsetTop + element.offsetHeight;
|
||||
|
||||
if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) {
|
||||
window.scrollTo(0, element.offsetTop - 10);
|
||||
}
|
||||
}
|
||||
|
||||
static getVisibleElements(selector) {
|
||||
let elements = document.querySelectorAll(selector);
|
||||
let result = [];
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (this.isVisible(elements[i])) {
|
||||
result.push(elements[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static findParent(element, selector) {
|
||||
for (; element && element !== document; element = element.parentNode) {
|
||||
if (element.classList.contains(selector)) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static hasPassiveEventListenerOption() {
|
||||
var passiveSupported = false;
|
||||
|
||||
try {
|
||||
var options = Object.defineProperty({}, "passive", {
|
||||
get: function() {
|
||||
passiveSupported = true;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("test", options, options);
|
||||
window.removeEventListener("test", options, options);
|
||||
} catch(err) {
|
||||
passiveSupported = false;
|
||||
}
|
||||
|
||||
return passiveSupported;
|
||||
}
|
||||
}
|
72
internal/ui/static/js/keyboard_handler.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
class KeyboardHandler {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.shortcuts = {};
|
||||
this.triggers = [];
|
||||
}
|
||||
|
||||
on(combination, callback) {
|
||||
this.shortcuts[combination] = callback;
|
||||
this.triggers.push(combination.split(" ")[0]);
|
||||
}
|
||||
|
||||
listen() {
|
||||
document.onkeydown = (event) => {
|
||||
let key = this.getKey(event);
|
||||
if (this.isEventIgnored(event, key) || this.isModifierKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.queue.push(key);
|
||||
|
||||
for (let combination in this.shortcuts) {
|
||||
let keys = combination.split(" ");
|
||||
|
||||
if (keys.every((value, index) => value === this.queue[index])) {
|
||||
this.queue = [];
|
||||
this.shortcuts[combination](event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keys.length === 1 && key === keys[0]) {
|
||||
this.queue = [];
|
||||
this.shortcuts[combination](event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.queue.length >= 2) {
|
||||
this.queue = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
isEventIgnored(event, key) {
|
||||
return event.target.tagName === "INPUT" ||
|
||||
event.target.tagName === "TEXTAREA" ||
|
||||
(this.queue.length < 1 && !this.triggers.includes(key));
|
||||
}
|
||||
|
||||
isModifierKeyDown(event) {
|
||||
return event.getModifierState("Control") || event.getModifierState("Alt") || event.getModifierState("Meta");
|
||||
}
|
||||
|
||||
getKey(event) {
|
||||
const mapping = {
|
||||
'Esc': 'Escape',
|
||||
'Up': 'ArrowUp',
|
||||
'Down': 'ArrowDown',
|
||||
'Left': 'ArrowLeft',
|
||||
'Right': 'ArrowRight'
|
||||
};
|
||||
|
||||
for (let key in mapping) {
|
||||
if (mapping.hasOwnProperty(key) && key === event.key) {
|
||||
return mapping[key];
|
||||
}
|
||||
}
|
||||
|
||||
return event.key;
|
||||
}
|
||||
}
|
101
internal/ui/static/js/modal_handler.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
class ModalHandler {
|
||||
static exists() {
|
||||
return document.getElementById("modal-container") !== null;
|
||||
}
|
||||
|
||||
static getModalContainer() {
|
||||
return document.getElementById("modal-container");
|
||||
}
|
||||
|
||||
static getFocusableElements() {
|
||||
let container = this.getModalContainer();
|
||||
|
||||
if (container === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
}
|
||||
|
||||
static setupFocusTrap() {
|
||||
let focusableElements = this.getFocusableElements();
|
||||
|
||||
if (focusableElements === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let firstFocusableElement = focusableElements[0];
|
||||
let lastFocusableElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
this.getModalContainer().onkeydown = (e) => {
|
||||
if (e.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is only one focusable element in the dialog we always want to focus that one with the tab key.
|
||||
// This handles the special case of having just one focusable element in a dialog where keyboard focus is placed on an element that is not in the tab order.
|
||||
if (focusableElements.length === 1) {
|
||||
firstFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey && document.activeElement === firstFocusableElement) {
|
||||
lastFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
} else if (!e.shiftKey && document.activeElement === lastFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static open(fragment, initialFocusElementId) {
|
||||
if (ModalHandler.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeElement = document.activeElement;
|
||||
|
||||
let container = document.createElement("div");
|
||||
container.id = "modal-container";
|
||||
container.setAttribute("role", "dialog");
|
||||
container.appendChild(document.importNode(fragment, true));
|
||||
document.body.appendChild(container);
|
||||
|
||||
let closeButton = document.querySelector("button.btn-close-modal");
|
||||
if (closeButton !== null) {
|
||||
closeButton.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
ModalHandler.close();
|
||||
};
|
||||
}
|
||||
|
||||
let initialFocusElement;
|
||||
if (initialFocusElementId !== undefined) {
|
||||
initialFocusElement = document.getElementById(initialFocusElementId);
|
||||
} else {
|
||||
let focusableElements = this.getFocusableElements();
|
||||
if (focusableElements !== null) {
|
||||
initialFocusElement = focusableElements[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (initialFocusElement !== undefined) {
|
||||
initialFocusElement.focus();
|
||||
}
|
||||
|
||||
this.setupFocusTrap();
|
||||
}
|
||||
|
||||
static close() {
|
||||
let container = this.getModalContainer();
|
||||
if (container !== null) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
|
||||
if (this.activeElement !== undefined && this.activeElement !== null) {
|
||||
this.activeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|