1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-09-15 18:57:04 +00:00

Move internal packages to an internal folder

For reference: https://go.dev/doc/go1.4#internalpackages
This commit is contained in:
Frédéric Guillot 2023-08-10 19:46:45 -07:00
parent c234903255
commit 168a870c02
433 changed files with 1121 additions and 1123 deletions

39
internal/ui/about.go Normal file
View 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"))
}

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

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

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

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

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

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

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

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

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

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

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

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

View 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
}

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

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

View 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))
}

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

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

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

View 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})
}

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

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

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

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

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

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

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

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

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

View 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))
}

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

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

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

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

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

View 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
View 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
View 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
}

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

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

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

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

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

View 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
View 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
View 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
View 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
View 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(),
)
}

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

View 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()))
}

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

View 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)
}

View 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
View 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
View 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
View 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()
})
}

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

View 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}
}

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

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

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

View 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
View 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()
})
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View 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>

File diff suppressed because it is too large Load diff

View 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;
}

View 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;
}

View 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);
}

View 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);
}

View 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;
}
}

View file

@ -0,0 +1,3 @@
{
"esversion": 8
}

View 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
View 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);
});
});

View 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;
}
}

View 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;
}
}

View 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();
}
}
}

Some files were not shown because too many files have changed in this diff Show more