aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/api/controller/category.go97
-rw-r--r--server/api/controller/controller.go21
-rw-r--r--server/api/controller/entry.go156
-rw-r--r--server/api/controller/feed.go138
-rw-r--r--server/api/controller/subscription.go35
-rw-r--r--server/api/controller/user.go163
-rw-r--r--server/api/payload/payload.go93
-rw-r--r--server/core/context.go99
-rw-r--r--server/core/handler.go57
-rw-r--r--server/core/html_response.go58
-rw-r--r--server/core/json_response.go94
-rw-r--r--server/core/request.go108
-rw-r--r--server/core/response.go63
-rw-r--r--server/core/xml_response.go21
-rw-r--r--server/middleware/basic_auth.go61
-rw-r--r--server/middleware/csrf.go48
-rw-r--r--server/middleware/middleware.go31
-rw-r--r--server/middleware/session.go72
-rw-r--r--server/route/route.go37
-rw-r--r--server/routes.go132
-rw-r--r--server/server.go33
-rw-r--r--server/static/bin.go12
-rw-r--r--server/static/bin/favicon.icobin0 -> 16958 bytes
-rw-r--r--server/static/css.go14
-rw-r--r--server/static/css/black.css197
-rw-r--r--server/static/css/common.css654
-rw-r--r--server/static/js.go52
-rw-r--r--server/static/js/app.js351
-rw-r--r--server/template/common.go111
-rw-r--r--server/template/helper/LICENSE21
-rw-r--r--server/template/helper/elapsed.go61
-rw-r--r--server/template/helper/elapsed_test.go37
-rw-r--r--server/template/html/about.html37
-rw-r--r--server/template/html/add_subscription.html45
-rw-r--r--server/template/html/categories.html50
-rw-r--r--server/template/html/category_entries.html47
-rw-r--r--server/template/html/choose_subscription.html36
-rw-r--r--server/template/html/common/entry_pagination.html19
-rw-r--r--server/template/html/common/layout.html59
-rw-r--r--server/template/html/common/pagination.html19
-rw-r--r--server/template/html/create_category.html27
-rw-r--r--server/template/html/create_user.html41
-rw-r--r--server/template/html/edit_category.html30
-rw-r--r--server/template/html/edit_feed.html61
-rw-r--r--server/template/html/edit_user.html44
-rw-r--r--server/template/html/entry.html75
-rw-r--r--server/template/html/feed_entries.html58
-rw-r--r--server/template/html/feeds.html65
-rw-r--r--server/template/html/history.html42
-rw-r--r--server/template/html/import.html34
-rw-r--r--server/template/html/login.html23
-rw-r--r--server/template/html/sessions.html42
-rw-r--r--server/template/html/settings.html63
-rw-r--r--server/template/html/unread.html47
-rw-r--r--server/template/html/users.html51
-rw-r--r--server/template/template.go117
-rw-r--r--server/template/views.go966
-rw-r--r--server/ui/controller/about.go24
-rw-r--r--server/ui/controller/category.go228
-rw-r--r--server/ui/controller/controller.go56
-rw-r--r--server/ui/controller/entry.go375
-rw-r--r--server/ui/controller/feed.go209
-rw-r--r--server/ui/controller/history.go47
-rw-r--r--server/ui/controller/icon.go31
-rw-r--r--server/ui/controller/login.go91
-rw-r--r--server/ui/controller/opml.go63
-rw-r--r--server/ui/controller/pagination.go46
-rw-r--r--server/ui/controller/proxy.go49
-rw-r--r--server/ui/controller/session.go49
-rw-r--r--server/ui/controller/settings.go92
-rw-r--r--server/ui/controller/static.go41
-rw-r--r--server/ui/controller/subscription.go127
-rw-r--r--server/ui/controller/unread.go43
-rw-r--r--server/ui/controller/user.go231
-rw-r--r--server/ui/filter/image_proxy_filter.go35
-rw-r--r--server/ui/filter/image_proxy_filter_test.go38
-rw-r--r--server/ui/form/auth.go30
-rw-r--r--server/ui/form/category.go34
-rw-r--r--server/ui/form/feed.go53
-rw-r--r--server/ui/form/settings.go62
-rw-r--r--server/ui/form/subscription.go36
-rw-r--r--server/ui/form/user.go80
-rw-r--r--server/ui/payload/payload.go31
83 files changed, 7426 insertions, 0 deletions
diff --git a/server/api/controller/category.go b/server/api/controller/category.go
new file mode 100644
index 0000000..01aa14b
--- /dev/null
+++ b/server/api/controller/category.go
@@ -0,0 +1,97 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/server/api/payload"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+// CreateCategory is the API handler to create a new category.
+func (c *Controller) CreateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ category, err := payload.DecodeCategoryPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ category.UserID = ctx.GetUserID()
+ if err := category.ValidateCategoryCreation(); err != nil {
+ response.Json().ServerError(err)
+ return
+ }
+
+ err = c.store.CreateCategory(category)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to create this category"))
+ return
+ }
+
+ response.Json().Created(category)
+}
+
+// UpdateCategory is the API handler to update a category.
+func (c *Controller) UpdateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ categoryID, err := request.GetIntegerParam("categoryID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ category, err := payload.DecodeCategoryPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ category.UserID = ctx.GetUserID()
+ category.ID = categoryID
+ if err := category.ValidateCategoryModification(); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ err = c.store.UpdateCategory(category)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to update this category"))
+ return
+ }
+
+ response.Json().Created(category)
+}
+
+// GetCategories is the API handler to get a list of categories for a given user.
+func (c *Controller) GetCategories(ctx *core.Context, request *core.Request, response *core.Response) {
+ categories, err := c.store.GetCategories(ctx.GetUserID())
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch categories"))
+ return
+ }
+
+ response.Json().Standard(categories)
+}
+
+// RemoveCategory is the API handler to remove a category.
+func (c *Controller) RemoveCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ categoryID, err := request.GetIntegerParam("categoryID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ if !c.store.CategoryExists(userID, categoryID) {
+ response.Json().NotFound(errors.New("Category not found"))
+ return
+ }
+
+ if err := c.store.RemoveCategory(userID, categoryID); err != nil {
+ response.Json().ServerError(errors.New("Unable to remove this category"))
+ return
+ }
+
+ response.Json().NoContent()
+}
diff --git a/server/api/controller/controller.go b/server/api/controller/controller.go
new file mode 100644
index 0000000..629d71a
--- /dev/null
+++ b/server/api/controller/controller.go
@@ -0,0 +1,21 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "github.com/miniflux/miniflux2/reader/feed"
+ "github.com/miniflux/miniflux2/storage"
+)
+
+// Controller holds all handlers for the API.
+type Controller struct {
+ store *storage.Storage
+ feedHandler *feed.Handler
+}
+
+// NewController creates a new controller.
+func NewController(store *storage.Storage, feedHandler *feed.Handler) *Controller {
+ return &Controller{store: store, feedHandler: feedHandler}
+}
diff --git a/server/api/controller/entry.go b/server/api/controller/entry.go
new file mode 100644
index 0000000..92420e0
--- /dev/null
+++ b/server/api/controller/entry.go
@@ -0,0 +1,156 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/api/payload"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+// GetEntry is the API handler to get a single feed entry.
+func (c *Controller) GetEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ entryID, err := request.GetIntegerParam("entryID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.GetUserTimezone())
+ builder.WithFeedID(feedID)
+ builder.WithEntryID(entryID)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch this entry from the database"))
+ return
+ }
+
+ if entry == nil {
+ response.Json().NotFound(errors.New("Entry not found"))
+ return
+ }
+
+ response.Json().Standard(entry)
+}
+
+// GetFeedEntries is the API handler to get all feed entries.
+func (c *Controller) GetFeedEntries(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ status := request.GetQueryStringParam("status", "")
+ if status != "" {
+ if err := model.ValidateEntryStatus(status); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+ }
+
+ order := request.GetQueryStringParam("order", "id")
+ if err := model.ValidateEntryOrder(order); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ direction := request.GetQueryStringParam("direction", "desc")
+ if err := model.ValidateDirection(direction); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ limit := request.GetQueryIntegerParam("limit", 100)
+ offset := request.GetQueryIntegerParam("offset", 0)
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.GetUserTimezone())
+ builder.WithFeedID(feedID)
+ builder.WithStatus(status)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.DefaultSortingDirection)
+ builder.WithOffset(offset)
+ builder.WithLimit(limit)
+
+ entries, err := builder.GetEntries()
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch the list of entries"))
+ return
+ }
+
+ count, err := builder.CountEntries()
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to count the number of entries"))
+ return
+ }
+
+ response.Json().Standard(&payload.EntriesResponse{Total: count, Entries: entries})
+}
+
+// SetEntryStatus is the API handler to change the status of an entry.
+func (c *Controller) SetEntryStatus(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ entryID, err := request.GetIntegerParam("entryID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ status, err := payload.DecodeEntryStatusPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(errors.New("Invalid JSON payload"))
+ return
+ }
+
+ if err := model.ValidateEntryStatus(status); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.GetUserTimezone())
+ builder.WithFeedID(feedID)
+ builder.WithEntryID(entryID)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch this entry from the database"))
+ return
+ }
+
+ if entry == nil {
+ response.Json().NotFound(errors.New("Entry not found"))
+ return
+ }
+
+ if err := c.store.SetEntriesStatus(userID, []int64{entry.ID}, status); err != nil {
+ response.Json().ServerError(errors.New("Unable to change entry status"))
+ return
+ }
+
+ entry, err = builder.GetEntry()
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch this entry from the database"))
+ return
+ }
+
+ response.Json().Standard(entry)
+}
diff --git a/server/api/controller/feed.go b/server/api/controller/feed.go
new file mode 100644
index 0000000..6b76fec
--- /dev/null
+++ b/server/api/controller/feed.go
@@ -0,0 +1,138 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/server/api/payload"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+// CreateFeed is the API handler to create a new feed.
+func (c *Controller) CreateFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedURL, categoryID, err := payload.DecodeFeedCreationPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ feed, err := c.feedHandler.CreateFeed(userID, categoryID, feedURL)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to create this feed"))
+ return
+ }
+
+ response.Json().Created(feed)
+}
+
+// RefreshFeed is the API handler to refresh a feed.
+func (c *Controller) RefreshFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ err = c.feedHandler.RefreshFeed(userID, feedID)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to refresh this feed"))
+ return
+ }
+
+ response.Json().NoContent()
+}
+
+// UpdateFeed is the API handler that is used to update a feed.
+func (c *Controller) UpdateFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ newFeed, err := payload.DecodeFeedModificationPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ originalFeed, err := c.store.GetFeedById(userID, feedID)
+ if err != nil {
+ response.Json().NotFound(errors.New("Unable to find this feed"))
+ return
+ }
+
+ if originalFeed == nil {
+ response.Json().NotFound(errors.New("Feed not found"))
+ return
+ }
+
+ originalFeed.Merge(newFeed)
+ if err := c.store.UpdateFeed(originalFeed); err != nil {
+ response.Json().ServerError(errors.New("Unable to update this feed"))
+ return
+ }
+
+ response.Json().Created(originalFeed)
+}
+
+// GetFeeds is the API handler that get all feeds that belongs to the given user.
+func (c *Controller) GetFeeds(ctx *core.Context, request *core.Request, response *core.Response) {
+ feeds, err := c.store.GetFeeds(ctx.GetUserID())
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch feeds from the database"))
+ return
+ }
+
+ response.Json().Standard(feeds)
+}
+
+// GetFeed is the API handler to get a feed.
+func (c *Controller) GetFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ feed, err := c.store.GetFeedById(userID, feedID)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch this feed"))
+ return
+ }
+
+ if feed == nil {
+ response.Json().NotFound(errors.New("Feed not found"))
+ return
+ }
+
+ response.Json().Standard(feed)
+}
+
+// RemoveFeed is the API handler to remove a feed.
+func (c *Controller) RemoveFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.GetUserID()
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ if !c.store.FeedExists(userID, feedID) {
+ response.Json().NotFound(errors.New("Feed not found"))
+ return
+ }
+
+ if err := c.store.RemoveFeed(userID, feedID); err != nil {
+ response.Json().ServerError(errors.New("Unable to remove this feed"))
+ return
+ }
+
+ response.Json().NoContent()
+}
diff --git a/server/api/controller/subscription.go b/server/api/controller/subscription.go
new file mode 100644
index 0000000..cb442d9
--- /dev/null
+++ b/server/api/controller/subscription.go
@@ -0,0 +1,35 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "errors"
+ "fmt"
+ "github.com/miniflux/miniflux2/reader/subscription"
+ "github.com/miniflux/miniflux2/server/api/payload"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+// GetSubscriptions is the API handler to find subscriptions.
+func (c *Controller) GetSubscriptions(ctx *core.Context, request *core.Request, response *core.Response) {
+ websiteURL, err := payload.DecodeURLPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ subscriptions, err := subscription.FindSubscriptions(websiteURL)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to discover subscriptions"))
+ return
+ }
+
+ if subscriptions == nil {
+ response.Json().NotFound(fmt.Errorf("No subscription found"))
+ return
+ }
+
+ response.Json().Standard(subscriptions)
+}
diff --git a/server/api/controller/user.go b/server/api/controller/user.go
new file mode 100644
index 0000000..c8276b3
--- /dev/null
+++ b/server/api/controller/user.go
@@ -0,0 +1,163 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/server/api/payload"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+// CreateUser is the API handler to create a new user.
+func (c *Controller) CreateUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ if !ctx.IsAdminUser() {
+ response.Json().Forbidden()
+ return
+ }
+
+ user, err := payload.DecodeUserPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ if err := user.ValidateUserCreation(); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ if c.store.UserExists(user.Username) {
+ response.Json().BadRequest(errors.New("This user already exists"))
+ return
+ }
+
+ err = c.store.CreateUser(user)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to create this user"))
+ return
+ }
+
+ user.Password = ""
+ response.Json().Created(user)
+}
+
+// UpdateUser is the API handler to update the given user.
+func (c *Controller) UpdateUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ if !ctx.IsAdminUser() {
+ response.Json().Forbidden()
+ return
+ }
+
+ userID, err := request.GetIntegerParam("userID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ user, err := payload.DecodeUserPayload(request.GetBody())
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ if err := user.ValidateUserModification(); err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ originalUser, err := c.store.GetUserById(userID)
+ if err != nil {
+ response.Json().BadRequest(errors.New("Unable to fetch this user from the database"))
+ return
+ }
+
+ if originalUser == nil {
+ response.Json().NotFound(errors.New("User not found"))
+ return
+ }
+
+ originalUser.Merge(user)
+ if err = c.store.UpdateUser(originalUser); err != nil {
+ response.Json().ServerError(errors.New("Unable to update this user"))
+ return
+ }
+
+ response.Json().Created(originalUser)
+}
+
+// GetUsers is the API handler to get the list of users.
+func (c *Controller) GetUsers(ctx *core.Context, request *core.Request, response *core.Response) {
+ if !ctx.IsAdminUser() {
+ response.Json().Forbidden()
+ return
+ }
+
+ users, err := c.store.GetUsers()
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch the list of users"))
+ return
+ }
+
+ response.Json().Standard(users)
+}
+
+// GetUser is the API handler to fetch the given user.
+func (c *Controller) GetUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ if !ctx.IsAdminUser() {
+ response.Json().Forbidden()
+ return
+ }
+
+ userID, err := request.GetIntegerParam("userID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ user, err := c.store.GetUserById(userID)
+ if err != nil {
+ response.Json().BadRequest(errors.New("Unable to fetch this user from the database"))
+ return
+ }
+
+ if user == nil {
+ response.Json().NotFound(errors.New("User not found"))
+ return
+ }
+
+ response.Json().Standard(user)
+}
+
+// RemoveUser is the API handler to remove an existing user.
+func (c *Controller) RemoveUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ if !ctx.IsAdminUser() {
+ response.Json().Forbidden()
+ return
+ }
+
+ userID, err := request.GetIntegerParam("userID")
+ if err != nil {
+ response.Json().BadRequest(err)
+ return
+ }
+
+ user, err := c.store.GetUserById(userID)
+ if err != nil {
+ response.Json().ServerError(errors.New("Unable to fetch this user from the database"))
+ return
+ }
+
+ if user == nil {
+ response.Json().NotFound(errors.New("User not found"))
+ return
+ }
+
+ if err := c.store.RemoveUser(user.ID); err != nil {
+ response.Json().BadRequest(errors.New("Unable to remove this user from the database"))
+ return
+ }
+
+ response.Json().NoContent()
+}
diff --git a/server/api/payload/payload.go b/server/api/payload/payload.go
new file mode 100644
index 0000000..e26f9fc
--- /dev/null
+++ b/server/api/payload/payload.go
@@ -0,0 +1,93 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package payload
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/miniflux/miniflux2/model"
+ "io"
+)
+
+type EntriesResponse struct {
+ Total int `json:"total"`
+ Entries model.Entries `json:"entries"`
+}
+
+func DecodeUserPayload(data io.Reader) (*model.User, error) {
+ var user model.User
+
+ decoder := json.NewDecoder(data)
+ if err := decoder.Decode(&user); err != nil {
+ return nil, fmt.Errorf("Unable to decode user JSON object: %v", err)
+ }
+
+ return &user, nil
+}
+
+func DecodeURLPayload(data io.Reader) (string, error) {
+ type payload struct {
+ URL string `json:"url"`
+ }
+
+ var p payload
+ decoder := json.NewDecoder(data)
+ if err := decoder.Decode(&p); err != nil {
+ return "", fmt.Errorf("invalid JSON payload: %v", err)
+ }
+
+ return p.URL, nil
+}
+
+func DecodeEntryStatusPayload(data io.Reader) (string, error) {
+ type payload struct {
+ Status string `json:"status"`
+ }
+
+ var p payload
+ decoder := json.NewDecoder(data)
+ if err := decoder.Decode(&p); err != nil {
+ return "", fmt.Errorf("invalid JSON payload: %v", err)
+ }
+
+ return p.Status, nil
+}
+
+func DecodeFeedCreationPayload(data io.Reader) (string, int64, error) {
+ type payload struct {
+ FeedURL string `json:"feed_url"`
+ CategoryID int64 `json:"category_id"`
+ }
+
+ var p payload
+ decoder := json.NewDecoder(data)
+ if err := decoder.Decode(&p); err != nil {
+ return "", 0, fmt.Errorf("invalid JSON payload: %v", err)
+ }
+
+ return p.FeedURL, p.CategoryID, nil
+}
+
+func DecodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
+ var feed model.Feed
+
+ decoder := json.NewDecoder(data)
+ if err := decoder.Decode(&feed); err != nil {
+ return nil, fmt.Errorf("Unable to decode feed JSON object: %v", err)
+ }
+
+ return &feed, nil
+}
+
+func DecodeCategoryPayload(data io.Reader) (*model.Category, error) {
+ var category model.Category
+
+ decoder := json.NewDecoder(data)
+ if err := decoder.Decode(&category); err != nil {
+ return nil, fmt.Errorf("Unable to decode category JSON object: %v", err)
+ }
+
+ return &category, nil
+}
diff --git a/server/core/context.go b/server/core/context.go
new file mode 100644
index 0000000..c9d2dc2
--- /dev/null
+++ b/server/core/context.go
@@ -0,0 +1,99 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/route"
+ "github.com/miniflux/miniflux2/storage"
+ "log"
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+// Context contains helper functions related to the current request.
+type Context struct {
+ writer http.ResponseWriter
+ request *http.Request
+ store *storage.Storage
+ router *mux.Router
+ user *model.User
+}
+
+// IsAdminUser checks if the logged user is administrator.
+func (c *Context) IsAdminUser() bool {
+ if v := c.request.Context().Value("IsAdminUser"); v != nil {
+ return v.(bool)
+ }
+ return false
+}
+
+// GetUserTimezone returns the timezone used by the logged user.
+func (c *Context) GetUserTimezone() string {
+ if v := c.request.Context().Value("UserTimezone"); v != nil {
+ return v.(string)
+ }
+ return "UTC"
+}
+
+// IsAuthenticated returns a boolean if the user is authenticated.
+func (c *Context) IsAuthenticated() bool {
+ if v := c.request.Context().Value("IsAuthenticated"); v != nil {
+ return v.(bool)
+ }
+ return false
+}
+
+// GetUserID returns the UserID of the logged user.
+func (c *Context) GetUserID() int64 {
+ if v := c.request.Context().Value("UserId"); v != nil {
+ return v.(int64)
+ }
+ return 0
+}
+
+// GetLoggedUser returns all properties related to the logged user.
+func (c *Context) GetLoggedUser() *model.User {
+ if c.user == nil {
+ var err error
+ c.user, err = c.store.GetUserById(c.GetUserID())
+ if err != nil {
+ log.Fatalln(err)
+ }
+
+ if c.user == nil {
+ log.Fatalln("Unable to find user from context")
+ }
+ }
+
+ return c.user
+}
+
+// GetUserLanguage get the locale used by the current logged user.
+func (c *Context) GetUserLanguage() string {
+ user := c.GetLoggedUser()
+ return user.Language
+}
+
+// GetCsrfToken returns the current CSRF token.
+func (c *Context) GetCsrfToken() string {
+ if v := c.request.Context().Value("CsrfToken"); v != nil {
+ return v.(string)
+ }
+
+ log.Println("No CSRF token in context!")
+ return ""
+}
+
+// GetRoute returns the path for the given arguments.
+func (c *Context) GetRoute(name string, args ...interface{}) string {
+ return route.GetRoute(c.router, name, args...)
+}
+
+// NewContext creates a new Context.
+func NewContext(w http.ResponseWriter, r *http.Request, store *storage.Storage, router *mux.Router) *Context {
+ return &Context{writer: w, request: r, store: store, router: router}
+}
diff --git a/server/core/handler.go b/server/core/handler.go
new file mode 100644
index 0000000..4320564
--- /dev/null
+++ b/server/core/handler.go
@@ -0,0 +1,57 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "github.com/miniflux/miniflux2/helper"
+ "github.com/miniflux/miniflux2/locale"
+ "github.com/miniflux/miniflux2/server/middleware"
+ "github.com/miniflux/miniflux2/server/template"
+ "github.com/miniflux/miniflux2/storage"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/mux"
+)
+
+type HandlerFunc func(ctx *Context, request *Request, response *Response)
+
+type Handler struct {
+ store *storage.Storage
+ translator *locale.Translator
+ template *template.TemplateEngine
+ router *mux.Router
+ middleware *middleware.MiddlewareChain
+}
+
+func (h *Handler) Use(f HandlerFunc) http.Handler {
+ return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer helper.ExecutionTime(time.Now(), r.URL.Path)
+ log.Println(r.Method, r.URL.Path)
+
+ ctx := NewContext(w, r, h.store, h.router)
+ request := NewRequest(w, r)
+ response := NewResponse(w, r, h.template)
+
+ if ctx.IsAuthenticated() {
+ h.template.SetLanguage(ctx.GetUserLanguage())
+ } else {
+ h.template.SetLanguage("en_US")
+ }
+
+ f(ctx, request, response)
+ }))
+}
+
+func NewHandler(store *storage.Storage, router *mux.Router, template *template.TemplateEngine, translator *locale.Translator, middleware *middleware.MiddlewareChain) *Handler {
+ return &Handler{
+ store: store,
+ translator: translator,
+ router: router,
+ template: template,
+ middleware: middleware,
+ }
+}
diff --git a/server/core/html_response.go b/server/core/html_response.go
new file mode 100644
index 0000000..9f493d2
--- /dev/null
+++ b/server/core/html_response.go
@@ -0,0 +1,58 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "github.com/miniflux/miniflux2/server/template"
+ "log"
+ "net/http"
+)
+
+type HtmlResponse struct {
+ writer http.ResponseWriter
+ request *http.Request
+ template *template.TemplateEngine
+}
+
+func (h *HtmlResponse) Render(template string, args map[string]interface{}) {
+ h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ h.template.Execute(h.writer, template, args)
+}
+
+func (h *HtmlResponse) ServerError(err error) {
+ h.writer.WriteHeader(http.StatusInternalServerError)
+ h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ if err != nil {
+ log.Println(err)
+ h.writer.Write([]byte("Internal Server Error: " + err.Error()))
+ } else {
+ h.writer.Write([]byte("Internal Server Error"))
+ }
+}
+
+func (h *HtmlResponse) BadRequest(err error) {
+ h.writer.WriteHeader(http.StatusBadRequest)
+ h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ if err != nil {
+ log.Println(err)
+ h.writer.Write([]byte("Bad Request: " + err.Error()))
+ } else {
+ h.writer.Write([]byte("Bad Request"))
+ }
+}
+
+func (h *HtmlResponse) NotFound() {
+ h.writer.WriteHeader(http.StatusNotFound)
+ h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ h.writer.Write([]byte("Page Not Found"))
+}
+
+func (h *HtmlResponse) Forbidden() {
+ h.writer.WriteHeader(http.StatusForbidden)
+ h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ h.writer.Write([]byte("Access Forbidden"))
+}
diff --git a/server/core/json_response.go b/server/core/json_response.go
new file mode 100644
index 0000000..51a9ede
--- /dev/null
+++ b/server/core/json_response.go
@@ -0,0 +1,94 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+)
+
+type JsonResponse struct {
+ writer http.ResponseWriter
+ request *http.Request
+}
+
+func (j *JsonResponse) Standard(v interface{}) {
+ j.writer.WriteHeader(http.StatusOK)
+ j.commonHeaders()
+ j.writer.Write(j.toJSON(v))
+}
+
+func (j *JsonResponse) Created(v interface{}) {
+ j.writer.WriteHeader(http.StatusCreated)
+ j.commonHeaders()
+ j.writer.Write(j.toJSON(v))
+}
+
+func (j *JsonResponse) NoContent() {
+ j.writer.WriteHeader(http.StatusNoContent)
+ j.commonHeaders()
+}
+
+func (j *JsonResponse) BadRequest(err error) {
+ log.Println("[API:BadRequest]", err)
+ j.writer.WriteHeader(http.StatusBadRequest)
+ j.commonHeaders()
+
+ if err != nil {
+ j.writer.Write(j.encodeError(err))
+ }
+}
+
+func (j *JsonResponse) NotFound(err error) {
+ log.Println("[API:NotFound]", err)
+ j.writer.WriteHeader(http.StatusNotFound)
+ j.commonHeaders()
+ j.writer.Write(j.encodeError(err))
+}
+
+func (j *JsonResponse) ServerError(err error) {
+ log.Println("[API:ServerError]", err)
+ j.writer.WriteHeader(http.StatusInternalServerError)
+ j.commonHeaders()
+ j.writer.Write(j.encodeError(err))
+}
+
+func (j *JsonResponse) Forbidden() {
+ log.Println("[API:Forbidden]")
+ j.writer.WriteHeader(http.StatusForbidden)
+ j.commonHeaders()
+ j.writer.Write(j.encodeError(errors.New("Access Forbidden")))
+}
+
+func (j *JsonResponse) commonHeaders() {
+ j.writer.Header().Set("Accept", "application/json")
+ j.writer.Header().Set("Content-Type", "application/json")
+}
+
+func (j *JsonResponse) encodeError(err error) []byte {
+ type errorMsg struct {
+ ErrorMessage string `json:"error_message"`
+ }
+
+ tmp := errorMsg{ErrorMessage: err.Error()}
+ data, err := json.Marshal(tmp)
+ if err != nil {
+ log.Println("encodeError:", err)
+ }
+
+ return data
+}
+
+func (j *JsonResponse) toJSON(v interface{}) []byte {
+ b, err := json.Marshal(v)
+ if err != nil {
+ log.Println("Unable to convert interface to JSON:", err)
+ return []byte("")
+ }
+
+ return b
+}
diff --git a/server/core/request.go b/server/core/request.go
new file mode 100644
index 0000000..189e249
--- /dev/null
+++ b/server/core/request.go
@@ -0,0 +1,108 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "mime/multipart"
+ "net/http"
+ "strconv"
+
+ "github.com/gorilla/mux"
+)
+
+type Request struct {
+ writer http.ResponseWriter
+ request *http.Request
+}
+
+func (r *Request) GetRequest() *http.Request {
+ return r.request
+}
+
+func (r *Request) GetBody() io.ReadCloser {
+ return r.request.Body
+}
+
+func (r *Request) GetHeaders() http.Header {
+ return r.request.Header
+}
+
+func (r *Request) GetScheme() string {
+ return r.request.URL.Scheme
+}
+
+func (r *Request) GetFile(name string) (multipart.File, *multipart.FileHeader, error) {
+ return r.request.FormFile(name)
+}
+
+func (r *Request) IsHTTPS() bool {
+ return r.request.URL.Scheme == "https"
+}
+
+func (r *Request) GetCookie(name string) string {
+ cookie, err := r.request.Cookie(name)
+ if err == http.ErrNoCookie {
+ return ""
+ }
+
+ return cookie.Value
+}
+
+func (r *Request) GetIntegerParam(param string) (int64, error) {
+ vars := mux.Vars(r.request)
+ value, err := strconv.Atoi(vars[param])
+ if err != nil {
+ log.Println(err)
+ return 0, fmt.Errorf("%s parameter is not an integer", param)
+ }
+
+ if value < 0 {
+ return 0, nil
+ }
+
+ return int64(value), nil
+}
+
+func (r *Request) GetStringParam(param, defaultValue string) string {
+ vars := mux.Vars(r.request)
+ value := vars[param]
+ if value == "" {
+ value = defaultValue
+ }
+ return value
+}
+
+func (r *Request) GetQueryStringParam(param, defaultValue string) string {
+ value := r.request.URL.Query().Get(param)
+ if value == "" {
+ value = defaultValue
+ }
+ return value
+}
+
+func (r *Request) GetQueryIntegerParam(param string, defaultValue int) int {
+ value := r.request.URL.Query().Get(param)
+ if value == "" {
+ return defaultValue
+ }
+
+ val, err := strconv.Atoi(value)
+ if err != nil {
+ return defaultValue
+ }
+
+ if val < 0 {
+ return defaultValue
+ }
+
+ return val
+}
+
+func NewRequest(w http.ResponseWriter, r *http.Request) *Request {
+ return &Request{writer: w, request: r}
+}
diff --git a/server/core/response.go b/server/core/response.go
new file mode 100644
index 0000000..4acbe95
--- /dev/null
+++ b/server/core/response.go
@@ -0,0 +1,63 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "github.com/miniflux/miniflux2/server/template"
+ "net/http"
+ "time"
+)
+
+type Response struct {
+ writer http.ResponseWriter
+ request *http.Request
+ template *template.TemplateEngine
+}
+
+func (r *Response) SetCookie(cookie *http.Cookie) {
+ http.SetCookie(r.writer, cookie)
+}
+
+func (r *Response) Json() *JsonResponse {
+ r.commonHeaders()
+ return &JsonResponse{writer: r.writer, request: r.request}
+}
+
+func (r *Response) Html() *HtmlResponse {
+ r.commonHeaders()
+ return &HtmlResponse{writer: r.writer, request: r.request, template: r.template}
+}
+
+func (r *Response) Xml() *XmlResponse {
+ r.commonHeaders()
+ return &XmlResponse{writer: r.writer, request: r.request}
+}
+
+func (r *Response) Redirect(path string) {
+ http.Redirect(r.writer, r.request, path, http.StatusFound)
+}
+
+func (r *Response) Cache(mime_type, etag string, content []byte, duration time.Duration) {
+ r.writer.Header().Set("Content-Type", mime_type)
+ r.writer.Header().Set("Etag", etag)
+ r.writer.Header().Set("Cache-Control", "public")
+ r.writer.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
+
+ if etag == r.request.Header.Get("If-None-Match") {
+ r.writer.WriteHeader(http.StatusNotModified)
+ } else {
+ r.writer.Write(content)
+ }
+}
+
+func (r *Response) commonHeaders() {
+ r.writer.Header().Set("X-XSS-Protection", "1; mode=block")
+ r.writer.Header().Set("X-Content-Type-Options", "nosniff")
+ r.writer.Header().Set("X-Frame-Options", "DENY")
+}
+
+func NewResponse(w http.ResponseWriter, r *http.Request, template *template.TemplateEngine) *Response {
+ return &Response{writer: w, request: r, template: template}
+}
diff --git a/server/core/xml_response.go b/server/core/xml_response.go
new file mode 100644
index 0000000..6ffd5c9
--- /dev/null
+++ b/server/core/xml_response.go
@@ -0,0 +1,21 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package core
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type XmlResponse struct {
+ writer http.ResponseWriter
+ request *http.Request
+}
+
+func (x *XmlResponse) Download(filename, data string) {
+ x.writer.Header().Set("Content-Type", "text/xml")
+ x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
+ x.writer.Write([]byte(data))
+}
diff --git a/server/middleware/basic_auth.go b/server/middleware/basic_auth.go
new file mode 100644
index 0000000..73dfb98
--- /dev/null
+++ b/server/middleware/basic_auth.go
@@ -0,0 +1,61 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package middleware
+
+import (
+ "context"
+ "github.com/miniflux/miniflux2/storage"
+ "log"
+ "net/http"
+)
+
+type BasicAuthMiddleware struct {
+ store *storage.Storage
+}
+
+func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
+ errorResponse := `{"error_message": "Not Authorized"}`
+
+ username, password, authOK := r.BasicAuth()
+ if !authOK {
+ log.Println("[Middleware:BasicAuth] No authentication headers sent")
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(errorResponse))
+ return
+ }
+
+ if err := b.store.CheckPassword(username, password); err != nil {
+ log.Println("[Middleware:BasicAuth] Invalid username or password:", username)
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(errorResponse))
+ return
+ }
+
+ user, err := b.store.GetUserByUsername(username)
+ if err != nil || user == nil {
+ log.Println("[Middleware:BasicAuth] User not found:", username)
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(errorResponse))
+ return
+ }
+
+ log.Println("[Middleware:BasicAuth] User authenticated:", username)
+ b.store.SetLastLogin(user.ID)
+
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, "UserId", user.ID)
+ ctx = context.WithValue(ctx, "UserTimezone", user.Timezone)
+ ctx = context.WithValue(ctx, "IsAdminUser", user.IsAdmin)
+ ctx = context.WithValue(ctx, "IsAuthenticated", true)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+func NewBasicAuthMiddleware(s *storage.Storage) *BasicAuthMiddleware {
+ return &BasicAuthMiddleware{store: s}
+}
diff --git a/server/middleware/csrf.go b/server/middleware/csrf.go
new file mode 100644
index 0000000..74736b5
--- /dev/null
+++ b/server/middleware/csrf.go
@@ -0,0 +1,48 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package middleware
+
+import (
+ "context"
+ "github.com/miniflux/miniflux2/helper"
+ "log"
+ "net/http"
+)
+
+func Csrf(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var csrfToken string
+
+ csrfCookie, err := r.Cookie("csrfToken")
+ if err == http.ErrNoCookie || csrfCookie.Value == "" {
+ csrfToken = helper.GenerateRandomString(64)
+ cookie := &http.Cookie{
+ Name: "csrfToken",
+ Value: csrfToken,
+ Path: "/",
+ Secure: r.URL.Scheme == "https",
+ HttpOnly: true,
+ }
+
+ http.SetCookie(w, cookie)
+ } else {
+ csrfToken = csrfCookie.Value
+ }
+
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, "CsrfToken", csrfToken)
+
+ w.Header().Add("Vary", "Cookie")
+ isTokenValid := csrfToken == r.FormValue("csrf") || csrfToken == r.Header.Get("X-Csrf-Token")
+
+ if r.Method == "POST" && !isTokenValid {
+ log.Println("[Middleware:CSRF] Invalid or missing CSRF token!")
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("Invalid or missing CSRF token!"))
+ } else {
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }
+ })
+}
diff --git a/server/middleware/middleware.go b/server/middleware/middleware.go
new file mode 100644
index 0000000..cab01c8
--- /dev/null
+++ b/server/middleware/middleware.go
@@ -0,0 +1,31 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package middleware
+
+import (
+ "net/http"
+)
+
+type Middleware func(http.Handler) http.Handler
+
+type MiddlewareChain struct {
+ middlewares []Middleware
+}
+
+func (m *MiddlewareChain) Wrap(h http.Handler) http.Handler {
+ for i := range m.middlewares {
+ h = m.middlewares[len(m.middlewares)-1-i](h)
+ }
+
+ return h
+}
+
+func (m *MiddlewareChain) WrapFunc(fn http.HandlerFunc) http.Handler {
+ return m.Wrap(fn)
+}
+
+func NewMiddlewareChain(middlewares ...Middleware) *MiddlewareChain {
+ return &MiddlewareChain{append(([]Middleware)(nil), middlewares...)}
+}
diff --git a/server/middleware/session.go b/server/middleware/session.go
new file mode 100644
index 0000000..5455972
--- /dev/null
+++ b/server/middleware/session.go
@@ -0,0 +1,72 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package middleware
+
+import (
+ "context"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/route"
+ "github.com/miniflux/miniflux2/storage"
+ "log"
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+type SessionMiddleware struct {
+ store *storage.Storage
+ router *mux.Router
+}
+
+func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := s.getSessionFromCookie(r)
+
+ if session == nil {
+ log.Println("[Middleware:Session] Session not found")
+ if s.isPublicRoute(r) {
+ next.ServeHTTP(w, r)
+ } else {
+ http.Redirect(w, r, route.GetRoute(s.router, "login"), http.StatusFound)
+ }
+ } else {
+ log.Println("[Middleware:Session]", session)
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, "UserId", session.UserID)
+ ctx = context.WithValue(ctx, "IsAuthenticated", true)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }
+ })
+}
+
+func (s *SessionMiddleware) isPublicRoute(r *http.Request) bool {
+ route := mux.CurrentRoute(r)
+ switch route.GetName() {
+ case "login", "checkLogin", "stylesheet", "javascript":
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *SessionMiddleware) getSessionFromCookie(r *http.Request) *model.Session {
+ sessionCookie, err := r.Cookie("sessionID")
+ if err == http.ErrNoCookie {
+ return nil
+ }
+
+ session, err := s.store.GetSessionByToken(sessionCookie.Value)
+ if err != nil {
+ log.Println(err)
+ return nil
+ }
+
+ return session
+}
+
+func NewSessionMiddleware(s *storage.Storage, r *mux.Router) *SessionMiddleware {
+ return &SessionMiddleware{store: s, router: r}
+}
diff --git a/server/route/route.go b/server/route/route.go
new file mode 100644
index 0000000..885f0bc
--- /dev/null
+++ b/server/route/route.go
@@ -0,0 +1,37 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package route
+
+import (
+ "log"
+ "strconv"
+
+ "github.com/gorilla/mux"
+)
+
+func GetRoute(router *mux.Router, name string, args ...interface{}) string {
+ route := router.Get(name)
+ if route == nil {
+ log.Fatalln("Route not found:", name)
+ }
+
+ var pairs []string
+ for _, param := range args {
+ switch param.(type) {
+ case string:
+ pairs = append(pairs, param.(string))
+ case int64:
+ val := param.(int64)
+ pairs = append(pairs, strconv.FormatInt(val, 10))
+ }
+ }
+
+ result, err := route.URLPath(pairs...)
+ if err != nil {
+ log.Fatalln(err)
+ }
+
+ return result.String()
+}
diff --git a/server/routes.go b/server/routes.go
new file mode 100644
index 0000000..0c5ec65
--- /dev/null
+++ b/server/routes.go
@@ -0,0 +1,132 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package server
+
+import (
+ "github.com/miniflux/miniflux2/locale"
+ "github.com/miniflux/miniflux2/reader/feed"
+ "github.com/miniflux/miniflux2/reader/opml"
+ api_controller "github.com/miniflux/miniflux2/server/api/controller"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/middleware"
+ "github.com/miniflux/miniflux2/server/template"
+ ui_controller "github.com/miniflux/miniflux2/server/ui/controller"
+ "github.com/miniflux/miniflux2/storage"
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+func getRoutes(store *storage.Storage, feedHandler *feed.Handler) *mux.Router {
+ router := mux.NewRouter()
+ translator := locale.Load()
+ templateEngine := template.NewTemplateEngine(router, translator)
+
+ apiController := api_controller.NewController(store, feedHandler)
+ uiController := ui_controller.NewController(store, feedHandler, opml.NewOpmlHandler(store))
+
+ apiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewMiddlewareChain(
+ middleware.NewBasicAuthMiddleware(store).Handler,
+ ))
+
+ uiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewMiddlewareChain(
+ middleware.NewSessionMiddleware(store, router).Handler,
+ middleware.Csrf,
+ ))
+
+ router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
+ router.Handle("/v1/users", apiHandler.Use(apiController.GetUsers)).Methods("GET")
+ router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.GetUser)).Methods("GET")
+ router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.UpdateUser)).Methods("PUT")
+ router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.RemoveUser)).Methods("DELETE")
+
+ router.Handle("/v1/categories", apiHandler.Use(apiController.CreateCategory)).Methods("POST")
+ router.Handle("/v1/categories", apiHandler.Use(apiController.GetCategories)).Methods("GET")
+ router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.UpdateCategory)).Methods("PUT")
+ router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.RemoveCategory)).Methods("DELETE")
+
+ router.Handle("/v1/discover", apiHandler.Use(apiController.GetSubscriptions)).Methods("POST")
+
+ router.Handle("/v1/feeds", apiHandler.Use(apiController.CreateFeed)).Methods("POST")
+ router.Handle("/v1/feeds", apiHandler.Use(apiController.GetFeeds)).Methods("Get")
+ router.Handle("/v1/feeds/{feedID}/refresh", apiHandler.Use(apiController.RefreshFeed)).Methods("PUT")
+ router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.GetFeed)).Methods("GET")
+ router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.UpdateFeed)).Methods("PUT")
+ router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.RemoveFeed)).Methods("DELETE")
+
+ router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET")
+ router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET")
+ router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.SetEntryStatus)).Methods("PUT")
+
+ router.Handle("/stylesheets/{name}.css", uiHandler.Use(uiController.Stylesheet)).Name("stylesheet").Methods("GET")
+ router.Handle("/js", uiHandler.Use(uiController.Javascript)).Name("javascript").Methods("GET")
+ router.Handle("/favicon.ico", uiHandler.Use(uiController.Favicon)).Name("favicon").Methods("GET")
+
+ router.Handle("/subscribe", uiHandler.Use(uiController.AddSubscription)).Name("addSubscription").Methods("GET")
+ router.Handle("/subscribe", uiHandler.Use(uiController.SubmitSubscription)).Name("submitSubscription").Methods("POST")
+ router.Handle("/subscriptions", uiHandler.Use(uiController.ChooseSubscription)).Name("chooseSubscription").Methods("POST")
+
+ router.Handle("/unread", uiHandler.Use(uiController.ShowUnreadPage)).Name("unread").Methods("GET")
+ router.Handle("/history", uiHandler.Use(uiController.ShowHistoryPage)).Name("history").Methods("GET")
+
+ router.Handle("/feed/{feedID}/refresh", uiHandler.Use(uiController.RefreshFeed)).Name("refreshFeed").Methods("GET")
+ router.Handle("/feed/{feedID}/edit", uiHandler.Use(uiController.EditFeed)).Name("editFeed").Methods("GET")
+ router.Handle("/feed/{feedID}/remove", uiHandler.Use(uiController.RemoveFeed)).Name("removeFeed").Methods("GET")
+ router.Handle("/feed/{feedID}/update", uiHandler.Use(uiController.UpdateFeed)).Name("updateFeed").Methods("POST")
+ router.Handle("/feed/{feedID}/entries", uiHandler.Use(uiController.ShowFeedEntries)).Name("feedEntries").Methods("GET")
+ router.Handle("/feeds", uiHandler.Use(uiController.ShowFeedsPage)).Name("feeds").Methods("GET")
+
+ router.Handle("/unread/entry/{entryID}", uiHandler.Use(uiController.ShowUnreadEntry)).Name("unreadEntry").Methods("GET")
+ router.Handle("/history/entry/{entryID}", uiHandler.Use(uiController.ShowReadEntry)).Name("readEntry").Methods("GET")
+ router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET")
+ router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET")
+
+ router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST")
+
+ router.Handle("/categories", uiHandler.Use(uiController.ShowCategories)).Name("categories").Methods("GET")
+ router.Handle("/category/create", uiHandler.Use(uiController.CreateCategory)).Name("createCategory").Methods("GET")
+ router.Handle("/category/save", uiHandler.Use(uiController.SaveCategory)).Name("saveCategory").Methods("POST")
+ router.Handle("/category/{categoryID}/entries", uiHandler.Use(uiController.ShowCategoryEntries)).Name("categoryEntries").Methods("GET")
+ router.Handle("/category/{categoryID}/edit", uiHandler.Use(uiController.EditCategory)).Name("editCategory").Methods("GET")
+ router.Handle("/category/{categoryID}/update", uiHandler.Use(uiController.UpdateCategory)).Name("updateCategory").Methods("POST")
+ router.Handle("/category/{categoryID}/remove", uiHandler.Use(uiController.RemoveCategory)).Name("removeCategory").Methods("GET")
+
+ router.Handle("/icon/{iconID}", uiHandler.Use(uiController.ShowIcon)).Name("icon").Methods("GET")
+ router.Handle("/proxy/{encodedURL}", uiHandler.Use(uiController.ImageProxy)).Name("proxy").Methods("GET")
+
+ router.Handle("/users", uiHandler.Use(uiController.ShowUsers)).Name("users").Methods("GET")
+ router.Handle("/user/create", uiHandler.Use(uiController.CreateUser)).Name("createUser").Methods("GET")
+ router.Handle("/user/save", uiHandler.Use(uiController.SaveUser)).Name("saveUser").Methods("POST")
+ router.Handle("/users/{userID}/edit", uiHandler.Use(uiController.EditUser)).Name("editUser").Methods("GET")
+ router.Handle("/users/{userID}/update", uiHandler.Use(uiController.UpdateUser)).Name("updateUser").Methods("POST")
+ router.Handle("/users/{userID}/remove", uiHandler.Use(uiController.RemoveUser)).Name("removeUser").Methods("GET")
+
+ router.Handle("/about", uiHandler.Use(uiController.AboutPage)).Name("about").Methods("GET")
+
+ router.Handle("/settings", uiHandler.Use(uiController.ShowSettings)).Name("settings").Methods("GET")
+ router.Handle("/settings", uiHandler.Use(uiController.UpdateSettings)).Name("updateSettings").Methods("POST")
+
+ router.Handle("/sessions", uiHandler.Use(uiController.ShowSessions)).Name("sessions").Methods("GET")
+ router.Handle("/sessions/{sessionID}/remove", uiHandler.Use(uiController.RemoveSession)).Name("removeSession").Methods("GET")
+
+ router.Handle("/export", uiHandler.Use(uiController.Export)).Name("export").Methods("GET")
+ router.Handle("/import", uiHandler.Use(uiController.Import)).Name("import").Methods("GET")
+ router.Handle("/upload", uiHandler.Use(uiController.UploadOPML)).Name("uploadOPML").Methods("POST")
+
+ router.Handle("/login", uiHandler.Use(uiController.CheckLogin)).Name("checkLogin").Methods("POST")
+ router.Handle("/logout", uiHandler.Use(uiController.Logout)).Name("logout").Methods("GET")
+ router.Handle("/", uiHandler.Use(uiController.ShowLoginPage)).Name("login").Methods("GET")
+
+ router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("OK"))
+ })
+
+ router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte("User-agent: *\nDisallow: /"))
+ })
+
+ return router
+}
diff --git a/server/server.go b/server/server.go
new file mode 100644
index 0000000..ec32329
--- /dev/null
+++ b/server/server.go
@@ -0,0 +1,33 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package server
+
+import (
+ "github.com/miniflux/miniflux2/config"
+ "github.com/miniflux/miniflux2/reader/feed"
+ "github.com/miniflux/miniflux2/storage"
+ "log"
+ "net/http"
+ "time"
+)
+
+func NewServer(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler) *http.Server {
+ server := &http.Server{
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ Addr: cfg.Get("LISTEN_ADDR", "127.0.0.1:8080"),
+ Handler: getRoutes(store, feedHandler),
+ }
+
+ go func() {
+ log.Printf("Listening on %s\n", server.Addr)
+ if err := server.ListenAndServe(); err != nil {
+ log.Fatal(err)
+ }
+ }()
+
+ return server
+}
diff --git a/server/static/bin.go b/server/static/bin.go
new file mode 100644
index 0000000..1188329
--- /dev/null
+++ b/server/static/bin.go
@@ -0,0 +1,12 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.922229748 -0800 PST m=+0.003062891
+
+package static
+
+var Binaries = map[string]string{
+ "favicon.ico": `AAABAAEAQEAAAAEAIAAoQgAAFgAAACgAAABAAAAAgAAAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAADoAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAFf///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAAAYAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAADf///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAADYAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAGf///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAYQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADe////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADZAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA3v///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA2QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAVv///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAABhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAN7///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAYQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADe////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADZAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA3v///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA2QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAVv///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAABhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAN7///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAYQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADe////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADZAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA3v///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA2QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAVv///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAABhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAN7///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAYQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADe////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADZAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA3v///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA2QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAVv///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAABhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAN7///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAYQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADe////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADZAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA3v///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA2QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAVv///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAABhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAN7///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAYQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADe////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADZAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA3v///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA2QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAXf///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAABhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAOX///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGv///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAaAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADz////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADVAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGb///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAB/////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAHYAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/gAAAAn///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAzQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABm////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAApv///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAACDAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAy////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAANMAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZv///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANf///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAkAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZ////wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAF////8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAKf///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAJ8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAALf///8A////AP///wD///8A////AP///wD///8A////AP///wAAAAACAAAA/QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABR////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAJP///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADVAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD7AAAAI////wD///8A////AP///wD///8A////AP///wD///8AAAAAJwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAQ////wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD4AAAAJ////wD///8A////AP///wD///8A////AP///wAAAAAdAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAKr///8A////AP///wD///8A////AP///wD///8A////AAAAAIQAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAC7///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANoAAAAa////AP///wD///8A////AP///wD///8AAAAAgQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAfP///wD///8A////AP///wD///8A////AAAAABoAAADlAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPcAAAAF////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA6wAAAGUAAAAN////AP///wAAAAAdAAAAhwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACyAAAAOf///wD///8AAAAACQAAAEAAAADgAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADI////AP///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/QAAAOYAAADtAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD0AAAA4AAAAPwAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAiP///wD///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/gAAACX///8A////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA8AAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD+AAAAUQAAAOEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAALz///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGcAAADYAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAmP///wAAAAAxAAAA9gAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPoAAAAu////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAA6QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABWAAAAIwAAAOsAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA4gAAAA////8A////AAAAAFoAAAD9AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAB9////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAOkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAVv///wAAAAAxAAAA3QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA0QAAAB3///8A////AP///wD///8AAAAARgAAAOsAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACQAAAAAf///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAADpAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAFb///8A////AAAAAA8AAACsAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAswAAAA3///8A////AP///wD///8A////AP///wAAAAAbAAAAwgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPMAAABy////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAArwAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAABA////AP///wD///8A////AAAAAEUAAACrAAAA+gAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPsAAACpAAAAOf///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAAA9AAAApwAAAPoAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA5wAAAJEAAAAW////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAABcAAABLAAAAagAAAIkAAACGAAAAZQAAAEQAAAAY////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wAAAAAUAAAAQgAAAGMAAACFAAAAjwAAAHsAAABnAAAAMwAAAAH///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////AB/+AD/4AP8AH/4AP/gA/wAf/gA/+AD/AB/+AD/4AP8AH/4AP/gA/wAf/gA/+AD/AB/+AD/4AP8AH/4AP/gA/wAf/gA/+AD/AB/+AD/4AP8AH/4AP/gA/wAf/gA/+AD/AB/+AD/4AP8AH/4AP/gA/wAf/gA/+AD/AB/+AD/4AP8AH/4AP/gA/wAf/gA/+AD/AB/+AD/4AP8AH/4AP/gA/wAP/AA/+AD/AA/8AD/4AP8AD/wAH/gA/wAH/AAf+AD/AAf8AA/wAP8AA/gAD/AA/wAB8AAD4AD/AAAAAAAAAP8AAAAAAAAB/wAAAACAAAH/ABAAAMAAA/8AGAAB4AAH/wAcAAPwAAf/AB4AB/gAH/8AH4Af/gA/////+f//5///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8=`,
+}
+
+var BinariesChecksums = map[string]string{
+ "favicon.ico": "abb2a2675b0696252719f51dbfc1efc50affb2f17ec82166e27f9529eec896fb",
+}
diff --git a/server/static/bin/favicon.ico b/server/static/bin/favicon.ico
new file mode 100644
index 0000000..77af6f9
--- /dev/null
+++ b/server/static/bin/favicon.ico
Binary files differ
diff --git a/server/static/css.go b/server/static/css.go
new file mode 100644
index 0000000..c53dee0
--- /dev/null
+++ b/server/static/css.go
@@ -0,0 +1,14 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.922613988 -0800 PST m=+0.003447131
+
+package static
+
+var Stylesheets = map[string]string{
+ "black": `body{background:#222;color:#efefef}h1,h2,h3{color:#aaa}a{color:#aaa}a:focus,a:hover{color:#ddd}.header li{border-color:#333}.header a{color:#ddd;font-weight:400}.header .active a{font-weight:400;color:#9b9494}.header a:focus,.header a:hover{color:rgba(82,168,236,.85)}.page-header h1{border-color:#333}.logo a:hover span{color:#555}table,th,td{border:1px solid #555}th{background:#333;color:#aaa;font-weight:400}tr:hover{background-color:#333;color:#aaa}input[type=url],input[type=password],input[type=text]{border:1px solid #555;background:#333;color:#ccc}input[type=url]:focus,input[type=password]:focus,input[type=text]:focus{color:#efefef;border-color:rgba(82,168,236,.8);box-shadow:0 0 8px rgba(82,168,236,.6)}.button-primary{border-color:#444;background:#333;color:#efefef}.button-primary:hover,.button-primary:focus{border-color:#888;background:#555}.alert,.alert-success,.alert-error,.alert-info,.alert-normal{color:#efefef;background-color:#333;border-color:#444}.panel{background:#333;border-color:#555}.unread-counter{color:#bbb}.category{color:#efefef;background-color:#333;border-color:#444}.category a{color:#999}.category a:hover,.category a:focus{color:#aaa}.pagination a{color:#aaa}.pagination-bottom{border-color:#333}.item{border-color:#666;padding:4px}.item.current-item{border-width:2px;border-color:rgba(82,168,236,.8);box-shadow:0 0 8px rgba(82,168,236,.6)}.item-title a{font-weight:400}.item-status-read .item-title a{color:#666}.item-status-read .item-title a:focus,.item-status-read .item-title a:hover{color:rgba(82,168,236,.6)}.item-meta a:hover,.item-meta a:focus{color:#aaa}.item-meta li:after{color:#ddd}.entry header{border-color:#333}.entry header h1 a{color:#bbb}.entry-content,.entry-content p,ul{color:#999}.entry-content pre,.entry-content code{color:#fff;background:#555;border-color:#888}.entry-enclosure{border-color:#333}`,
+ "common": `*{margin:0;padding:0;box-sizing:border-box}body{font-family:helvetica neue,Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.main{padding-left:3px;padding-right:3px}a{color:#36c}a:focus{outline:0;color:red;text-decoration:none;border:1px dotted #aaa}a:hover{color:#333;text-decoration:none}.header{margin-top:10px;margin-bottom:20px}.header nav ul{display:none}.header li{cursor:pointer;padding-left:10px;line-height:2.1em;font-size:1.2em;border-bottom:1px dotted #ddd}.header li:hover a{color:#888}.header a{font-size:.9em;color:#444;text-decoration:none;border:0}.header .active a{font-weight:600}.header a:hover,.header a:focus{color:#888}.page-header{margin-bottom:25px}.page-header h1{font-weight:500;border-bottom:1px dotted #ddd}.page-header ul{margin-left:25px;font-size:.9em}.page-header li{list-style-type:circle;line-height:1.4em}.logo{cursor:pointer;text-align:center}.logo a{color:#000;letter-spacing:1px}.logo a:hover{color:#396}.logo a span{color:#396}.logo a:hover span{color:#000}@media(min-width:600px){body{margin:auto;max-width:750px}.logo{text-align:left;float:left;margin-right:15px}.header nav ul{display:block}.header li{display:inline;padding:0;padding-right:15px;line-height:normal;font-size:1em;border:0}.page-header ul{margin-left:0}.page-header li{display:inline;padding-right:15px}}table{width:100%;border-collapse:collapse}table,th,td{border:1px solid #ddd}th,td{padding:5px;text-align:left}td{vertical-align:top}th{background:#fcfcfc}.table-overflow td{max-width:0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}tr:hover{background-color:#f9f9f9}.column-40{width:40%}.column-25{width:25%}.column-20{width:20%}label{cursor:pointer;display:block}.radio-group{line-height:1.9em}div.radio-group label{display:inline-block}select{margin-bottom:15px}input[type=url],input[type=password],input[type=text]{border:1px solid #ccc;padding:3px;line-height:15px;width:250px;font-size:99%;margin-bottom:10px;margin-top:5px;-webkit-appearance:none}input[type=url]:focus,input[type=password]:focus,input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}::-moz-placeholder,::-ms-input-placeholder,::-webkit-input-placeholder{color:#ddd;padding-top:2px}.form-help{font-size:.9em;color:brown;margin-bottom:15px}a.button{text-decoration:none}.button{display:inline-block;-webkit-appearance:none;-moz-appearance:none;font-size:1.1em;cursor:pointer;padding:3px 10px;border:1px solid;border-radius:unset}.button-primary{border-color:#3079ed;background:#4d90fe;color:#fff}.button-primary:hover,.button-primary:focus{border-color:#2f5bb7;background:#357ae8}.button-danger{border-color:#b0281a;background:#d14836;color:#fff}.button-danger:hover,.button-danger:focus{color:#fff;background:#c53727}.button:disabled{color:#ccc;background:#f7f7f7;border-color:#ccc}.buttons{margin-top:10px;margin-bottom:20px}.alert{padding:8px 35px 8px 14px;margin-bottom:20px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px;overflow:auto}.alert h3{margin-top:0;margin-bottom:15px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-error a{color:#b94a48}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.panel{color:#333;background-color:#f0f0f0;border:1px solid #ddd;border-radius:5px;padding:10px;margin-bottom:15px}.panel h3{font-weight:500;margin-top:0;margin-bottom:20px}.panel ul{margin-left:30px}.login-form{margin:auto;margin-top:50px;width:350px}.unread-counter{font-size:.8em;font-weight:300;color:#666}.category{font-size:.75em;background-color:#fffcd7;border:1px solid #d5d458;border-radius:5px;margin-left:.25em;padding:1px .4em;white-space:nowrap}.category a{color:#555;text-decoration:none}.category a:hover,.category a:focus{color:#000}.pagination{font-size:1.1em;display:flex;align-items:center;padding-top:8px}.pagination-bottom{border-top:1px dotted #ddd;margin-bottom:15px;margin-top:50px}.pagination>div{flex:1}.pagination-next{text-align:right}.pagination-prev:before{content:"« "}.pagination-next:after{content:" »"}.pagination a{color:#333}.pagination a:hover,.pagination a:focus{text-decoration:none}.item{border:1px dotted #ddd;margin-bottom:20px;padding:5px;overflow:hidden}.item.current-item{border:3px solid #bce;padding:3px}.item-title a{text-decoration:none;font-weight:600}.item-status-read .item-title a{color:#777}.item-meta{color:#777;font-size:.8em}.item-meta a{color:#777;text-decoration:none}.item-meta a:hover,.item-meta a:focus{color:#333}.item-meta ul{margin-top:5px}.item-meta li{display:inline}.item-meta li:after{content:"|";color:#aaa}.item-meta li:last-child:after{content:""}.hide-read-items .item-status-read{display:none}.entry header{padding-bottom:5px;border-bottom:1px dotted #ddd}.entry header h1{font-size:2em;line-height:1.25em;margin:30px 0}.entry header h1 a{text-decoration:none;color:#333}.entry header h1 a:hover,.entry header h1 a:focus{color:#666}.entry-meta{font-size:.95em;margin:0 0 20px;color:#666}.entry-website img{vertical-align:top}.entry-website a{color:#666;vertical-align:top;text-decoration:none}.entry-website a:hover,.entry-website a:focus{text-decoration:underline}.entry-date{font-size:.65em;font-style:italic;color:#555}.entry-content{padding-top:15px;font-size:1.1em;font-weight:300;color:#444}.entry-content h1,h2,h3,h4,h5,h6{margin-top:15px}.entry-content iframe,.entry-content video,.entry-content img{max-width:100%}.entry-content figure img{border:1px solid #000}.entry-content figcaption{font-size:.75em;text-transform:uppercase;color:#777}.entry-content p{margin-top:15px;margin-bottom:15px;text-align:justify}.entry-content a:visited{color:purple}.entry-content dt{font-weight:500;margin-top:15px;color:#555}.entry-content dd{margin-left:15px;margin-top:5px;padding-left:20px;border-left:3px solid #ddd;color:#777;font-weight:300;line-height:1.4em}.entry-content blockquote{border-left:4px solid #ddd;padding-left:25px;margin-left:20px;margin-top:20px;margin-bottom:20px;color:#888;line-height:1.4em;font-family:Georgia,serif}.entry-content blockquote+p{color:#555;font-style:italic;font-weight:200}.entry-content q{color:purple;font-family:Georgia,serif;font-style:italic}.entry-content q:before{content:"“"}.entry-content q:after{content:"”"}.entry-content pre{padding:5px;background:#f0f0f0;border:1px solid #ddd;overflow:scroll}.entry-content ul,.entry-content ol{margin-left:30px}.entry-content ul{list-style-type:square}.entry-enclosures h3{font-weight:500}.entry-enclosure{border:1px dotted #ddd;padding:5px;margin-top:10px;max-width:100%}.entry-enclosure-download{font-size:.85em}.enclosure-video video,.enclosure-image img{max-width:100%}`,
+}
+
+var StylesheetsChecksums = map[string]string{
+ "black": "38e7fee92187a036ce37f3c15fde2deff59a55c5ab693c7b8578af79d6a117d2",
+ "common": "0f4de90d16570a37392ff64dd85b336372477afee298c47b6a3d98d3fb4bd4b3",
+}
diff --git a/server/static/css/black.css b/server/static/css/black.css
new file mode 100644
index 0000000..793e51f
--- /dev/null
+++ b/server/static/css/black.css
@@ -0,0 +1,197 @@
+/* Layout */
+body {
+ background: #222;
+ color: #efefef;
+}
+
+h1, h2, h3 {
+ color: #aaa;
+}
+
+a {
+ color: #aaa;
+}
+
+a:focus,
+a:hover {
+ color: #ddd;
+}
+
+.header li {
+ border-color: #333;
+}
+
+.header a {
+ color: #ddd;
+ font-weight: 400;
+}
+
+.header .active a {
+ font-weight: 400;
+ color: #9b9494;
+}
+
+.header a:focus,
+.header a:hover {
+ color: rgba(82, 168, 236, 0.85);
+}
+
+.page-header h1 {
+ border-color: #333;
+}
+
+.logo a:hover span {
+ color: #555;
+}
+
+/* Tables */
+table, th, td {
+ border: 1px solid #555;
+}
+
+th {
+ background: #333;
+ color: #aaa;
+ font-weight: 400;
+}
+
+tr:hover {
+ background-color: #333;
+ color: #aaa;
+}
+
+/* Forms */
+input[type="url"],
+input[type="password"],
+input[type="text"] {
+ border: 1px solid #555;
+ background: #333;
+ color: #ccc;
+}
+
+input[type="url"]:focus,
+input[type="password"]:focus,
+input[type="text"]:focus {
+ color: #efefef;
+ border-color: rgba(82, 168, 236, 0.8);
+ box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
+}
+
+/* Buttons */
+.button-primary {
+ border-color: #444;
+ background: #333;
+ color: #efefef;
+}
+
+.button-primary:hover,
+.button-primary:focus {
+ border-color: #888;
+ background: #555;
+}
+
+/* Alerts */
+.alert,
+.alert-success,
+.alert-error,
+.alert-info,
+.alert-normal {
+ color: #efefef;
+ background-color: #333;
+ border-color: #444;
+}
+
+/* Panel */
+.panel {
+ background: #333;
+ border-color: #555;
+}
+
+/* Counter */
+.unread-counter {
+ color: #bbb;
+}
+
+/* Category label */
+.category {
+ color: #efefef;
+ background-color: #333;
+ border-color: #444;
+}
+
+.category a {
+ color: #999;
+}
+
+.category a:hover,
+.category a:focus {
+ color: #aaa;
+}
+
+/* Pagination */
+.pagination a {
+ color: #aaa;
+}
+
+.pagination-bottom {
+ border-color: #333;
+}
+
+/* List view */
+.item {
+ border-color: #666;
+ padding: 4px;
+}
+
+.item.current-item {
+ border-width: 2px;
+ border-color: rgba(82, 168, 236, 0.8);
+ box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
+}
+
+.item-title a {
+ font-weight: 400;
+}
+
+.item-status-read .item-title a {
+ color: #666;
+}
+
+.item-status-read .item-title a:focus,
+.item-status-read .item-title a:hover {
+ color: rgba(82, 168, 236, 0.6);
+}
+
+.item-meta a:hover,
+.item-meta a:focus {
+ color: #aaa;
+}
+
+.item-meta li:after {
+ color: #ddd;
+}
+
+/* Entry view */
+.entry header {
+ border-color: #333;
+}
+
+.entry header h1 a {
+ color: #bbb;
+}
+
+.entry-content,
+.entry-content p, ul {
+ color: #999;
+}
+
+.entry-content pre,
+.entry-content code {
+ color: #fff;
+ background: #555;
+ border-color: #888;
+}
+
+.entry-enclosure {
+ border-color: #333;
+}
diff --git a/server/static/css/common.css b/server/static/css/common.css
new file mode 100644
index 0000000..bfbc43e
--- /dev/null
+++ b/server/static/css/common.css
@@ -0,0 +1,654 @@
+/* Layout */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ text-rendering: optimizeLegibility;
+}
+
+.main {
+ padding-left: 3px;
+ padding-right: 3px;
+}
+
+a {
+ color: #3366CC;
+}
+
+a:focus {
+ outline: 0;
+ color: red;
+ text-decoration: none;
+ border: 1px dotted #aaa;
+}
+
+a:hover {
+ color: #333;
+ text-decoration: none;
+}
+
+.header {
+ margin-top: 10px;
+ margin-bottom: 20px;
+}
+
+.header nav ul {
+ display: none;
+}
+
+.header li {
+ cursor: pointer;
+ padding-left: 10px;
+ line-height: 2.1em;
+ font-size: 1.2em;
+ border-bottom: 1px dotted #ddd;
+}
+
+.header li:hover a {
+ color: #888;
+}
+
+.header a {
+ font-size: 0.9em;
+ color: #444;
+ text-decoration: none;
+ border: none;
+}
+
+.header .active a {
+ font-weight: 600;
+}
+
+.header a:hover,
+.header a:focus {
+ color: #888;
+}
+
+.page-header {
+ margin-bottom: 25px;
+}
+
+.page-header h1 {
+ font-weight: 500;
+ border-bottom: 1px dotted #ddd;
+}
+
+.page-header ul {
+ margin-left: 25px;
+ font-size: 0.9em;
+}
+
+.page-header li {
+ list-style-type: circle;
+ line-height: 1.4em;
+}
+
+.logo {
+ cursor: pointer;
+ text-align: center;
+}
+
+.logo a {
+ color: #000;
+ letter-spacing: 1px;
+}
+
+.logo a:hover {
+ color: #339966;
+}
+
+.logo a span {
+ color: #339966;
+}
+
+.logo a:hover span {
+ color: #000;
+}
+
+@media (min-width: 600px) {
+ body {
+ margin: auto;
+ max-width: 750px;
+ }
+
+ .logo {
+ text-align: left;
+ float: left;
+ margin-right: 15px;
+ }
+
+ .header nav ul {
+ display: block;
+ }
+
+ .header li {
+ display: inline;
+ padding: 0;
+ padding-right: 15px;
+ line-height: normal;
+ font-size: 1.0em;
+ border: none;
+ }
+
+ .page-header ul {
+ margin-left: 0;
+ }
+
+ .page-header li {
+ display: inline;
+ padding-right: 15px;
+ }
+}
+
+/* Tables */
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+table, th, td {
+ border: 1px solid #ddd;
+}
+
+th, td {
+ padding: 5px;
+ text-align: left;
+}
+
+td {
+ vertical-align: top;
+}
+
+th {
+ background: #fcfcfc;
+}
+
+.table-overflow td {
+ max-width: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+tr:hover {
+ background-color: #f9f9f9;
+}
+
+.column-40 {
+ width: 40%;
+}
+
+.column-25 {
+ width: 25%;
+}
+
+.column-20 {
+ width: 20%;
+}
+
+/* Forms */
+label {
+ cursor: pointer;
+ display: block;
+}
+
+.radio-group {
+ line-height: 1.9em;
+}
+
+div.radio-group label {
+ display: inline-block;
+}
+
+select {
+ margin-bottom: 15px;
+}
+
+input[type="url"],
+input[type="password"],
+input[type="text"] {
+ border: 1px solid #ccc;
+ padding: 3px;
+ line-height: 15px;
+ width: 250px;
+ font-size: 99%;
+ margin-bottom: 10px;
+ margin-top: 5px;
+ -webkit-appearance: none;
+}
+
+input[type="url"]:focus,
+input[type="password"]:focus,
+input[type="text"]:focus {
+ color: #000;
+ border-color: rgba(82, 168, 236, 0.8);
+ outline: 0;
+ box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
+}
+
+::-moz-placeholder,
+::-ms-input-placeholder,
+::-webkit-input-placeholder {
+ color: #ddd;
+ padding-top: 2px;
+}
+
+.form-help {
+ font-size: 0.9em;
+ color: brown;
+ margin-bottom: 15px;
+}
+
+/* Buttons */
+a.button {
+ text-decoration: none;
+}
+
+.button {
+ display: inline-block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ font-size: 1.1em;
+ cursor: pointer;
+ padding: 3px 10px;
+ border: 1px solid;
+ border-radius: unset;
+}
+
+.button-primary {
+ border-color: #3079ed;
+ background: #4d90fe;
+ color: #fff;
+}
+
+.button-primary:hover,
+.button-primary:focus {
+ border-color: #2f5bb7;
+ background: #357ae8;
+}
+
+.button-danger {
+ border-color: #b0281a;
+ background: #d14836;
+ color: #fff;
+}
+
+.button-danger:hover,
+.button-danger:focus {
+ color: #fff;
+ background: #c53727;
+}
+
+.button:disabled {
+ color: #ccc;
+ background: #f7f7f7;
+ border-color: #ccc;
+}
+
+.buttons {
+ margin-top: 10px;
+ margin-bottom: 20px;
+}
+
+/* Alerts */
+.alert {
+ padding: 8px 35px 8px 14px;
+ margin-bottom: 20px;
+ color: #c09853;
+ background-color: #fcf8e3;
+ border: 1px solid #fbeed5;
+ border-radius: 4px;
+ overflow: auto;
+}
+
+.alert h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+}
+
+.alert-success {
+ color: #468847;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+
+.alert-error {
+ color: #b94a48;
+ background-color: #f2dede;
+ border-color: #eed3d7;
+}
+
+.alert-error a {
+ color: #b94a48;
+}
+
+.alert-info {
+ color: #3a87ad;
+ background-color: #d9edf7;
+ border-color: #bce8f1;
+}
+
+/* Panel */
+.panel {
+ color: #333;
+ background-color: #f0f0f0;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ padding: 10px;
+ margin-bottom: 15px;
+}
+
+.panel h3 {
+ font-weight: 500;
+ margin-top: 0;
+ margin-bottom: 20px;
+}
+
+.panel ul {
+ margin-left: 30px;
+}
+
+/* Login form */
+.login-form {
+ margin: auto;
+ margin-top: 50px;
+ width: 350px;
+}
+
+/* Counter */
+.unread-counter {
+ font-size: 0.8em;
+ font-weight: 300;
+ color: #666;
+}
+
+/* Category label */
+.category {
+ font-size: 0.75em;
+ background-color: #fffcd7;
+ border: 1px solid #d5d458;
+ border-radius: 5px;
+ margin-left: 0.25em;
+ padding: 1px 0.4em 1px 0.4em;
+ white-space: nowrap;
+}
+
+.category a {
+ color: #555;
+ text-decoration: none;
+}
+
+.category a:hover,
+.category a:focus {
+ color: #000;
+}
+
+/* Pagination */
+.pagination {
+ font-size: 1.1em;
+ display: flex;
+ align-items: center;
+ padding-top: 8px;
+}
+
+.pagination-bottom {
+ border-top: 1px dotted #ddd;
+ margin-bottom: 15px;
+ margin-top: 50px;
+}
+
+.pagination > div {
+ flex: 1;
+}
+
+.pagination-next {
+ text-align: right;
+}
+
+.pagination-prev:before {
+ content: "« ";
+}
+
+.pagination-next:after {
+ content: " »";
+}
+
+.pagination a {
+ color: #333;
+}
+
+.pagination a:hover,
+.pagination a:focus {
+ text-decoration: none;
+}
+
+/* List view */
+.item {
+ border: 1px dotted #ddd;
+ margin-bottom: 20px;
+ padding: 5px;
+ overflow: hidden;
+}
+
+.item.current-item {
+ border: 3px solid #bce;
+ padding: 3px;
+}
+
+.item-title a {
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.item-status-read .item-title a {
+ color: #777;
+}
+
+.item-meta {
+ color: #777;
+ font-size: 0.8em;
+}
+
+.item-meta a {
+ color: #777;
+ text-decoration: none;
+}
+
+.item-meta a:hover,
+.item-meta a:focus {
+ color: #333;
+}
+
+.item-meta ul {
+ margin-top: 5px;
+}
+
+.item-meta li {
+ display: inline;
+}
+
+.item-meta li:after {
+ content: "|";
+ color: #aaa;
+}
+
+.item-meta li:last-child:after {
+ content: "";
+}
+
+.hide-read-items .item-status-read {
+ display: none;
+}
+
+/* Entry view */
+.entry header {
+ padding-bottom: 5px;
+ border-bottom: 1px dotted #ddd;
+}
+
+.entry header h1 {
+ font-size: 2.0em;
+ line-height: 1.25em;
+ margin: 30px 0;
+}
+
+.entry header h1 a {
+ text-decoration: none;
+ color: #333;
+}
+
+.entry header h1 a:hover,
+.entry header h1 a:focus {
+ color: #666;
+}
+
+.entry-meta {
+ font-size: 0.95em;
+ margin: 0 0 20px;
+ color: #666;
+}
+
+.entry-website img {
+ vertical-align: top;
+}
+
+.entry-website a {
+ color: #666;
+ vertical-align: top;
+ text-decoration: none;
+}
+
+.entry-website a:hover,
+.entry-website a:focus {
+ text-decoration: underline;
+}
+
+.entry-date {
+ font-size: 0.65em;
+ font-style: italic;
+ color: #555;
+}
+
+.entry-content {
+ padding-top: 15px;
+ font-size: 1.1em;
+ font-weight: 300;
+ color: #444;
+}
+
+.entry-content h1, h2, h3, h4, h5, h6 {
+ margin-top: 15px;
+}
+
+.entry-content iframe,
+.entry-content video,
+.entry-content img {
+ max-width: 100%;
+}
+
+.entry-content figure img {
+ border: 1px solid #000;
+}
+
+.entry-content figcaption {
+ font-size: 0.75em;
+ text-transform: uppercase;
+ color: #777;
+}
+
+.entry-content p {
+ margin-top: 15px;
+ margin-bottom: 15px;
+ text-align: justify;
+}
+
+.entry-content a:visited {
+ color: purple;
+}
+
+.entry-content dt {
+ font-weight: 500;
+ margin-top: 15px;
+ color: #555;
+}
+
+.entry-content dd {
+ margin-left: 15px;
+ margin-top: 5px;
+ padding-left: 20px;
+ border-left: 3px solid #ddd;
+ color: #777;
+ font-weight: 300;
+ line-height: 1.4em;
+}
+
+.entry-content blockquote {
+ border-left: 4px solid #ddd;
+ padding-left: 25px;
+ margin-left: 20px;
+ margin-top: 20px;
+ margin-bottom: 20px;
+ color: #888;
+ line-height: 1.4em;
+ font-family: Georgia, serif;
+}
+
+.entry-content blockquote + p {
+ color: #555;
+ font-style: italic;
+ font-weight: 200;
+}
+
+.entry-content q {
+ color: purple;
+ font-family: Georgia, serif;
+ font-style: italic;
+}
+
+.entry-content q:before {
+ content: "“";
+}
+
+.entry-content q:after {
+ content: "”";
+}
+
+.entry-content pre {
+ padding: 5px;
+ background: #f0f0f0;
+ border: 1px solid #ddd;
+ overflow: scroll;
+}
+
+.entry-content ul,
+.entry-content ol {
+ margin-left: 30px;
+}
+
+.entry-content ul {
+ list-style-type: square;
+}
+
+.entry-enclosures h3 {
+ font-weight: 500;
+}
+
+.entry-enclosure {
+ border: 1px dotted #ddd;
+ padding: 5px;
+ margin-top: 10px;
+ max-width: 100%;
+}
+
+.entry-enclosure-download {
+ font-size: 0.85em;
+}
+
+.enclosure-video video,
+.enclosure-image img {
+ max-width: 100%;
+}
diff --git a/server/static/js.go b/server/static/js.go
new file mode 100644
index 0000000..641aa2c
--- /dev/null
+++ b/server/static/js.go
@@ -0,0 +1,52 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.923282889 -0800 PST m=+0.004116032
+
+package static
+
+var Javascript = map[string]string{
+ "app": `(function(){'use strict';class KeyboardHandler{constructor(){this.queue=[];this.shortcuts={};}
+on(combination,callback){this.shortcuts[combination]=callback;}
+listen(){document.onkeydown=(event)=>{if(this.isEventIgnored(event)){return;}
+let key=this.getKey(event);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]();return;}
+if(keys.length===1&&key===keys[0]){this.queue=[];this.shortcuts[combination]();return;}}
+if(this.queue.length>=2){this.queue=[];}};}
+isEventIgnored(event){return event.target.tagName==="INPUT"||event.target.tagName==="TEXTAREA";}
+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;}}
+class FormHandler{static handleSubmitButtons(){let elements=document.querySelectorAll("form");elements.forEach(function(element){element.onsubmit=function(){let button=document.querySelector("button");if(button){button.innerHTML=button.dataset.labelLoading;button.disabled=true;}};});}}
+class MouseHandler{onClick(selector,callback){let elements=document.querySelectorAll(selector);elements.forEach((element)=>{element.onclick=(event)=>{event.preventDefault();callback(event);};});}}
+class App{run(){FormHandler.handleSubmitButtons();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>this.goToPage("unread"));keyboardHandler.on("g h",()=>this.goToPage("history"));keyboardHandler.on("g f",()=>this.goToPage("feeds"));keyboardHandler.on("g c",()=>this.goToPage("categories"));keyboardHandler.on("g s",()=>this.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>this.goToPrevious());keyboardHandler.on("ArrowRight",()=>this.goToNext());keyboardHandler.on("j",()=>this.goToPrevious());keyboardHandler.on("p",()=>this.goToPrevious());keyboardHandler.on("k",()=>this.goToNext());keyboardHandler.on("n",()=>this.goToNext());keyboardHandler.on("h",()=>this.goToPage("previous"));keyboardHandler.on("l",()=>this.goToPage("next"));keyboardHandler.on("o",()=>this.openSelectedItem());keyboardHandler.on("v",()=>this.openOriginalLink());keyboardHandler.on("m",()=>this.toggleEntryStatus());keyboardHandler.on("A",()=>this.markPageAsRead());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>this.markPageAsRead());if(document.documentElement.clientWidth<600){mouseHandler.onClick(".logo",()=>this.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>this.clickMenuListItem(event));}}
+clickMenuListItem(event){let element=event.target;console.log(element);if(element.tagName==="A"){window.location.href=element.getAttribute("href");}else{window.location.href=element.querySelector("a").getAttribute("href");}}
+toggleMainMenu(){let menu=document.querySelector(".header nav ul");if(this.isVisible(menu)){menu.style.display="none";}else{menu.style.display="block";}}
+updateEntriesStatus(entryIDs,status){let url=document.body.dataset.entriesStatusUrl;let request=new Request(url,{method:"POST",cache:"no-cache",credentials:"include",body:JSON.stringify({entry_ids:entryIDs,status:status}),headers:new Headers({"Content-Type":"application/json","X-Csrf-Token":this.getCsrfToken()})});fetch(request);}
+markPageAsRead(){let items=this.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){this.updateEntriesStatus(entryIDs,"read");}
+this.goToPage("next");}
+toggleEntryStatus(){let currentItem=document.querySelector(".current-item");if(currentItem!==null){let entryID=parseInt(currentItem.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(currentItem.classList.contains("item-status-"+currentStatus)){this.goToNextListItem();currentItem.classList.remove("item-status-"+currentStatus);currentItem.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);break;}}}}
+openOriginalLink(){let entryLink=document.querySelector(".entry h1 a");if(entryLink!==null){this.openNewTab(entryLink.getAttribute("href"));return;}
+let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){this.openNewTab(currentItemOriginalLink.getAttribute("href"));}}
+openSelectedItem(){let currentItemLink=document.querySelector(".current-item .item-title a");if(currentItemLink!==null){window.location.href=currentItemLink.getAttribute("href");}}
+goToPage(page){let element=document.querySelector("a[data-page="+page+"]");if(element){document.location.href=element.href;}}
+goToPrevious(){if(this.isListView()){this.goToPreviousListItem();}else{this.goToPage("previous");}}
+goToNext(){if(this.isListView()){this.goToNextListItem();}else{this.goToPage("next");}}
+goToPreviousListItem(){let items=this.getVisibleElements(".items .item");if(items.length===0){return;}
+if(document.querySelector(".current-item")===null){items[0].classList.add("current-item");return;}
+for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i-1>=0){items[i-1].classList.add("current-item");this.scrollPageTo(items[i-1]);}
+break;}}}
+goToNextListItem(){let items=this.getVisibleElements(".items .item");if(items.length===0){return;}
+if(document.querySelector(".current-item")===null){items[0].classList.add("current-item");return;}
+for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i+1<items.length){items[i+1].classList.add("current-item");this.scrollPageTo(items[i+1]);}
+break;}}}
+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;}
+isListView(){return document.querySelector(".items")!==null;}
+scrollPageTo(item){let windowScrollPosition=window.pageYOffset;let windowHeight=document.documentElement.clientHeight;let viewportPosition=windowScrollPosition+windowHeight;let itemBottomPosition=item.offsetTop+item.offsetHeight;if(viewportPosition-itemBottomPosition<0||viewportPosition-item.offsetTop>windowHeight){window.scrollTo(0,item.offsetTop-10);}}
+openNewTab(url){let win=window.open(url,"_blank");win.focus();}
+isVisible(element){return element.offsetParent!==null;}
+getCsrfToken(){let element=document.querySelector("meta[name=X-CSRF-Token]");if(element!==null){return element.getAttribute("value");}
+return "";}}
+document.addEventListener("DOMContentLoaded",function(){(new App()).run();});})();`,
+}
+
+var JavascriptChecksums = map[string]string{
+ "app": "e250c2af19dea14fd75681a81080cf183919a7a589b0886a093586ee894c8282",
+}
diff --git a/server/static/js/app.js b/server/static/js/app.js
new file mode 100644
index 0000000..46a8f72
--- /dev/null
+++ b/server/static/js/app.js
@@ -0,0 +1,351 @@
+/*jshint esversion: 6 */
+(function() {
+'use strict';
+
+class KeyboardHandler {
+ constructor() {
+ this.queue = [];
+ this.shortcuts = {};
+ }
+
+ on(combination, callback) {
+ this.shortcuts[combination] = callback;
+ }
+
+ listen() {
+ document.onkeydown = (event) => {
+ if (this.isEventIgnored(event)) {
+ return;
+ }
+
+ let key = this.getKey(event);
+ 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]();
+ return;
+ }
+
+ if (keys.length === 1 && key === keys[0]) {
+ this.queue = [];
+ this.shortcuts[combination]();
+ return;
+ }
+ }
+
+ if (this.queue.length >= 2) {
+ this.queue = [];
+ }
+ };
+ }
+
+ isEventIgnored(event) {
+ return event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA";
+ }
+
+ 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;
+ }
+}
+
+class FormHandler {
+ static handleSubmitButtons() {
+ let elements = document.querySelectorAll("form");
+ elements.forEach(function (element) {
+ element.onsubmit = function () {
+ let button = document.querySelector("button");
+
+ if (button) {
+ button.innerHTML = button.dataset.labelLoading;
+ button.disabled = true;
+ }
+ };
+ });
+ }
+}
+
+class MouseHandler {
+ onClick(selector, callback) {
+ let elements = document.querySelectorAll(selector);
+ elements.forEach((element) => {
+ element.onclick = (event) => {
+ event.preventDefault();
+ callback(event);
+ };
+ });
+ }
+}
+
+class App {
+ run() {
+ FormHandler.handleSubmitButtons();
+
+ let keyboardHandler = new KeyboardHandler();
+ keyboardHandler.on("g u", () => this.goToPage("unread"));
+ keyboardHandler.on("g h", () => this.goToPage("history"));
+ keyboardHandler.on("g f", () => this.goToPage("feeds"));
+ keyboardHandler.on("g c", () => this.goToPage("categories"));
+ keyboardHandler.on("g s", () => this.goToPage("settings"));
+ keyboardHandler.on("ArrowLeft", () => this.goToPrevious());
+ keyboardHandler.on("ArrowRight", () => this.goToNext());
+ keyboardHandler.on("j", () => this.goToPrevious());
+ keyboardHandler.on("p", () => this.goToPrevious());
+ keyboardHandler.on("k", () => this.goToNext());
+ keyboardHandler.on("n", () => this.goToNext());
+ keyboardHandler.on("h", () => this.goToPage("previous"));
+ keyboardHandler.on("l", () => this.goToPage("next"));
+ keyboardHandler.on("o", () => this.openSelectedItem());
+ keyboardHandler.on("v", () => this.openOriginalLink());
+ keyboardHandler.on("m", () => this.toggleEntryStatus());
+ keyboardHandler.on("A", () => this.markPageAsRead());
+ keyboardHandler.listen();
+
+ let mouseHandler = new MouseHandler();
+ mouseHandler.onClick("a[data-on-click=markPageAsRead]", () => this.markPageAsRead());
+
+ if (document.documentElement.clientWidth < 600) {
+ mouseHandler.onClick(".logo", () => this.toggleMainMenu());
+ mouseHandler.onClick(".header nav li", (event) => this.clickMenuListItem(event));
+ }
+ }
+
+ clickMenuListItem(event) {
+ let element = event.target;console.log(element);
+
+ if (element.tagName === "A") {
+ window.location.href = element.getAttribute("href");
+ } else {
+ window.location.href = element.querySelector("a").getAttribute("href");
+ }
+ }
+
+ toggleMainMenu() {
+ let menu = document.querySelector(".header nav ul");
+ if (this.isVisible(menu)) {
+ menu.style.display = "none";
+ } else {
+ menu.style.display = "block";
+ }
+ }
+
+ updateEntriesStatus(entryIDs, status) {
+ let url = document.body.dataset.entriesStatusUrl;
+ let request = new Request(url, {
+ method: "POST",
+ cache: "no-cache",
+ credentials: "include",
+ body: JSON.stringify({entry_ids: entryIDs, status: status}),
+ headers: new Headers({
+ "Content-Type": "application/json",
+ "X-Csrf-Token": this.getCsrfToken()
+ })
+ });
+
+ fetch(request);
+ }
+
+ markPageAsRead() {
+ let items = this.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) {
+ this.updateEntriesStatus(entryIDs, "read");
+ }
+
+ this.goToPage("next");
+ }
+
+ toggleEntryStatus() {
+ let currentItem = document.querySelector(".current-item");
+ if (currentItem !== null) {
+ let entryID = parseInt(currentItem.dataset.id, 10);
+ let statuses = {read: "unread", unread: "read"};
+
+ for (let currentStatus in statuses) {
+ let newStatus = statuses[currentStatus];
+
+ if (currentItem.classList.contains("item-status-" + currentStatus)) {
+ this.goToNextListItem();
+
+ currentItem.classList.remove("item-status-" + currentStatus);
+ currentItem.classList.add("item-status-" + newStatus);
+
+ this.updateEntriesStatus([entryID], newStatus);
+ break;
+ }
+ }
+ }
+ }
+
+ openOriginalLink() {
+ let entryLink = document.querySelector(".entry h1 a");
+ if (entryLink !== null) {
+ this.openNewTab(entryLink.getAttribute("href"));
+ return;
+ }
+
+ let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]");
+ if (currentItemOriginalLink !== null) {
+ this.openNewTab(currentItemOriginalLink.getAttribute("href"));
+ }
+ }
+
+ openSelectedItem() {
+ let currentItemLink = document.querySelector(".current-item .item-title a");
+ if (currentItemLink !== null) {
+ window.location.href = currentItemLink.getAttribute("href");
+ }
+ }
+
+ goToPage(page) {
+ let element = document.querySelector("a[data-page=" + page + "]");
+
+ if (element) {
+ document.location.href = element.href;
+ }
+ }
+
+ goToPrevious() {
+ if (this.isListView()) {
+ this.goToPreviousListItem();
+ } else {
+ this.goToPage("previous");
+ }
+ }
+
+ goToNext() {
+ if (this.isListView()) {
+ this.goToNextListItem();
+ } else {
+ this.goToPage("next");
+ }
+ }
+
+ goToPreviousListItem() {
+ let items = this.getVisibleElements(".items .item");
+
+ if (items.length === 0) {
+ return;
+ }
+
+ if (document.querySelector(".current-item") === null) {
+ items[0].classList.add("current-item");
+ return;
+ }
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].classList.contains("current-item")) {
+ items[i].classList.remove("current-item");
+
+ if (i - 1 >= 0) {
+ items[i - 1].classList.add("current-item");
+ this.scrollPageTo(items[i - 1]);
+ }
+
+ break;
+ }
+ }
+ }
+
+ goToNextListItem() {
+ let items = this.getVisibleElements(".items .item");
+
+ if (items.length === 0) {
+ return;
+ }
+
+ if (document.querySelector(".current-item") === null) {
+ items[0].classList.add("current-item");
+ return;
+ }
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].classList.contains("current-item")) {
+ items[i].classList.remove("current-item");
+
+ if (i + 1 < items.length) {
+ items[i + 1].classList.add("current-item");
+ this.scrollPageTo(items[i + 1]);
+ }
+
+ break;
+ }
+ }
+ }
+
+ 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;
+ }
+
+ isListView() {
+ return document.querySelector(".items") !== null;
+ }
+
+ scrollPageTo(item) {
+ let windowScrollPosition = window.pageYOffset;
+ let windowHeight = document.documentElement.clientHeight;
+ let viewportPosition = windowScrollPosition + windowHeight;
+ let itemBottomPosition = item.offsetTop + item.offsetHeight;
+
+ if (viewportPosition - itemBottomPosition < 0 || viewportPosition - item.offsetTop > windowHeight) {
+ window.scrollTo(0, item.offsetTop - 10);
+ }
+ }
+
+ openNewTab(url) {
+ let win = window.open(url, "_blank");
+ win.focus();
+ }
+
+ isVisible(element) {
+ return element.offsetParent !== null;
+ }
+
+ getCsrfToken() {
+ let element = document.querySelector("meta[name=X-CSRF-Token]");
+
+ if (element !== null) {
+ return element.getAttribute("value");
+ }
+
+ return "";
+ }
+}
+
+document.addEventListener("DOMContentLoaded", function() {
+ (new App()).run();
+});
+
+})();
diff --git a/server/template/common.go b/server/template/common.go
new file mode 100644
index 0000000..5423918
--- /dev/null
+++ b/server/template/common.go
@@ -0,0 +1,111 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.924938666 -0800 PST m=+0.005771809
+
+package template
+
+var templateCommonMap = map[string]string{
+ "entry_pagination": `{{ define "entry_pagination" }}
+<div class="pagination">
+ <div class="pagination-prev">
+ {{ if .prevEntry }}
+ <a href="{{ .prevEntryRoute }}" title="{{ .prevEntry.Title }}" data-page="previous">{{ t "Previous" }}</a>
+ {{ else }}
+ {{ t "Previous" }}
+ {{ end }}
+ </div>
+
+ <div class="pagination-next">
+ {{ if .nextEntry }}
+ <a href="{{ .nextEntryRoute }}" title="{{ .nextEntry.Title }}" data-page="next">{{ t "Next" }}</a>
+ {{ else }}
+ {{ t "Next" }}
+ {{ end }}
+ </div>
+</div>
+{{ end }}`,
+ "layout": `{{ define "base" }}
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width">
+ <meta name="robots" content="noindex,nofollow">
+ <meta name="referrer" content="no-referrer">
+ {{ if .csrf }}
+ <meta name="X-CSRF-Token" value="{{ .csrf }}">
+ {{ end }}
+ <title>{{template "title" .}} - Miniflux</title>
+ {{ if .user }}
+ <link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .user.Theme }}">
+ {{ else }}
+ <link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" "white" }}">
+ {{ end }}
+ <script type="text/javascript" src="{{ route "javascript" }}" defer></script>
+</head>
+<body data-entries-status-url="{{ route "updateEntriesStatus" }}">
+ {{ if .user }}
+ <header class="header">
+ <nav>
+ <div class="logo">
+ <a href="{{ route "unread" }}">Mini<span>flux</span></a>
+ </div>
+ <ul>
+ <li {{ if eq .menu "unread" }}class="active"{{ end }}>
+ <a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
+ {{ if gt .countUnread 0 }}
+ <span class="unread-counter" title="Unread articles">({{ .countUnread }})</span>
+ {{ end }}
+ </li>
+ <li {{ if eq .menu "history" }}class="active"{{ end }}>
+ <a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a>
+ </li>
+ <li {{ if eq .menu "feeds" }}class="active"{{ end }}>
+ <a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a>
+ </li>
+ <li {{ if eq .menu "categories" }}class="active"{{ end }}>
+ <a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a>
+ </li>
+ <li {{ if eq .menu "settings" }}class="active"{{ end }}>
+ <a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "logout" }}" title="Logged as {{ .user.Username }}">{{ t "Logout" }}</a>
+ </li>
+ </ul>
+ </nav>
+ </header>
+ {{ end }}
+ <section class="main">
+ {{template "content" .}}
+ </section>
+</body>
+</html>
+{{ end }}`,
+ "pagination": `{{ define "pagination" }}
+<div class="pagination">
+ <div class="pagination-prev">
+ {{ if .ShowPrev }}
+ <a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ end }}" data-page="previous">{{ t "Previous" }}</a>
+ {{ else }}
+ {{ t "Previous" }}
+ {{ end }}
+ </div>
+
+ <div class="pagination-next">
+ {{ if .ShowNext }}
+ <a href="{{ .Route }}?offset={{ .NextOffset }}" data-page="next">{{ t "Next" }}</a>
+ {{ else }}
+ {{ t "Next" }}
+ {{ end }}
+ </div>
+</div>
+{{ end }}
+`,
+}
+
+var templateCommonMapChecksums = map[string]string{
+ "entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
+ "layout": "8be69cc93fdc99eb36841ae645f58488bd675670507dcdb2de0e593602893178",
+ "pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924",
+}
diff --git a/server/template/helper/LICENSE b/server/template/helper/LICENSE
new file mode 100644
index 0000000..036a2a1
--- /dev/null
+++ b/server/template/helper/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2017 Hervé GOUCHET
+
+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.
diff --git a/server/template/helper/elapsed.go b/server/template/helper/elapsed.go
new file mode 100644
index 0000000..bc31206
--- /dev/null
+++ b/server/template/helper/elapsed.go
@@ -0,0 +1,61 @@
+// Copyright (c) 2017 Hervé Gouchet. All rights reserved.
+// Use of this source code is governed by the MIT License
+// that can be found in the LICENSE file.
+
+package helper
+
+import (
+ "github.com/miniflux/miniflux2/locale"
+ "math"
+ "time"
+)
+
+// Texts to be translated if necessary.
+var (
+ NotYet = `not yet`
+ JustNow = `just now`
+ LastMinute = `1 minute ago`
+ Minutes = `%d minutes ago`
+ LastHour = `1 hour ago`
+ Hours = `%d hours ago`
+ Yesterday = `yesterday`
+ Days = `%d days ago`
+ Weeks = `%d weeks ago`
+ Months = `%d months ago`
+ Years = `%d years ago`
+)
+
+// GetElapsedTime returns in a human readable format the elapsed time
+// since the given datetime.
+func GetElapsedTime(translator *locale.Language, t time.Time) string {
+ if t.IsZero() || time.Now().Before(t) {
+ return translator.Get(NotYet)
+ }
+ diff := time.Since(t)
+ // Duration in seconds
+ s := diff.Seconds()
+ // Duration in days
+ d := int(s / 86400)
+ switch {
+ case s < 60:
+ return translator.Get(JustNow)
+ case s < 120:
+ return translator.Get(LastMinute)
+ case s < 3600:
+ return translator.Get(Minutes, int(diff.Minutes()))
+ case s < 7200:
+ return translator.Get(LastHour)
+ case s < 86400:
+ return translator.Get(Hours, int(diff.Hours()))
+ case d == 1:
+ return translator.Get(Yesterday)
+ case d < 7:
+ return translator.Get(Days, d)
+ case d < 31:
+ return translator.Get(Weeks, int(math.Ceil(float64(d)/7)))
+ case d < 365:
+ return translator.Get(Months, int(math.Ceil(float64(d)/30)))
+ default:
+ return translator.Get(Years, int(math.Ceil(float64(d)/365)))
+ }
+}
diff --git a/server/template/helper/elapsed_test.go b/server/template/helper/elapsed_test.go
new file mode 100644
index 0000000..67b8d6b
--- /dev/null
+++ b/server/template/helper/elapsed_test.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2017 Hervé Gouchet. All rights reserved.
+// Use of this source code is governed by the MIT License
+// that can be found in the LICENSE file.
+
+package helper
+
+import (
+ "fmt"
+ "github.com/miniflux/miniflux2/locale"
+ "testing"
+ "time"
+)
+
+func TestElapsedTime(t *testing.T) {
+ var dt = []struct {
+ in time.Time
+ out string
+ }{
+ {time.Time{}, NotYet},
+ {time.Now().Add(time.Hour), NotYet},
+ {time.Now(), JustNow},
+ {time.Now().Add(-time.Minute), LastMinute},
+ {time.Now().Add(-time.Minute * 40), fmt.Sprintf(Minutes, 40)},
+ {time.Now().Add(-time.Hour), LastHour},
+ {time.Now().Add(-time.Hour * 3), fmt.Sprintf(Hours, 3)},
+ {time.Now().Add(-time.Hour * 32), Yesterday},
+ {time.Now().Add(-time.Hour * 24 * 3), fmt.Sprintf(Days, 3)},
+ {time.Now().Add(-time.Hour * 24 * 14), fmt.Sprintf(Weeks, 2)},
+ {time.Now().Add(-time.Hour * 24 * 60), fmt.Sprintf(Months, 2)},
+ {time.Now().Add(-time.Hour * 24 * 365 * 3), fmt.Sprintf(Years, 3)},
+ }
+ for i, tt := range dt {
+ if out := GetElapsedTime(&locale.Language{}, tt.in); out != tt.out {
+ t.Errorf("%d. content mismatch for %v:exp=%q got=%q", i, tt.in, tt.out, out)
+ }
+ }
+}
diff --git a/server/template/html/about.html b/server/template/html/about.html
new file mode 100644
index 0000000..3596327
--- /dev/null
+++ b/server/template/html/about.html
@@ -0,0 +1,37 @@
+{{ define "title"}}{{ t "About" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "About" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ {{ if .user.IsAdmin }}
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ {{ end }}
+ </ul>
+</section>
+
+<div class="panel">
+ <h3>{{ t "Version" }}</h3>
+ <ul>
+ <li><strong>{{ t "Version:" }}</strong> {{ .version }}</li>
+ <li><strong>{{ t "Build Date:" }}</strong> {{ .build_date }}</li>
+ </ul>
+</div>
+
+<div class="panel">
+ <h3>{{ t "Authors" }}</h3>
+ <ul>
+ <li><strong>{{ t "Author:" }}</strong> Frédéric Guillot</li>
+ <li><strong>{{ t "License:" }}</strong> Apache 2.0</li>
+ </ul>
+</div>
+
+{{ end }}
diff --git a/server/template/html/add_subscription.html b/server/template/html/add_subscription.html
new file mode 100644
index 0000000..99d9e07
--- /dev/null
+++ b/server/template/html/add_subscription.html
@@ -0,0 +1,45 @@
+{{ define "title"}}{{ t "New Subscription" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New Subscription" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .categories }}
+ <p class="alert alert-error">{{ t "There is no category. You must have at least one category." }}</p>
+{{ else }}
+ <form action="{{ route "submitSubscription" }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-url">{{ t "URL" }}</label>
+ <input type="url" name="url" id="form-url" placeholder="https://domain.tld/" value="{{ .form.URL }}" required autofocus>
+
+ <label for="form-category">{{ t "Category" }}</label>
+ <select id="form-category" name="category_id">
+ {{ range .categories }}
+ <option value="{{ .ID }}">{{ .Title }}</option>
+ {{ end }}
+ </select>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Find a subscription" }}</button>
+ </div>
+ </form>
+{{ end }}
+
+{{ end }}
diff --git a/server/template/html/categories.html b/server/template/html/categories.html
new file mode 100644
index 0000000..88b0ebe
--- /dev/null
+++ b/server/template/html/categories.html
@@ -0,0 +1,50 @@
+{{ define "title"}}{{ t "Categories" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Categories" }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .categories }}
+ <p class="alert alert-error">{{ t "There is no category." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .categories }}
+ <article class="item">
+ <div class="item-header">
+ <span class="item-title">
+ <a href="{{ route "categoryEntries" "categoryID" .ID }}">{{ .Title }}</a>
+ </span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ {{ if eq .FeedCount 0 }}
+ {{ t "No feed." }}
+ {{ else }}
+ {{ plural "plural.categories.feed_count" .FeedCount .FeedCount }}
+ {{ end }}
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <a href="{{ route "editCategory" "categoryID" .ID }}">{{ t "Edit" }}</a>
+ </li>
+ {{ if eq .FeedCount 0 }}
+ <li>
+ <a href="{{ route "removeCategory" "categoryID" .ID }}">{{ t "Remove" }}</a>
+ </li>
+ {{ end }}
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+{{ end }}
+
+{{ end }}
diff --git a/server/template/html/category_entries.html b/server/template/html/category_entries.html
new file mode 100644
index 0000000..d36a5ee
--- /dev/null
+++ b/server/template/html/category_entries.html
@@ -0,0 +1,47 @@
+{{ define "title"}}{{ .category.Title }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ .category.Title }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .entries }}
+ <p class="alert">{{ t "There is no article in this category." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}
diff --git a/server/template/html/choose_subscription.html b/server/template/html/choose_subscription.html
new file mode 100644
index 0000000..72c68fd
--- /dev/null
+++ b/server/template/html/choose_subscription.html
@@ -0,0 +1,36 @@
+{{ define "title"}}{{ t "Choose a Subscription" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New Subscription" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "chooseSubscription" }}" method="POST">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+ <input type="hidden" name="category_id" value="{{ .categoryID }}">
+
+ <h3>{{ t "Choose a Subscription" }}</h3>
+
+ {{ range .subscriptions }}
+ <div class="radio-group">
+ <label title="{{ .URL }}"><input type="radio" name="url" value="{{ .URL }}"> {{ .Title }}</label> ({{ .Type }})
+ <small title="Type = {{ .Type }}"><a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL }}</a></small>
+ </div>
+ {{ end }}
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Subscribe" }}</button>
+ </div>
+</form>
+{{ end }}
diff --git a/server/template/html/common/entry_pagination.html b/server/template/html/common/entry_pagination.html
new file mode 100644
index 0000000..6c9f29c
--- /dev/null
+++ b/server/template/html/common/entry_pagination.html
@@ -0,0 +1,19 @@
+{{ define "entry_pagination" }}
+<div class="pagination">
+ <div class="pagination-prev">
+ {{ if .prevEntry }}
+ <a href="{{ .prevEntryRoute }}" title="{{ .prevEntry.Title }}" data-page="previous">{{ t "Previous" }}</a>
+ {{ else }}
+ {{ t "Previous" }}
+ {{ end }}
+ </div>
+
+ <div class="pagination-next">
+ {{ if .nextEntry }}
+ <a href="{{ .nextEntryRoute }}" title="{{ .nextEntry.Title }}" data-page="next">{{ t "Next" }}</a>
+ {{ else }}
+ {{ t "Next" }}
+ {{ end }}
+ </div>
+</div>
+{{ end }} \ No newline at end of file
diff --git a/server/template/html/common/layout.html b/server/template/html/common/layout.html
new file mode 100644
index 0000000..defa3c9
--- /dev/null
+++ b/server/template/html/common/layout.html
@@ -0,0 +1,59 @@
+{{ define "base" }}
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width">
+ <meta name="robots" content="noindex,nofollow">
+ <meta name="referrer" content="no-referrer">
+ {{ if .csrf }}
+ <meta name="X-CSRF-Token" value="{{ .csrf }}">
+ {{ end }}
+ <title>{{template "title" .}} - Miniflux</title>
+ {{ if .user }}
+ <link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .user.Theme }}">
+ {{ else }}
+ <link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" "white" }}">
+ {{ end }}
+ <script type="text/javascript" src="{{ route "javascript" }}" defer></script>
+</head>
+<body data-entries-status-url="{{ route "updateEntriesStatus" }}">
+ {{ if .user }}
+ <header class="header">
+ <nav>
+ <div class="logo">
+ <a href="{{ route "unread" }}">Mini<span>flux</span></a>
+ </div>
+ <ul>
+ <li {{ if eq .menu "unread" }}class="active"{{ end }}>
+ <a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
+ {{ if gt .countUnread 0 }}
+ <span class="unread-counter" title="Unread articles">({{ .countUnread }})</span>
+ {{ end }}
+ </li>
+ <li {{ if eq .menu "history" }}class="active"{{ end }}>
+ <a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a>
+ </li>
+ <li {{ if eq .menu "feeds" }}class="active"{{ end }}>
+ <a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a>
+ </li>
+ <li {{ if eq .menu "categories" }}class="active"{{ end }}>
+ <a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a>
+ </li>
+ <li {{ if eq .menu "settings" }}class="active"{{ end }}>
+ <a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "logout" }}" title="Logged as {{ .user.Username }}">{{ t "Logout" }}</a>
+ </li>
+ </ul>
+ </nav>
+ </header>
+ {{ end }}
+ <section class="main">
+ {{template "content" .}}
+ </section>
+</body>
+</html>
+{{ end }} \ No newline at end of file
diff --git a/server/template/html/common/pagination.html b/server/template/html/common/pagination.html
new file mode 100644
index 0000000..4c6766a
--- /dev/null
+++ b/server/template/html/common/pagination.html
@@ -0,0 +1,19 @@
+{{ define "pagination" }}
+<div class="pagination">
+ <div class="pagination-prev">
+ {{ if .ShowPrev }}
+ <a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ end }}" data-page="previous">{{ t "Previous" }}</a>
+ {{ else }}
+ {{ t "Previous" }}
+ {{ end }}
+ </div>
+
+ <div class="pagination-next">
+ {{ if .ShowNext }}
+ <a href="{{ .Route }}?offset={{ .NextOffset }}" data-page="next">{{ t "Next" }}</a>
+ {{ else }}
+ {{ t "Next" }}
+ {{ end }}
+ </div>
+</div>
+{{ end }}
diff --git a/server/template/html/create_category.html b/server/template/html/create_category.html
new file mode 100644
index 0000000..7c4c93f
--- /dev/null
+++ b/server/template/html/create_category.html
@@ -0,0 +1,27 @@
+{{ define "title"}}{{ t "New Category" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New Category" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "categories" }}">{{ t "Categories" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "saveCategory" }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-title">{{ t "Title" }}</label>
+ <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
diff --git a/server/template/html/create_user.html b/server/template/html/create_user.html
new file mode 100644
index 0000000..36af356
--- /dev/null
+++ b/server/template/html/create_user.html
@@ -0,0 +1,41 @@
+{{ define "title"}}{{ t "New User" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New User" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "saveUser" }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" value="{{ .form.Password }}" required>
+
+ <label for="form-confirmation">{{ t "Confirmation" }}</label>
+ <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" required>
+
+ <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
diff --git a/server/template/html/edit_category.html b/server/template/html/edit_category.html
new file mode 100644
index 0000000..2981fa4
--- /dev/null
+++ b/server/template/html/edit_category.html
@@ -0,0 +1,30 @@
+{{ define "title"}}{{ t "Edit Category: %s" .category.Title }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Edit Category: %s" .category.Title }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "categories" }}">{{ t "Categories" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "updateCategory" "categoryID" .category.ID }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-title">{{ t "Title" }}</label>
+ <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
diff --git a/server/template/html/edit_feed.html b/server/template/html/edit_feed.html
new file mode 100644
index 0000000..fac2a9b
--- /dev/null
+++ b/server/template/html/edit_feed.html
@@ -0,0 +1,61 @@
+{{ define "title"}}{{ t "Edit Feed: %s" .feed.Title }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ .feed.Title }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .categories }}
+ <p class="alert alert-error">{{ t "There is no category!" }}</p>
+{{ else }}
+ {{ if ne .feed.ParsingErrorCount 0 }}
+ <div class="alert alert-error">
+ <h3>{{ t "Last Parsing Error" }}</h3>
+ {{ .feed.ParsingErrorMsg }}
+ </div>
+ {{ end }}
+
+ <form action="{{ route "updateFeed" "feedID" .feed.ID }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-title">{{ t "Title" }}</label>
+ <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
+
+ <label for="form-site-url">{{ t "Site URL" }}</label>
+ <input type="url" name="site_url" id="form-site-url" placeholder="https://domain.tld/" value="{{ .form.SiteURL }}" required>
+
+ <label for="form-feed-url">{{ t "Feed URL" }}</label>
+ <input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" required>
+
+ <label for="form-category">{{ t "Category" }}</label>
+ <select id="form-category" name="category_id">
+ {{ range .categories }}
+ <option value="{{ .ID }}" {{ if eq .ID $.form.CategoryID }}selected="selected"{{ end }}>{{ .Title }}</option>
+ {{ end }}
+ </select>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "feeds" }}">{{ t "cancel" }}</a>
+ </div>
+ </form>
+{{ end }}
+
+{{ end }} \ No newline at end of file
diff --git a/server/template/html/edit_user.html b/server/template/html/edit_user.html
new file mode 100644
index 0000000..8f63307
--- /dev/null
+++ b/server/template/html/edit_user.html
@@ -0,0 +1,44 @@
+{{ define "title"}}{{ t "Edit user: %s" .selected_user.Username }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Edit user %s" .selected_user.Username }}"</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "updateUser" "userID" .selected_user.ID }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" value="{{ .form.Password }}">
+
+ <label for="form-confirmation">{{ t "Confirmation" }}</label>
+ <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}">
+
+ <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
diff --git a/server/template/html/entry.html b/server/template/html/entry.html
new file mode 100644
index 0000000..3bb296f
--- /dev/null
+++ b/server/template/html/entry.html
@@ -0,0 +1,75 @@
+{{ define "title"}}{{ .entry.Title }}{{ end }}
+
+{{ define "content"}}
+<section class="entry">
+ <header class="entry-header">
+ <h1>
+ <a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
+ </h1>
+ <div class="entry-meta">
+ <span class="entry-website">
+ {{ if ne .entry.Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .entry.Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
+ </span>
+ {{ if .entry.Author }}
+ <span class="entry-author">
+ {{ if contains .entry.Author "@" }}
+ - <a href="mailto:{{ .entry.Author }}">{{ .entry.Author }}</a>
+ {{ else }}
+ – <em>{{ .entry.Author }}</em>
+ {{ end }}
+ </span>
+ {{ end }}
+ <span class="category">
+ <a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
+ </span>
+ </div>
+ <div class="entry-date">
+ <time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
+ </div>
+ </header>
+ <div class="pagination-top">
+ {{ template "entry_pagination" . }}
+ </div>
+ <article class="entry-content">
+ {{ noescape (proxyFilter .entry.Content) }}
+ </article>
+ {{ if .entry.Enclosures }}
+ <aside class="entry-enclosures">
+ <h3>{{ t "Attachments" }}</h3>
+ {{ range .entry.Enclosures }}
+ <div class="entry-enclosure">
+ {{ if hasPrefix .MimeType "audio/" }}
+ <div class="enclosure-audio">
+ <audio controls preload="metadata">
+ <source src="{{ .URL }}" type="{{ .MimeType }}">
+ </audio>
+ </div>
+ {{ else if hasPrefix .MimeType "video/" }}
+ <div class="enclosure-video">
+ <video controls preload="metadata">
+ <source src="{{ .URL }}" type="{{ .MimeType }}">
+ </video>
+ </div>
+ {{ else if hasPrefix .MimeType "image/" }}
+ <div class="enclosure-image">
+ <img src="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" alt="{{ .URL }} ({{ .MimeType }})">
+ </div>
+ {{ end }}
+
+ <div class="entry-enclosure-download">
+ <a href="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Download" }}</a>
+ <small>({{ .URL }})</small>
+ </div>
+ </div>
+ {{ end }}
+ </aside>
+ {{ end }}
+</section>
+
+<div class="pagination-bottom">
+ {{ template "entry_pagination" . }}
+</div>
+{{ end }}
diff --git a/server/template/html/feed_entries.html b/server/template/html/feed_entries.html
new file mode 100644
index 0000000..5028df4
--- /dev/null
+++ b/server/template/html/feed_entries.html
@@ -0,0 +1,58 @@
+{{ define "title"}}{{ .feed.Title }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ .feed.Title }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="{{ route "refreshFeed" "feedID" .feed.ID }}">{{ t "Refresh" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "editFeed" "feedID" .feed.ID }}">{{ t "Edit" }}</a>
+ </li>
+ <li>
+ <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if ne .feed.ParsingErrorCount 0 }}
+<div class="alert alert-error">
+ <h3>{{ t "There is a problem with this feed" }}</h3>
+ {{ .feed.ParsingErrorMsg }}
+</div>
+{{ else if not .entries }}
+ <p class="alert">{{ t "There is no article for this feed." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}
diff --git a/server/template/html/feeds.html b/server/template/html/feeds.html
new file mode 100644
index 0000000..d753754
--- /dev/null
+++ b/server/template/html/feeds.html
@@ -0,0 +1,65 @@
+{{ define "title"}}{{ t "Feeds" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Feeds" }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .feeds }}
+ <p class="alert">{{ t "You don't have any subscription." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .feeds }}
+ <article class="item">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "feedEntries" "feedID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category">
+ <a href="{{ route "categoryEntries" "categoryID" .Category.ID }}">{{ .Category.Title }}</a>
+ </span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
+ </li>
+ <li>
+ {{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
+ </li>
+ {{ if ne .ParsingErrorCount 0 }}
+ <li><strong title="{{ .ParsingErrorMsg }}">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong></li>
+ {{ end }}
+ </ul>
+ <ul>
+ <li>
+ <a href="{{ route "refreshFeed" "feedID" .ID }}">{{ t "Refresh" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "editFeed" "feedID" .ID }}">{{ t "Edit" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "removeFeed" "feedID" .ID }}">{{ t "Remove" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+{{ end }}
+
+{{ end }}
diff --git a/server/template/html/history.html b/server/template/html/history.html
new file mode 100644
index 0000000..a344da1
--- /dev/null
+++ b/server/template/html/history.html
@@ -0,0 +1,42 @@
+{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "History" }} ({{ .total }})</h1>
+</section>
+
+{{ if not .entries }}
+ <p class="alert alert-info">{{ t "There is no history at the moment." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "readEntry" "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}
diff --git a/server/template/html/import.html b/server/template/html/import.html
new file mode 100644
index 0000000..dbdb9b0
--- /dev/null
+++ b/server/template/html/import.html
@@ -0,0 +1,34 @@
+{{ define "title"}}{{ t "Import" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Import" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "uploadOPML" }}" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-file">{{ t "OPML file" }}</label>
+ <input type="file" name="file" id="form-file">
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Import" }}</button>
+ </div>
+</form>
+
+{{ end }}
diff --git a/server/template/html/login.html b/server/template/html/login.html
new file mode 100644
index 0000000..07a3212
--- /dev/null
+++ b/server/template/html/login.html
@@ -0,0 +1,23 @@
+{{ define "title"}}{{ t "Sign In" }}{{ end }}
+
+{{ define "content"}}
+<section class="login-form">
+ <form action="{{ route "checkLogin" }}" method="post">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" required autofocus>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" required>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button>
+ </div>
+ </form>
+</section>
+{{ end }}
diff --git a/server/template/html/sessions.html b/server/template/html/sessions.html
new file mode 100644
index 0000000..048719e
--- /dev/null
+++ b/server/template/html/sessions.html
@@ -0,0 +1,42 @@
+{{ define "title"}}{{ t "Sessions" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Sessions" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
+ </li>
+ </ul>
+</section>
+
+<table class="table-overflow">
+ <tr>
+ <th>{{ t "Date" }}</th>
+ <th>{{ t "IP Address" }}</th>
+ <th>{{ t "User Agent" }}</th>
+ <th>{{ t "Actions" }}</th>
+ </tr>
+ {{ range .sessions }}
+ <tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
+ <td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
+ <td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
+ <td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
+ <td class="column-20">
+ {{ if eq .Token $.currentSessionToken }}
+ {{ t "Current session" }}
+ {{ else }}
+ <a href="{{ route "removeSession" "sessionID" .ID }}">{{ t "Remove" }}</a>
+ {{ end }}
+ </td>
+ </tr>
+ {{ end }}
+</table>
+
+{{ end }}
diff --git a/server/template/html/settings.html b/server/template/html/settings.html
new file mode 100644
index 0000000..f916708
--- /dev/null
+++ b/server/template/html/settings.html
@@ -0,0 +1,63 @@
+{{ define "title"}}{{ t "Settings" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Settings" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ {{ if .user.IsAdmin }}
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ {{ end }}
+ <li>
+ <a href="{{ route "about" }}">{{ t "About" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form method="post" autocomplete="off" action="{{ route "updateSettings" }}">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" value="{{ .form.Username }}" required>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" value="{{ .form.Password }}" autocomplete="off">
+
+ <label for="form-confirmation">{{ t "Confirmation" }}</label>
+ <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" autocomplete="off">
+
+ <label for="form-language">{{ t "Language" }}</label>
+ <select id="form-language" name="language">
+ {{ range $key, $value := .languages }}
+ <option value="{{ $key }}" {{ if eq $key $.form.Language }}selected="selected"{{ end }}>{{ $value }}</option>
+ {{ end }}
+ </select>
+
+ <label for="form-timezone">{{ t "Timezone" }}</label>
+ <select id="form-timezone" name="timezone">
+ {{ range $key, $value := .timezones }}
+ <option value="{{ $key }}" {{ if eq $key $.form.Timezone }}selected="selected"{{ end }}>{{ $value }}</option>
+ {{ end }}
+ </select>
+
+ <label for="form-theme">{{ t "Theme" }}</label>
+ <select id="form-theme" name="theme">
+ {{ range $key, $value := .themes }}
+ <option value="{{ $key }}" {{ if eq $key $.form.Theme }}selected="selected"{{ end }}>{{ $value }}</option>
+ {{ end }}
+ </select>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
+ </div>
+</form>
+
+{{ end }}
diff --git a/server/template/html/unread.html b/server/template/html/unread.html
new file mode 100644
index 0000000..413965e
--- /dev/null
+++ b/server/template/html/unread.html
@@ -0,0 +1,47 @@
+{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Unread" }} ({{ .countUnread }})</h1>
+ <ul>
+ <li>
+ <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .entries }}
+ <p class="alert">{{ t "There is no unread article." }}</p>
+{{ else }}
+ <div class="items hide-read-items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "unreadEntry" "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }} \ No newline at end of file
diff --git a/server/template/html/users.html b/server/template/html/users.html
new file mode 100644
index 0000000..69acd00
--- /dev/null
+++ b/server/template/html/users.html
@@ -0,0 +1,51 @@
+{{ define "title"}}{{ t "Users" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Users" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if eq (len .users) 1 }}
+ <p class="alert">{{ t "You are the only user." }}</p>
+{{ else }}
+ <table>
+ <tr>
+ <th class="column-20">{{ t "Username" }}</th>
+ <th>{{ t "Administrator" }}</th>
+ <th>{{ t "Last Login" }}</th>
+ <th>{{ t "Actions" }}</th>
+ </tr>
+ {{ range .users }}
+ {{ if ne .ID $.user.ID }}
+ <tr>
+ <td>{{ .Username }}</td>
+ <td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
+ <td>
+ {{ if .LastLoginAt }}
+ <time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
+ {{ else }}
+ {{ t "Never" }}
+ {{ end }}
+ </td>
+ <td>
+ <a href="{{ route "editUser" "userID" .ID }}">{{ t "Edit" }}</a>,
+ <a href="{{ route "removeUser" "userID" .ID }}">{{ t "Remove" }}</a>
+ </td>
+ </tr>
+ {{ end }}
+ {{ end }}
+ </table>
+{{ end }}
+
+{{ end }}
diff --git a/server/template/template.go b/server/template/template.go
new file mode 100644
index 0000000..086cdc5
--- /dev/null
+++ b/server/template/template.go
@@ -0,0 +1,117 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package template
+
+import (
+ "bytes"
+ "github.com/miniflux/miniflux2/errors"
+ "github.com/miniflux/miniflux2/locale"
+ "github.com/miniflux/miniflux2/server/route"
+ "github.com/miniflux/miniflux2/server/template/helper"
+ "github.com/miniflux/miniflux2/server/ui/filter"
+ "html/template"
+ "io"
+ "log"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/gorilla/mux"
+)
+
+type TemplateEngine struct {
+ templates map[string]*template.Template
+ router *mux.Router
+ translator *locale.Translator
+ currentLocale *locale.Language
+}
+
+func (t *TemplateEngine) ParseAll() {
+ funcMap := template.FuncMap{
+ "route": func(name string, args ...interface{}) string {
+ return route.GetRoute(t.router, name, args...)
+ },
+ "noescape": func(str string) template.HTML {
+ return template.HTML(str)
+ },
+ "proxyFilter": func(data string) string {
+ return filter.ImageProxyFilter(t.router, data)
+ },
+ "domain": func(websiteURL string) string {
+ parsedURL, err := url.Parse(websiteURL)
+ if err != nil {
+ return websiteURL
+ }
+
+ return parsedURL.Host
+ },
+ "hasPrefix": func(str, prefix string) bool {
+ return strings.HasPrefix(str, prefix)
+ },
+ "contains": func(str, substr string) bool {
+ return strings.Contains(str, substr)
+ },
+ "isodate": func(ts time.Time) string {
+ return ts.Format("2006-01-02 15:04:05")
+ },
+ "elapsed": func(ts time.Time) string {
+ return helper.GetElapsedTime(t.currentLocale, ts)
+ },
+ "t": func(key interface{}, args ...interface{}) string {
+ switch key.(type) {
+ case string, error:
+ return t.currentLocale.Get(key.(string), args...)
+ case errors.LocalizedError:
+ err := key.(errors.LocalizedError)
+ return err.Localize(t.currentLocale)
+ default:
+ return ""
+ }
+ },
+ "plural": func(key string, n int, args ...interface{}) string {
+ return t.currentLocale.Plural(key, n, args...)
+ },
+ }
+
+ commonTemplates := ""
+ for _, content := range templateCommonMap {
+ commonTemplates += content
+ }
+
+ for name, content := range templateViewsMap {
+ log.Println("Parsing template:", name)
+ t.templates[name] = template.Must(template.New("main").Funcs(funcMap).Parse(commonTemplates + content))
+ }
+}
+
+func (t *TemplateEngine) SetLanguage(language string) {
+ t.currentLocale = t.translator.GetLanguage(language)
+}
+
+func (t *TemplateEngine) Execute(w io.Writer, name string, data interface{}) {
+ tpl, ok := t.templates[name]
+ if !ok {
+ log.Fatalf("The template %s does not exists.\n", name)
+ }
+
+ var b bytes.Buffer
+ err := tpl.ExecuteTemplate(&b, "base", data)
+ if err != nil {
+ log.Fatalf("Unable to render template: %v\n", err)
+ }
+
+ b.WriteTo(w)
+}
+
+func NewTemplateEngine(router *mux.Router, translator *locale.Translator) *TemplateEngine {
+ tpl := &TemplateEngine{
+ templates: make(map[string]*template.Template),
+ router: router,
+ translator: translator,
+ }
+
+ tpl.ParseAll()
+ return tpl
+}
diff --git a/server/template/views.go b/server/template/views.go
new file mode 100644
index 0000000..2f8319e
--- /dev/null
+++ b/server/template/views.go
@@ -0,0 +1,966 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.923713128 -0800 PST m=+0.004546271
+
+package template
+
+var templateViewsMap = map[string]string{
+ "about": `{{ define "title"}}{{ t "About" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "About" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ {{ if .user.IsAdmin }}
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ {{ end }}
+ </ul>
+</section>
+
+<div class="panel">
+ <h3>{{ t "Version" }}</h3>
+ <ul>
+ <li><strong>{{ t "Version:" }}</strong> {{ .version }}</li>
+ <li><strong>{{ t "Build Date:" }}</strong> {{ .build_date }}</li>
+ </ul>
+</div>
+
+<div class="panel">
+ <h3>{{ t "Authors" }}</h3>
+ <ul>
+ <li><strong>{{ t "Author:" }}</strong> Frédéric Guillot</li>
+ <li><strong>{{ t "License:" }}</strong> Apache 2.0</li>
+ </ul>
+</div>
+
+{{ end }}
+`,
+ "add_subscription": `{{ define "title"}}{{ t "New Subscription" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New Subscription" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .categories }}
+ <p class="alert alert-error">{{ t "There is no category. You must have at least one category." }}</p>
+{{ else }}
+ <form action="{{ route "submitSubscription" }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-url">{{ t "URL" }}</label>
+ <input type="url" name="url" id="form-url" placeholder="https://domain.tld/" value="{{ .form.URL }}" required autofocus>
+
+ <label for="form-category">{{ t "Category" }}</label>
+ <select id="form-category" name="category_id">
+ {{ range .categories }}
+ <option value="{{ .ID }}">{{ .Title }}</option>
+ {{ end }}
+ </select>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Find a subscription" }}</button>
+ </div>
+ </form>
+{{ end }}
+
+{{ end }}
+`,
+ "categories": `{{ define "title"}}{{ t "Categories" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Categories" }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .categories }}
+ <p class="alert alert-error">{{ t "There is no category." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .categories }}
+ <article class="item">
+ <div class="item-header">
+ <span class="item-title">
+ <a href="{{ route "categoryEntries" "categoryID" .ID }}">{{ .Title }}</a>
+ </span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ {{ if eq .FeedCount 0 }}
+ {{ t "No feed." }}
+ {{ else }}
+ {{ plural "plural.categories.feed_count" .FeedCount .FeedCount }}
+ {{ end }}
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <a href="{{ route "editCategory" "categoryID" .ID }}">{{ t "Edit" }}</a>
+ </li>
+ {{ if eq .FeedCount 0 }}
+ <li>
+ <a href="{{ route "removeCategory" "categoryID" .ID }}">{{ t "Remove" }}</a>
+ </li>
+ {{ end }}
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+{{ end }}
+
+{{ end }}
+`,
+ "category_entries": `{{ define "title"}}{{ .category.Title }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ .category.Title }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .entries }}
+ <p class="alert">{{ t "There is no article in this category." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}
+`,
+ "choose_subscription": `{{ define "title"}}{{ t "Choose a Subscription" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New Subscription" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "chooseSubscription" }}" method="POST">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+ <input type="hidden" name="category_id" value="{{ .categoryID }}">
+
+ <h3>{{ t "Choose a Subscription" }}</h3>
+
+ {{ range .subscriptions }}
+ <div class="radio-group">
+ <label title="{{ .URL }}"><input type="radio" name="url" value="{{ .URL }}"> {{ .Title }}</label> ({{ .Type }})
+ <small title="Type = {{ .Type }}"><a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL }}</a></small>
+ </div>
+ {{ end }}
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Subscribe" }}</button>
+ </div>
+</form>
+{{ end }}
+`,
+ "create_category": `{{ define "title"}}{{ t "New Category" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New Category" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "categories" }}">{{ t "Categories" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "saveCategory" }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-title">{{ t "Title" }}</label>
+ <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
+`,
+ "create_user": `{{ define "title"}}{{ t "New User" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "New User" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "saveUser" }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" value="{{ .form.Password }}" required>
+
+ <label for="form-confirmation">{{ t "Confirmation" }}</label>
+ <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" required>
+
+ <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
+`,
+ "edit_category": `{{ define "title"}}{{ t "Edit Category: %s" .category.Title }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Edit Category: %s" .category.Title }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "categories" }}">{{ t "Categories" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "updateCategory" "categoryID" .category.ID }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-title">{{ t "Title" }}</label>
+ <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
+`,
+ "edit_feed": `{{ define "title"}}{{ t "Edit Feed: %s" .feed.Title }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ .feed.Title }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .categories }}
+ <p class="alert alert-error">{{ t "There is no category!" }}</p>
+{{ else }}
+ {{ if ne .feed.ParsingErrorCount 0 }}
+ <div class="alert alert-error">
+ <h3>{{ t "Last Parsing Error" }}</h3>
+ {{ .feed.ParsingErrorMsg }}
+ </div>
+ {{ end }}
+
+ <form action="{{ route "updateFeed" "feedID" .feed.ID }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-title">{{ t "Title" }}</label>
+ <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
+
+ <label for="form-site-url">{{ t "Site URL" }}</label>
+ <input type="url" name="site_url" id="form-site-url" placeholder="https://domain.tld/" value="{{ .form.SiteURL }}" required>
+
+ <label for="form-feed-url">{{ t "Feed URL" }}</label>
+ <input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" required>
+
+ <label for="form-category">{{ t "Category" }}</label>
+ <select id="form-category" name="category_id">
+ {{ range .categories }}
+ <option value="{{ .ID }}" {{ if eq .ID $.form.CategoryID }}selected="selected"{{ end }}>{{ .Title }}</option>
+ {{ end }}
+ </select>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "feeds" }}">{{ t "cancel" }}</a>
+ </div>
+ </form>
+{{ end }}
+
+{{ end }}`,
+ "edit_user": `{{ define "title"}}{{ t "Edit user: %s" .selected_user.Username }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Edit user %s" .selected_user.Username }}"</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "updateUser" "userID" .selected_user.ID }}" method="post" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" value="{{ .form.Password }}">
+
+ <label for="form-confirmation">{{ t "Confirmation" }}</label>
+ <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}">
+
+ <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
+ </div>
+</form>
+{{ end }}
+`,
+ "entry": `{{ define "title"}}{{ .entry.Title }}{{ end }}
+
+{{ define "content"}}
+<section class="entry">
+ <header class="entry-header">
+ <h1>
+ <a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
+ </h1>
+ <div class="entry-meta">
+ <span class="entry-website">
+ {{ if ne .entry.Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .entry.Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
+ </span>
+ {{ if .entry.Author }}
+ <span class="entry-author">
+ {{ if contains .entry.Author "@" }}
+ - <a href="mailto:{{ .entry.Author }}">{{ .entry.Author }}</a>
+ {{ else }}
+ – <em>{{ .entry.Author }}</em>
+ {{ end }}
+ </span>
+ {{ end }}
+ <span class="category">
+ <a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
+ </span>
+ </div>
+ <div class="entry-date">
+ <time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
+ </div>
+ </header>
+ <div class="pagination-top">
+ {{ template "entry_pagination" . }}
+ </div>
+ <article class="entry-content">
+ {{ noescape (proxyFilter .entry.Content) }}
+ </article>
+ {{ if .entry.Enclosures }}
+ <aside class="entry-enclosures">
+ <h3>{{ t "Attachments" }}</h3>
+ {{ range .entry.Enclosures }}
+ <div class="entry-enclosure">
+ {{ if hasPrefix .MimeType "audio/" }}
+ <div class="enclosure-audio">
+ <audio controls preload="metadata">
+ <source src="{{ .URL }}" type="{{ .MimeType }}">
+ </audio>
+ </div>
+ {{ else if hasPrefix .MimeType "video/" }}
+ <div class="enclosure-video">
+ <video controls preload="metadata">
+ <source src="{{ .URL }}" type="{{ .MimeType }}">
+ </video>
+ </div>
+ {{ else if hasPrefix .MimeType "image/" }}
+ <div class="enclosure-image">
+ <img src="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" alt="{{ .URL }} ({{ .MimeType }})">
+ </div>
+ {{ end }}
+
+ <div class="entry-enclosure-download">
+ <a href="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Download" }}</a>
+ <small>({{ .URL }})</small>
+ </div>
+ </div>
+ {{ end }}
+ </aside>
+ {{ end }}
+</section>
+
+<div class="pagination-bottom">
+ {{ template "entry_pagination" . }}
+</div>
+{{ end }}
+`,
+ "feed_entries": `{{ define "title"}}{{ .feed.Title }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ .feed.Title }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="{{ route "refreshFeed" "feedID" .feed.ID }}">{{ t "Refresh" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "editFeed" "feedID" .feed.ID }}">{{ t "Edit" }}</a>
+ </li>
+ <li>
+ <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if ne .feed.ParsingErrorCount 0 }}
+<div class="alert alert-error">
+ <h3>{{ t "There is a problem with this feed" }}</h3>
+ {{ .feed.ParsingErrorMsg }}
+</div>
+{{ else if not .entries }}
+ <p class="alert">{{ t "There is no article for this feed." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}
+`,
+ "feeds": `{{ define "title"}}{{ t "Feeds" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Feeds" }} ({{ .total }})</h1>
+ <ul>
+ <li>
+ <a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "import" }}">{{ t "Import" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .feeds }}
+ <p class="alert">{{ t "You don't have any subscription." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .feeds }}
+ <article class="item">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "feedEntries" "feedID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category">
+ <a href="{{ route "categoryEntries" "categoryID" .Category.ID }}">{{ .Category.Title }}</a>
+ </span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
+ </li>
+ <li>
+ {{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
+ </li>
+ {{ if ne .ParsingErrorCount 0 }}
+ <li><strong title="{{ .ParsingErrorMsg }}">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong></li>
+ {{ end }}
+ </ul>
+ <ul>
+ <li>
+ <a href="{{ route "refreshFeed" "feedID" .ID }}">{{ t "Refresh" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "editFeed" "feedID" .ID }}">{{ t "Edit" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "removeFeed" "feedID" .ID }}">{{ t "Remove" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+{{ end }}
+
+{{ end }}
+`,
+ "history": `{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "History" }} ({{ .total }})</h1>
+</section>
+
+{{ if not .entries }}
+ <p class="alert alert-info">{{ t "There is no history at the moment." }}</p>
+{{ else }}
+ <div class="items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "readEntry" "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}
+`,
+ "import": `{{ define "title"}}{{ t "Import" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Import" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "export" }}">{{ t "Export" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form action="{{ route "uploadOPML" }}" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-file">{{ t "OPML file" }}</label>
+ <input type="file" name="file" id="form-file">
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Import" }}</button>
+ </div>
+</form>
+
+{{ end }}
+`,
+ "login": `{{ define "title"}}{{ t "Sign In" }}{{ end }}
+
+{{ define "content"}}
+<section class="login-form">
+ <form action="{{ route "checkLogin" }}" method="post">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" required autofocus>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" required>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button>
+ </div>
+ </form>
+</section>
+{{ end }}
+`,
+ "sessions": `{{ define "title"}}{{ t "Sessions" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Sessions" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
+ </li>
+ </ul>
+</section>
+
+<table class="table-overflow">
+ <tr>
+ <th>{{ t "Date" }}</th>
+ <th>{{ t "IP Address" }}</th>
+ <th>{{ t "User Agent" }}</th>
+ <th>{{ t "Actions" }}</th>
+ </tr>
+ {{ range .sessions }}
+ <tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
+ <td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
+ <td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
+ <td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
+ <td class="column-20">
+ {{ if eq .Token $.currentSessionToken }}
+ {{ t "Current session" }}
+ {{ else }}
+ <a href="{{ route "removeSession" "sessionID" .ID }}">{{ t "Remove" }}</a>
+ {{ end }}
+ </td>
+ </tr>
+ {{ end }}
+</table>
+
+{{ end }}
+`,
+ "settings": `{{ define "title"}}{{ t "Settings" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Settings" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ {{ if .user.IsAdmin }}
+ <li>
+ <a href="{{ route "users" }}">{{ t "Users" }}</a>
+ </li>
+ {{ end }}
+ <li>
+ <a href="{{ route "about" }}">{{ t "About" }}</a>
+ </li>
+ </ul>
+</section>
+
+<form method="post" autocomplete="off" action="{{ route "updateSettings" }}">
+ <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+ {{ if .errorMessage }}
+ <div class="alert alert-error">{{ t .errorMessage }}</div>
+ {{ end }}
+
+ <label for="form-username">{{ t "Username" }}</label>
+ <input type="text" name="username" id="form-username" value="{{ .form.Username }}" required>
+
+ <label for="form-password">{{ t "Password" }}</label>
+ <input type="password" name="password" id="form-password" value="{{ .form.Password }}" autocomplete="off">
+
+ <label for="form-confirmation">{{ t "Confirmation" }}</label>
+ <input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" autocomplete="off">
+
+ <label for="form-language">{{ t "Language" }}</label>
+ <select id="form-language" name="language">
+ {{ range $key, $value := .languages }}
+ <option value="{{ $key }}" {{ if eq $key $.form.Language }}selected="selected"{{ end }}>{{ $value }}</option>
+ {{ end }}
+ </select>
+
+ <label for="form-timezone">{{ t "Timezone" }}</label>
+ <select id="form-timezone" name="timezone">
+ {{ range $key, $value := .timezones }}
+ <option value="{{ $key }}" {{ if eq $key $.form.Timezone }}selected="selected"{{ end }}>{{ $value }}</option>
+ {{ end }}
+ </select>
+
+ <label for="form-theme">{{ t "Theme" }}</label>
+ <select id="form-theme" name="theme">
+ {{ range $key, $value := .themes }}
+ <option value="{{ $key }}" {{ if eq $key $.form.Theme }}selected="selected"{{ end }}>{{ $value }}</option>
+ {{ end }}
+ </select>
+
+ <div class="buttons">
+ <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
+ </div>
+</form>
+
+{{ end }}
+`,
+ "unread": `{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Unread" }} ({{ .countUnread }})</h1>
+ <ul>
+ <li>
+ <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if not .entries }}
+ <p class="alert">{{ t "There is no unread article." }}</p>
+{{ else }}
+ <div class="items hide-read-items">
+ {{ range .entries }}
+ <article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
+ <div class="item-header">
+ <span class="item-title">
+ {{ if ne .Feed.Icon.IconID 0 }}
+ <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+ {{ end }}
+ <a href="{{ route "unreadEntry" "entryID" .ID }}">{{ .Title }}</a>
+ </span>
+ <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+ </div>
+ <div class="item-meta">
+ <ul>
+ <li>
+ <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+ </li>
+ <li>
+ <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+ </li>
+ <li>
+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+ </li>
+ </ul>
+ </div>
+ </article>
+ {{ end }}
+ </div>
+ {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}`,
+ "users": `{{ define "title"}}{{ t "Users" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+ <h1>{{ t "Users" }}</h1>
+ <ul>
+ <li>
+ <a href="{{ route "settings" }}">{{ t "Settings" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
+ </li>
+ <li>
+ <a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
+ </li>
+ </ul>
+</section>
+
+{{ if eq (len .users) 1 }}
+ <p class="alert">{{ t "You are the only user." }}</p>
+{{ else }}
+ <table>
+ <tr>
+ <th class="column-20">{{ t "Username" }}</th>
+ <th>{{ t "Administrator" }}</th>
+ <th>{{ t "Last Login" }}</th>
+ <th>{{ t "Actions" }}</th>
+ </tr>
+ {{ range .users }}
+ {{ if ne .ID $.user.ID }}
+ <tr>
+ <td>{{ .Username }}</td>
+ <td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
+ <td>
+ {{ if .LastLoginAt }}
+ <time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
+ {{ else }}
+ {{ t "Never" }}
+ {{ end }}
+ </td>
+ <td>
+ <a href="{{ route "editUser" "userID" .ID }}">{{ t "Edit" }}</a>,
+ <a href="{{ route "removeUser" "userID" .ID }}">{{ t "Remove" }}</a>
+ </td>
+ </tr>
+ {{ end }}
+ {{ end }}
+ </table>
+{{ end }}
+
+{{ end }}
+`,
+}
+
+var templateViewsMapChecksums = map[string]string{
+ "about": "56f1d45d8b9944306c66be0712320527e739a0ce4fccbd97a4c414c8f9cfab04",
+ "add_subscription": "098ea9e492e18242bd414b22c4d8638006d113f728e5ae78c9186663f60ae3f1",
+ "categories": "721b6bae6aa6461f4e020d667707fabe53c94b399f7d74febef2de5eb9f15071",
+ "category_entries": "0bdcf28ef29b976b78d1add431896a8c56791476abd7a4240998d52c3efe1f35",
+ "choose_subscription": "d37682743d8bbd84738a964e238103db2651f95fa340c6e285ffe2e12548d673",
+ "create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275",
+ "create_user": "966b31d0414e0d0a547ef9ada428cbd24a91100bfed491f780c0461892a2489b",
+ "edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5",
+ "edit_feed": "c5bc4c22bf7e8348d880395250545595d21fb8c8e723fc5d7cca68e25d250884",
+ "edit_user": "f0f79704983de3ca7858bd8cda7a372c3999f5e4e0cf951fba5fa2c1752f9111",
+ "entry": "32e605edd6d43773ac31329d247ebd81d38d974cd43689d91de79fffec7fe04b",
+ "feed_entries": "9aff923b6c7452dec1514feada7e0d2bbc1ec21c6f5e9f48b2de41d1b731ffe4",
+ "feeds": "ddcf12a47c850e6a1f3b85c9ab6566b4e45adfcd7a3546381a0c3a7a54f2b7d4",
+ "history": "439000d0be8fd716f3b89860af4d721e05baef0c2ccd2325ba020c940d6aa847",
+ "import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
+ "login": "568f2f69f248048f3e55e9bbc719077a74ae23fe18f237aa40e3de37e97b7a41",
+ "sessions": "7fcd3bb794d4ad01eb9fa515660f04c8e79e1568970fd541cc7b2de8a76e1542",
+ "settings": "9c89bfd70ff288b4256e5205be78a7645450b364db1df51d10fee3cb915b2c6b",
+ "unread": "b6f9be1a72188947c75a6fdcac6ff7878db7745f9efa46318e0433102892a722",
+ "users": "5bd535de3e46d9b14667d8159a5ec1478d6e028a77bf306c89d7b55813eeb625",
+}
diff --git a/server/ui/controller/about.go b/server/ui/controller/about.go
new file mode 100644
index 0000000..dcfe0d7
--- /dev/null
+++ b/server/ui/controller/about.go
@@ -0,0 +1,24 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/version"
+)
+
+func (c *Controller) AboutPage(ctx *core.Context, request *core.Request, response *core.Response) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("about", args.Merge(tplParams{
+ "version": version.Version,
+ "build_date": version.BuildDate,
+ "menu": "settings",
+ }))
+}
diff --git a/server/ui/controller/category.go b/server/ui/controller/category.go
new file mode 100644
index 0000000..dbc8067
--- /dev/null
+++ b/server/ui/controller/category.go
@@ -0,0 +1,228 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/form"
+ "log"
+)
+
+func (c *Controller) ShowCategories(ctx *core.Context, request *core.Request, response *core.Response) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ user := ctx.GetLoggedUser()
+ categories, err := c.store.GetCategoriesWithFeedCount(user.ID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("categories", args.Merge(tplParams{
+ "categories": categories,
+ "total": len(categories),
+ "menu": "categories",
+ }))
+}
+
+func (c *Controller) ShowCategoryEntries(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ offset := request.GetQueryIntegerParam("offset", 0)
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ category, err := c.getCategoryFromURL(ctx, request, response)
+ if err != nil {
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithCategoryID(category.ID)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.DefaultSortingDirection)
+ builder.WithOffset(offset)
+ builder.WithLimit(NbItemsPerPage)
+
+ entries, err := builder.GetEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ count, err := builder.CountEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("category_entries", args.Merge(tplParams{
+ "category": category,
+ "entries": entries,
+ "total": count,
+ "pagination": c.getPagination(ctx.GetRoute("categoryEntries", "categoryID", category.ID), count, offset),
+ "menu": "categories",
+ }))
+}
+
+func (c *Controller) CreateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("create_category", args.Merge(tplParams{
+ "menu": "categories",
+ }))
+}
+
+func (c *Controller) SaveCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ categoryForm := form.NewCategoryForm(request.GetRequest())
+ if err := categoryForm.Validate(); err != nil {
+ response.Html().Render("create_category", args.Merge(tplParams{
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ category := model.Category{Title: categoryForm.Title, UserID: user.ID}
+ err = c.store.CreateCategory(&category)
+ if err != nil {
+ log.Println(err)
+ response.Html().Render("create_category", args.Merge(tplParams{
+ "errorMessage": "Unable to create this category.",
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("categories"))
+}
+
+func (c *Controller) EditCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ category, err := c.getCategoryFromURL(ctx, request, response)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+
+ args, err := c.getCategoryFormTemplateArgs(ctx, user, category, nil)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("edit_category", args)
+}
+
+func (c *Controller) UpdateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ category, err := c.getCategoryFromURL(ctx, request, response)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+
+ categoryForm := form.NewCategoryForm(request.GetRequest())
+ args, err := c.getCategoryFormTemplateArgs(ctx, user, category, categoryForm)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if err := categoryForm.Validate(); err != nil {
+ response.Html().Render("edit_category", args.Merge(tplParams{
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ err = c.store.UpdateCategory(categoryForm.Merge(category))
+ if err != nil {
+ log.Println(err)
+ response.Html().Render("edit_category", args.Merge(tplParams{
+ "errorMessage": "Unable to update this category.",
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("categories"))
+}
+
+func (c *Controller) RemoveCategory(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ category, err := c.getCategoryFromURL(ctx, request, response)
+ if err != nil {
+ return
+ }
+
+ if err := c.store.RemoveCategory(user.ID, category.ID); err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("categories"))
+}
+
+func (c *Controller) getCategoryFromURL(ctx *core.Context, request *core.Request, response *core.Response) (*model.Category, error) {
+ categoryID, err := request.GetIntegerParam("categoryID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return nil, err
+ }
+
+ user := ctx.GetLoggedUser()
+ category, err := c.store.GetCategory(user.ID, categoryID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return nil, err
+ }
+
+ if category == nil {
+ response.Html().NotFound()
+ return nil, errors.New("Category not found")
+ }
+
+ return category, nil
+}
+
+func (c *Controller) getCategoryFormTemplateArgs(ctx *core.Context, user *model.User, category *model.Category, categoryForm *form.CategoryForm) (tplParams, error) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if categoryForm == nil {
+ args["form"] = form.CategoryForm{
+ Title: category.Title,
+ }
+ } else {
+ args["form"] = categoryForm
+ }
+
+ args["category"] = category
+ args["menu"] = "categories"
+ return args, nil
+}
diff --git a/server/ui/controller/controller.go b/server/ui/controller/controller.go
new file mode 100644
index 0000000..aad3258
--- /dev/null
+++ b/server/ui/controller/controller.go
@@ -0,0 +1,56 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/reader/feed"
+ "github.com/miniflux/miniflux2/reader/opml"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/storage"
+)
+
+type tplParams map[string]interface{}
+
+func (t tplParams) Merge(d tplParams) tplParams {
+ for k, v := range d {
+ t[k] = v
+ }
+
+ return t
+}
+
+type Controller struct {
+ store *storage.Storage
+ feedHandler *feed.Handler
+ opmlHandler *opml.OpmlHandler
+}
+
+func (c *Controller) getCommonTemplateArgs(ctx *core.Context) (tplParams, error) {
+ user := ctx.GetLoggedUser()
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusUnread)
+
+ countUnread, err := builder.CountEntries()
+ if err != nil {
+ return nil, err
+ }
+
+ params := tplParams{
+ "menu": "",
+ "user": user,
+ "countUnread": countUnread,
+ "csrf": ctx.GetCsrfToken(),
+ }
+ return params, nil
+}
+
+func NewController(store *storage.Storage, feedHandler *feed.Handler, opmlHandler *opml.OpmlHandler) *Controller {
+ return &Controller{
+ store: store,
+ feedHandler: feedHandler,
+ opmlHandler: opmlHandler,
+ }
+}
diff --git a/server/ui/controller/entry.go b/server/ui/controller/entry.go
new file mode 100644
index 0000000..5a3a979
--- /dev/null
+++ b/server/ui/controller/entry.go
@@ -0,0 +1,375 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/payload"
+ "log"
+)
+
+func (c *Controller) ShowFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ sortingDirection := model.DefaultSortingDirection
+
+ entryID, err := request.GetIntegerParam("entryID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithFeedID(feedID)
+ builder.WithEntryID(entryID)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if entry == nil {
+ response.Html().NotFound()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithFeedID(feedID)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", "<=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.DefaultSortingDirection)
+ nextEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithFeedID(feedID)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", ">=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.GetOppositeDirection(sortingDirection))
+ prevEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ nextEntryRoute := ""
+ if nextEntry != nil {
+ nextEntryRoute = ctx.GetRoute("feedEntry", "feedID", feedID, "entryID", nextEntry.ID)
+ }
+
+ prevEntryRoute := ""
+ if prevEntry != nil {
+ prevEntryRoute = ctx.GetRoute("feedEntry", "feedID", feedID, "entryID", prevEntry.ID)
+ }
+
+ if entry.Status == model.EntryStatusUnread {
+ err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+ if err != nil {
+ log.Println(err)
+ response.Html().ServerError(nil)
+ return
+ }
+ }
+
+ response.Html().Render("entry", args.Merge(tplParams{
+ "entry": entry,
+ "prevEntry": prevEntry,
+ "nextEntry": nextEntry,
+ "nextEntryRoute": nextEntryRoute,
+ "prevEntryRoute": prevEntryRoute,
+ "menu": "feeds",
+ }))
+}
+
+func (c *Controller) ShowCategoryEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ sortingDirection := model.DefaultSortingDirection
+
+ categoryID, err := request.GetIntegerParam("categoryID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ entryID, err := request.GetIntegerParam("entryID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithCategoryID(categoryID)
+ builder.WithEntryID(entryID)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if entry == nil {
+ response.Html().NotFound()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithCategoryID(categoryID)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", "<=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(sortingDirection)
+ nextEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithCategoryID(categoryID)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", ">=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.GetOppositeDirection(sortingDirection))
+ prevEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ nextEntryRoute := ""
+ if nextEntry != nil {
+ nextEntryRoute = ctx.GetRoute("categoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
+ }
+
+ prevEntryRoute := ""
+ if prevEntry != nil {
+ prevEntryRoute = ctx.GetRoute("categoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
+ }
+
+ if entry.Status == model.EntryStatusUnread {
+ err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+ if err != nil {
+ log.Println(err)
+ response.Html().ServerError(nil)
+ return
+ }
+ }
+
+ response.Html().Render("entry", args.Merge(tplParams{
+ "entry": entry,
+ "prevEntry": prevEntry,
+ "nextEntry": nextEntry,
+ "nextEntryRoute": nextEntryRoute,
+ "prevEntryRoute": prevEntryRoute,
+ "menu": "categories",
+ }))
+}
+
+func (c *Controller) ShowUnreadEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ sortingDirection := model.DefaultSortingDirection
+
+ entryID, err := request.GetIntegerParam("entryID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithEntryID(entryID)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if entry == nil {
+ response.Html().NotFound()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusUnread)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", "<=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(sortingDirection)
+ nextEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusUnread)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", ">=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.GetOppositeDirection(sortingDirection))
+ prevEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ nextEntryRoute := ""
+ if nextEntry != nil {
+ nextEntryRoute = ctx.GetRoute("unreadEntry", "entryID", nextEntry.ID)
+ }
+
+ prevEntryRoute := ""
+ if prevEntry != nil {
+ prevEntryRoute = ctx.GetRoute("unreadEntry", "entryID", prevEntry.ID)
+ }
+
+ if entry.Status == model.EntryStatusUnread {
+ err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+ if err != nil {
+ log.Println(err)
+ response.Html().ServerError(nil)
+ return
+ }
+ }
+
+ response.Html().Render("entry", args.Merge(tplParams{
+ "entry": entry,
+ "prevEntry": prevEntry,
+ "nextEntry": nextEntry,
+ "nextEntryRoute": nextEntryRoute,
+ "prevEntryRoute": prevEntryRoute,
+ "menu": "unread",
+ }))
+}
+
+func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ sortingDirection := model.DefaultSortingDirection
+
+ entryID, err := request.GetIntegerParam("entryID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithEntryID(entryID)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if entry == nil {
+ response.Html().NotFound()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusRead)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", "<=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(sortingDirection)
+ nextEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusRead)
+ builder.WithCondition("e.id", "!=", entryID)
+ builder.WithCondition("e.published_at", ">=", entry.Date)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.GetOppositeDirection(sortingDirection))
+ prevEntry, err := builder.GetEntry()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ nextEntryRoute := ""
+ if nextEntry != nil {
+ nextEntryRoute = ctx.GetRoute("readEntry", "entryID", nextEntry.ID)
+ }
+
+ prevEntryRoute := ""
+ if prevEntry != nil {
+ prevEntryRoute = ctx.GetRoute("readEntry", "entryID", prevEntry.ID)
+ }
+
+ response.Html().Render("entry", args.Merge(tplParams{
+ "entry": entry,
+ "prevEntry": prevEntry,
+ "nextEntry": nextEntry,
+ "nextEntryRoute": nextEntryRoute,
+ "prevEntryRoute": prevEntryRoute,
+ "menu": "history",
+ }))
+}
+
+func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ entryIDs, status, err := payload.DecodeEntryStatusPayload(request.GetBody())
+ if err != nil {
+ log.Println(err)
+ response.Json().BadRequest(nil)
+ return
+ }
+
+ if len(entryIDs) == 0 {
+ response.Html().BadRequest(errors.New("The list of entryID is empty"))
+ return
+ }
+
+ err = c.store.SetEntriesStatus(user.ID, entryIDs, status)
+ if err != nil {
+ log.Println(err)
+ response.Html().ServerError(nil)
+ return
+ }
+
+ response.Json().Standard("OK")
+}
diff --git a/server/ui/controller/feed.go b/server/ui/controller/feed.go
new file mode 100644
index 0000000..400f81a
--- /dev/null
+++ b/server/ui/controller/feed.go
@@ -0,0 +1,209 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/form"
+ "log"
+)
+
+func (c *Controller) ShowFeedsPage(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ feeds, err := c.store.GetFeeds(user.ID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("feeds", args.Merge(tplParams{
+ "feeds": feeds,
+ "total": len(feeds),
+ "menu": "feeds",
+ }))
+}
+
+func (c *Controller) ShowFeedEntries(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ offset := request.GetQueryIntegerParam("offset", 0)
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ feed, err := c.getFeedFromURL(request, response, user)
+ if err != nil {
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithFeedID(feed.ID)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.DefaultSortingDirection)
+ builder.WithOffset(offset)
+ builder.WithLimit(NbItemsPerPage)
+
+ entries, err := builder.GetEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ count, err := builder.CountEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("feed_entries", args.Merge(tplParams{
+ "feed": feed,
+ "entries": entries,
+ "total": count,
+ "pagination": c.getPagination(ctx.GetRoute("feedEntries", "feedID", feed.ID), count, offset),
+ "menu": "feeds",
+ }))
+}
+
+func (c *Controller) EditFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ feed, err := c.getFeedFromURL(request, response, user)
+ if err != nil {
+ return
+ }
+
+ args, err := c.getFeedFormTemplateArgs(ctx, user, feed, nil)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("edit_feed", args)
+}
+
+func (c *Controller) UpdateFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ feed, err := c.getFeedFromURL(request, response, user)
+ if err != nil {
+ return
+ }
+
+ feedForm := form.NewFeedForm(request.GetRequest())
+ args, err := c.getFeedFormTemplateArgs(ctx, user, feed, feedForm)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if err := feedForm.ValidateModification(); err != nil {
+ response.Html().Render("edit_feed", args.Merge(tplParams{
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ err = c.store.UpdateFeed(feedForm.Merge(feed))
+ if err != nil {
+ log.Println(err)
+ response.Html().Render("edit_feed", args.Merge(tplParams{
+ "errorMessage": "Unable to update this feed.",
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("feeds"))
+}
+
+func (c *Controller) RemoveFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ user := ctx.GetLoggedUser()
+ if err := c.store.RemoveFeed(user.ID, feedID); err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("feeds"))
+}
+
+func (c *Controller) RefreshFeed(ctx *core.Context, request *core.Request, response *core.Response) {
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ user := ctx.GetLoggedUser()
+ if err := c.feedHandler.RefreshFeed(user.ID, feedID); err != nil {
+ log.Println("[UI:RefreshFeed]", err)
+ }
+
+ response.Redirect(ctx.GetRoute("feedEntries", "feedID", feedID))
+}
+
+func (c *Controller) getFeedFromURL(request *core.Request, response *core.Response, user *model.User) (*model.Feed, error) {
+ feedID, err := request.GetIntegerParam("feedID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return nil, err
+ }
+
+ feed, err := c.store.GetFeedById(user.ID, feedID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return nil, err
+ }
+
+ if feed == nil {
+ response.Html().NotFound()
+ return nil, errors.New("Feed not found")
+ }
+
+ return feed, nil
+}
+
+func (c *Controller) getFeedFormTemplateArgs(ctx *core.Context, user *model.User, feed *model.Feed, feedForm *form.FeedForm) (tplParams, error) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ categories, err := c.store.GetCategories(user.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ if feedForm == nil {
+ args["form"] = form.FeedForm{
+ SiteURL: feed.SiteURL,
+ FeedURL: feed.FeedURL,
+ Title: feed.Title,
+ CategoryID: feed.Category.ID,
+ }
+ } else {
+ args["form"] = feedForm
+ }
+
+ args["categories"] = categories
+ args["feed"] = feed
+ args["menu"] = "feeds"
+ return args, nil
+}
diff --git a/server/ui/controller/history.go b/server/ui/controller/history.go
new file mode 100644
index 0000000..2c06737
--- /dev/null
+++ b/server/ui/controller/history.go
@@ -0,0 +1,47 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+func (c *Controller) ShowHistoryPage(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ offset := request.GetQueryIntegerParam("offset", 0)
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusRead)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.DefaultSortingDirection)
+ builder.WithOffset(offset)
+ builder.WithLimit(NbItemsPerPage)
+
+ entries, err := builder.GetEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ count, err := builder.CountEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("history", args.Merge(tplParams{
+ "entries": entries,
+ "total": count,
+ "pagination": c.getPagination(ctx.GetRoute("history"), count, offset),
+ "menu": "history",
+ }))
+}
diff --git a/server/ui/controller/icon.go b/server/ui/controller/icon.go
new file mode 100644
index 0000000..37954c2
--- /dev/null
+++ b/server/ui/controller/icon.go
@@ -0,0 +1,31 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/server/core"
+ "time"
+)
+
+func (c *Controller) ShowIcon(ctx *core.Context, request *core.Request, response *core.Response) {
+ iconID, err := request.GetIntegerParam("iconID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ icon, err := c.store.GetIconByID(iconID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if icon == nil {
+ response.Html().NotFound()
+ return
+ }
+
+ response.Cache(icon.MimeType, icon.Hash, icon.Content, 72*time.Hour)
+}
diff --git a/server/ui/controller/login.go b/server/ui/controller/login.go
new file mode 100644
index 0000000..225978c
--- /dev/null
+++ b/server/ui/controller/login.go
@@ -0,0 +1,91 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/form"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/tomasen/realip"
+)
+
+func (c *Controller) ShowLoginPage(ctx *core.Context, request *core.Request, response *core.Response) {
+ if ctx.IsAuthenticated() {
+ response.Redirect(ctx.GetRoute("unread"))
+ return
+ }
+
+ response.Html().Render("login", tplParams{
+ "csrf": ctx.GetCsrfToken(),
+ })
+}
+
+func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, response *core.Response) {
+ authForm := form.NewAuthForm(request.GetRequest())
+ tplParams := tplParams{
+ "errorMessage": "Invalid username or password.",
+ "csrf": ctx.GetCsrfToken(),
+ }
+
+ if err := authForm.Validate(); err != nil {
+ log.Println(err)
+ response.Html().Render("login", tplParams)
+ return
+ }
+
+ if err := c.store.CheckPassword(authForm.Username, authForm.Password); err != nil {
+ log.Println(err)
+ response.Html().Render("login", tplParams)
+ return
+ }
+
+ sessionToken, err := c.store.CreateSession(
+ authForm.Username,
+ request.GetHeaders().Get("User-Agent"),
+ realip.RealIP(request.GetRequest()),
+ )
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ log.Printf("[UI:CheckLogin] username=%s just logged in\n", authForm.Username)
+
+ cookie := &http.Cookie{
+ Name: "sessionID",
+ Value: sessionToken,
+ Path: "/",
+ Secure: request.IsHTTPS(),
+ HttpOnly: true,
+ }
+
+ response.SetCookie(cookie)
+ response.Redirect(ctx.GetRoute("unread"))
+}
+
+func (c *Controller) Logout(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ sessionCookie := request.GetCookie("sessionID")
+ if err := c.store.RemoveSessionByToken(user.ID, sessionCookie); err != nil {
+ log.Printf("[UI:Logout] %v", err)
+ }
+
+ cookie := &http.Cookie{
+ Name: "sessionID",
+ Value: "",
+ Path: "/",
+ Secure: request.IsHTTPS(),
+ HttpOnly: true,
+ MaxAge: -1,
+ Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
+ }
+
+ response.SetCookie(cookie)
+ response.Redirect(ctx.GetRoute("login"))
+}
diff --git a/server/ui/controller/opml.go b/server/ui/controller/opml.go
new file mode 100644
index 0000000..45d34f8
--- /dev/null
+++ b/server/ui/controller/opml.go
@@ -0,0 +1,63 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/server/core"
+ "log"
+)
+
+func (c *Controller) Export(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ opml, err := c.opmlHandler.Export(user.ID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Xml().Download("feeds.opml", opml)
+}
+
+func (c *Controller) Import(ctx *core.Context, request *core.Request, response *core.Response) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("import", args.Merge(tplParams{
+ "menu": "feeds",
+ }))
+}
+
+func (c *Controller) UploadOPML(ctx *core.Context, request *core.Request, response *core.Response) {
+ file, fileHeader, err := request.GetFile("file")
+ if err != nil {
+ log.Println(err)
+ response.Redirect(ctx.GetRoute("import"))
+ return
+ }
+ defer file.Close()
+
+ user := ctx.GetLoggedUser()
+ log.Printf("[UI:UploadOPML] User #%d uploaded this file: %s (%d bytes)\n", user.ID, fileHeader.Filename, fileHeader.Size)
+
+ if impErr := c.opmlHandler.Import(user.ID, file); impErr != nil {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("import", args.Merge(tplParams{
+ "errorMessage": impErr.Error(),
+ "menu": "feeds",
+ }))
+
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("feeds"))
+}
diff --git a/server/ui/controller/pagination.go b/server/ui/controller/pagination.go
new file mode 100644
index 0000000..b649d90
--- /dev/null
+++ b/server/ui/controller/pagination.go
@@ -0,0 +1,46 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+const (
+ NbItemsPerPage = 100
+)
+
+type Pagination struct {
+ Route string
+ Total int
+ Offset int
+ ItemsPerPage int
+ ShowNext bool
+ ShowPrev bool
+ NextOffset int
+ PrevOffset int
+}
+
+func (c *Controller) getPagination(route string, total, offset 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,
+ }
+}
diff --git a/server/ui/controller/proxy.go b/server/ui/controller/proxy.go
new file mode 100644
index 0000000..8a2f2bf
--- /dev/null
+++ b/server/ui/controller/proxy.go
@@ -0,0 +1,49 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "encoding/base64"
+ "errors"
+ "github.com/miniflux/miniflux2/helper"
+ "github.com/miniflux/miniflux2/server/core"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "time"
+)
+
+func (c *Controller) ImageProxy(ctx *core.Context, request *core.Request, response *core.Response) {
+ encodedURL := request.GetStringParam("encodedURL", "")
+ if encodedURL == "" {
+ response.Html().BadRequest(errors.New("No URL provided"))
+ return
+ }
+
+ decodedURL, err := base64.StdEncoding.DecodeString(encodedURL)
+ if err != nil {
+ response.Html().BadRequest(errors.New("Unable to decode this URL"))
+ return
+ }
+
+ resp, err := http.Get(string(decodedURL))
+ if err != nil {
+ log.Println(err)
+ response.Html().NotFound()
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ response.Html().NotFound()
+ return
+ }
+
+ body, _ := ioutil.ReadAll(resp.Body)
+ etag := helper.HashFromBytes(body)
+ contentType := resp.Header.Get("Content-Type")
+
+ response.Cache(contentType, etag, body, 72*time.Hour)
+}
diff --git a/server/ui/controller/session.go b/server/ui/controller/session.go
new file mode 100644
index 0000000..0255728
--- /dev/null
+++ b/server/ui/controller/session.go
@@ -0,0 +1,49 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/server/core"
+ "log"
+)
+
+func (c *Controller) ShowSessions(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ sessions, err := c.store.GetSessions(user.ID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ sessionCookie := request.GetCookie("sessionID")
+ response.Html().Render("sessions", args.Merge(tplParams{
+ "sessions": sessions,
+ "currentSessionToken": sessionCookie,
+ "menu": "settings",
+ }))
+}
+
+func (c *Controller) RemoveSession(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ sessionID, err := request.GetIntegerParam("sessionID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return
+ }
+
+ err = c.store.RemoveSessionByID(user.ID, sessionID)
+ if err != nil {
+ log.Println("[UI:RemoveSession]", err)
+ }
+
+ response.Redirect(ctx.GetRoute("sessions"))
+}
diff --git a/server/ui/controller/settings.go b/server/ui/controller/settings.go
new file mode 100644
index 0000000..a7cca78
--- /dev/null
+++ b/server/ui/controller/settings.go
@@ -0,0 +1,92 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/locale"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/form"
+ "log"
+)
+
+func (c *Controller) ShowSettings(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ args, err := c.getSettingsFormTemplateArgs(ctx, user, nil)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("settings", args)
+}
+
+func (c *Controller) UpdateSettings(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ settingsForm := form.NewSettingsForm(request.GetRequest())
+ args, err := c.getSettingsFormTemplateArgs(ctx, user, settingsForm)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ if err := settingsForm.Validate(); err != nil {
+ response.Html().Render("settings", args.Merge(tplParams{
+ "form": settingsForm,
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ if c.store.AnotherUserExists(user.ID, settingsForm.Username) {
+ response.Html().Render("settings", args.Merge(tplParams{
+ "form": settingsForm,
+ "errorMessage": "This user already exists.",
+ }))
+ return
+ }
+
+ err = c.store.UpdateUser(settingsForm.Merge(user))
+ if err != nil {
+ log.Println(err)
+ response.Html().Render("settings", args.Merge(tplParams{
+ "form": settingsForm,
+ "errorMessage": "Unable to update this user.",
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("settings"))
+}
+
+func (c *Controller) getSettingsFormTemplateArgs(ctx *core.Context, user *model.User, settingsForm *form.SettingsForm) (tplParams, error) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ return args, err
+ }
+
+ if settingsForm == nil {
+ args["form"] = form.SettingsForm{
+ Username: user.Username,
+ Theme: user.Theme,
+ Language: user.Language,
+ Timezone: user.Timezone,
+ }
+ } else {
+ args["form"] = settingsForm
+ }
+
+ args["menu"] = "settings"
+ args["themes"] = model.GetThemes()
+ args["languages"] = locale.GetAvailableLanguages()
+ args["timezones"], err = c.store.GetTimezones()
+ if err != nil {
+ return args, err
+ }
+
+ return args, nil
+}
diff --git a/server/ui/controller/static.go b/server/ui/controller/static.go
new file mode 100644
index 0000000..7b6a1de
--- /dev/null
+++ b/server/ui/controller/static.go
@@ -0,0 +1,41 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "encoding/base64"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/static"
+ "log"
+ "time"
+)
+
+func (c *Controller) Stylesheet(ctx *core.Context, request *core.Request, response *core.Response) {
+ stylesheet := request.GetStringParam("name", "white")
+ body := static.Stylesheets["common"]
+ etag := static.StylesheetsChecksums["common"]
+
+ if theme, found := static.Stylesheets[stylesheet]; found {
+ body += theme
+ etag += static.StylesheetsChecksums[stylesheet]
+ }
+
+ response.Cache("text/css", etag, []byte(body), 48*time.Hour)
+}
+
+func (c *Controller) Javascript(ctx *core.Context, request *core.Request, response *core.Response) {
+ response.Cache("text/javascript", static.JavascriptChecksums["app"], []byte(static.Javascript["app"]), 48*time.Hour)
+}
+
+func (c *Controller) Favicon(ctx *core.Context, request *core.Request, response *core.Response) {
+ blob, err := base64.StdEncoding.DecodeString(static.Binaries["favicon.ico"])
+ if err != nil {
+ log.Println(err)
+ response.Html().NotFound()
+ return
+ }
+
+ response.Cache("image/x-icon", static.BinariesChecksums["favicon.ico"], blob, 48*time.Hour)
+}
diff --git a/server/ui/controller/subscription.go b/server/ui/controller/subscription.go
new file mode 100644
index 0000000..b155769
--- /dev/null
+++ b/server/ui/controller/subscription.go
@@ -0,0 +1,127 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/reader/subscription"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/form"
+ "log"
+)
+
+func (c *Controller) AddSubscription(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("add_subscription", args)
+}
+
+func (c *Controller) SubmitSubscription(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ subscriptionForm := form.NewSubscriptionForm(request.GetRequest())
+ if err := subscriptionForm.Validate(); err != nil {
+ response.Html().Render("add_subscription", args.Merge(tplParams{
+ "form": subscriptionForm,
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ subscriptions, err := subscription.FindSubscriptions(subscriptionForm.URL)
+ if err != nil {
+ log.Println(err)
+ response.Html().Render("add_subscription", args.Merge(tplParams{
+ "form": subscriptionForm,
+ "errorMessage": err,
+ }))
+ return
+ }
+
+ log.Println("[UI:SubmitSubscription]", subscriptions)
+
+ n := len(subscriptions)
+ switch {
+ case n == 0:
+ response.Html().Render("add_subscription", args.Merge(tplParams{
+ "form": subscriptionForm,
+ "errorMessage": "Unable to find any subscription.",
+ }))
+ case n == 1:
+ feed, err := c.feedHandler.CreateFeed(user.ID, subscriptionForm.CategoryID, subscriptions[0].URL)
+ if err != nil {
+ response.Html().Render("add_subscription", args.Merge(tplParams{
+ "form": subscriptionForm,
+ "errorMessage": err,
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("feedEntries", "feedID", feed.ID))
+ case n > 1:
+ response.Html().Render("choose_subscription", args.Merge(tplParams{
+ "categoryID": subscriptionForm.CategoryID,
+ "subscriptions": subscriptions,
+ }))
+ }
+}
+
+func (c *Controller) ChooseSubscription(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ subscriptionForm := form.NewSubscriptionForm(request.GetRequest())
+ if err := subscriptionForm.Validate(); err != nil {
+ response.Html().Render("add_subscription", args.Merge(tplParams{
+ "form": subscriptionForm,
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ feed, err := c.feedHandler.CreateFeed(user.ID, subscriptionForm.CategoryID, subscriptionForm.URL)
+ if err != nil {
+ response.Html().Render("add_subscription", args.Merge(tplParams{
+ "form": subscriptionForm,
+ "errorMessage": err,
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("feedEntries", "feedID", feed.ID))
+}
+
+func (c *Controller) getSubscriptionFormTemplateArgs(ctx *core.Context, user *model.User) (tplParams, error) {
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ categories, err := c.store.GetCategories(user.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ args["categories"] = categories
+ args["menu"] = "feeds"
+ return args, nil
+}
diff --git a/server/ui/controller/unread.go b/server/ui/controller/unread.go
new file mode 100644
index 0000000..63d7db0
--- /dev/null
+++ b/server/ui/controller/unread.go
@@ -0,0 +1,43 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+)
+
+func (c *Controller) ShowUnreadPage(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ offset := request.GetQueryIntegerParam("offset", 0)
+
+ builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+ builder.WithStatus(model.EntryStatusUnread)
+ builder.WithOrder(model.DefaultSortingOrder)
+ builder.WithDirection(model.DefaultSortingDirection)
+ builder.WithOffset(offset)
+ builder.WithLimit(NbItemsPerPage)
+
+ entries, err := builder.GetEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ countUnread, err := builder.CountEntries()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("unread", tplParams{
+ "user": user,
+ "countUnread": countUnread,
+ "entries": entries,
+ "pagination": c.getPagination(ctx.GetRoute("unread"), countUnread, offset),
+ "menu": "unread",
+ "csrf": ctx.GetCsrfToken(),
+ })
+}
diff --git a/server/ui/controller/user.go b/server/ui/controller/user.go
new file mode 100644
index 0000000..c69b0f8
--- /dev/null
+++ b/server/ui/controller/user.go
@@ -0,0 +1,231 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package controller
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/ui/form"
+ "log"
+)
+
+func (c *Controller) ShowUsers(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ if !user.IsAdmin {
+ response.Html().Forbidden()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ users, err := c.store.GetUsers()
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("users", args.Merge(tplParams{
+ "users": users,
+ "menu": "settings",
+ }))
+}
+
+func (c *Controller) CreateUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ if !user.IsAdmin {
+ response.Html().Forbidden()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Html().Render("create_user", args.Merge(tplParams{
+ "menu": "settings",
+ "form": &form.UserForm{},
+ }))
+}
+
+func (c *Controller) SaveUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ if !user.IsAdmin {
+ response.Html().Forbidden()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ userForm := form.NewUserForm(request.GetRequest())
+ if err := userForm.ValidateCreation(); err != nil {
+ response.Html().Render("create_user", args.Merge(tplParams{
+ "menu": "settings",
+ "form": userForm,
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ if c.store.UserExists(userForm.Username) {
+ response.Html().Render("create_user", args.Merge(tplParams{
+ "menu": "settings",
+ "form": userForm,
+ "errorMessage": "This user already exists.",
+ }))
+ return
+ }
+
+ newUser := userForm.ToUser()
+ if err := c.store.CreateUser(newUser); err != nil {
+ log.Println(err)
+ response.Html().Render("edit_user", args.Merge(tplParams{
+ "menu": "settings",
+ "form": userForm,
+ "errorMessage": "Unable to create this user.",
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("users"))
+}
+
+func (c *Controller) EditUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ if !user.IsAdmin {
+ response.Html().Forbidden()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ selectedUser, err := c.getUserFromURL(ctx, request, response)
+ if err != nil {
+ return
+ }
+
+ response.Html().Render("edit_user", args.Merge(tplParams{
+ "menu": "settings",
+ "selected_user": selectedUser,
+ "form": &form.UserForm{
+ Username: selectedUser.Username,
+ IsAdmin: selectedUser.IsAdmin,
+ },
+ }))
+}
+
+func (c *Controller) UpdateUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+
+ if !user.IsAdmin {
+ response.Html().Forbidden()
+ return
+ }
+
+ args, err := c.getCommonTemplateArgs(ctx)
+ if err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ selectedUser, err := c.getUserFromURL(ctx, request, response)
+ if err != nil {
+ return
+ }
+
+ userForm := form.NewUserForm(request.GetRequest())
+ if err := userForm.ValidateModification(); err != nil {
+ response.Html().Render("edit_user", args.Merge(tplParams{
+ "menu": "settings",
+ "selected_user": selectedUser,
+ "form": userForm,
+ "errorMessage": err.Error(),
+ }))
+ return
+ }
+
+ if c.store.AnotherUserExists(selectedUser.ID, userForm.Username) {
+ response.Html().Render("edit_user", args.Merge(tplParams{
+ "menu": "settings",
+ "selected_user": selectedUser,
+ "form": userForm,
+ "errorMessage": "This user already exists.",
+ }))
+ return
+ }
+
+ userForm.Merge(selectedUser)
+ if err := c.store.UpdateUser(selectedUser); err != nil {
+ log.Println(err)
+ response.Html().Render("edit_user", args.Merge(tplParams{
+ "menu": "settings",
+ "selected_user": selectedUser,
+ "form": userForm,
+ "errorMessage": "Unable to update this user.",
+ }))
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("users"))
+}
+
+func (c *Controller) RemoveUser(ctx *core.Context, request *core.Request, response *core.Response) {
+ user := ctx.GetLoggedUser()
+ if !user.IsAdmin {
+ response.Html().Forbidden()
+ return
+ }
+
+ selectedUser, err := c.getUserFromURL(ctx, request, response)
+ if err != nil {
+ return
+ }
+
+ if err := c.store.RemoveUser(selectedUser.ID); err != nil {
+ response.Html().ServerError(err)
+ return
+ }
+
+ response.Redirect(ctx.GetRoute("users"))
+}
+
+func (c *Controller) getUserFromURL(ctx *core.Context, request *core.Request, response *core.Response) (*model.User, error) {
+ userID, err := request.GetIntegerParam("userID")
+ if err != nil {
+ response.Html().BadRequest(err)
+ return nil, err
+ }
+
+ user, err := c.store.GetUserById(userID)
+ if err != nil {
+ response.Html().ServerError(err)
+ return nil, err
+ }
+
+ if user == nil {
+ response.Html().NotFound()
+ return nil, errors.New("User not found")
+ }
+
+ return user, nil
+}
diff --git a/server/ui/filter/image_proxy_filter.go b/server/ui/filter/image_proxy_filter.go
new file mode 100644
index 0000000..71da869
--- /dev/null
+++ b/server/ui/filter/image_proxy_filter.go
@@ -0,0 +1,35 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package filter
+
+import (
+ "encoding/base64"
+ "github.com/miniflux/miniflux2/reader/url"
+ "github.com/miniflux/miniflux2/server/route"
+ "strings"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/gorilla/mux"
+)
+
+// ImageProxyFilter rewrites image tag URLs without HTTPS to local proxy URL
+func ImageProxyFilter(r *mux.Router, data string) string {
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
+ if err != nil {
+ return data
+ }
+
+ doc.Find("img").Each(func(i int, img *goquery.Selection) {
+ if srcAttr, ok := img.Attr("src"); ok {
+ if !url.IsHTTPS(srcAttr) {
+ path := route.GetRoute(r, "proxy", "encodedURL", base64.StdEncoding.EncodeToString([]byte(srcAttr)))
+ img.SetAttr("src", path)
+ }
+ }
+ })
+
+ output, _ := doc.Find("body").First().Html()
+ return output
+}
diff --git a/server/ui/filter/image_proxy_filter_test.go b/server/ui/filter/image_proxy_filter_test.go
new file mode 100644
index 0000000..992516e
--- /dev/null
+++ b/server/ui/filter/image_proxy_filter_test.go
@@ -0,0 +1,38 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package filter
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/gorilla/mux"
+)
+
+func TestProxyFilterWithHttp(t *testing.T) {
+ r := mux.NewRouter()
+ r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
+
+ input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
+ output := ImageProxyFilter(r, input)
+ expected := `<p><img src="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
+
+ if expected != output {
+ t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
+ }
+}
+
+func TestProxyFilterWithHttps(t *testing.T) {
+ r := mux.NewRouter()
+ r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
+
+ input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
+ output := ImageProxyFilter(r, input)
+ expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
+
+ if expected != output {
+ t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
+ }
+}
diff --git a/server/ui/form/auth.go b/server/ui/form/auth.go
new file mode 100644
index 0000000..3cfc217
--- /dev/null
+++ b/server/ui/form/auth.go
@@ -0,0 +1,30 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form
+
+import (
+ "errors"
+ "net/http"
+)
+
+type AuthForm struct {
+ Username string
+ Password string
+}
+
+func (a AuthForm) Validate() error {
+ if a.Username == "" || a.Password == "" {
+ return errors.New("All fields are mandatory.")
+ }
+
+ return nil
+}
+
+func NewAuthForm(r *http.Request) *AuthForm {
+ return &AuthForm{
+ Username: r.FormValue("username"),
+ Password: r.FormValue("password"),
+ }
+}
diff --git a/server/ui/form/category.go b/server/ui/form/category.go
new file mode 100644
index 0000000..510d1b4
--- /dev/null
+++ b/server/ui/form/category.go
@@ -0,0 +1,34 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "net/http"
+)
+
+// CategoryForm represents a feed form in the UI
+type CategoryForm struct {
+ Title string
+}
+
+func (c CategoryForm) Validate() error {
+ if c.Title == "" {
+ return errors.New("The title is mandatory.")
+ }
+ return nil
+}
+
+func (c CategoryForm) Merge(category *model.Category) *model.Category {
+ category.Title = c.Title
+ return category
+}
+
+func NewCategoryForm(r *http.Request) *CategoryForm {
+ return &CategoryForm{
+ Title: r.FormValue("title"),
+ }
+}
diff --git a/server/ui/form/feed.go b/server/ui/form/feed.go
new file mode 100644
index 0000000..e21e6ca
--- /dev/null
+++ b/server/ui/form/feed.go
@@ -0,0 +1,53 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "net/http"
+ "strconv"
+)
+
+// FeedForm represents a feed form in the UI
+type FeedForm struct {
+ FeedURL string
+ SiteURL string
+ Title string
+ CategoryID int64
+}
+
+// ValidateModification validates FeedForm fields
+func (f FeedForm) ValidateModification() error {
+ if f.FeedURL == "" || f.SiteURL == "" || f.Title == "" || f.CategoryID == 0 {
+ return errors.New("All fields are mandatory.")
+ }
+ return nil
+}
+
+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.ParsingErrorCount = 0
+ feed.ParsingErrorMsg = ""
+ 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"),
+ CategoryID: int64(categoryID),
+ }
+}
diff --git a/server/ui/form/settings.go b/server/ui/form/settings.go
new file mode 100644
index 0000000..1e40b97
--- /dev/null
+++ b/server/ui/form/settings.go
@@ -0,0 +1,62 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "net/http"
+)
+
+type SettingsForm struct {
+ Username string
+ Password string
+ Confirmation string
+ Theme string
+ Language string
+ Timezone string
+}
+
+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
+
+ if s.Password != "" {
+ user.Password = s.Password
+ }
+
+ return user
+}
+
+func (s *SettingsForm) Validate() error {
+ if s.Username == "" || s.Theme == "" || s.Language == "" || s.Timezone == "" {
+ return errors.New("The username, theme, language and timezone fields are mandatory.")
+ }
+
+ if s.Password != "" {
+ if s.Password != s.Confirmation {
+ return errors.New("Passwords are not the same.")
+ }
+
+ if len(s.Password) < 6 {
+ return errors.New("You must use at least 6 characters")
+ }
+ }
+
+ return nil
+}
+
+func NewSettingsForm(r *http.Request) *SettingsForm {
+ 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"),
+ }
+}
diff --git a/server/ui/form/subscription.go b/server/ui/form/subscription.go
new file mode 100644
index 0000000..6696b22
--- /dev/null
+++ b/server/ui/form/subscription.go
@@ -0,0 +1,36 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+)
+
+type SubscriptionForm struct {
+ URL string
+ CategoryID int64
+}
+
+func (s *SubscriptionForm) Validate() error {
+ if s.URL == "" || s.CategoryID == 0 {
+ return errors.New("The URL and the category are mandatory.")
+ }
+
+ return nil
+}
+
+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),
+ }
+}
diff --git a/server/ui/form/user.go b/server/ui/form/user.go
new file mode 100644
index 0000000..1197b48
--- /dev/null
+++ b/server/ui/form/user.go
@@ -0,0 +1,80 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form
+
+import (
+ "errors"
+ "github.com/miniflux/miniflux2/model"
+ "net/http"
+)
+
+type UserForm struct {
+ Username string
+ Password string
+ Confirmation string
+ IsAdmin bool
+}
+
+func (u UserForm) ValidateCreation() error {
+ if u.Username == "" || u.Password == "" || u.Confirmation == "" {
+ return errors.New("All fields are mandatory.")
+ }
+
+ if u.Password != u.Confirmation {
+ return errors.New("Passwords are not the same.")
+ }
+
+ if len(u.Password) < 6 {
+ return errors.New("You must use at least 6 characters.")
+ }
+
+ return nil
+}
+
+func (u UserForm) ValidateModification() error {
+ if u.Username == "" {
+ return errors.New("The username is mandatory.")
+ }
+
+ if u.Password != "" {
+ if u.Password != u.Confirmation {
+ return errors.New("Passwords are not the same.")
+ }
+
+ if len(u.Password) < 6 {
+ return errors.New("You must use at least 6 characters.")
+ }
+ }
+
+ return nil
+}
+
+func (u UserForm) ToUser() *model.User {
+ return &model.User{
+ Username: u.Username,
+ Password: u.Password,
+ IsAdmin: u.IsAdmin,
+ }
+}
+
+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
+}
+
+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",
+ }
+}
diff --git a/server/ui/payload/payload.go b/server/ui/payload/payload.go
new file mode 100644
index 0000000..b2fef95
--- /dev/null
+++ b/server/ui/payload/payload.go
@@ -0,0 +1,31 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package payload
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/miniflux/miniflux2/model"
+ "io"
+)
+
+func DecodeEntryStatusPayload(data io.Reader) (entryIDs []int64, status string, err error) {
+ type payload struct {
+ EntryIDs []int64 `json:"entry_ids"`
+ Status string `json:"status"`
+ }
+
+ var p payload
+ decoder := json.NewDecoder(data)
+ if err = decoder.Decode(&p); err != nil {
+ return nil, "", fmt.Errorf("invalid JSON payload: %v", err)
+ }
+
+ if err := model.ValidateEntryStatus(p.Status); err != nil {
+ return nil, "", err
+ }
+
+ return p.EntryIDs, p.Status, nil
+}