diff options
Diffstat (limited to 'server')
100 files changed, 0 insertions, 10950 deletions
diff --git a/server/api/controller/category.go b/server/api/controller/category.go deleted file mode 100644 index d7b2922..0000000 --- a/server/api/controller/category.go +++ /dev/null @@ -1,104 +0,0 @@ -// 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/miniflux/server/api/payload" - "github.com/miniflux/miniflux/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) { - userID := ctx.UserID() - category, err := payload.DecodeCategoryPayload(request.Body()) - if err != nil { - response.JSON().BadRequest(err) - return - } - - category.UserID = userID - if err := category.ValidateCategoryCreation(); err != nil { - response.JSON().BadRequest(err) - return - } - - if c, err := c.store.CategoryByTitle(userID, category.Title); err != nil || c != nil { - response.JSON().BadRequest(errors.New("This category already exists")) - 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.IntegerParam("categoryID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - category, err := payload.DecodeCategoryPayload(request.Body()) - if err != nil { - response.JSON().BadRequest(err) - return - } - - category.UserID = ctx.UserID() - 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.Categories(ctx.UserID()) - 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.UserID() - categoryID, err := request.IntegerParam("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 deleted file mode 100644 index c9798e3..0000000 --- a/server/api/controller/controller.go +++ /dev/null @@ -1,21 +0,0 @@ -// 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/miniflux/reader/feed" - "github.com/miniflux/miniflux/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 deleted file mode 100644 index 9c86a7a..0000000 --- a/server/api/controller/entry.go +++ /dev/null @@ -1,224 +0,0 @@ -// 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/miniflux/model" - "github.com/miniflux/miniflux/server/api/payload" - "github.com/miniflux/miniflux/server/core" -) - -// GetFeedEntry is the API handler to get a single feed entry. -func (c *Controller) GetFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - 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) -} - -// GetEntry is the API handler to get a single entry. -func (c *Controller) GetEntry(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - 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.UserID() - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - status := request.QueryStringParam("status", "") - if status != "" { - if err := model.ValidateEntryStatus(status); err != nil { - response.JSON().BadRequest(err) - return - } - } - - order := request.QueryStringParam("order", model.DefaultSortingOrder) - if err := model.ValidateEntryOrder(order); err != nil { - response.JSON().BadRequest(err) - return - } - - direction := request.QueryStringParam("direction", model.DefaultSortingDirection) - if err := model.ValidateDirection(direction); err != nil { - response.JSON().BadRequest(err) - return - } - - limit := request.QueryIntegerParam("limit", 100) - offset := request.QueryIntegerParam("offset", 0) - if err := model.ValidateRange(offset, limit); err != nil { - response.JSON().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithFeedID(feedID) - builder.WithStatus(status) - builder.WithOrder(order) - builder.WithDirection(direction) - 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}) -} - -// GetEntries is the API handler to fetch entries. -func (c *Controller) GetEntries(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - - status := request.QueryStringParam("status", "") - if status != "" { - if err := model.ValidateEntryStatus(status); err != nil { - response.JSON().BadRequest(err) - return - } - } - - order := request.QueryStringParam("order", model.DefaultSortingOrder) - if err := model.ValidateEntryOrder(order); err != nil { - response.JSON().BadRequest(err) - return - } - - direction := request.QueryStringParam("direction", model.DefaultSortingDirection) - if err := model.ValidateDirection(direction); err != nil { - response.JSON().BadRequest(err) - return - } - - limit := request.QueryIntegerParam("limit", 100) - offset := request.QueryIntegerParam("offset", 0) - if err := model.ValidateRange(offset, limit); err != nil { - response.JSON().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithStatus(status) - builder.WithOrder(order) - builder.WithDirection(direction) - 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 entries. -func (c *Controller) SetEntryStatus(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - - entryIDs, status, err := payload.DecodeEntryStatusPayload(request.Body()) - if err != nil { - response.JSON().BadRequest(errors.New("Invalid JSON payload")) - return - } - - if err := model.ValidateEntryStatus(status); err != nil { - response.JSON().BadRequest(err) - return - } - - if err := c.store.SetEntriesStatus(userID, entryIDs, status); err != nil { - response.JSON().ServerError(errors.New("Unable to change entries status")) - return - } - - response.JSON().NoContent() -} - -// ToggleBookmark is the API handler to toggle bookmark status. -func (c *Controller) ToggleBookmark(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - if err := c.store.ToggleBookmark(userID, entryID); err != nil { - response.JSON().ServerError(errors.New("Unable to toggle bookmark value")) - return - } - - response.JSON().NoContent() -} diff --git a/server/api/controller/feed.go b/server/api/controller/feed.go deleted file mode 100644 index fcaeee7..0000000 --- a/server/api/controller/feed.go +++ /dev/null @@ -1,179 +0,0 @@ -// 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/miniflux/server/api/payload" - "github.com/miniflux/miniflux/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.UserID() - feedURL, categoryID, err := payload.DecodeFeedCreationPayload(request.Body()) - if err != nil { - response.JSON().BadRequest(err) - return - } - - if feedURL == "" { - response.JSON().BadRequest(errors.New("The feed_url is required")) - return - } - - if categoryID <= 0 { - response.JSON().BadRequest(errors.New("The category_id is required")) - return - } - - if c.store.FeedURLExists(userID, feedURL) { - response.JSON().BadRequest(errors.New("This feed_url already exists")) - return - } - - if !c.store.CategoryExists(userID, categoryID) { - response.JSON().BadRequest(errors.New("This category_id doesn't exists or doesn't belongs to this user")) - return - } - - feed, err := c.feedHandler.CreateFeed(userID, categoryID, feedURL, false) - if err != nil { - response.JSON().ServerError(errors.New("Unable to create this feed")) - return - } - - type result struct { - FeedID int64 `json:"feed_id"` - } - - response.JSON().Created(&result{FeedID: feed.ID}) -} - -// RefreshFeed is the API handler to refresh a feed. -func (c *Controller) RefreshFeed(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - if !c.store.FeedExists(userID, feedID) { - response.JSON().NotFound(errors.New("Unable to find this feed")) - 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.UserID() - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - newFeed, err := payload.DecodeFeedModificationPayload(request.Body()) - if err != nil { - response.JSON().BadRequest(err) - return - } - - if newFeed.Category != nil && newFeed.Category.ID != 0 && !c.store.CategoryExists(userID, newFeed.Category.ID) { - response.JSON().BadRequest(errors.New("This category_id doesn't exists or doesn't belongs to this user")) - return - } - - originalFeed, err := c.store.FeedByID(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 - } - - originalFeed, err = c.store.FeedByID(userID, feedID) - if err != nil { - response.JSON().ServerError(errors.New("Unable to fetch 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.Feeds(ctx.UserID()) - 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.UserID() - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - feed, err := c.store.FeedByID(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.UserID() - feedID, err := request.IntegerParam("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/icon.go b/server/api/controller/icon.go deleted file mode 100644 index b8e7a61..0000000 --- a/server/api/controller/icon.go +++ /dev/null @@ -1,44 +0,0 @@ -// 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/miniflux/server/api/payload" - "github.com/miniflux/miniflux/server/core" -) - -// FeedIcon returns a feed icon. -func (c *Controller) FeedIcon(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - if !c.store.HasIcon(feedID) { - response.JSON().NotFound(errors.New("This feed doesn't have any icon")) - return - } - - icon, err := c.store.IconByFeedID(userID, feedID) - if err != nil { - response.JSON().ServerError(errors.New("Unable to fetch feed icon")) - return - } - - if icon == nil { - response.JSON().NotFound(errors.New("This feed doesn't have any icon")) - return - } - - response.JSON().Standard(&payload.FeedIcon{ - ID: icon.ID, - MimeType: icon.MimeType, - Data: icon.DataURL(), - }) -} diff --git a/server/api/controller/subscription.go b/server/api/controller/subscription.go deleted file mode 100644 index aa2a26f..0000000 --- a/server/api/controller/subscription.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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/miniflux/reader/subscription" - "github.com/miniflux/miniflux/server/api/payload" - "github.com/miniflux/miniflux/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.Body()) - 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 deleted file mode 100644 index a925908..0000000 --- a/server/api/controller/user.go +++ /dev/null @@ -1,186 +0,0 @@ -// 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/miniflux/server/api/payload" - "github.com/miniflux/miniflux/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.Body()) - 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.IntegerParam("userID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - user, err := payload.DecodeUserPayload(request.Body()) - if err != nil { - response.JSON().BadRequest(err) - return - } - - if err := user.ValidateUserModification(); err != nil { - response.JSON().BadRequest(err) - return - } - - originalUser, err := c.store.UserByID(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) -} - -// Users is the API handler to get the list of users. -func (c *Controller) Users(ctx *core.Context, request *core.Request, response *core.Response) { - if !ctx.IsAdminUser() { - response.JSON().Forbidden() - return - } - - users, err := c.store.Users() - if err != nil { - response.JSON().ServerError(errors.New("Unable to fetch the list of users")) - return - } - - response.JSON().Standard(users) -} - -// UserByID is the API handler to fetch the given user by the ID. -func (c *Controller) UserByID(ctx *core.Context, request *core.Request, response *core.Response) { - if !ctx.IsAdminUser() { - response.JSON().Forbidden() - return - } - - userID, err := request.IntegerParam("userID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - user, err := c.store.UserByID(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) -} - -// UserByUsername is the API handler to fetch the given user by the username. -func (c *Controller) UserByUsername(ctx *core.Context, request *core.Request, response *core.Response) { - if !ctx.IsAdminUser() { - response.JSON().Forbidden() - return - } - - username := request.StringParam("username", "") - user, err := c.store.UserByUsername(username) - 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.IntegerParam("userID") - if err != nil { - response.JSON().BadRequest(err) - return - } - - user, err := c.store.UserByID(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 deleted file mode 100644 index 25cd657..0000000 --- a/server/api/payload/payload.go +++ /dev/null @@ -1,109 +0,0 @@ -// 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" - "io" - - "github.com/miniflux/miniflux/model" -) - -// FeedIcon represents the feed icon response. -type FeedIcon struct { - ID int64 `json:"id"` - MimeType string `json:"mime_type"` - Data string `json:"data"` -} - -// EntriesResponse represents the response sent when fetching entries. -type EntriesResponse struct { - Total int `json:"total"` - Entries model.Entries `json:"entries"` -} - -// DecodeUserPayload unserialize JSON user object. -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 -} - -// DecodeURLPayload unserialize JSON subscription object. -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 -} - -// DecodeEntryStatusPayload unserialize JSON entry statuses object. -func DecodeEntryStatusPayload(data io.Reader) ([]int64, string, 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) - } - - return p.EntryIDs, p.Status, nil -} - -// DecodeFeedCreationPayload unserialize JSON feed creation object. -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 -} - -// DecodeFeedModificationPayload unserialize JSON feed object. -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 -} - -// DecodeCategoryPayload unserialize JSON category object. -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/cookie/cookie.go b/server/cookie/cookie.go deleted file mode 100644 index d1f3e72..0000000 --- a/server/cookie/cookie.go +++ /dev/null @@ -1,44 +0,0 @@ -// 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 cookie - -import ( - "net/http" - "time" -) - -// Cookie names. -const ( - CookieSessionID = "sessionID" - CookieUserSessionID = "userSessionID" - - // Cookie duration in days. - cookieDuration = 30 -) - -// New creates a new cookie. -func New(name, value string, isHTTPS bool) *http.Cookie { - return &http.Cookie{ - Name: name, - Value: value, - Path: "/", - Secure: isHTTPS, - HttpOnly: true, - Expires: time.Now().Add(cookieDuration * 24 * time.Hour), - } -} - -// Expired returns an expired cookie. -func Expired(name string, isHTTPS bool) *http.Cookie { - return &http.Cookie{ - Name: name, - Value: "", - Path: "/", - Secure: isHTTPS, - HttpOnly: true, - MaxAge: -1, - Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), - } -} diff --git a/server/core/context.go b/server/core/context.go deleted file mode 100644 index 8145b47..0000000 --- a/server/core/context.go +++ /dev/null @@ -1,160 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/crypto" - "github.com/miniflux/miniflux/locale" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/middleware" - "github.com/miniflux/miniflux/server/route" - "github.com/miniflux/miniflux/storage" - - "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 - translator *locale.Translator -} - -// IsAdminUser checks if the logged user is administrator. -func (c *Context) IsAdminUser() bool { - if v := c.request.Context().Value(middleware.IsAdminUserContextKey); v != nil { - return v.(bool) - } - return false -} - -// UserTimezone returns the timezone used by the logged user. -func (c *Context) UserTimezone() string { - value := c.getContextStringValue(middleware.UserTimezoneContextKey) - if value == "" { - value = "UTC" - } - return value -} - -// IsAuthenticated returns a boolean if the user is authenticated. -func (c *Context) IsAuthenticated() bool { - if v := c.request.Context().Value(middleware.IsAuthenticatedContextKey); v != nil { - return v.(bool) - } - return false -} - -// UserID returns the UserID of the logged user. -func (c *Context) UserID() int64 { - if v := c.request.Context().Value(middleware.UserIDContextKey); v != nil { - return v.(int64) - } - return 0 -} - -// LoggedUser returns all properties related to the logged user. -func (c *Context) LoggedUser() *model.User { - if c.user == nil { - var err error - c.user, err = c.store.UserByID(c.UserID()) - if err != nil { - logger.Fatal("[Context] %v", err) - } - - if c.user == nil { - logger.Fatal("Unable to find user from context") - } - } - - return c.user -} - -// UserLanguage get the locale used by the current logged user. -func (c *Context) UserLanguage() string { - user := c.LoggedUser() - return user.Language -} - -// Translate translates a message in the current language. -func (c *Context) Translate(message string, args ...interface{}) string { - return c.translator.GetLanguage(c.UserLanguage()).Get(message, args...) -} - -// CSRF returns the current CSRF token. -func (c *Context) CSRF() string { - return c.getContextStringValue(middleware.CSRFContextKey) -} - -// SessionID returns the current session ID. -func (c *Context) SessionID() string { - return c.getContextStringValue(middleware.SessionIDContextKey) -} - -// UserSessionToken returns the current user session token. -func (c *Context) UserSessionToken() string { - return c.getContextStringValue(middleware.UserSessionTokenContextKey) -} - -// OAuth2State returns the current OAuth2 state. -func (c *Context) OAuth2State() string { - return c.getContextStringValue(middleware.OAuth2StateContextKey) -} - -// GenerateOAuth2State generate a new OAuth2 state. -func (c *Context) GenerateOAuth2State() string { - state := crypto.GenerateRandomString(32) - c.store.UpdateSessionField(c.SessionID(), "oauth2_state", state) - return state -} - -// SetFlashMessage defines a new flash message. -func (c *Context) SetFlashMessage(message string) { - c.store.UpdateSessionField(c.SessionID(), "flash_message", message) -} - -// FlashMessage returns the flash message and remove it. -func (c *Context) FlashMessage() string { - message := c.getContextStringValue(middleware.FlashMessageContextKey) - c.store.UpdateSessionField(c.SessionID(), "flash_message", "") - return message -} - -// SetFlashErrorMessage defines a new flash error message. -func (c *Context) SetFlashErrorMessage(message string) { - c.store.UpdateSessionField(c.SessionID(), "flash_error_message", message) -} - -// FlashErrorMessage returns the error flash message and remove it. -func (c *Context) FlashErrorMessage() string { - message := c.getContextStringValue(middleware.FlashErrorMessageContextKey) - c.store.UpdateSessionField(c.SessionID(), "flash_error_message", "") - return message -} - -func (c *Context) getContextStringValue(key *middleware.ContextKey) string { - if v := c.request.Context().Value(key); v != nil { - return v.(string) - } - - logger.Error("[Core:Context] Missing key: %s", key) - return "" -} - -// Route returns the path for the given arguments. -func (c *Context) Route(name string, args ...interface{}) string { - return route.Path(c.router, name, args...) -} - -// NewContext creates a new Context. -func NewContext(w http.ResponseWriter, r *http.Request, store *storage.Storage, router *mux.Router, translator *locale.Translator) *Context { - return &Context{writer: w, request: r, store: store, router: router, translator: translator} -} diff --git a/server/core/handler.go b/server/core/handler.go deleted file mode 100644 index e6aca98..0000000 --- a/server/core/handler.go +++ /dev/null @@ -1,70 +0,0 @@ -// 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 ( - "net/http" - "time" - - "github.com/miniflux/miniflux/config" - "github.com/miniflux/miniflux/locale" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/server/middleware" - "github.com/miniflux/miniflux/server/template" - "github.com/miniflux/miniflux/storage" - "github.com/miniflux/miniflux/timer" - - "github.com/gorilla/mux" - "github.com/tomasen/realip" -) - -// HandlerFunc is an application HTTP handler. -type HandlerFunc func(ctx *Context, request *Request, response *Response) - -// Handler manages HTTP handlers and middlewares. -type Handler struct { - cfg *config.Config - store *storage.Storage - translator *locale.Translator - template *template.Engine - router *mux.Router - middleware *middleware.Chain -} - -// Use is a wrapper around an HTTP handler. -func (h *Handler) Use(f HandlerFunc) http.Handler { - return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer timer.ExecutionTime(time.Now(), r.URL.Path) - logger.Debug("[HTTP] %s %s %s", realip.RealIP(r), r.Method, r.URL.Path) - - if r.Header.Get("X-Forwarded-Proto") == "https" { - h.cfg.IsHTTPS = true - } - - ctx := NewContext(w, r, h.store, h.router, h.translator) - request := NewRequest(w, r) - response := NewResponse(w, r, h.template) - - if ctx.IsAuthenticated() { - h.template.SetLanguage(ctx.UserLanguage()) - } else { - h.template.SetLanguage("en_US") - } - - f(ctx, request, response) - })) -} - -// NewHandler returns a new Handler. -func NewHandler(cfg *config.Config, store *storage.Storage, router *mux.Router, template *template.Engine, translator *locale.Translator, middleware *middleware.Chain) *Handler { - return &Handler{ - cfg: cfg, - store: store, - translator: translator, - router: router, - template: template, - middleware: middleware, - } -} diff --git a/server/core/html_response.go b/server/core/html_response.go deleted file mode 100644 index a194163..0000000 --- a/server/core/html_response.go +++ /dev/null @@ -1,65 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/server/template" -) - -// HTMLResponse handles HTML responses. -type HTMLResponse struct { - writer http.ResponseWriter - request *http.Request - template *template.Engine -} - -// Render execute a template and send to the client the generated HTML. -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) -} - -// ServerError sends a 500 error to the browser. -func (h *HTMLResponse) ServerError(err error) { - h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") - h.writer.WriteHeader(http.StatusInternalServerError) - - if err != nil { - logger.Error("[Internal Server Error] %v", err) - h.writer.Write([]byte("Internal Server Error: " + err.Error())) - } else { - h.writer.Write([]byte("Internal Server Error")) - } -} - -// BadRequest sends a 400 error to the browser. -func (h *HTMLResponse) BadRequest(err error) { - h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") - h.writer.WriteHeader(http.StatusBadRequest) - - if err != nil { - logger.Error("[Bad Request] %v", err) - h.writer.Write([]byte("Bad Request: " + err.Error())) - } else { - h.writer.Write([]byte("Bad Request")) - } -} - -// NotFound sends a 404 error to the browser. -func (h *HTMLResponse) NotFound() { - h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") - h.writer.WriteHeader(http.StatusNotFound) - h.writer.Write([]byte("Page Not Found")) -} - -// Forbidden sends a 403 error to the browser. -func (h *HTMLResponse) Forbidden() { - h.writer.Header().Set("Content-Type", "text/html; charset=utf-8") - h.writer.WriteHeader(http.StatusForbidden) - h.writer.Write([]byte("Access Forbidden")) -} diff --git a/server/core/json_response.go b/server/core/json_response.go deleted file mode 100644 index 8ee0b7f..0000000 --- a/server/core/json_response.go +++ /dev/null @@ -1,111 +0,0 @@ -// 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" - "net/http" - - "github.com/miniflux/miniflux/logger" -) - -// JSONResponse handles JSON responses. -type JSONResponse struct { - writer http.ResponseWriter - request *http.Request -} - -// Standard sends a JSON response with the status code 200. -func (j *JSONResponse) Standard(v interface{}) { - j.commonHeaders() - j.writer.WriteHeader(http.StatusOK) - j.writer.Write(j.toJSON(v)) -} - -// Created sends a JSON response with the status code 201. -func (j *JSONResponse) Created(v interface{}) { - j.commonHeaders() - j.writer.WriteHeader(http.StatusCreated) - j.writer.Write(j.toJSON(v)) -} - -// NoContent sends a JSON response with the status code 204. -func (j *JSONResponse) NoContent() { - j.commonHeaders() - j.writer.WriteHeader(http.StatusNoContent) -} - -// BadRequest sends a JSON response with the status code 400. -func (j *JSONResponse) BadRequest(err error) { - logger.Error("[Bad Request] %v", err) - j.commonHeaders() - j.writer.WriteHeader(http.StatusBadRequest) - - if err != nil { - j.writer.Write(j.encodeError(err)) - } -} - -// NotFound sends a JSON response with the status code 404. -func (j *JSONResponse) NotFound(err error) { - logger.Error("[Not Found] %v", err) - j.commonHeaders() - j.writer.WriteHeader(http.StatusNotFound) - j.writer.Write(j.encodeError(err)) -} - -// ServerError sends a JSON response with the status code 500. -func (j *JSONResponse) ServerError(err error) { - logger.Error("[Internal Server Error] %v", err) - j.commonHeaders() - j.writer.WriteHeader(http.StatusInternalServerError) - - if err != nil { - j.writer.Write(j.encodeError(err)) - } -} - -// Forbidden sends a JSON response with the status code 403. -func (j *JSONResponse) Forbidden() { - logger.Info("[API:Forbidden]") - j.commonHeaders() - j.writer.WriteHeader(http.StatusForbidden) - 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; charset=utf-8") -} - -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 { - logger.Error("encoding error: %v", err) - } - - return data -} - -func (j *JSONResponse) toJSON(v interface{}) []byte { - b, err := json.Marshal(v) - if err != nil { - logger.Error("encoding error: %v", err) - return []byte("") - } - - return b -} - -// NewJSONResponse returns a new JSONResponse. -func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse { - return &JSONResponse{request: r, writer: w} -} diff --git a/server/core/request.go b/server/core/request.go deleted file mode 100644 index f3a3652..0000000 --- a/server/core/request.go +++ /dev/null @@ -1,125 +0,0 @@ -// 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" - "mime/multipart" - "net/http" - "strconv" - - "github.com/gorilla/mux" - "github.com/miniflux/miniflux/logger" -) - -// Request is a thin wrapper around "http.Request". -type Request struct { - writer http.ResponseWriter - request *http.Request -} - -// Request returns the raw Request struct. -func (r *Request) Request() *http.Request { - return r.request -} - -// Body returns the request body. -func (r *Request) Body() io.ReadCloser { - return r.request.Body -} - -// File returns uploaded file properties. -func (r *Request) File(name string) (multipart.File, *multipart.FileHeader, error) { - return r.request.FormFile(name) -} - -// Cookie returns the cookie value. -func (r *Request) Cookie(name string) string { - cookie, err := r.request.Cookie(name) - if err == http.ErrNoCookie { - return "" - } - - return cookie.Value -} - -// FormValue returns a form value as integer. -func (r *Request) FormValue(param string) string { - return r.request.FormValue(param) -} - -// FormIntegerValue returns a form value as integer. -func (r *Request) FormIntegerValue(param string) int64 { - value := r.request.FormValue(param) - integer, _ := strconv.Atoi(value) - return int64(integer) -} - -// IntegerParam returns an URL parameter as integer. -func (r *Request) IntegerParam(param string) (int64, error) { - vars := mux.Vars(r.request) - value, err := strconv.Atoi(vars[param]) - if err != nil { - logger.Error("[IntegerParam] %v", err) - return 0, fmt.Errorf("%s parameter is not an integer", param) - } - - if value < 0 { - return 0, nil - } - - return int64(value), nil -} - -// StringParam returns an URL parameter as string. -func (r *Request) StringParam(param, defaultValue string) string { - vars := mux.Vars(r.request) - value := vars[param] - if value == "" { - value = defaultValue - } - return value -} - -// QueryStringParam returns a querystring parameter as string. -func (r *Request) QueryStringParam(param, defaultValue string) string { - value := r.request.URL.Query().Get(param) - if value == "" { - value = defaultValue - } - return value -} - -// QueryIntegerParam returns a querystring parameter as string. -func (r *Request) QueryIntegerParam(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 -} - -// HasQueryParam checks if the query string contains the given parameter. -func (r *Request) HasQueryParam(param string) bool { - values := r.request.URL.Query() - _, ok := values[param] - return ok -} - -// NewRequest returns a new Request struct. -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 deleted file mode 100644 index f3fc7a1..0000000 --- a/server/core/response.go +++ /dev/null @@ -1,82 +0,0 @@ -// 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 ( - "net/http" - "time" - - "github.com/miniflux/miniflux/server/template" -) - -// Response handles HTTP responses. -type Response struct { - writer http.ResponseWriter - request *http.Request - template *template.Engine -} - -// SetCookie send a cookie to the client. -func (r *Response) SetCookie(cookie *http.Cookie) { - http.SetCookie(r.writer, cookie) -} - -// JSON returns a JSONResponse. -func (r *Response) JSON() *JSONResponse { - r.commonHeaders() - return NewJSONResponse(r.writer, r.request) -} - -// HTML returns a HTMLResponse. -func (r *Response) HTML() *HTMLResponse { - r.commonHeaders() - return &HTMLResponse{writer: r.writer, request: r.request, template: r.template} -} - -// XML returns a XMLResponse. -func (r *Response) XML() *XMLResponse { - r.commonHeaders() - return &XMLResponse{writer: r.writer, request: r.request} -} - -// Redirect redirects the user to another location. -func (r *Response) Redirect(path string) { - http.Redirect(r.writer, r.request, path, http.StatusFound) -} - -// NotModified sends a response with a 304 status code. -func (r *Response) NotModified() { - r.commonHeaders() - r.writer.WriteHeader(http.StatusNotModified) -} - -// Cache returns a response with caching headers. -func (r *Response) Cache(mimeType, etag string, content []byte, duration time.Duration) { - r.writer.Header().Set("Content-Type", mimeType) - 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") - - // Even if the directive "frame-src" has been deprecated in Firefox, - // we keep it to stay compatible with other browsers. - r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *") -} - -// NewResponse returns a new Response. -func NewResponse(w http.ResponseWriter, r *http.Request, template *template.Engine) *Response { - return &Response{writer: w, request: r, template: template} -} diff --git a/server/core/xml_response.go b/server/core/xml_response.go deleted file mode 100644 index e9a2d3f..0000000 --- a/server/core/xml_response.go +++ /dev/null @@ -1,23 +0,0 @@ -// 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" -) - -// XMLResponse handles XML responses. -type XMLResponse struct { - writer http.ResponseWriter - request *http.Request -} - -// Download force the download of a XML document. -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/fever/fever.go b/server/fever/fever.go deleted file mode 100644 index 6690b7f..0000000 --- a/server/fever/fever.go +++ /dev/null @@ -1,631 +0,0 @@ -// 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 fever - -import ( - "strconv" - "strings" - "time" - - "github.com/miniflux/miniflux/integration" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/storage" -) - -type baseResponse struct { - Version int `json:"api_version"` - Authenticated int `json:"auth"` - LastRefresh int64 `json:"last_refreshed_on_time"` -} - -func (b *baseResponse) SetCommonValues() { - b.Version = 3 - b.Authenticated = 1 - b.LastRefresh = time.Now().Unix() -} - -/* -The default response is a JSON object containing two members: - - api_version contains the version of the API responding (positive integer) - auth whether the request was successfully authenticated (boolean integer) - -The API can also return XML by passing xml as the optional value of the api argument like so: - -http://yourdomain.com/fever/?api=xml - -The top level XML element is named response. - -The response to each successfully authenticated request will have auth set to 1 and include -at least one additional member: - - last_refreshed_on_time contains the time of the most recently refreshed (not updated) - feed (Unix timestamp/integer) - -*/ -func newBaseResponse() baseResponse { - r := baseResponse{} - r.SetCommonValues() - return r -} - -type groupsResponse struct { - baseResponse - Groups []group `json:"groups"` - FeedsGroups []feedsGroups `json:"feeds_groups"` -} - -type feedsResponse struct { - baseResponse - Feeds []feed `json:"feeds"` - FeedsGroups []feedsGroups `json:"feeds_groups"` -} - -type faviconsResponse struct { - baseResponse - Favicons []favicon `json:"favicons"` -} - -type itemsResponse struct { - baseResponse - Items []item `json:"items"` - Total int `json:"total_items"` -} - -type unreadResponse struct { - baseResponse - ItemIDs string `json:"unread_item_ids"` -} - -type savedResponse struct { - baseResponse - ItemIDs string `json:"saved_item_ids"` -} - -type group struct { - ID int64 `json:"id"` - Title string `json:"title"` -} - -type feedsGroups struct { - GroupID int64 `json:"group_id"` - FeedIDs string `json:"feed_ids"` -} - -type feed struct { - ID int64 `json:"id"` - FaviconID int64 `json:"favicon_id"` - Title string `json:"title"` - URL string `json:"url"` - SiteURL string `json:"site_url"` - IsSpark int `json:"is_spark"` - LastUpdated int64 `json:"last_updated_on_time"` -} - -type item struct { - ID int64 `json:"id"` - FeedID int64 `json:"feed_id"` - Title string `json:"title"` - Author string `json:"author"` - HTML string `json:"html"` - URL string `json:"url"` - IsSaved int `json:"is_saved"` - IsRead int `json:"is_read"` - CreatedAt int64 `json:"created_on_time"` -} - -type favicon struct { - ID int64 `json:"id"` - Data string `json:"data"` -} - -// Controller implements the Fever API. -type Controller struct { - store *storage.Storage -} - -// Handler handles Fever API calls -func (c *Controller) Handler(ctx *core.Context, request *core.Request, response *core.Response) { - switch { - case request.HasQueryParam("groups"): - c.handleGroups(ctx, request, response) - case request.HasQueryParam("feeds"): - c.handleFeeds(ctx, request, response) - case request.HasQueryParam("favicons"): - c.handleFavicons(ctx, request, response) - case request.HasQueryParam("unread_item_ids"): - c.handleUnreadItems(ctx, request, response) - case request.HasQueryParam("saved_item_ids"): - c.handleSavedItems(ctx, request, response) - case request.HasQueryParam("items"): - c.handleItems(ctx, request, response) - case request.FormValue("mark") == "item": - c.handleWriteItems(ctx, request, response) - case request.FormValue("mark") == "feed": - c.handleWriteFeeds(ctx, request, response) - case request.FormValue("mark") == "group": - c.handleWriteGroups(ctx, request, response) - default: - response.JSON().Standard(newBaseResponse()) - } -} - -/* -A request with the groups argument will return two additional members: - - groups contains an array of group objects - feeds_groups contains an array of feeds_group objects - -A group object has the following members: - - id (positive integer) - title (utf-8 string) - -The feeds_group object is documented under “Feeds/Groups Relationships.” - -The “Kindling” super group is not included in this response and is composed of all feeds with -an is_spark equal to 0. - -The “Sparks” super group is not included in this response and is composed of all feeds with an -is_spark equal to 1. - -*/ -func (c *Controller) handleGroups(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Fetching groups for userID=%d", userID) - - categories, err := c.store.Categories(userID) - if err != nil { - response.JSON().ServerError(err) - return - } - - feeds, err := c.store.Feeds(userID) - if err != nil { - response.JSON().ServerError(err) - return - } - - var result groupsResponse - for _, category := range categories { - result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title}) - } - - result.FeedsGroups = c.buildFeedGroups(feeds) - result.SetCommonValues() - response.JSON().Standard(result) -} - -/* -A request with the feeds argument will return two additional members: - - feeds contains an array of group objects - feeds_groups contains an array of feeds_group objects - -A feed object has the following members: - - id (positive integer) - favicon_id (positive integer) - title (utf-8 string) - url (utf-8 string) - site_url (utf-8 string) - is_spark (boolean integer) - last_updated_on_time (Unix timestamp/integer) - -The feeds_group object is documented under “Feeds/Groups Relationships.” - -The “All Items” super feed is not included in this response and is composed of all items from all feeds -that belong to a given group. For the “Kindling” super group and all user created groups the items -should be limited to feeds with an is_spark equal to 0. - -For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1. -*/ -func (c *Controller) handleFeeds(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Fetching feeds for userID=%d", userID) - - feeds, err := c.store.Feeds(userID) - if err != nil { - response.JSON().ServerError(err) - return - } - - var result feedsResponse - result.Feeds = make([]feed, 0) - for _, f := range feeds { - subscripion := feed{ - ID: f.ID, - Title: f.Title, - URL: f.FeedURL, - SiteURL: f.SiteURL, - IsSpark: 0, - LastUpdated: f.CheckedAt.Unix(), - } - - if f.Icon != nil { - subscripion.FaviconID = f.Icon.IconID - } - - result.Feeds = append(result.Feeds, subscripion) - } - - result.FeedsGroups = c.buildFeedGroups(feeds) - result.SetCommonValues() - response.JSON().Standard(result) -} - -/* -A request with the favicons argument will return one additional member: - - favicons contains an array of favicon objects - -A favicon object has the following members: - - id (positive integer) - data (base64 encoded image data; prefixed by image type) - -An example data value: - - image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== - -The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML. -A PHP/HTML example: - - echo '<img src="data:'.$favicon['data'].'">'; -*/ -func (c *Controller) handleFavicons(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Fetching favicons for userID=%d", userID) - - icons, err := c.store.Icons(userID) - if err != nil { - response.JSON().ServerError(err) - return - } - - var result faviconsResponse - for _, i := range icons { - result.Favicons = append(result.Favicons, favicon{ - ID: i.ID, - Data: i.DataURL(), - }) - } - - result.SetCommonValues() - response.JSON().Standard(result) -} - -/* -A request with the items argument will return two additional members: - - items contains an array of item objects - total_items contains the total number of items stored in the database (added in API version 2) - -An item object has the following members: - - id (positive integer) - feed_id (positive integer) - title (utf-8 string) - author (utf-8 string) - html (utf-8 string) - url (utf-8 string) - is_saved (boolean integer) - is_read (boolean integer) - created_on_time (Unix timestamp/integer) - -Most servers won’t have enough memory allocated to PHP to dump all items at once. -Three optional arguments control determine the items included in the response. - - Use the since_id argument with the highest id of locally cached items to request 50 additional items. - Repeat until the items array in the response is empty. - - Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items. - Repeat until the items array in the response is empty. (added in API version 2) - - Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items. - (added in API version 2) - -*/ -func (c *Controller) handleItems(ctx *core.Context, request *core.Request, response *core.Response) { - var result itemsResponse - - userID := ctx.UserID() - logger.Debug("[Fever] Fetching items for userID=%d", userID) - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithoutStatus(model.EntryStatusRemoved) - builder.WithLimit(50) - builder.WithOrder("id") - builder.WithDirection(model.DefaultSortingDirection) - - sinceID := request.QueryIntegerParam("since_id", 0) - if sinceID > 0 { - builder.WithGreaterThanEntryID(int64(sinceID)) - } - - maxID := request.QueryIntegerParam("max_id", 0) - if maxID > 0 { - builder.WithOffset(maxID) - } - - csvItemIDs := request.QueryStringParam("with_ids", "") - if csvItemIDs != "" { - var itemIDs []int64 - - for _, strItemID := range strings.Split(csvItemIDs, ",") { - strItemID = strings.TrimSpace(strItemID) - itemID, _ := strconv.Atoi(strItemID) - itemIDs = append(itemIDs, int64(itemID)) - } - - builder.WithEntryIDs(itemIDs) - } - - entries, err := builder.GetEntries() - if err != nil { - response.JSON().ServerError(err) - return - } - - builder = c.store.NewEntryQueryBuilder(userID) - builder.WithoutStatus(model.EntryStatusRemoved) - result.Total, err = builder.CountEntries() - if err != nil { - response.JSON().ServerError(err) - return - } - - for _, entry := range entries { - isRead := 0 - if entry.Status == model.EntryStatusRead { - isRead = 1 - } - - isSaved := 0 - if entry.Starred { - isSaved = 1 - } - - result.Items = append(result.Items, item{ - ID: entry.ID, - FeedID: entry.FeedID, - Title: entry.Title, - Author: entry.Author, - HTML: entry.Content, - URL: entry.URL, - IsSaved: isSaved, - IsRead: isRead, - CreatedAt: entry.Date.Unix(), - }) - } - - result.SetCommonValues() - response.JSON().Standard(result) -} - -/* -The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced -with the remote Fever installation. - -A request with the unread_item_ids argument will return one additional member: - unread_item_ids (string/comma-separated list of positive integers) -*/ -func (c *Controller) handleUnreadItems(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Fetching unread items for userID=%d", userID) - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithStatus(model.EntryStatusUnread) - entries, err := builder.GetEntries() - if err != nil { - response.JSON().ServerError(err) - return - } - - var itemIDs []string - for _, entry := range entries { - itemIDs = append(itemIDs, strconv.FormatInt(entry.ID, 10)) - } - - var result unreadResponse - result.ItemIDs = strings.Join(itemIDs, ",") - result.SetCommonValues() - response.JSON().Standard(result) -} - -/* -The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced -with the remote Fever installation. - - A request with the saved_item_ids argument will return one additional member: - - saved_item_ids (string/comma-separated list of positive integers) -*/ -func (c *Controller) handleSavedItems(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Fetching saved items for userID=%d", userID) - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithStarred() - - entryIDs, err := builder.GetEntryIDs() - if err != nil { - response.JSON().ServerError(err) - return - } - - var itemsIDs []string - for _, entryID := range entryIDs { - itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10)) - } - - result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")} - result.SetCommonValues() - response.JSON().Standard(result) -} - -/* - mark=item - as=? where ? is replaced with read, saved or unsaved - id=? where ? is replaced with the id of the item to modify -*/ -func (c *Controller) handleWriteItems(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID) - - entryID := request.FormIntegerValue("id") - if entryID <= 0 { - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - entry, err := builder.GetEntry() - if err != nil { - response.JSON().ServerError(err) - return - } - - if entry == nil { - return - } - - switch request.FormValue("as") { - case "read": - c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead) - case "unread": - c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread) - case "saved": - if err := c.store.ToggleBookmark(userID, entryID); err != nil { - response.JSON().ServerError(err) - return - } - - settings, err := c.store.Integration(userID) - if err != nil { - response.JSON().ServerError(err) - return - } - - go func() { - integration.SendEntry(entry, settings) - }() - } - - response.JSON().Standard(newBaseResponse()) -} - -/* - mark=? where ? is replaced with feed or group - as=read - id=? where ? is replaced with the id of the feed or group to modify - before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request -*/ -func (c *Controller) handleWriteFeeds(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Receiving mark=feed call for userID=%d", userID) - - feedID := request.FormIntegerValue("id") - if feedID <= 0 { - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithStatus(model.EntryStatusUnread) - builder.WithFeedID(feedID) - - before := request.FormIntegerValue("before") - if before > 0 { - t := time.Unix(before, 0) - builder.Before(&t) - } - - entryIDs, err := builder.GetEntryIDs() - if err != nil { - response.JSON().ServerError(err) - return - } - - err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead) - if err != nil { - response.JSON().ServerError(err) - return - } - - response.JSON().Standard(newBaseResponse()) -} - -/* - mark=? where ? is replaced with feed or group - as=read - id=? where ? is replaced with the id of the feed or group to modify - before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request -*/ -func (c *Controller) handleWriteGroups(ctx *core.Context, request *core.Request, response *core.Response) { - userID := ctx.UserID() - logger.Debug("[Fever] Receiving mark=group call for userID=%d", userID) - - groupID := request.FormIntegerValue("id") - if groupID < 0 { - return - } - - builder := c.store.NewEntryQueryBuilder(userID) - builder.WithStatus(model.EntryStatusUnread) - builder.WithCategoryID(groupID) - - before := request.FormIntegerValue("before") - if before > 0 { - t := time.Unix(before, 0) - builder.Before(&t) - } - - entryIDs, err := builder.GetEntryIDs() - if err != nil { - response.JSON().ServerError(err) - return - } - - err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead) - if err != nil { - response.JSON().ServerError(err) - return - } - - response.JSON().Standard(newBaseResponse()) -} - -/* -A feeds_group object has the following members: - - group_id (positive integer) - feed_ids (string/comma-separated list of positive integers) - -*/ -func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups { - feedsGroupedByCategory := make(map[int64][]string) - for _, feed := range feeds { - feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10)) - } - - result := make([]feedsGroups, 0) - for categoryID, feedIDs := range feedsGroupedByCategory { - result = append(result, feedsGroups{ - GroupID: categoryID, - FeedIDs: strings.Join(feedIDs, ","), - }) - } - - return result -} - -// NewController returns a new Fever API. -func NewController(store *storage.Storage) *Controller { - return &Controller{store: store} -} diff --git a/server/middleware/basic_auth.go b/server/middleware/basic_auth.go deleted file mode 100644 index 35a9f81..0000000 --- a/server/middleware/basic_auth.go +++ /dev/null @@ -1,72 +0,0 @@ -// 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" - "net/http" - - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/storage" -) - -// BasicAuthMiddleware is the middleware for HTTP Basic authentication. -type BasicAuthMiddleware struct { - store *storage.Storage -} - -// Handler executes the middleware. -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 { - logger.Debug("[Middleware:BasicAuth] No authentication headers sent") - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(errorResponse)) - return - } - - if err := b.store.CheckPassword(username, password); err != nil { - logger.Info("[Middleware:BasicAuth] Invalid username or password: %s", username) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(errorResponse)) - return - } - - user, err := b.store.UserByUsername(username) - if err != nil { - logger.Error("[Middleware:BasicAuth] %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(errorResponse)) - return - } - - if user == nil { - logger.Info("[Middleware:BasicAuth] User not found: %s", username) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(errorResponse)) - return - } - - logger.Info("[Middleware:BasicAuth] User authenticated: %s", username) - b.store.SetLastLogin(user.ID) - - ctx := r.Context() - ctx = context.WithValue(ctx, UserIDContextKey, user.ID) - ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone) - ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin) - ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) - - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// NewBasicAuthMiddleware returns a new BasicAuthMiddleware. -func NewBasicAuthMiddleware(s *storage.Storage) *BasicAuthMiddleware { - return &BasicAuthMiddleware{store: s} -} diff --git a/server/middleware/context_keys.go b/server/middleware/context_keys.go deleted file mode 100644 index 31ad286..0000000 --- a/server/middleware/context_keys.go +++ /dev/null @@ -1,46 +0,0 @@ -// 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 - -// ContextKey represents a context key. -type ContextKey struct { - name string -} - -func (c ContextKey) String() string { - return c.name -} - -var ( - // UserIDContextKey is the context key used to store the user ID. - UserIDContextKey = &ContextKey{"UserID"} - - // UserTimezoneContextKey is the context key used to store the user timezone. - UserTimezoneContextKey = &ContextKey{"UserTimezone"} - - // IsAdminUserContextKey is the context key used to store the user role. - IsAdminUserContextKey = &ContextKey{"IsAdminUser"} - - // IsAuthenticatedContextKey is the context key used to store the authentication flag. - IsAuthenticatedContextKey = &ContextKey{"IsAuthenticated"} - - // UserSessionTokenContextKey is the context key used to store the user session ID. - UserSessionTokenContextKey = &ContextKey{"UserSessionToken"} - - // SessionIDContextKey is the context key used to store the session ID. - SessionIDContextKey = &ContextKey{"SessionID"} - - // CSRFContextKey is the context key used to store CSRF token. - CSRFContextKey = &ContextKey{"CSRF"} - - // OAuth2StateContextKey is the context key used to store OAuth2 state. - OAuth2StateContextKey = &ContextKey{"OAuth2State"} - - // FlashMessageContextKey is the context key used to store a flash message. - FlashMessageContextKey = &ContextKey{"FlashMessage"} - - // FlashErrorMessageContextKey is the context key used to store a flash error message. - FlashErrorMessageContextKey = &ContextKey{"FlashErrorMessage"} -) diff --git a/server/middleware/fever.go b/server/middleware/fever.go deleted file mode 100644 index 54eb0ca..0000000 --- a/server/middleware/fever.go +++ /dev/null @@ -1,57 +0,0 @@ -// 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" - "net/http" - - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/storage" -) - -// FeverMiddleware is the middleware that handles Fever API. -type FeverMiddleware struct { - store *storage.Storage -} - -// Handler executes the middleware. -func (f *FeverMiddleware) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logger.Debug("[Middleware:Fever]") - - apiKey := r.FormValue("api_key") - user, err := f.store.UserByFeverToken(apiKey) - if err != nil { - logger.Error("[Fever] %v", err) - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"api_version": 3, "auth": 0}`)) - return - } - - if user == nil { - logger.Info("[Middleware:Fever] Fever authentication failure") - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"api_version": 3, "auth": 0}`)) - return - } - - logger.Info("[Middleware:Fever] User #%d is authenticated", user.ID) - f.store.SetLastLogin(user.ID) - - ctx := r.Context() - ctx = context.WithValue(ctx, UserIDContextKey, user.ID) - ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone) - ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin) - ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) - - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// NewFeverMiddleware returns a new FeverMiddleware. -func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware { - return &FeverMiddleware{store: s} -} diff --git a/server/middleware/middleware.go b/server/middleware/middleware.go deleted file mode 100644 index 9853bc3..0000000 --- a/server/middleware/middleware.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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" -) - -// Middleware represents a HTTP middleware. -type Middleware func(http.Handler) http.Handler - -// Chain handles a list of middlewares. -type Chain struct { - middlewares []Middleware -} - -// Wrap adds a HTTP handler into the chain. -func (m *Chain) Wrap(h http.Handler) http.Handler { - for i := range m.middlewares { - h = m.middlewares[len(m.middlewares)-1-i](h) - } - - return h -} - -// WrapFunc adds a HTTP handler function into the chain. -func (m *Chain) WrapFunc(fn http.HandlerFunc) http.Handler { - return m.Wrap(fn) -} - -// NewChain returns a new Chain. -func NewChain(middlewares ...Middleware) *Chain { - return &Chain{append(([]Middleware)(nil), middlewares...)} -} diff --git a/server/middleware/session.go b/server/middleware/session.go deleted file mode 100644 index ad02bb2..0000000 --- a/server/middleware/session.go +++ /dev/null @@ -1,84 +0,0 @@ -// 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" - "net/http" - - "github.com/miniflux/miniflux/config" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/cookie" - "github.com/miniflux/miniflux/storage" -) - -// SessionMiddleware represents a session middleware. -type SessionMiddleware struct { - cfg *config.Config - store *storage.Storage -} - -// Handler execute the middleware. -func (s *SessionMiddleware) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var err error - session := s.getSessionValueFromCookie(r) - - if session == nil { - logger.Debug("[Middleware:Session] Session not found") - session, err = s.store.CreateSession() - if err != nil { - logger.Error("[Middleware:Session] %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS)) - } else { - logger.Debug("[Middleware:Session] %s", session) - } - - if r.Method == "POST" { - formValue := r.FormValue("csrf") - headerValue := r.Header.Get("X-Csrf-Token") - - if session.Data.CSRF != formValue && session.Data.CSRF != headerValue { - logger.Error(`[Middleware:Session] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue) - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Invalid or missing CSRF session!")) - return - } - } - - ctx := r.Context() - ctx = context.WithValue(ctx, SessionIDContextKey, session.ID) - ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF) - ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State) - ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage) - ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -func (s *SessionMiddleware) getSessionValueFromCookie(r *http.Request) *model.Session { - sessionCookie, err := r.Cookie(cookie.CookieSessionID) - if err == http.ErrNoCookie { - return nil - } - - session, err := s.store.Session(sessionCookie.Value) - if err != nil { - logger.Error("[Middleware:Session] %v", err) - return nil - } - - return session -} - -// NewSessionMiddleware returns a new SessionMiddleware. -func NewSessionMiddleware(cfg *config.Config, store *storage.Storage) *SessionMiddleware { - return &SessionMiddleware{cfg, store} -} diff --git a/server/middleware/user_session.go b/server/middleware/user_session.go deleted file mode 100644 index 3d1dae6..0000000 --- a/server/middleware/user_session.go +++ /dev/null @@ -1,78 +0,0 @@ -// 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" - "net/http" - - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/cookie" - "github.com/miniflux/miniflux/server/route" - "github.com/miniflux/miniflux/storage" - - "github.com/gorilla/mux" -) - -// UserSessionMiddleware represents a user session middleware. -type UserSessionMiddleware struct { - store *storage.Storage - router *mux.Router -} - -// Handler execute the middleware. -func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := s.getSessionFromCookie(r) - - if session == nil { - logger.Debug("[Middleware:UserSession] Session not found") - if s.isPublicRoute(r) { - next.ServeHTTP(w, r) - } else { - http.Redirect(w, r, route.Path(s.router, "login"), http.StatusFound) - } - } else { - logger.Debug("[Middleware:UserSession] %s", session) - ctx := r.Context() - ctx = context.WithValue(ctx, UserIDContextKey, session.UserID) - ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true) - ctx = context.WithValue(ctx, UserSessionTokenContextKey, session.Token) - - next.ServeHTTP(w, r.WithContext(ctx)) - } - }) -} - -func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool { - route := mux.CurrentRoute(r) - switch route.GetName() { - case "login", "checkLogin", "stylesheet", "javascript", "oauth2Redirect", "oauth2Callback", "appIcon", "favicon", "webManifest": - return true - default: - return false - } -} - -func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSession { - sessionCookie, err := r.Cookie(cookie.CookieUserSessionID) - if err == http.ErrNoCookie { - return nil - } - - session, err := s.store.UserSessionByToken(sessionCookie.Value) - if err != nil { - logger.Error("[Middleware:UserSession] %v", err) - return nil - } - - return session -} - -// NewUserSessionMiddleware returns a new UserSessionMiddleware. -func NewUserSessionMiddleware(s *storage.Storage, r *mux.Router) *UserSessionMiddleware { - return &UserSessionMiddleware{store: s, router: r} -} diff --git a/server/oauth2/google.go b/server/oauth2/google.go deleted file mode 100644 index e57e027..0000000 --- a/server/oauth2/google.go +++ /dev/null @@ -1,74 +0,0 @@ -// 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 oauth2 - -import ( - "context" - "encoding/json" - "fmt" - - "golang.org/x/oauth2" -) - -type googleProfile struct { - Sub string `json:"sub"` - Email string `json:"email"` -} - -type googleProvider struct { - clientID string - clientSecret string - redirectURL string -} - -func (g googleProvider) GetUserExtraKey() string { - return "google_id" -} - -func (g googleProvider) GetRedirectURL(state string) string { - return g.config().AuthCodeURL(state) -} - -func (g googleProvider) GetProfile(code string) (*Profile, error) { - conf := g.config() - ctx := context.Background() - token, err := conf.Exchange(ctx, code) - if err != nil { - return nil, err - } - - client := conf.Client(ctx, token) - resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var user googleProfile - decoder := json.NewDecoder(resp.Body) - if err := decoder.Decode(&user); err != nil { - return nil, fmt.Errorf("unable to unserialize google profile: %v", err) - } - - profile := &Profile{Key: g.GetUserExtraKey(), ID: user.Sub, Username: user.Email} - return profile, nil -} - -func (g googleProvider) config() *oauth2.Config { - return &oauth2.Config{ - RedirectURL: g.redirectURL, - ClientID: g.clientID, - ClientSecret: g.clientSecret, - Scopes: []string{"email"}, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://accounts.google.com/o/oauth2/auth", - TokenURL: "https://accounts.google.com/o/oauth2/token", - }, - } -} - -func newGoogleProvider(clientID, clientSecret, redirectURL string) *googleProvider { - return &googleProvider{clientID: clientID, clientSecret: clientSecret, redirectURL: redirectURL} -} diff --git a/server/oauth2/manager.go b/server/oauth2/manager.go deleted file mode 100644 index 08360a9..0000000 --- a/server/oauth2/manager.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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 oauth2 - -import "errors" - -// Manager handles OAuth2 providers. -type Manager struct { - providers map[string]Provider -} - -// Provider returns the given provider. -func (m *Manager) Provider(name string) (Provider, error) { - if provider, found := m.providers[name]; found { - return provider, nil - } - - return nil, errors.New("oauth2 provider not found") -} - -// AddProvider add a new OAuth2 provider. -func (m *Manager) AddProvider(name string, provider Provider) { - m.providers[name] = provider -} - -// NewManager returns a new Manager. -func NewManager(clientID, clientSecret, redirectURL string) *Manager { - m := &Manager{providers: make(map[string]Provider)} - m.AddProvider("google", newGoogleProvider(clientID, clientSecret, redirectURL)) - return m -} diff --git a/server/oauth2/profile.go b/server/oauth2/profile.go deleted file mode 100644 index 488ffb2..0000000 --- a/server/oauth2/profile.go +++ /dev/null @@ -1,12 +0,0 @@ -// 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 oauth2 - -// Profile is the OAuth2 user profile. -type Profile struct { - Key string - ID string - Username string -} diff --git a/server/oauth2/provider.go b/server/oauth2/provider.go deleted file mode 100644 index c43931c..0000000 --- a/server/oauth2/provider.go +++ /dev/null @@ -1,12 +0,0 @@ -// 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 oauth2 - -// Provider is an interface for OAuth2 providers. -type Provider interface { - GetUserExtraKey() string - GetRedirectURL(state string) string - GetProfile(code string) (*Profile, error) -} diff --git a/server/route/route.go b/server/route/route.go deleted file mode 100644 index ee574a5..0000000 --- a/server/route/route.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 ( - "strconv" - - "github.com/gorilla/mux" - "github.com/miniflux/miniflux/logger" -) - -// Path returns the defined route based on given arguments. -func Path(router *mux.Router, name string, args ...interface{}) string { - route := router.Get(name) - if route == nil { - logger.Fatal("[Route] Route not found: %s", 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 { - logger.Fatal("[Route] %v", err) - } - - return result.String() -} diff --git a/server/routes.go b/server/routes.go deleted file mode 100644 index e56b8cc..0000000 --- a/server/routes.go +++ /dev/null @@ -1,166 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/scheduler" - - "github.com/miniflux/miniflux/config" - "github.com/miniflux/miniflux/locale" - "github.com/miniflux/miniflux/reader/feed" - "github.com/miniflux/miniflux/reader/opml" - api_controller "github.com/miniflux/miniflux/server/api/controller" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/fever" - "github.com/miniflux/miniflux/server/middleware" - "github.com/miniflux/miniflux/server/template" - ui_controller "github.com/miniflux/miniflux/server/ui/controller" - "github.com/miniflux/miniflux/storage" - - "github.com/gorilla/mux" -) - -func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool) *mux.Router { - router := mux.NewRouter() - translator := locale.Load() - templateEngine := template.NewEngine(cfg, router, translator) - - apiController := api_controller.NewController(store, feedHandler) - feverController := fever.NewController(store) - uiController := ui_controller.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store)) - - apiHandler := core.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain( - middleware.NewBasicAuthMiddleware(store).Handler, - )) - - feverHandler := core.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain( - middleware.NewFeverMiddleware(store).Handler, - )) - - uiHandler := core.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain( - middleware.NewUserSessionMiddleware(store, router).Handler, - middleware.NewSessionMiddleware(cfg, store).Handler, - )) - - router.Handle("/fever/", feverHandler.Use(feverController.Handler)) - - router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST") - router.Handle("/v1/users", apiHandler.Use(apiController.Users)).Methods("GET") - router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.UserByID)).Methods("GET") - router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.UpdateUser)).Methods("PUT") - router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.RemoveUser)).Methods("DELETE") - router.Handle("/v1/users/{username}", apiHandler.Use(apiController.UserByUsername)).Methods("GET") - - 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}/icon", apiHandler.Use(apiController.FeedIcon)).Methods("GET") - - router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET") - router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetFeedEntry)).Methods("GET") - router.Handle("/v1/entries", apiHandler.Use(apiController.GetEntries)).Methods("GET") - router.Handle("/v1/entries", apiHandler.Use(apiController.SetEntryStatus)).Methods("PUT") - router.Handle("/v1/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET") - router.Handle("/v1/entries/{entryID}/bookmark", apiHandler.Use(apiController.ToggleBookmark)).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("/icon/{filename}", uiHandler.Use(uiController.AppIcon)).Name("appIcon").Methods("GET") - router.Handle("/manifest.json", uiHandler.Use(uiController.WebManifest)).Name("webManifest").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("/starred", uiHandler.Use(uiController.ShowStarredPage)).Name("starred").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("POST") - 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("/feeds/refresh", uiHandler.Use(uiController.RefreshAllFeeds)).Name("refreshAllFeeds").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("/history/flush", uiHandler.Use(uiController.FlushHistory)).Name("flushHistory").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("/starred/entry/{entryID}", uiHandler.Use(uiController.ShowStarredEntry)).Name("starredEntry").Methods("GET") - - router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST") - router.Handle("/entry/save/{entryID}", uiHandler.Use(uiController.SaveEntry)).Name("saveEntry").Methods("POST") - router.Handle("/entry/download/{entryID}", uiHandler.Use(uiController.FetchContent)).Name("fetchContent").Methods("POST") - router.Handle("/entry/bookmark/{entryID}", uiHandler.Use(uiController.ToggleBookmark)).Name("toggleBookmark").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("POST") - - router.Handle("/feed/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("POST") - - 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("/bookmarklet", uiHandler.Use(uiController.Bookmarklet)).Name("bookmarklet").Methods("GET") - router.Handle("/integrations", uiHandler.Use(uiController.ShowIntegrations)).Name("integrations").Methods("GET") - router.Handle("/integration", uiHandler.Use(uiController.UpdateIntegration)).Name("updateIntegration").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("POST") - - 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("/oauth2/{provider}/unlink", uiHandler.Use(uiController.OAuth2Unlink)).Name("oauth2Unlink").Methods("GET") - router.Handle("/oauth2/{provider}/redirect", uiHandler.Use(uiController.OAuth2Redirect)).Name("oauth2Redirect").Methods("GET") - router.Handle("/oauth2/{provider}/callback", uiHandler.Use(uiController.OAuth2Callback)).Name("oauth2Callback").Methods("GET") - - 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 deleted file mode 100644 index 4627b83..0000000 --- a/server/server.go +++ /dev/null @@ -1,69 +0,0 @@ -// 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 ( - "crypto/tls" - "net/http" - "time" - - "github.com/gorilla/mux" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/scheduler" - "golang.org/x/crypto/acme/autocert" - - "github.com/miniflux/miniflux/config" - "github.com/miniflux/miniflux/reader/feed" - "github.com/miniflux/miniflux/storage" -) - -// NewServer returns a new HTTP server. -func NewServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler) *http.Server { - return startServer(cfg, getRoutes(cfg, store, feedHandler, pool)) -} - -func startServer(cfg *config.Config, handler *mux.Router) *http.Server { - certFile := cfg.Get("CERT_FILE", config.DefaultCertFile) - keyFile := cfg.Get("KEY_FILE", config.DefaultKeyFile) - certDomain := cfg.Get("CERT_DOMAIN", config.DefaultCertDomain) - certCache := cfg.Get("CERT_CACHE", config.DefaultCertCache) - server := &http.Server{ - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 60 * time.Second, - Addr: cfg.Get("LISTEN_ADDR", config.DefaultListenAddr), - Handler: handler, - } - - if certDomain != "" && certCache != "" { - cfg.IsHTTPS = true - server.Addr = ":https" - certManager := autocert.Manager{ - Cache: autocert.DirCache(certCache), - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(certDomain), - } - - go func() { - logger.Info(`Listening on "%s" by using auto-configured certificate for "%s"`, server.Addr, certDomain) - logger.Fatal(server.Serve(certManager.Listener()).Error()) - }() - } else if certFile != "" && keyFile != "" { - server.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} - cfg.IsHTTPS = true - - go func() { - logger.Info(`Listening on "%s" by using certificate "%s" and key "%s"`, server.Addr, certFile, keyFile) - logger.Fatal(server.ListenAndServeTLS(certFile, keyFile).Error()) - }() - } else { - go func() { - logger.Info(`Listening on "%s" without TLS`, server.Addr) - logger.Fatal(server.ListenAndServe().Error()) - }() - } - - return server -} diff --git a/server/static/bin.go b/server/static/bin.go deleted file mode 100644 index b464f30..0000000 --- a/server/static/bin.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by go generate; DO NOT EDIT. -// 2017-12-22 11:25:01.957187237 -0800 PST m=+0.022154999 - -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=`, - "favicon.png": `iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAGJwAABicBTVTYxwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAALMSURBVHic7Zo7a1RRFIW/I8YXaBBEJRJEU8RqQBBBQRBEWxHBwlZUsLRWUFBsA4L4G4IY0TaF2PhEEQwmhuADJIkRUUOMr2RZ3Em8mcxkzrkPtjhnwS7msveadT/Ofc44SbSyllkHsFYEYB3AWhGAdQBrRQDWAawVAVgHsFYEYB3AWhGAdQBrLS/L2Dm3CdgFbK3WDPC6Wi8kjWX03QBUgG3AdmAN8LFaT4CnCnjEdbW9zrk+YL3n/AVJd2vmDwKngMNAW4O538BNoEfSfa+gzu0DzgBHl/AFGAN6gcuSPjQ1lrSggHFAnnUsNdcO3AiYnas7wNraHCnfLcC9DL6TwNlGvvP+RQAAdgIjGULO1XOgs06WQ8BEDl8BPVRXeikAgK4CQgp4B7SnchwnOW/k9RVwviwAp4HBgkIKuJ5aUd8K9P0JVMoA8LnAkAJmgSPA24J9BfTXA1DvKjAObOT/k4BuScPpjWXcCM0Co8CnErynSFbHTIZZB5xYtDXnIZCuCeAkqUsa0AlcyeiXrtvAnpTvamA/8CbQ50HR54C5egV0LHEtv5hj588t4dsBvA/wmgbaigbwneTYanyzkayELDvf2/RGBi4FelaKBnC1Wciq70Cg7y+gy8O3O9D3QHq+iJPgNc++R4G+/ZJGPPqGSU68vlqX/pAXwKCkl569XwK9b/k0SZoleRL0VaEAngX0TgZ6Pw7obf7U91cr0x/yAhgK6A0BIMB3ZUFyq5tJeQGELL2vAb1TkqYD+lcF9C5QXgAhO/WjJF/I8WYrL4CQnfoXfBep5V+KRgDWAawVAVgHsFYEYB3AWhGAdQBrRQDWAawVAVgHsFYEYB3AWi0PoN6Po3uBFZ7zA5ImvL7Iuc3ADk/faUkPPXtxzu0m+a+Qj4Ykjc7P1gJoNbX8IRABWAewVgRgHcBaEYB1AGtFANYBrBUBWAewVssD+AMBy6wzsaDiAwAAAABJRU5ErkJggg==`, - "touch-icon-ipad-retina.png": `iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAgCSURBVHic7d1NSFRdHMfx/2iOzeSMQ5SaqZk5RWaO2btZiQVFRW9EYWSRhRZtWrRqWbRoq4twYS0CwcJeFtkqIiQqa1EkGgwSglGaNmOZM5r1fxbPM2D26Nzxf8+9d+L3gbO7nTn38J3rdV7MxsxMADOUYPYCIL4hIBBBQCCCgEAEAYEIAgIRBAQiCAhEEBCIICAQQUAggoBABAGBCAICEQQEIggIRBAQiCAgEEFAIIKAQAQBgQgCAhEEBCIICEQQEIggIBBBQCCCgEAEAYEIAgIRBAQis8xegN7Gxsaoq6uL3rx5Qx8+fKBgMEjBYJBGRkbI5XKRx+Mhj8dDGRkZtGrVKiooKKDExESzl/2HQCBAr1+/pq6uLvry5QsFAgEKBoM0OjpKTqeTnE4nuVwuys3Npby8PPJ6vZSVlWX4Ov+KgDo6OqilpYXu379PHR0d9OPHD83/1uFwUHFxMW3fvp0qKytp+fLlClc6tXA4TK2trXT79m168eIFvX//PuY5MjMzqbS0lLZu3UoHDx6kzMxMBSv9nU3LXyg7deoUdXd3K11IdXU1HT9+XPPx4XCYbty4QXV1dfTu3Tvd1uHz+aiqqorOnDlDc+bM0W3eqbx8+ZLq6+vp3r179O3bN93mtdlsVFZWRjU1NVRZWanuKssarFy5kolI6bh06ZKWpXAoFOKrV69yRkaG0vWkp6dzfX09j46OalpXrNrb23nXrl3K95WIOC8vjxsbG/nXr1+6n0dcBfT06VNetmyZIZseGYsXL+a2tjbxRkf09fXxoUOHDD2HyCgtLeWOjg7dzoU5TgIaGxvj8+fPc0JCgikbn5iYyFeuXBE/g2/dusXz5s0z5Rwiw263c0NDg+g8JrJ8QIODg1xeXm7qpkfGjh07eHh4OOZNDofDfOzYMdPXP3HU1tby+Ph4zOcymaUD8vv9vHTpUtM3e+LYsmVLTBENDg5yWVmZ6ev+v1FVVSW+qlo2IL/fr/xGWXVE3d3dht+zxTrOnj07s3L+Y8mAenp6OCcnx/TNnW4cOHBg2j3r7e21/DlERmNj498TUF9fH+fn55u+qVpGXV3d/+5XIBDgwsJC09endTgcjhn/dmapgMbHx7miosL0DdU67HY7v3r16re9CoVClr3nmW6sX79+RvdDlgro4sWLpm9krGP16tW/bfzp06dNX9NMx/Xr1+M3oNLSUrbZbKZv4kxGc3MzMzM3NTWZvhbJSE9P55GRkfgMKJ6H1+vlzs5Odrlcpq9FOq5du4aAzBipqammr0GP4fV6+efPn5oDwgfKdDI0NGT2EnTh9/vp8ePHmo9HQPCH5uZmzcciIPjDnTt3NH8oLy4/keh2u6m8vJxycnIoKyuLFixYQMPDw/T582fy+/304MEDCgaDZi9Tk9zcXNqwYQNlZWVRdnY2eTweGhgYoP7+fmpra6Nnz54RG/y/kg4ODtLz589p8+bN0Q+Op5voiooKvnnzJn///n3a9Y6NjfHdu3ct+1bC7Nmzubq6mp88eRL1xbve3l4+d+6c4S9xXL58WdNNdFwElJqayk1NTZpOaKKvX7/y3r17TQ9m4igqKuK3b9/GfC6PHj3ilJQUw9a5bds2TeuyfEDr1q3jnp6emDc8Ynh4mIuKikwPh+jfd77D4fCMz6W1tZUTExMNWavL5dL01oalA1qyZAkPDAzMeMMj2tvbTY/n8OHD4vNgZr5w4YJha9byxLVsQG63mzs7O8UbHuHz+UyLZ82aNTG/RTCVvr4+djgchqy7tbU16nos+2t8Q0ODrt/ROnnypG5zxSI5OZlaWlrI4XDoMl9aWhodPXpUl7mi0fJ1KUsGVFJSQkeOHNF1zk2bNuk6n1a1tbWUk5Oj65y7d+/Wdb6pfPz4MfpBWi6bRv8Ie/jwofhSP9no6Cjb7XZDz8PpdPKnT590P5ehoSFOSkpSvv4TJ05EXYvlrkAbN26knTt36j6v3W6nwsJC3eedTk1NDaWnp+s+r9vtNuQr2P39/VGPsVxAlZWVyuY2+nvvKs8lPz9f2dwRgUAg6jGWC2jfvn3K5lZxNZjKwoULae3atcrm93q9yuaOCIVCUY+xVEAlJSW633BONH/+fGVzT7Z//36y2WzK5le5TxHhcDjqMZYKSPVvF2lpaUrnn2jPnj1K509JSVE6P1EcBuTz+ZTOb2RAxcXFSud3Op1K5yciTR/psFRABQUFSud3u91K54+YO3cuZWRkKH0MI/52kRaWCSgpKUn5jaHdblc6f4QRLxfMmmWNj3JZJiCv16t8U5KTk5XOH7FixQrlj6Hl/sQIlgkoOztb+WMYFdCiRYuUPwYCmsTlcil/DKN+hBlxr6XlNRojWCYgI34tNeoKZMSTAVegSYwIyKi/B23EFWh8fFz5Y2hhmYCMeNYaxaiXC6zAMgHp9YErKzDiRT6rsExAfxOV74FZDQICEQQEIggIRBAQiCAgEEFAIIKAQAQBgQgCAhEEBCIICEQQEIggIBBBQCCCgEAEAYEIAgIRBAQiCAhEEBCIICAQQUAggoBABAGBCAICEQQEIggIRBAQiCAgEEFAIIKAQAQBgQgCAhEEBCIICEQQEIggIBCxMTObvQiIX7gCgQgCAhEEBCIICEQQEIggIBBBQCCCgEAEAYEIAgIRBAQiCAhEEBCIICAQQUAggoBABAGBCAICEQQEIggIRBAQiCAgEEFAIIKAQAQBgQgCAhEEBCIICEQQEIggIBD5B/3K+BzBV8ffAAAAAElFTkSuQmCC`, - "touch-icon-ipad.png": `iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAP9SURBVHic7Zo/SHJvFMePmaH90Uot0UH6A5aBEuHgIoQXXAKXBhvaHWoOanAMmqOWhsClaKuhWoqWIIoIKiGoMKGGltQCK7qe3+TLq/Z27nPfe3vjx/nAszye53wfPt5/4jUgIgLzRxr+9QZ+OiyIgAURsCACFkTAgghYEAELImBBBCyIgAURsCACFkTAgghYEEGj3gGyLMP29jacnp5CLpeDXC4H+XwebDYbeDweCAQCIEkSBAIB1RmFQgG2trbg9vYWstksZLNZKJVK4HA4wOl0Qm9vL8RiMRgZGYGGBsFjAmtIp9Po9XqFxtHRUW0bvL+/x1QqhR6PBwGAHMFgEDc2Nur6fMXFxQUmk0lsaWlRlOFwOHB2dhYLhYLijDpBS0tLisJ+HwcHB1U91tfX0Wq1CvcBAJQkCZ+enr7c9MvLC05OTqrqXxG1urr6/YJKpRImk0nVG6+MgYEBvLm5+XTDmUwG/X7/X2cAAM7Pz3+fIFmWcWxsTJONV77ly8vLqr3t7u4qPp2UjoWFhe8RND09renGAQDD4TDKsoyIiFdXV2iz2TTPMJlMeHZ2pq+g8fFxzTdeGYuLi5jP59Hn8+mWEQwGf30RugjSc1itVhwdHdU9Z2dn51NBP/5BsVgswv7+vu45Kysrn87r9qBoMpkgEAiA2+2Gh4cHOD8/h/f3d81zenp6oL+/HxARTk5OIJ/Pq+qzubkJxWIRrFZr9Qdan2KNjY2YSqXw8fGxqu/r6ysuLy+j0WjU5JSIRqN4fHxclVEul/Hw8BDdbreqnnt7e/peg8xmc91DYy1ra2t/LWdqauqPF1VExLu7O3S5XMJ9P7vlayoonU5/KQcRUZZltNvtqjMkSfpSToW5uTnh3olEQj9B4XCY3HSFRCKhKsNoNGImk1GUcX19jQaDQah/JBKp66PZXWxmZkZxbTQaVZURj8dhcHBQUW1fXx+4XC6h/oVCoW5OE0Gtra0Qi8UU13d3d6vKicfjQvVdXV1C9boJikQiYDabFdfX3UoVIkmSUL2ooOfn57o5TQQNDQ0J1be1tQlndHZ2gtvtFlrjdDqF6svlct2cJoJ8Pp9QvZojyO/3C69BDV6e00RQR0eHUL2aI8hutwuveXt7E15Ti2YXaRGampqEM9rb24XXfHx8CK+pRRNBIhdotTQ3N+ue8Rk//tf8v4YFEbAgAhZEwIIIWBABCyJgQQQsiIAFEbAgAhZEwIIIWBABCyJgQQQsiIAFEdS9/hIKhSCVSgk18Xq9QvUWi0U4IxQKCdUDAExMTMDw8LDieovFUjdnQC3+G/kfw6cYAQsiYEEELIiABRGwIAIWRMCCCFgQAQsiYEEELIiABRGwIIL/ABedeOtpRvUuAAAAAElFTkSuQmCC`, - "touch-icon-iphone-retina.png": `iVBORw0KGgoAAAANSUhEUgAAAHIAAAByCAYAAACP3YV9AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAK9QAACvUBbxZRbgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAaGSURBVHic7Z1fSFNvGMe/+6eujWbLkmXoGlbSwMiigowiggIjjKI/EF50oYEYRH+uggiMIrsxSqgo6iKEILzpMqSC9GY30UVZwRYF0tQgFV1z8/ldxPhpupnu2Tnz6fnAe3UO3/d73g/zeLZzNgsREZRFj9XsAgoPKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIKlIIdrML/C2/fv3Cx48f8ePHDwwPDyMej8Pj8cDj8WDlypWoqKgwrRsRIRwOY2BgAD9//sTw8DAcDgecTieWLVsGv9+PFStW5LRD3opMJBLo7u7Gs2fP0NPTgw8fPiCRSKTd3+v1YvPmzdi5cyeOHTuGdevW5bRfKBTC06dP0dvbi7dv32JkZCTj/m63G1u3bsWOHTtQV1eHbdu2sfaxZHpkoLGxEa9fv2ab7Pbt29i7d2/GfaLRKNrb23H37l0MDQ0teK6amhq0tLSgoaEBVivPGWR0dBQdHR24f/8+Pn/+nFVWRUUFGhsb0dzcDI/Hk305ysD+/fsJANvo6upKO9fY2BhdvHiRnE4n65zBYJCeP3+e6TDnJBaL0fXr16mkpIS1GwBaunQp3bhxgyYmJrLqmBciX716RZWVleyLNHU0NTXR+Pj4vBcoFApRMBjMaTcAVF1dTX19ffPul8J0kR0dHWSz2XK+UKnF+vr1618vzrVr18hutxvSDQAVFxfTixcv5m+RTBZ57tw5wxYpNQKBAH358iXjosTjcTp16pTh3QBQUVERdXd3Lx6RFy5cMGWhAJDf76f+/v5ZjzkWi9G+fftM6waAXC4XvXv3Lv9Ftra2mrpQAGjPnj2UTCanHW8ymaQjR46Y3g0AVVVV0ejoaP6K7OrqMn2RUuPKlSvTjre5udn0TlPH2bNn81NkW1sbeTwe0xcoNQoKCigcDhMR0ePHj03v8+dwOBz0/v37/BNZWFho+uL8ORoaGqivr4/cbrfpXWYbJ06cyD+R+TisViutXbvW9B7pht1up0gkMqfIf/7Tj8nJSXz69MnsGmlJJBK4d+/enPv98yIXA52dnXPuk3effjgcDpSVlWHVqlXweDwYGBhAJBLB4OCg2dUAADabDT6fD2VlZfB6vRgaGkIkEkE0Gs3ZnOFwGKFQCFu2bEm/U76cI2tra+nhw4c0MjIyo0cymaQ3b96Yeo23ceNGam9vp2g0OqPf5OQk9fb20tGjR3M2/82bNzOeI00X6Xa76dGjRxlLTuXBgweGCrTb7XT16tUZbx6k48mTJ2S1Wtl71NfX56/I0tLSBb3jf/78eUMkFhUV0cuXL+fdr62tjb1LeXl5xjlNE1lYWEg9PT3zXiQiosHBQUOuSTs7OxfUb2JiggKBAGsXi8Uy62knhWki79y5s6BFSnH8+PGcSjxz5kxW/W7dusXeKRQKpZ3PlMuP9evXo6mpKauM2tpapjYzcbvduHTpUlYZdXV1TG3+59u3b2m3mSKytbUVNpstq4yamhqmNjNpaWnJ+q63QCCANWvWMDX6TX9/f9pthov0+Xw4fPhw1jnV1dUMbWbn9OnTLDkbNmxgyUnx/fv3tNsMF3nw4EFYLJasc1wuF4qLixkaTWfTpk0oLy9nyaqsrGTJSTE+Pp52m+Ei6+vr2bJKS0vZslIcOnSILWv16tVsWQAQi8XSbjNUpNVqxa5du9jyciFy9+7dbFkul4stC/h9t306DBXp9/vhdDrZ8pYvX86WlSIYDLJlLVmyhC0LACjD188bKpL75O9wOFjzfD4fvF4vW15BQQFb1lwYKrKqqoo1j3uhOF+NQOY/hdwYKrKkpIQ1j/sVyX3OFSuS++TPLZLlYZopjI2NseZlwlCRbrebNY/rKasU3Nel8XicNS8Ti1okN9yvSCMxVKTdnnd3lkzDyP8yudGbr4SgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWgIoWQ8fGokydPsv48Hvej5wcOHGB9ynj79u1sWcDvr1m7fPkyW16mL97N+LODyuJB/7QKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUKQUUK4T96hT9reYw+rgAAAABJRU5ErkJggg==`, - "touch-icon-iphone.png": `iVBORw0KGgoAAAANSUhEUgAAADkAAAA5CAYAAACMGIOFAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAFewAABXsBE7im1wAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAMwSURBVGiB7Zk7SOtgFMf/bYLFZ7VoW6EEikPAF1iwONS6iOBWF50EdRJE7aCLi5urODoUBBcndxfFV3EVB3EoIlIE3XxBtMm5k96bW9oe09zXd78/fEPSc345vyS0TeshIoLg8f7pAX5HpKQokZKiREqKEikpSqSkKFF/BdSyLNzd3cEwDGiaBlWt7jDv7++4ubmBqqoIBoOoq6v7Ur/t6JeXl9jb26vYpOs6RkdHbfvy+TwymQx2dnaQy+Xw9vYGAFAUBZqmIZlMYmFhAbFYjDXY0dERNjc3kc1mcXt7C9M0P18LhUIYGRlBKpXC2NgYPB5PeRj9kK2tLQJQcU1MTHz2GIZBi4uLpCgKqzeZTNL19TWVyvHxMXV1dbFYAKivr4+y2WxJHhFRVZK5XI5isRh7oI8VDAbp7OysaJj19XVSVfXLPJ/PR7u7u+5LPjw8UDQa/fJAH6u2tpbOz88/j51Opx2zAJCiKHRycuKeZCqVokQiUdVQACgej5NpmpTJZKpmAaCOjg56eXlxR9Lr9boyFACanp6mmpoa13gbGxvuSP7Nq6enp0jSlc9JXdeRSCTQ39+PfD6P/f19nJ6eOuZFo1EMDg5iYGAA9/f3ODg4wOHhIav34uICV1dX0HX9+85qr+Tq6mrRmbMsi+bn5x1dibm5ObIsq4i5vLzMZmxvb7t3u66trRUN85HX11fy+Xxf4i0tLZXkmaZJkUiExUmn0+5IappGhUKh5FBERMPDw2ye3++n5+fnsryVlRUWa3x83Nbn+Av67OwsFEUpWxOPx9m8yclJ1NfXl63p7u5msR4fH23bjiVnZmYq1rS0tLB5U1NTFWtaW1tZrKenJ9u2I0m/349QKMSq48b2blgigUCAxSoUCrZtR5Lt7e2sOq5kc3MzGhoaKtY5fWRzJBkOh1l1jY2NrLpIJMKqMwyDVfdzHEm2tbXx4F4ennvSyOF/U44kucNzU+ldutr8F7/xSElRIiVFiZQUJVJSlEhJUSIlRYntKTQcDmNoaKhiU2dnJwseCARYvN7eXhavqanJ0XwecvqQ9g/lv7hdpaQokZKiREqKEikpSqSkKPkGbzop0HEztyoAAAAASUVORK5CYII=`, -} - -var BinariesChecksums = map[string]string{ - "favicon.ico": "abb2a2675b0696252719f51dbfc1efc50affb2f17ec82166e27f9529eec896fb", - "favicon.png": "86465aec3a1bcd2c9b19ce8b5ccbc41b1209e0246100371ca2ac2a0222d0b67c", - "touch-icon-ipad-retina.png": "7c2495ef638c56c2479cacce66b676ead381e6606415523bf8c9a814ff48cc0d", - "touch-icon-ipad.png": "0044edc3bd2f7a240abbe6ddb5a54bb4c5eb8d45692f79b1683a803841cfdf5d", - "touch-icon-iphone-retina.png": "cfdea4cef7d5c04cc5375ffd919d9a94da739cd0730bc3fb807bb187d8290255", - "touch-icon-iphone.png": "9a18a70f4389e83d5fbee0c6f6286c3fa2f6db4cdd9b53fc3f2232fde938fd5b", -} diff --git a/server/static/bin/favicon.ico b/server/static/bin/favicon.ico Binary files differdeleted file mode 100644 index 77af6f9..0000000 --- a/server/static/bin/favicon.ico +++ /dev/null diff --git a/server/static/bin/favicon.png b/server/static/bin/favicon.png Binary files differdeleted file mode 100644 index 7f96f55..0000000 --- a/server/static/bin/favicon.png +++ /dev/null diff --git a/server/static/bin/touch-icon-ipad-retina.png b/server/static/bin/touch-icon-ipad-retina.png Binary files differdeleted file mode 100644 index 92da239..0000000 --- a/server/static/bin/touch-icon-ipad-retina.png +++ /dev/null diff --git a/server/static/bin/touch-icon-ipad.png b/server/static/bin/touch-icon-ipad.png Binary files differdeleted file mode 100644 index 4de6120..0000000 --- a/server/static/bin/touch-icon-ipad.png +++ /dev/null diff --git a/server/static/bin/touch-icon-iphone-retina.png b/server/static/bin/touch-icon-iphone-retina.png Binary files differdeleted file mode 100644 index 71de36e..0000000 --- a/server/static/bin/touch-icon-iphone-retina.png +++ /dev/null diff --git a/server/static/bin/touch-icon-iphone.png b/server/static/bin/touch-icon-iphone.png Binary files differdeleted file mode 100644 index 1a46c5e..0000000 --- a/server/static/bin/touch-icon-iphone.png +++ /dev/null diff --git a/server/static/css.go b/server/static/css.go deleted file mode 100644 index e7f678f..0000000 --- a/server/static/css.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by go generate; DO NOT EDIT. -// 2017-12-28 18:55:07.393604426 -0800 PST m=+0.020325012 - -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;color:#9b9b9b}#modal-left{background:#333;color:#efefef;box-shadow:0 0 10px rgba(82,168,236,.6)}.keyboard-shortcuts li{color:#9b9b9b}.unread-counter-wrapper{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}article.feed-parsing-error{background-color:#343434}.parsing-error{color:#eee}.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:5px;padding-right:5px}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}.page-header li{list-style-type:circle;line-height:1.8em}.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;border:0;font-size:1em}.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}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:20px;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)}input[type=checkbox]{margin-bottom:15px}::-moz-placeholder,::-ms-input-placeholder,::-webkit-input-placeholder{color:#ddd;padding-top:2px}.form-help{font-size:.9em;color:brown;margin-bottom:15px}.form-section{border-left:2px dotted #ddd;padding-left:20px;margin-left:10px}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:#fcfcfc;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}#modal-left{position:fixed;top:0;left:0;bottom:0;width:350px;overflow:auto;background:#f0f0f0;box-shadow:2px 0 5px 0 #ccc;padding:5px;padding-top:30px}#modal-left h3{font-weight:400}.btn-close-modal{position:absolute;top:0;right:0;font-size:1.7em;color:#ccc;padding:0 .2em;margin:10px;text-decoration:none}.btn-close-modal:hover{color:#999}.keyboard-shortcuts li{margin-left:25px;list-style-type:square;color:#333;font-size:.95em;line-height:1.45em}.keyboard-shortcuts p{line-height:1.9em}.login-form{margin:50px auto 0;max-width:280px}.unread-counter-wrapper{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}article.feed-parsing-error{background-color:#fcf8e3;border-color:#aaa}.parsing-error{font-size:.85em;margin-top:2px;color:#333}.parsing-error-count{cursor:pointer}.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-actions{margin-bottom:20px}.entry-actions li{display:inline}.entry-actions li:not(:last-child):after{content:"|"}.entry-meta{font-size:.95em;margin:0 0 20px;color:#666;overflow-wrap:break-word}.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.2em;font-weight:300;font-family:Georgia,times new roman,Times,serif;color:#555;line-height:1.4em;overflow-wrap:break-word}.entry-content h1,h2,h3,h4,h5,h6{margin-top:15px;margin-bottom:10px}.entry-content iframe,.entry-content video,.entry-content img{max-width:100%}.entry-content figure{margin-top:15px;margin-bottom:15px}.entry-content figure img{border:1px solid #000}.entry-content figcaption{font-size:.75em;text-transform:uppercase;color:#777}.entry-content p{margin-top:10px;margin-bottom:15px}.entry-content a{overflow-wrap:break-word}.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;overflow-wrap:initial}.entry-content table{table-layout:fixed;max-width:100%}.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;overflow-wrap:break-word}.enclosure-video video,.enclosure-image img{max-width:100%}.confirm{font-weight:500;color:#ed2d04}.confirm a{color:#ed2d04}.loading{font-style:italic}.bookmarklet{border:1px dashed #ccc;border-radius:5px;padding:15px;margin:15px;text-align:center}.bookmarklet a{font-weight:600;text-decoration:none;font-size:1.2em}`, -} - -var StylesheetsChecksums = map[string]string{ - "black": "832d07879dbb5e91a55055d66797f87003adbb09e5e54234a1ff4d722a33168e", - "common": "c978d4bfc06bdeb345d55ce7e14ccdf6bd7608d607df2d2eeec37ea74eaeaa67", -} diff --git a/server/static/css/black.css b/server/static/css/black.css deleted file mode 100644 index f97ed2f..0000000 --- a/server/static/css/black.css +++ /dev/null @@ -1,219 +0,0 @@ -/* 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; - color: #9b9b9b; -} - -/* Modals */ -#modal-left { - background: #333; - color: #efefef; - box-shadow: 0 0 10px rgba(82, 168, 236, 0.6); -} - -/* Keyboard Shortcuts */ -.keyboard-shortcuts li { - color: #9b9b9b; -} - -/* Counter */ -.unread-counter-wrapper { - 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; -} - -/* Feeds list */ -article.feed-parsing-error { - background-color: #343434; -} - -.parsing-error { - color: #eee; -} - -/* 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 deleted file mode 100644 index accefb0..0000000 --- a/server/static/css/common.css +++ /dev/null @@ -1,778 +0,0 @@ -/* Layout */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - text-rendering: optimizeLegibility; -} - -main { - padding-left: 5px; - padding-right: 5px; -} - -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; -} - -.page-header li { - list-style-type: circle; - line-height: 1.8em; -} - -.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; - border: none; - font-size: 1.0em; - } - - .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; -} - -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: 20px; - 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); -} - -input[type="checkbox"] { - margin-bottom: 15px; -} - -::-moz-placeholder, -::-ms-input-placeholder, -::-webkit-input-placeholder { - color: #ddd; - padding-top: 2px; -} - -.form-help { - font-size: 0.9em; - color: brown; - margin-bottom: 15px; -} - -.form-section { - border-left: 2px dotted #ddd; - padding-left: 20px; - margin-left: 10px; -} - -/* 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: #fcfcfc; - 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; -} - -/* Modals */ -#modal-left { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: 350px; - overflow: auto; - background: #f0f0f0; - box-shadow: 2px 0 5px 0 #ccc; - padding: 5px; - padding-top: 30px; -} - -#modal-left h3 { - font-weight: 400; -} - -.btn-close-modal { - position: absolute; - top: 0; - right: 0; - font-size: 1.7em; - color: #ccc; - padding:0 .2em; - margin: 10px; - text-decoration: none; -} - -.btn-close-modal:hover { - color: #999; -} - -/* Keyboard Shortcuts */ -.keyboard-shortcuts li { - margin-left: 25px; - list-style-type: square; - color: #333; - font-size: 0.95em; - line-height: 1.45em; -} - -.keyboard-shortcuts p { - line-height: 1.9em; -} - -/* Login form */ -.login-form { - margin: 50px auto 0; - max-width: 280px; -} - -/* Counter */ -.unread-counter-wrapper { - 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; -} - -/* Feeds list */ -article.feed-parsing-error { - background-color: #fcf8e3; - border-color: #aaa; -} - -.parsing-error { - font-size: 0.85em; - margin-top: 2px; - color: #333; -} - -.parsing-error-count { - cursor: pointer; -} - -/* 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-actions { - margin-bottom: 20px; -} - -.entry-actions li { - display: inline; -} - -.entry-actions li:not(:last-child):after { - content: "|"; -} - -.entry-meta { - font-size: 0.95em; - margin: 0 0 20px; - color: #666; - overflow-wrap: break-word; -} - -.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.2em; - font-weight: 300; - font-family: Georgia, 'Times New Roman', Times, serif; - color: #555; - line-height: 1.4em; - overflow-wrap: break-word; -} - -.entry-content h1, h2, h3, h4, h5, h6 { - margin-top: 15px; - margin-bottom: 10px; -} - -.entry-content iframe, -.entry-content video, -.entry-content img { - max-width: 100%; -} - -.entry-content figure { - margin-top: 15px; - margin-bottom: 15px; -} - -.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: 10px; - margin-bottom: 15px; -} - -.entry-content a { - overflow-wrap: break-word; -} - -.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; - overflow-wrap: initial; -} - -.entry-content table { - table-layout: fixed; - max-width: 100%; -} - -.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; - overflow-wrap: break-word; -} - -.enclosure-video video, -.enclosure-image img { - max-width: 100%; -} - -/* Confirmation */ -.confirm { - font-weight: 500; - color: #ed2d04; -} - -.confirm a { - color: #ed2d04; -} - -.loading { - font-style: italic; -} - -/* Bookmarlet */ -.bookmarklet { - border: 1px dashed #ccc; - border-radius: 5px; - padding: 15px; - margin: 15px; - text-align: center; -} - -.bookmarklet a { - font-weight: 600; - text-decoration: none; - font-size: 1.2em; -} diff --git a/server/static/js.go b/server/static/js.go deleted file mode 100644 index ce9d070..0000000 --- a/server/static/js.go +++ /dev/null @@ -1,92 +0,0 @@ -// Code generated by go generate; DO NOT EDIT. -// 2017-12-28 18:55:07.395760341 -0800 PST m=+0.022480927 - -package static - -var Javascript = map[string]string{ - "app": `(function(){'use strict';class DomHelper{static isVisible(element){return element.offsetParent!==null;} -static openNewTab(url){let win=window.open(url,"_blank");win.focus();} -static scrollPageTo(element){let windowScrollPosition=window.pageYOffset;let windowHeight=document.documentElement.clientHeight;let viewportPosition=windowScrollPosition+windowHeight;let itemBottomPosition=element.offsetTop+element.offsetHeight;if(viewportPosition-itemBottomPosition<0||viewportPosition-element.offsetTop>windowHeight){window.scrollTo(0,element.offsetTop-10);}} -static getVisibleElements(selector){let elements=document.querySelectorAll(selector);let result=[];for(let i=0;i<elements.length;i++){if(this.isVisible(elements[i])){result.push(elements[i]);}} -return result;}} -class TouchHandler{constructor(){this.reset();} -reset(){this.touch={start:{x:-1,y:-1},move:{x:-1,y:-1},element:null};} -calculateDistance(){if(this.touch.start.x>=-1&&this.touch.move.x>=-1){let horizontalDistance=Math.abs(this.touch.move.x-this.touch.start.x);let verticalDistance=Math.abs(this.touch.move.y-this.touch.start.y);if(horizontalDistance>30&&verticalDistance<70){return this.touch.move.x-this.touch.start.x;}} -return 0;} -findElement(element){if(element.classList.contains("touch-item")){return element;} -for(;element&&element!==document;element=element.parentNode){if(element.classList.contains("touch-item")){return element;}} -return null;} -onTouchStart(event){if(event.touches===undefined||event.touches.length!==1){return;} -this.reset();this.touch.start.x=event.touches[0].clientX;this.touch.start.y=event.touches[0].clientY;this.touch.element=this.findElement(event.touches[0].target);} -onTouchMove(event){if(event.touches===undefined||event.touches.length!==1||this.element===null){return;} -this.touch.move.x=event.touches[0].clientX;this.touch.move.y=event.touches[0].clientY;let distance=this.calculateDistance();let absDistance=Math.abs(distance);if(absDistance>0){let opacity=1-(absDistance>75?0.9:absDistance/75*0.9);let tx=distance>75?75:(distance<-75?-75:distance);this.touch.element.style.opacity=opacity;this.touch.element.style.transform="translateX("+tx+"px)";}} -onTouchEnd(event){if(event.touches===undefined){return;} -if(this.touch.element!==null){let distance=Math.abs(this.calculateDistance());if(distance>75){EntryHandler.toggleEntryStatus(this.touch.element);this.touch.element.style.opacity=1;this.touch.element.style.transform="none";}} -this.reset();} -listen(){let elements=document.querySelectorAll(".touch-item");elements.forEach((element)=>{element.addEventListener("touchstart",(e)=>this.onTouchStart(e),false);element.addEventListener("touchmove",(e)=>this.onTouchMove(e),false);element.addEventListener("touchend",(e)=>this.onTouchEnd(e),false);element.addEventListener("touchcancel",()=>this.reset(),false);});}} -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((element)=>{element.onsubmit=()=>{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 RequestBuilder{constructor(url){this.callback=null;this.url=url;this.options={method:"POST",cache:"no-cache",credentials:"include",body:null,headers:new Headers({"Content-Type":"application/json","X-Csrf-Token":this.getCsrfToken()})};} -withBody(body){this.options.body=JSON.stringify(body);return this;} -withCallback(callback){this.callback=callback;return this;} -getCsrfToken(){let element=document.querySelector("meta[name=X-CSRF-Token]");if(element!==null){return element.getAttribute("value");} -return "";} -execute(){fetch(new Request(this.url,this.options)).then((response)=>{if(this.callback){this.callback(response);}});}} -class UnreadCounterHandler{static decrement(n){this.updateValue((current)=>{return current-n;});} -static increment(n){this.updateValue((current)=>{return current+n;});} -static updateValue(callback){let counterElements=document.querySelectorAll("span.unread-counter");counterElements.forEach((element)=>{let oldValue=parseInt(element.textContent,10);element.innerHTML=callback(oldValue);});}} -class EntryHandler{static updateEntriesStatus(entryIDs,status,callback){let url=document.body.dataset.entriesStatusUrl;let request=new RequestBuilder(url);request.withBody({entry_ids:entryIDs,status:status});request.withCallback(callback);request.execute();} -static toggleEntryStatus(element){let entryID=parseInt(element.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(element.classList.contains("item-status-"+currentStatus)){element.classList.remove("item-status-"+currentStatus);element.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);if(newStatus==="read"){UnreadCounterHandler.decrement(1);}else{UnreadCounterHandler.increment(1);} -break;}}} -static toggleBookmark(element){element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.bookmarkUrl);request.withCallback(()=>{if(element.dataset.value==="star"){element.innerHTML=element.dataset.labelStar;element.dataset.value="unstar";}else{element.innerHTML=element.dataset.labelUnstar;element.dataset.value="star";}});request.execute();} -static markEntryAsRead(element){if(element.classList.contains("item-status-unread")){element.classList.remove("item-status-unread");element.classList.add("item-status-read");let entryID=parseInt(element.dataset.id,10);this.updateEntriesStatus([entryID],"read");}} -static saveEntry(element){if(element.dataset.completed){return;} -element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.saveUrl);request.withCallback(()=>{element.innerHTML=element.dataset.labelDone;element.dataset.completed=true;});request.execute();} -static fetchOriginalContent(element){if(element.dataset.completed){return;} -element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.fetchContentUrl);request.withCallback((response)=>{element.innerHTML=element.dataset.labelDone;element.dataset.completed=true;response.json().then((data)=>{if(data.hasOwnProperty("content")){document.querySelector(".entry-content").innerHTML=data.content;}});});request.execute();}} -class ConfirmHandler{remove(url){let request=new RequestBuilder(url);request.withCallback(()=>window.location.reload());request.execute();} -handle(event){let questionElement=document.createElement("span");let linkElement=event.target;let containerElement=linkElement.parentNode;linkElement.style.display="none";let yesElement=document.createElement("a");yesElement.href="#";yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));yesElement.onclick=(event)=>{event.preventDefault();let loadingElement=document.createElement("span");loadingElement.className="loading";loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));questionElement.remove();containerElement.appendChild(loadingElement);this.remove(linkElement.dataset.url);};let noElement=document.createElement("a");noElement.href="#";noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));noElement.onclick=(event)=>{event.preventDefault();linkElement.style.display="inline";questionElement.remove();};questionElement.className="confirm";questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion+" "));questionElement.appendChild(yesElement);questionElement.appendChild(document.createTextNode(", "));questionElement.appendChild(noElement);containerElement.appendChild(questionElement);}} -class MenuHandler{clickMenuListItem(event){let element=event.target;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(DomHelper.isVisible(menu)){menu.style.display="none";}else{menu.style.display="block";}}} -class ModalHandler{static exists(){return document.getElementById("modal-container")!==null;} -static open(fragment){if(ModalHandler.exists()){return;} -let container=document.createElement("div");container.id="modal-container";container.appendChild(document.importNode(fragment,true));document.body.appendChild(container);let closeButton=document.querySelector("a.btn-close-modal");if(closeButton!==null){closeButton.onclick=(event)=>{event.preventDefault();ModalHandler.close();};}} -static close(){let container=document.getElementById("modal-container");if(container!==null){container.parentNode.removeChild(container);}}} -class NavHandler{showKeyboardShortcuts(){let template=document.getElementById("keyboard-shortcuts");if(template!==null){ModalHandler.open(template.content);}} -markPageAsRead(){let items=DomHelper.getVisibleElements(".items .item");let entryIDs=[];items.forEach((element)=>{element.classList.add("item-status-read");entryIDs.push(parseInt(element.dataset.id,10));});if(entryIDs.length>0){EntryHandler.updateEntriesStatus(entryIDs,"read",()=>{this.goToPage("next",true);});}} -saveEntry(){if(this.isListView()){let currentItem=document.querySelector(".current-item");if(currentItem!==null){let saveLink=currentItem.querySelector("a[data-save-entry]");if(saveLink){EntryHandler.saveEntry(saveLink);}}}else{let saveLink=document.querySelector("a[data-save-entry]");if(saveLink){EntryHandler.saveEntry(saveLink);}}} -fetchOriginalContent(){if(!this.isListView()){let link=document.querySelector("a[data-fetch-content-entry]");if(link){EntryHandler.fetchOriginalContent(link);}}} -toggleEntryStatus(){let currentItem=document.querySelector(".current-item");if(currentItem!==null){this.goToNextListItem();EntryHandler.toggleEntryStatus(currentItem);}} -toggleBookmark(){if(!this.isListView()){this.toggleBookmarkLink(document.querySelector(".entry"));return;} -let currentItem=document.querySelector(".current-item");if(currentItem!==null){this.toggleBookmarkLink(currentItem);}} -toggleBookmarkLink(parent){let bookmarkLink=parent.querySelector("a[data-toggle-bookmark]");if(bookmarkLink){EntryHandler.toggleBookmark(bookmarkLink);}} -openOriginalLink(){let entryLink=document.querySelector(".entry h1 a");if(entryLink!==null){DomHelper.openNewTab(entryLink.getAttribute("href"));return;} -let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));let currentItem=document.querySelector(".current-item");this.goToNextListItem();EntryHandler.markEntryAsRead(currentItem);}} -openSelectedItem(){let currentItemLink=document.querySelector(".current-item .item-title a");if(currentItemLink!==null){window.location.href=currentItemLink.getAttribute("href");}} -goToPage(page,fallbackSelf){let element=document.querySelector("a[data-page="+page+"]");if(element){document.location.href=element.href;}else if(fallbackSelf){window.location.reload();}} -goToPrevious(){if(this.isListView()){this.goToPreviousListItem();}else{this.goToPage("previous");}} -goToNext(){if(this.isListView()){this.goToNextListItem();}else{this.goToPage("next");}} -goToPreviousListItem(){let items=DomHelper.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");DomHelper.scrollPageTo(items[i-1]);} -break;}}} -goToNextListItem(){let currentItem=document.querySelector(".current-item");let items=DomHelper.getVisibleElements(".items .item");if(items.length===0){return;} -if(currentItem===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");DomHelper.scrollPageTo(items[i+1]);} -break;}}} -isListView(){return document.querySelector(".items")!==null;}} -document.addEventListener("DOMContentLoaded",function(){FormHandler.handleSubmitButtons();let touchHandler=new TouchHandler();touchHandler.listen();let navHandler=new NavHandler();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>navHandler.goToPage("unread"));keyboardHandler.on("g b",()=>navHandler.goToPage("starred"));keyboardHandler.on("g h",()=>navHandler.goToPage("history"));keyboardHandler.on("g f",()=>navHandler.goToPage("feeds"));keyboardHandler.on("g c",()=>navHandler.goToPage("categories"));keyboardHandler.on("g s",()=>navHandler.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>navHandler.goToPrevious());keyboardHandler.on("ArrowRight",()=>navHandler.goToNext());keyboardHandler.on("j",()=>navHandler.goToPrevious());keyboardHandler.on("p",()=>navHandler.goToPrevious());keyboardHandler.on("k",()=>navHandler.goToNext());keyboardHandler.on("n",()=>navHandler.goToNext());keyboardHandler.on("h",()=>navHandler.goToPage("previous"));keyboardHandler.on("l",()=>navHandler.goToPage("next"));keyboardHandler.on("o",()=>navHandler.openSelectedItem());keyboardHandler.on("v",()=>navHandler.openOriginalLink());keyboardHandler.on("m",()=>navHandler.toggleEntryStatus());keyboardHandler.on("A",()=>navHandler.markPageAsRead());keyboardHandler.on("s",()=>navHandler.saveEntry());keyboardHandler.on("d",()=>navHandler.fetchOriginalContent());keyboardHandler.on("f",()=>navHandler.toggleBookmark());keyboardHandler.on("?",()=>navHandler.showKeyboardShortcuts());keyboardHandler.on("Escape",()=>ModalHandler.close());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-save-entry]",(event)=>{event.preventDefault();EntryHandler.saveEntry(event.target);});mouseHandler.onClick("a[data-toggle-bookmark]",(event)=>{event.preventDefault();EntryHandler.toggleBookmark(event.target);});mouseHandler.onClick("a[data-fetch-content-entry]",(event)=>{event.preventDefault();EntryHandler.fetchOriginalContent(event.target);});mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>navHandler.markPageAsRead());mouseHandler.onClick("a[data-confirm]",(event)=>{(new ConfirmHandler()).handle(event);});if(document.documentElement.clientWidth<600){let menuHandler=new MenuHandler();mouseHandler.onClick(".logo",()=>menuHandler.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>menuHandler.clickMenuListItem(event));}});})();`, -} - -var JavascriptChecksums = map[string]string{ - "app": "36dfcfb33ddc3f75f701fc4353873e2ce6da813dbfdd3b37100a4475a32b0545", -} diff --git a/server/static/js/app.js b/server/static/js/app.js deleted file mode 100644 index 4ec82e4..0000000 --- a/server/static/js/app.js +++ /dev/null @@ -1,748 +0,0 @@ -/*jshint esversion: 6 */ -(function() { -'use strict'; - -class DomHelper { - static isVisible(element) { - return element.offsetParent !== null; - } - - static openNewTab(url) { - let win = window.open(url, "_blank"); - win.focus(); - } - - static scrollPageTo(element) { - let windowScrollPosition = window.pageYOffset; - let windowHeight = document.documentElement.clientHeight; - let viewportPosition = windowScrollPosition + windowHeight; - let itemBottomPosition = element.offsetTop + element.offsetHeight; - - if (viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) { - window.scrollTo(0, element.offsetTop - 10); - } - } - - static getVisibleElements(selector) { - let elements = document.querySelectorAll(selector); - let result = []; - - for (let i = 0; i < elements.length; i++) { - if (this.isVisible(elements[i])) { - result.push(elements[i]); - } - } - - return result; - } -} - -class TouchHandler { - constructor() { - this.reset(); - } - - reset() { - this.touch = { - start: {x: -1, y: -1}, - move: {x: -1, y: -1}, - element: null - }; - } - - calculateDistance() { - if (this.touch.start.x >= -1 && this.touch.move.x >= -1) { - let horizontalDistance = Math.abs(this.touch.move.x - this.touch.start.x); - let verticalDistance = Math.abs(this.touch.move.y - this.touch.start.y); - - if (horizontalDistance > 30 && verticalDistance < 70) { - return this.touch.move.x - this.touch.start.x; - } - } - - return 0; - } - - findElement(element) { - if (element.classList.contains("touch-item")) { - return element; - } - - for (; element && element !== document; element = element.parentNode) { - if (element.classList.contains("touch-item")) { - return element; - } - } - - return null; - } - - onTouchStart(event) { - if (event.touches === undefined || event.touches.length !== 1) { - return; - } - - this.reset(); - this.touch.start.x = event.touches[0].clientX; - this.touch.start.y = event.touches[0].clientY; - this.touch.element = this.findElement(event.touches[0].target); - } - - onTouchMove(event) { - if (event.touches === undefined || event.touches.length !== 1 || this.element === null) { - return; - } - - this.touch.move.x = event.touches[0].clientX; - this.touch.move.y = event.touches[0].clientY; - - let distance = this.calculateDistance(); - let absDistance = Math.abs(distance); - - if (absDistance > 0) { - let opacity = 1 - (absDistance > 75 ? 0.9 : absDistance / 75 * 0.9); - let tx = distance > 75 ? 75 : (distance < -75 ? -75 : distance); - - this.touch.element.style.opacity = opacity; - this.touch.element.style.transform = "translateX(" + tx + "px)"; - } - } - - onTouchEnd(event) { - if (event.touches === undefined) { - return; - } - - if (this.touch.element !== null) { - let distance = Math.abs(this.calculateDistance()); - - if (distance > 75) { - EntryHandler.toggleEntryStatus(this.touch.element); - this.touch.element.style.opacity = 1; - this.touch.element.style.transform = "none"; - } - } - - this.reset(); - } - - listen() { - let elements = document.querySelectorAll(".touch-item"); - - elements.forEach((element) => { - element.addEventListener("touchstart", (e) => this.onTouchStart(e), false); - element.addEventListener("touchmove", (e) => this.onTouchMove(e), false); - element.addEventListener("touchend", (e) => this.onTouchEnd(e), false); - element.addEventListener("touchcancel", () => this.reset(), false); - }); - } -} - -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((element) => { - element.onsubmit = () => { - 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 RequestBuilder { - constructor(url) { - this.callback = null; - this.url = url; - this.options = { - method: "POST", - cache: "no-cache", - credentials: "include", - body: null, - headers: new Headers({ - "Content-Type": "application/json", - "X-Csrf-Token": this.getCsrfToken() - }) - }; - } - - withBody(body) { - this.options.body = JSON.stringify(body); - return this; - } - - withCallback(callback) { - this.callback = callback; - return this; - } - - getCsrfToken() { - let element = document.querySelector("meta[name=X-CSRF-Token]"); - if (element !== null) { - return element.getAttribute("value"); - } - - return ""; - } - - execute() { - fetch(new Request(this.url, this.options)).then((response) => { - if (this.callback) { - this.callback(response); - } - }); - } -} - -class UnreadCounterHandler { - static decrement(n) { - this.updateValue((current) => { - return current - n; - }); - } - - static increment(n) { - this.updateValue((current) => { - return current + n; - }); - } - - static updateValue(callback) { - let counterElements = document.querySelectorAll("span.unread-counter"); - counterElements.forEach((element) => { - let oldValue = parseInt(element.textContent, 10); - element.innerHTML = callback(oldValue); - }); - } -} - -class EntryHandler { - static updateEntriesStatus(entryIDs, status, callback) { - let url = document.body.dataset.entriesStatusUrl; - let request = new RequestBuilder(url); - request.withBody({entry_ids: entryIDs, status: status}); - request.withCallback(callback); - request.execute(); - } - - static toggleEntryStatus(element) { - let entryID = parseInt(element.dataset.id, 10); - let statuses = {read: "unread", unread: "read"}; - - for (let currentStatus in statuses) { - let newStatus = statuses[currentStatus]; - - if (element.classList.contains("item-status-" + currentStatus)) { - element.classList.remove("item-status-" + currentStatus); - element.classList.add("item-status-" + newStatus); - - this.updateEntriesStatus([entryID], newStatus); - - if (newStatus === "read") { - UnreadCounterHandler.decrement(1); - } else { - UnreadCounterHandler.increment(1); - } - - break; - } - } - } - - static toggleBookmark(element) { - element.innerHTML = element.dataset.labelLoading; - - let request = new RequestBuilder(element.dataset.bookmarkUrl); - request.withCallback(() => { - if (element.dataset.value === "star") { - element.innerHTML = element.dataset.labelStar; - element.dataset.value = "unstar"; - } else { - element.innerHTML = element.dataset.labelUnstar; - element.dataset.value = "star"; - } - }); - request.execute(); - } - - static markEntryAsRead(element) { - if (element.classList.contains("item-status-unread")) { - element.classList.remove("item-status-unread"); - element.classList.add("item-status-read"); - - let entryID = parseInt(element.dataset.id, 10); - this.updateEntriesStatus([entryID], "read"); - } - } - - static saveEntry(element) { - if (element.dataset.completed) { - return; - } - - element.innerHTML = element.dataset.labelLoading; - - let request = new RequestBuilder(element.dataset.saveUrl); - request.withCallback(() => { - element.innerHTML = element.dataset.labelDone; - element.dataset.completed = true; - }); - request.execute(); - } - - static fetchOriginalContent(element) { - if (element.dataset.completed) { - return; - } - - element.innerHTML = element.dataset.labelLoading; - - let request = new RequestBuilder(element.dataset.fetchContentUrl); - request.withCallback((response) => { - element.innerHTML = element.dataset.labelDone; - element.dataset.completed = true; - - response.json().then((data) => { - if (data.hasOwnProperty("content")) { - document.querySelector(".entry-content").innerHTML = data.content; - } - }); - }); - request.execute(); - } -} - -class ConfirmHandler { - remove(url) { - let request = new RequestBuilder(url); - request.withCallback(() => window.location.reload()); - request.execute(); - } - - handle(event) { - let questionElement = document.createElement("span"); - let linkElement = event.target; - let containerElement = linkElement.parentNode; - linkElement.style.display = "none"; - - let yesElement = document.createElement("a"); - yesElement.href = "#"; - yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes)); - yesElement.onclick = (event) => { - event.preventDefault(); - - let loadingElement = document.createElement("span"); - loadingElement.className = "loading"; - loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading)); - - questionElement.remove(); - containerElement.appendChild(loadingElement); - - this.remove(linkElement.dataset.url); - }; - - let noElement = document.createElement("a"); - noElement.href = "#"; - noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo)); - noElement.onclick = (event) => { - event.preventDefault(); - linkElement.style.display = "inline"; - questionElement.remove(); - }; - - questionElement.className = "confirm"; - questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " ")); - questionElement.appendChild(yesElement); - questionElement.appendChild(document.createTextNode(", ")); - questionElement.appendChild(noElement); - - containerElement.appendChild(questionElement); - } -} - -class MenuHandler { - clickMenuListItem(event) { - let element = event.target; - - 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 (DomHelper.isVisible(menu)) { - menu.style.display = "none"; - } else { - menu.style.display = "block"; - } - } -} - -class ModalHandler { - static exists() { - return document.getElementById("modal-container") !== null; - } - - static open(fragment) { - if (ModalHandler.exists()) { - return; - } - - let container = document.createElement("div"); - container.id = "modal-container"; - container.appendChild(document.importNode(fragment, true)); - document.body.appendChild(container); - - let closeButton = document.querySelector("a.btn-close-modal"); - if (closeButton !== null) { - closeButton.onclick = (event) => { - event.preventDefault(); - ModalHandler.close(); - }; - } - } - - static close() { - let container = document.getElementById("modal-container"); - if (container !== null) { - container.parentNode.removeChild(container); - } - } -} - -class NavHandler { - showKeyboardShortcuts() { - let template = document.getElementById("keyboard-shortcuts"); - if (template !== null) { - ModalHandler.open(template.content); - } - } - - markPageAsRead() { - let items = DomHelper.getVisibleElements(".items .item"); - let entryIDs = []; - - items.forEach((element) => { - element.classList.add("item-status-read"); - entryIDs.push(parseInt(element.dataset.id, 10)); - }); - - if (entryIDs.length > 0) { - EntryHandler.updateEntriesStatus(entryIDs, "read", () => { - // This callback make sure the Ajax request reach the server before we reload the page. - this.goToPage("next", true); - }); - } - } - - saveEntry() { - if (this.isListView()) { - let currentItem = document.querySelector(".current-item"); - if (currentItem !== null) { - let saveLink = currentItem.querySelector("a[data-save-entry]"); - if (saveLink) { - EntryHandler.saveEntry(saveLink); - } - } - } else { - let saveLink = document.querySelector("a[data-save-entry]"); - if (saveLink) { - EntryHandler.saveEntry(saveLink); - } - } - } - - fetchOriginalContent() { - if (! this.isListView()){ - let link = document.querySelector("a[data-fetch-content-entry]"); - if (link) { - EntryHandler.fetchOriginalContent(link); - } - } - } - - toggleEntryStatus() { - let currentItem = document.querySelector(".current-item"); - if (currentItem !== null) { - // The order is important here, - // On the unread page, the read item will be hidden. - this.goToNextListItem(); - EntryHandler.toggleEntryStatus(currentItem); - } - } - - toggleBookmark() { - if (! this.isListView()) { - this.toggleBookmarkLink(document.querySelector(".entry")); - return; - } - - let currentItem = document.querySelector(".current-item"); - if (currentItem !== null) { - this.toggleBookmarkLink(currentItem); - } - } - - toggleBookmarkLink(parent) { - let bookmarkLink = parent.querySelector("a[data-toggle-bookmark]"); - if (bookmarkLink) { - EntryHandler.toggleBookmark(bookmarkLink); - } - } - - openOriginalLink() { - let entryLink = document.querySelector(".entry h1 a"); - if (entryLink !== null) { - DomHelper.openNewTab(entryLink.getAttribute("href")); - return; - } - - let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]"); - if (currentItemOriginalLink !== null) { - DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href")); - - // Move to the next item and if we are on the unread page mark this item as read. - let currentItem = document.querySelector(".current-item"); - this.goToNextListItem(); - EntryHandler.markEntryAsRead(currentItem); - } - } - - openSelectedItem() { - let currentItemLink = document.querySelector(".current-item .item-title a"); - if (currentItemLink !== null) { - window.location.href = currentItemLink.getAttribute("href"); - } - } - - /** - * @param {string} page Page to redirect to. - * @param {boolean} fallbackSelf Refresh actual page if the page is not found. - */ - goToPage(page, fallbackSelf) { - let element = document.querySelector("a[data-page=" + page + "]"); - - if (element) { - document.location.href = element.href; - } else if (fallbackSelf) { - window.location.reload(); - } - } - - goToPrevious() { - if (this.isListView()) { - this.goToPreviousListItem(); - } else { - this.goToPage("previous"); - } - } - - goToNext() { - if (this.isListView()) { - this.goToNextListItem(); - } else { - this.goToPage("next"); - } - } - - goToPreviousListItem() { - let items = DomHelper.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"); - DomHelper.scrollPageTo(items[i - 1]); - } - - break; - } - } - } - - goToNextListItem() { - let currentItem = document.querySelector(".current-item"); - let items = DomHelper.getVisibleElements(".items .item"); - if (items.length === 0) { - return; - } - - if (currentItem === 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"); - DomHelper.scrollPageTo(items[i + 1]); - } - - break; - } - } - } - - isListView() { - return document.querySelector(".items") !== null; - } -} - -document.addEventListener("DOMContentLoaded", function() { - FormHandler.handleSubmitButtons(); - - let touchHandler = new TouchHandler(); - touchHandler.listen(); - - let navHandler = new NavHandler(); - let keyboardHandler = new KeyboardHandler(); - keyboardHandler.on("g u", () => navHandler.goToPage("unread")); - keyboardHandler.on("g b", () => navHandler.goToPage("starred")); - keyboardHandler.on("g h", () => navHandler.goToPage("history")); - keyboardHandler.on("g f", () => navHandler.goToPage("feeds")); - keyboardHandler.on("g c", () => navHandler.goToPage("categories")); - keyboardHandler.on("g s", () => navHandler.goToPage("settings")); - keyboardHandler.on("ArrowLeft", () => navHandler.goToPrevious()); - keyboardHandler.on("ArrowRight", () => navHandler.goToNext()); - keyboardHandler.on("j", () => navHandler.goToPrevious()); - keyboardHandler.on("p", () => navHandler.goToPrevious()); - keyboardHandler.on("k", () => navHandler.goToNext()); - keyboardHandler.on("n", () => navHandler.goToNext()); - keyboardHandler.on("h", () => navHandler.goToPage("previous")); - keyboardHandler.on("l", () => navHandler.goToPage("next")); - keyboardHandler.on("o", () => navHandler.openSelectedItem()); - keyboardHandler.on("v", () => navHandler.openOriginalLink()); - keyboardHandler.on("m", () => navHandler.toggleEntryStatus()); - keyboardHandler.on("A", () => navHandler.markPageAsRead()); - keyboardHandler.on("s", () => navHandler.saveEntry()); - keyboardHandler.on("d", () => navHandler.fetchOriginalContent()); - keyboardHandler.on("f", () => navHandler.toggleBookmark()); - keyboardHandler.on("?", () => navHandler.showKeyboardShortcuts()); - keyboardHandler.on("Escape", () => ModalHandler.close()); - keyboardHandler.listen(); - - let mouseHandler = new MouseHandler(); - mouseHandler.onClick("a[data-save-entry]", (event) => { - event.preventDefault(); - EntryHandler.saveEntry(event.target); - }); - - mouseHandler.onClick("a[data-toggle-bookmark]", (event) => { - event.preventDefault(); - EntryHandler.toggleBookmark(event.target); - }); - - mouseHandler.onClick("a[data-fetch-content-entry]", (event) => { - event.preventDefault(); - EntryHandler.fetchOriginalContent(event.target); - }); - - mouseHandler.onClick("a[data-on-click=markPageAsRead]", () => navHandler.markPageAsRead()); - mouseHandler.onClick("a[data-confirm]", (event) => { - (new ConfirmHandler()).handle(event); - }); - - if (document.documentElement.clientWidth < 600) { - let menuHandler = new MenuHandler(); - mouseHandler.onClick(".logo", () => menuHandler.toggleMainMenu()); - mouseHandler.onClick(".header nav li", (event) => menuHandler.clickMenuListItem(event)); - } -}); - -})(); diff --git a/server/template/common.go b/server/template/common.go deleted file mode 100644 index f0a481d..0000000 --- a/server/template/common.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by go generate; DO NOT EDIT. -// 2017-12-31 18:38:42.07097409 -0800 PST m=+0.051805248 - -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, initial-scale=1.0, user-scalable=no"> - <meta name="mobile-web-app-capable" content="yes"> - <meta name="apple-mobile-web-app-title" content="Miniflux"> - <link rel="manifest" href="{{ route "webManifest" }}"> - - <meta name="robots" content="noindex,nofollow"> - <meta name="referrer" content="no-referrer"> - - <link rel="icon" type="image/png" href="{{ route "appIcon" "filename" "favicon.png" }}"> - <link rel="apple-touch-icon" href="{{ route "appIcon" "filename" "touch-icon-iphone.png" }}"> - <link rel="apple-touch-icon" sizes="72x72" href="{{ route "appIcon" "filename" "touch-icon-ipad.png" }}"> - <link rel="apple-touch-icon" sizes="114x114" href="{{ route "appIcon" "filename" "touch-icon-iphone-retina.png" }}"> - <link rel="apple-touch-icon" sizes="144x144" href="{{ route "appIcon" "filename" "touch-icon-ipad-retina.png" }}"> - <link rel="shortcut icon" type="image/x-icon" href="{{ route "favicon" }}"> - - {{ 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 }} title="{{ t "Keyboard Shortcut: %s" "g u" }}"> - <a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a> - {{ if gt .countUnread 0 }} - <span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span> - {{ end }} - </li> - <li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g b" }}"> - <a href="{{ route "starred" }}" data-page="starred">{{ t "Starred" }}</a> - </li> - <li {{ if eq .menu "history" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g h" }}"> - <a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a> - </li> - <li {{ if eq .menu "feeds" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g f" }}"> - <a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a> - </li> - <li {{ if eq .menu "categories" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g c" }}"> - <a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a> - </li> - <li {{ if eq .menu "settings" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g s" }}"> - <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 }} - {{ if .flashMessage }} - <div class="flash-message alert alert-success">{{ .flashMessage }}</div> - {{ end }} - {{ if .flashErrorMessage }} - <div class="flash-error-message alert alert-error">{{ .flashErrorMessage }}</div> - {{ end }} - <main> - {{template "content" .}} - </main> - <template id="keyboard-shortcuts"> - <div id="modal-left"> - <a href="#" class="btn-close-modal">x</a> - <h3>{{ t "Keyboard Shortcuts" }}</h3> - - <div class="keyboard-shortcuts"> - <p>{{ t "Sections Navigation" }}</p> - <ul> - <li>{{ t "Go to unread" }} = <strong>g + u</strong></li> - <li>{{ t "Go to bookmarks" }} = <strong>g + b</strong></li> - <li>{{ t "Go to history" }} = <strong>g + h</strong></li> - <li>{{ t "Go to feeds" }} = <strong>g + f</strong></li> - <li>{{ t "Go to categories" }} = <strong>g + c</strong></li> - <li>{{ t "Go to settings" }} = <strong>g + s</strong></li> - <li>{{ t "Show keyboard shortcuts" }} = <strong>?</strong></li> - </ul> - - <p>{{ t "Items Navigation" }}</p> - <ul> - <li>{{ t "Go to previous item" }} = <strong>p or j or ◄</strong></li> - <li>{{ t "Go to next item" }} = <strong>n or k or ►</strong></li> - </ul> - - <p>{{ t "Pages Navigation" }}</p> - <ul> - <li>{{ t "Go to previous page" }} = <strong>h</strong></li> - <li>{{ t "Go to next page" }} = <strong>l</strong></li> - </ul> - - <p>{{ t "Actions" }}</p> - <ul> - <li>{{ t "Open selected item" }} = <strong>o</strong></li> - <li>{{ t "Open original link" }} = <strong>v</strong></li> - <li>{{ t "Toggle read/unread" }} = <strong>m</strong></li> - <li>{{ t "Mark current page as read" }} = <strong>A</strong></li> - <li>{{ t "Download original content" }} = <strong>d</strong></li> - <li>{{ t "Toggle bookmark" }} = <strong>f</strong></li> - <li>{{ t "Save article" }} = <strong>s</strong></li> - <li>{{ t "Close modal dialog" }} = <strong>Esc</strong></li> - </ul> - </div> - </div> - </template> -</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": "83786d9e657a17cb531007b5639dc021b7cb2bff1a19162769b3a961a22e5087", - "pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924", -} diff --git a/server/template/html/about.html b/server/template/html/about.html deleted file mode 100644 index 24c0f2c..0000000 --- a/server/template/html/about.html +++ /dev/null @@ -1,40 +0,0 @@ -{{ 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 "integrations" }}">{{ t "Integrations" }}</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 deleted file mode 100644 index b65dabb..0000000 --- a/server/template/html/add_subscription.html +++ /dev/null @@ -1,47 +0,0 @@ -{{ 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> - - <label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "Fetch original content" }}</label> - - <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 deleted file mode 100644 index c2d7850..0000000 --- a/server/template/html/categories.html +++ /dev/null @@ -1,56 +0,0 @@ -{{ 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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ 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 deleted file mode 100644 index ff73a16..0000000 --- a/server/template/html/category_entries.html +++ /dev/null @@ -1,68 +0,0 @@ -{{ define "title"}}{{ .category.Title }} ({{ .total }}){{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ .category.Title }} ({{ .total }})</h1> - {{ if .entries }} - <ul> - <li> - <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a> - </li> - </ul> - {{ end }} -</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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 deleted file mode 100644 index a1a8e68..0000000 --- a/server/template/html/choose_subscription.html +++ /dev/null @@ -1,39 +0,0 @@ -{{ 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 }} - - <br> - <label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "Fetch original content" }}</label> - - <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 deleted file mode 100644 index 6c9f29c..0000000 --- a/server/template/html/common/entry_pagination.html +++ /dev/null @@ -1,19 +0,0 @@ -{{ 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 deleted file mode 100644 index 85f1df4..0000000 --- a/server/template/html/common/layout.html +++ /dev/null @@ -1,124 +0,0 @@ -{{ 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, initial-scale=1.0, user-scalable=no"> - <meta name="mobile-web-app-capable" content="yes"> - <meta name="apple-mobile-web-app-title" content="Miniflux"> - <link rel="manifest" href="{{ route "webManifest" }}"> - - <meta name="robots" content="noindex,nofollow"> - <meta name="referrer" content="no-referrer"> - - <link rel="icon" type="image/png" href="{{ route "appIcon" "filename" "favicon.png" }}"> - <link rel="apple-touch-icon" href="{{ route "appIcon" "filename" "touch-icon-iphone.png" }}"> - <link rel="apple-touch-icon" sizes="72x72" href="{{ route "appIcon" "filename" "touch-icon-ipad.png" }}"> - <link rel="apple-touch-icon" sizes="114x114" href="{{ route "appIcon" "filename" "touch-icon-iphone-retina.png" }}"> - <link rel="apple-touch-icon" sizes="144x144" href="{{ route "appIcon" "filename" "touch-icon-ipad-retina.png" }}"> - <link rel="shortcut icon" type="image/x-icon" href="{{ route "favicon" }}"> - - {{ 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 }} title="{{ t "Keyboard Shortcut: %s" "g u" }}"> - <a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a> - {{ if gt .countUnread 0 }} - <span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span> - {{ end }} - </li> - <li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g b" }}"> - <a href="{{ route "starred" }}" data-page="starred">{{ t "Starred" }}</a> - </li> - <li {{ if eq .menu "history" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g h" }}"> - <a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a> - </li> - <li {{ if eq .menu "feeds" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g f" }}"> - <a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a> - </li> - <li {{ if eq .menu "categories" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g c" }}"> - <a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a> - </li> - <li {{ if eq .menu "settings" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g s" }}"> - <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 }} - {{ if .flashMessage }} - <div class="flash-message alert alert-success">{{ .flashMessage }}</div> - {{ end }} - {{ if .flashErrorMessage }} - <div class="flash-error-message alert alert-error">{{ .flashErrorMessage }}</div> - {{ end }} - <main> - {{template "content" .}} - </main> - <template id="keyboard-shortcuts"> - <div id="modal-left"> - <a href="#" class="btn-close-modal">x</a> - <h3>{{ t "Keyboard Shortcuts" }}</h3> - - <div class="keyboard-shortcuts"> - <p>{{ t "Sections Navigation" }}</p> - <ul> - <li>{{ t "Go to unread" }} = <strong>g + u</strong></li> - <li>{{ t "Go to bookmarks" }} = <strong>g + b</strong></li> - <li>{{ t "Go to history" }} = <strong>g + h</strong></li> - <li>{{ t "Go to feeds" }} = <strong>g + f</strong></li> - <li>{{ t "Go to categories" }} = <strong>g + c</strong></li> - <li>{{ t "Go to settings" }} = <strong>g + s</strong></li> - <li>{{ t "Show keyboard shortcuts" }} = <strong>?</strong></li> - </ul> - - <p>{{ t "Items Navigation" }}</p> - <ul> - <li>{{ t "Go to previous item" }} = <strong>p or j or ◄</strong></li> - <li>{{ t "Go to next item" }} = <strong>n or k or ►</strong></li> - </ul> - - <p>{{ t "Pages Navigation" }}</p> - <ul> - <li>{{ t "Go to previous page" }} = <strong>h</strong></li> - <li>{{ t "Go to next page" }} = <strong>l</strong></li> - </ul> - - <p>{{ t "Actions" }}</p> - <ul> - <li>{{ t "Open selected item" }} = <strong>o</strong></li> - <li>{{ t "Open original link" }} = <strong>v</strong></li> - <li>{{ t "Toggle read/unread" }} = <strong>m</strong></li> - <li>{{ t "Mark current page as read" }} = <strong>A</strong></li> - <li>{{ t "Download original content" }} = <strong>d</strong></li> - <li>{{ t "Toggle bookmark" }} = <strong>f</strong></li> - <li>{{ t "Save article" }} = <strong>s</strong></li> - <li>{{ t "Close modal dialog" }} = <strong>Esc</strong></li> - </ul> - </div> - </div> - </template> -</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 deleted file mode 100644 index 4c6766a..0000000 --- a/server/template/html/common/pagination.html +++ /dev/null @@ -1,19 +0,0 @@ -{{ 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 deleted file mode 100644 index 7c4c93f..0000000 --- a/server/template/html/create_category.html +++ /dev/null @@ -1,27 +0,0 @@ -{{ 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 deleted file mode 100644 index 8faab49..0000000 --- a/server/template/html/create_user.html +++ /dev/null @@ -1,44 +0,0 @@ -{{ 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 "integrations" }}">{{ t "Integrations" }}</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{{ 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 deleted file mode 100644 index 2981fa4..0000000 --- a/server/template/html/edit_category.html +++ /dev/null @@ -1,30 +0,0 @@ -{{ 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 deleted file mode 100644 index ebc9a02..0000000 --- a/server/template/html/edit_feed.html +++ /dev/null @@ -1,77 +0,0 @@ -{{ 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-scraper-rules">{{ t "Scraper Rules" }}</label> - <input type="text" name="scraper_rules" id="form-scraper-rules" value="{{ .form.ScraperRules }}"> - - <label for="form-rewrite-rules">{{ t "Rewrite Rules" }}</label> - <input type="text" name="rewrite_rules" id="form-rewrite-rules" value="{{ .form.RewriteRules }}"> - - <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> - - <label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "Fetch original content" }}</label> - - <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> - - <div class="panel"> - <ul> - <li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed .feed.CheckedAt }}</time></li> - <li><strong>{{ t "ETag header:" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t "None" }}{{ end }}</li> - <li><strong>{{ t "LastModified header:" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t "None" }}{{ end }}</li> - </ul> - </div> -{{ end }} - -{{ end }}
\ No newline at end of file diff --git a/server/template/html/edit_user.html b/server/template/html/edit_user.html deleted file mode 100644 index 6611943..0000000 --- a/server/template/html/edit_user.html +++ /dev/null @@ -1,47 +0,0 @@ -{{ 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 "integrations" }}">{{ t "Integrations" }}</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{{ 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 deleted file mode 100644 index 66d08fb..0000000 --- a/server/template/html/entry.html +++ /dev/null @@ -1,109 +0,0 @@ -{{ 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-actions"> - <ul> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a> - </li> - <li> - <a href="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="#" - title="{{ t "Fetch original content" }}" - data-fetch-content-entry="true" - data-fetch-content-url="{{ route "fetchContent" "entryID" .entry.ID }}" - data-label-loading="{{ t "Loading..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Fetch original content" }}</a> - </li> - </ul> - </div> - <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 isEmail .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> - {{ if gt (len .entry.Content) 120 }} - <div class="pagination-top"> - {{ template "entry_pagination" . }} - </div> - {{ end }} - <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="{{ proxyURL .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 deleted file mode 100644 index 4317f88..0000000 --- a/server/template/html/feed_entries.html +++ /dev/null @@ -1,79 +0,0 @@ -{{ 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> - {{ if .entries }} - <li> - <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a> - </li> - {{ end }} - </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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 deleted file mode 100644 index 5500c92..0000000 --- a/server/template/html/feeds.html +++ /dev/null @@ -1,77 +0,0 @@ -{{ 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> - <li> - <a href="{{ route "refreshAllFeeds" }}">{{ t "Refresh all feeds in background" }}</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 {{ if ne .ParsingErrorCount 0 }}feed-parsing-error{{ end }}"> - <div class="item-header"> - <span class="item-title"> - {{ if .Icon }} - <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> - </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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ route "removeFeed" "feedID" .ID }}">{{ t "Remove" }}</a> - </li> - </ul> - </div> - {{ if ne .ParsingErrorCount 0 }} - <div class="parsing-error"> - <strong title="{{ .ParsingErrorMsg }}" class="parsing-error-count">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong> - <small class="parsing-error-message">({{ .ParsingErrorMsg }})</small> - </div> - {{ end }} - </article> - {{ end }} - </div> -{{ end }} - -{{ end }} diff --git a/server/template/html/history.html b/server/template/html/history.html deleted file mode 100644 index 5baa0df..0000000 --- a/server/template/html/history.html +++ /dev/null @@ -1,68 +0,0 @@ -{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "History" }} ({{ .total }})</h1> - {{ if .entries }} - <ul> - <li> - <a href="{{ route "flushHistory" }}">{{ t "Flush history" }}</a> - </li> - </ul> - {{ end }} -</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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 deleted file mode 100644 index dbdb9b0..0000000 --- a/server/template/html/import.html +++ /dev/null @@ -1,34 +0,0 @@ -{{ 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/integrations.html b/server/template/html/integrations.html deleted file mode 100644 index 5005d68..0000000 --- a/server/template/html/integrations.html +++ /dev/null @@ -1,112 +0,0 @@ -{{ define "title"}}{{ t "Integrations" }}{{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Integrations" }}</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 }} - <li> - <a href="{{ route "about" }}">{{ t "About" }}</a> - </li> - </ul> -</section> - -<form method="post" autocomplete="off" action="{{ route "updateIntegration" }}"> - <input type="hidden" name="csrf" value="{{ .csrf }}"> - - {{ if .errorMessage }} - <div class="alert alert-error">{{ t .errorMessage }}</div> - {{ end }} - - <h3>Fever</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }} - </label> - - <label for="form-fever-username">{{ t "Fever Username" }}</label> - <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}"> - - <label for="form-fever-password">{{ t "Fever Password" }}</label> - <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}"> - </div> - - <h3>Pinboard</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }} - </label> - - <label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label> - <input type="password" name="pinboard_token" id="form-pinboard-token" value="{{ .form.PinboardToken }}"> - - <label for="form-pinboard-tags">{{ t "Pinboard Tags" }}</label> - <input type="text" name="pinboard_tags" id="form-pinboard-tags" value="{{ .form.PinboardTags }}"> - - <label> - <input type="checkbox" name="pinboard_mark_as_unread" value="1" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t "Mark bookmark as unread" }} - </label> - </div> - - <h3>Instapaper</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }} - </label> - - <label for="form-instapaper-username">{{ t "Instapaper Username" }}</label> - <input type="text" name="instapaper_username" id="form-instapaper-username" value="{{ .form.InstapaperUsername }}"> - - <label for="form-instapaper-password">{{ t "Instapaper Password" }}</label> - <input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}"> - </div> - - <h3>Wallabag</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="wallabag_enabled" value="1" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t "Save articles to Wallabag" }} - </label> - - <label for="form-wallabag-url">{{ t "Wallabag API Endpoint" }}</label> - <input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/"> - - <label for="form-wallabag-client-id">{{ t "Wallabag Client ID" }}</label> - <input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}"> - - <label for="form-wallabag-client-secret">{{ t "Wallabag Client Secret" }}</label> - <input type="password" name="wallabag_client_secret" id="form-wallabag-client-secret" value="{{ .form.WallabagClientSecret }}"> - - <label for="form-wallabag-username">{{ t "Wallabag Username" }}</label> - <input type="text" name="wallabag_username" id="form-wallabag-username" value="{{ .form.WallabagUsername }}"> - - <label for="form-wallabag-password">{{ t "Wallabag Password" }}</label> - <input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}"> - </div> - - <div class="buttons"> - <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> - </div> -</form> - -<div class="panel"> - <h3>{{ t "Bookmarklet" }}</h3> - <p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p> - - <div class="bookmarklet"> - <a href="javascript:location.href='{{ baseURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a> - </div> - - <p>{{ t "Drag and drop this link to your bookmarks." }}</p> -</div> - -{{ end }} diff --git a/server/template/html/login.html b/server/template/html/login.html deleted file mode 100644 index 906458a..0000000 --- a/server/template/html/login.html +++ /dev/null @@ -1,28 +0,0 @@ -{{ 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" value="{{ .form.Username }}" required autofocus> - - <label for="form-password">{{ t "Password" }}</label> - <input type="password" name="password" id="form-password" value="{{ .form.Password }}" required> - - <div class="buttons"> - <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button> - </div> - </form> - {{ if hasOAuth2Provider "google" }} - <div class="oauth2"> - <a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "Sign in with Google" }}</a> - </div> - {{ end }} -</section> -{{ end }} diff --git a/server/template/html/sessions.html b/server/template/html/sessions.html deleted file mode 100644 index 6c76867..0000000 --- a/server/template/html/sessions.html +++ /dev/null @@ -1,51 +0,0 @@ -{{ 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 "integrations" }}">{{ t "Integrations" }}</a> - </li> - <li> - <a href="{{ route "users" }}">{{ t "Users" }}</a> - </li> - <li> - <a href="{{ route "createUser" }}">{{ t "Add user" }}</a> - </li> - </ul> -</section> - -<table> - <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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ 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 deleted file mode 100644 index 8e66a10..0000000 --- a/server/template/html/settings.html +++ /dev/null @@ -1,82 +0,0 @@ -{{ define "title"}}{{ t "Settings" }}{{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Settings" }}</h1> - <ul> - <li> - <a href="{{ route "integrations" }}">{{ t "Integrations" }}</a> - </li> - <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> - - <label for="form-entry-direction">{{ t "Entry Sorting" }}</label> - <select id="form-entry-direction" name="entry_direction"> - <option value="asc" {{ if eq "asc" $.form.EntryDirection }}selected="selected"{{ end }}>{{ t "Older entries first" }}</option> - <option value="desc" {{ if eq "desc" $.form.EntryDirection }}selected="selected"{{ end }}>{{ t "Recent entries first" }}</option> - </select> - - <div class="buttons"> - <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> - </div> -</form> - -{{ if hasOAuth2Provider "google" }} -<div class="panel"> - {{ if hasKey .user.Extra "google_id" }} - <a href="{{ route "oauth2Unlink" "provider" "google" }}">{{ t "Unlink my Google account" }}</a> - {{ else }} - <a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "Link my Google account" }}</a> - {{ end }} -</div> -{{ end }} - -{{ end }} diff --git a/server/template/html/starred.html b/server/template/html/starred.html deleted file mode 100644 index 1ed1b13..0000000 --- a/server/template/html/starred.html +++ /dev/null @@ -1,61 +0,0 @@ -{{ define "title"}}{{ t "Favorites" }} ({{ .total }}){{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Favorites" }} ({{ .total }})</h1> -</section> - -{{ if not .entries }} - <p class="alert alert-info">{{ t "There is no bookmark at the moment." }}</p> -{{ else }} - <div class="items"> - {{ range .entries }} - <article class="item touch-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 "starredEntry" "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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a> - </li> - </ul> - </div> - </article> - {{ end }} - </div> - {{ template "pagination" .pagination }} -{{ end }} - -{{ end }} diff --git a/server/template/html/unread.html b/server/template/html/unread.html deleted file mode 100644 index feb5beb..0000000 --- a/server/template/html/unread.html +++ /dev/null @@ -1,68 +0,0 @@ -{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Unread" }} (<span class="unread-counter">{{ .countUnread }}</span>)</h1> - {{ if .entries }} - <ul> - <li> - <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a> - </li> - </ul> - {{ end }} -</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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 deleted file mode 100644 index 40ca5e9..0000000 --- a/server/template/html/users.html +++ /dev/null @@ -1,60 +0,0 @@ -{{ 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 "integrations" }}">{{ t "Integrations" }}</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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ 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 deleted file mode 100644 index a87d097..0000000 --- a/server/template/template.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2017 Frédéric Guilloe. 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" - "html/template" - "io" - "net/mail" - "strings" - "time" - - "github.com/miniflux/miniflux/config" - "github.com/miniflux/miniflux/duration" - "github.com/miniflux/miniflux/errors" - "github.com/miniflux/miniflux/locale" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/server/route" - "github.com/miniflux/miniflux/server/ui/filter" - "github.com/miniflux/miniflux/url" - - "github.com/gorilla/mux" -) - -// Engine handles the templating system. -type Engine struct { - templates map[string]*template.Template - router *mux.Router - translator *locale.Translator - currentLocale *locale.Language - cfg *config.Config -} - -func (e *Engine) parseAll() { - funcMap := template.FuncMap{ - "baseURL": func() string { - return e.cfg.Get("BASE_URL", config.DefaultBaseURL) - }, - "hasOAuth2Provider": func(provider string) bool { - return e.cfg.Get("OAUTH2_PROVIDER", "") == provider - }, - "hasKey": func(dict map[string]string, key string) bool { - if value, found := dict[key]; found { - return value != "" - } - return false - }, - "route": func(name string, args ...interface{}) string { - return route.Path(e.router, name, args...) - }, - "noescape": func(str string) template.HTML { - return template.HTML(str) - }, - "proxyFilter": func(data string) string { - return filter.ImageProxyFilter(e.router, data) - }, - "proxyURL": func(link string) string { - if url.IsHTTPS(link) { - return link - } - - return filter.Proxify(e.router, link) - }, - "domain": func(websiteURL string) string { - return url.Domain(websiteURL) - }, - "isEmail": func(str string) bool { - _, err := mail.ParseAddress(str) - if err != nil { - return false - } - return true - }, - "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 duration.ElapsedTime(e.currentLocale, ts) - }, - "t": func(key interface{}, args ...interface{}) string { - switch key.(type) { - case string: - return e.currentLocale.Get(key.(string), args...) - case errors.LocalizedError: - err := key.(errors.LocalizedError) - return err.Localize(e.currentLocale) - case error: - return key.(error).Error() - default: - return "" - } - }, - "plural": func(key string, n int, args ...interface{}) string { - return e.currentLocale.Plural(key, n, args...) - }, - } - - commonTemplates := "" - for _, content := range templateCommonMap { - commonTemplates += content - } - - for name, content := range templateViewsMap { - logger.Debug("[Template] Parsing: %s", name) - e.templates[name] = template.Must(template.New("main").Funcs(funcMap).Parse(commonTemplates + content)) - } -} - -// SetLanguage change the language for template processing. -func (e *Engine) SetLanguage(language string) { - e.currentLocale = e.translator.GetLanguage(language) -} - -// Execute process a template. -func (e *Engine) Execute(w io.Writer, name string, data interface{}) { - tpl, ok := e.templates[name] - if !ok { - logger.Fatal("[Template] The template %s does not exists", name) - } - - var b bytes.Buffer - err := tpl.ExecuteTemplate(&b, "base", data) - if err != nil { - logger.Fatal("[Template] Unable to render template: %v", err) - } - - b.WriteTo(w) -} - -// NewEngine returns a new template Engine. -func NewEngine(cfg *config.Config, router *mux.Router, translator *locale.Translator) *Engine { - tpl := &Engine{ - templates: make(map[string]*template.Template), - router: router, - translator: translator, - cfg: cfg, - } - - tpl.parseAll() - return tpl -} diff --git a/server/template/views.go b/server/template/views.go deleted file mode 100644 index 4eac6e4..0000000 --- a/server/template/views.go +++ /dev/null @@ -1,1356 +0,0 @@ -// Code generated by go generate; DO NOT EDIT. -// 2017-12-31 18:38:42.048775793 -0800 PST m=+0.029606951 - -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 "integrations" }}">{{ t "Integrations" }}</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> - - <label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "Fetch original content" }}</label> - - <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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ 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> - {{ if .entries }} - <ul> - <li> - <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a> - </li> - </ul> - {{ end }} -</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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 }} - - <br> - <label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "Fetch original content" }}</label> - - <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 "integrations" }}">{{ t "Integrations" }}</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{{ 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-scraper-rules">{{ t "Scraper Rules" }}</label> - <input type="text" name="scraper_rules" id="form-scraper-rules" value="{{ .form.ScraperRules }}"> - - <label for="form-rewrite-rules">{{ t "Rewrite Rules" }}</label> - <input type="text" name="rewrite_rules" id="form-rewrite-rules" value="{{ .form.RewriteRules }}"> - - <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> - - <label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "Fetch original content" }}</label> - - <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> - - <div class="panel"> - <ul> - <li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed .feed.CheckedAt }}</time></li> - <li><strong>{{ t "ETag header:" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t "None" }}{{ end }}</li> - <li><strong>{{ t "LastModified header:" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t "None" }}{{ end }}</li> - </ul> - </div> -{{ 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 "integrations" }}">{{ t "Integrations" }}</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{{ 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-actions"> - <ul> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a> - </li> - <li> - <a href="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="#" - title="{{ t "Fetch original content" }}" - data-fetch-content-entry="true" - data-fetch-content-url="{{ route "fetchContent" "entryID" .entry.ID }}" - data-label-loading="{{ t "Loading..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Fetch original content" }}</a> - </li> - </ul> - </div> - <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 isEmail .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> - {{ if gt (len .entry.Content) 120 }} - <div class="pagination-top"> - {{ template "entry_pagination" . }} - </div> - {{ end }} - <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="{{ proxyURL .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> - {{ if .entries }} - <li> - <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a> - </li> - {{ end }} - </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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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> - <li> - <a href="{{ route "refreshAllFeeds" }}">{{ t "Refresh all feeds in background" }}</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 {{ if ne .ParsingErrorCount 0 }}feed-parsing-error{{ end }}"> - <div class="item-header"> - <span class="item-title"> - {{ if .Icon }} - <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> - </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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ route "removeFeed" "feedID" .ID }}">{{ t "Remove" }}</a> - </li> - </ul> - </div> - {{ if ne .ParsingErrorCount 0 }} - <div class="parsing-error"> - <strong title="{{ .ParsingErrorMsg }}" class="parsing-error-count">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong> - <small class="parsing-error-message">({{ .ParsingErrorMsg }})</small> - </div> - {{ end }} - </article> - {{ end }} - </div> -{{ end }} - -{{ end }} -`, - "history": `{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "History" }} ({{ .total }})</h1> - {{ if .entries }} - <ul> - <li> - <a href="{{ route "flushHistory" }}">{{ t "Flush history" }}</a> - </li> - </ul> - {{ end }} -</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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 }} -`, - "integrations": `{{ define "title"}}{{ t "Integrations" }}{{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Integrations" }}</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 }} - <li> - <a href="{{ route "about" }}">{{ t "About" }}</a> - </li> - </ul> -</section> - -<form method="post" autocomplete="off" action="{{ route "updateIntegration" }}"> - <input type="hidden" name="csrf" value="{{ .csrf }}"> - - {{ if .errorMessage }} - <div class="alert alert-error">{{ t .errorMessage }}</div> - {{ end }} - - <h3>Fever</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }} - </label> - - <label for="form-fever-username">{{ t "Fever Username" }}</label> - <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}"> - - <label for="form-fever-password">{{ t "Fever Password" }}</label> - <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}"> - </div> - - <h3>Pinboard</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }} - </label> - - <label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label> - <input type="password" name="pinboard_token" id="form-pinboard-token" value="{{ .form.PinboardToken }}"> - - <label for="form-pinboard-tags">{{ t "Pinboard Tags" }}</label> - <input type="text" name="pinboard_tags" id="form-pinboard-tags" value="{{ .form.PinboardTags }}"> - - <label> - <input type="checkbox" name="pinboard_mark_as_unread" value="1" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t "Mark bookmark as unread" }} - </label> - </div> - - <h3>Instapaper</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }} - </label> - - <label for="form-instapaper-username">{{ t "Instapaper Username" }}</label> - <input type="text" name="instapaper_username" id="form-instapaper-username" value="{{ .form.InstapaperUsername }}"> - - <label for="form-instapaper-password">{{ t "Instapaper Password" }}</label> - <input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}"> - </div> - - <h3>Wallabag</h3> - <div class="form-section"> - <label> - <input type="checkbox" name="wallabag_enabled" value="1" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t "Save articles to Wallabag" }} - </label> - - <label for="form-wallabag-url">{{ t "Wallabag API Endpoint" }}</label> - <input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/"> - - <label for="form-wallabag-client-id">{{ t "Wallabag Client ID" }}</label> - <input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}"> - - <label for="form-wallabag-client-secret">{{ t "Wallabag Client Secret" }}</label> - <input type="password" name="wallabag_client_secret" id="form-wallabag-client-secret" value="{{ .form.WallabagClientSecret }}"> - - <label for="form-wallabag-username">{{ t "Wallabag Username" }}</label> - <input type="text" name="wallabag_username" id="form-wallabag-username" value="{{ .form.WallabagUsername }}"> - - <label for="form-wallabag-password">{{ t "Wallabag Password" }}</label> - <input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}"> - </div> - - <div class="buttons"> - <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> - </div> -</form> - -<div class="panel"> - <h3>{{ t "Bookmarklet" }}</h3> - <p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p> - - <div class="bookmarklet"> - <a href="javascript:location.href='{{ baseURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a> - </div> - - <p>{{ t "Drag and drop this link to your bookmarks." }}</p> -</div> - -{{ 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" value="{{ .form.Username }}" required autofocus> - - <label for="form-password">{{ t "Password" }}</label> - <input type="password" name="password" id="form-password" value="{{ .form.Password }}" required> - - <div class="buttons"> - <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button> - </div> - </form> - {{ if hasOAuth2Provider "google" }} - <div class="oauth2"> - <a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "Sign in with Google" }}</a> - </div> - {{ end }} -</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 "integrations" }}">{{ t "Integrations" }}</a> - </li> - <li> - <a href="{{ route "users" }}">{{ t "Users" }}</a> - </li> - <li> - <a href="{{ route "createUser" }}">{{ t "Add user" }}</a> - </li> - </ul> -</section> - -<table> - <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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ 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 "integrations" }}">{{ t "Integrations" }}</a> - </li> - <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> - - <label for="form-entry-direction">{{ t "Entry Sorting" }}</label> - <select id="form-entry-direction" name="entry_direction"> - <option value="asc" {{ if eq "asc" $.form.EntryDirection }}selected="selected"{{ end }}>{{ t "Older entries first" }}</option> - <option value="desc" {{ if eq "desc" $.form.EntryDirection }}selected="selected"{{ end }}>{{ t "Recent entries first" }}</option> - </select> - - <div class="buttons"> - <button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> - </div> -</form> - -{{ if hasOAuth2Provider "google" }} -<div class="panel"> - {{ if hasKey .user.Extra "google_id" }} - <a href="{{ route "oauth2Unlink" "provider" "google" }}">{{ t "Unlink my Google account" }}</a> - {{ else }} - <a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "Link my Google account" }}</a> - {{ end }} -</div> -{{ end }} - -{{ end }} -`, - "starred": `{{ define "title"}}{{ t "Favorites" }} ({{ .total }}){{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Favorites" }} ({{ .total }})</h1> -</section> - -{{ if not .entries }} - <p class="alert alert-info">{{ t "There is no bookmark at the moment." }}</p> -{{ else }} - <div class="items"> - {{ range .entries }} - <article class="item touch-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 "starredEntry" "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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a> - </li> - </ul> - </div> - </article> - {{ end }} - </div> - {{ template "pagination" .pagination }} -{{ end }} - -{{ end }} -`, - "unread": `{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }} - -{{ define "content"}} -<section class="page-header"> - <h1>{{ t "Unread" }} (<span class="unread-counter">{{ .countUnread }}</span>)</h1> - {{ if .entries }} - <ul> - <li> - <a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a> - </li> - </ul> - {{ end }} -</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 touch-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="#" - title="{{ t "Save this article" }}" - data-save-entry="true" - data-save-url="{{ route "saveEntry" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-done="{{ t "Done!" }}" - >{{ t "Save" }}</a> - </li> - <li> - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a> - </li> - <li> - <a href="#" - data-toggle-bookmark="true" - data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}" - data-label-loading="{{ t "Saving..." }}" - data-label-star="☆ {{ t "Star" }}" - data-label-unstar="★ {{ t "Unstar" }}" - data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}" - >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</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 "integrations" }}">{{ t "Integrations" }}</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="#" - data-confirm="true" - data-label-question="{{ t "Are you sure?" }}" - data-label-yes="{{ t "yes" }}" - data-label-no="{{ t "no" }}" - data-label-loading="{{ t "Work in progress..." }}" - data-url="{{ route "removeUser" "userID" .ID }}">{{ t "Remove" }}</a> - </td> - </tr> - {{ end }} - {{ end }} - </table> -{{ end }} - -{{ end }} -`, -} - -var templateViewsMapChecksums = map[string]string{ - "about": "ad2fb778fc73c39b733b3f81b13e5c7d689b041fadd24ee2d4577f545aa788ad", - "add_subscription": "053c920b0d7e109ea19dce6a448e304ce720db8633588ea04db16677f7209a7b", - "categories": "ca1280cd157bb527d4fc907da67b05a8347378f6dce965b9389d4bcdf3600a11", - "category_entries": "ce59529666520b8363c9588ce2c437de5a3f6d91941e5c46be25ca08f6900364", - "choose_subscription": "a325f9c976ca2b2dc148e25c8fef0cf6ccab0e04e86e604e7812bb18dc4cdde1", - "create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275", - "create_user": "45e226df757126d5fe7c464e295e9a34f07952cfdb71e31e49839850d35af139", - "edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5", - "edit_feed": "15f19ab44057fca1630c6860d5951d6073f82f83ad682a176c475591c6f26377", - "edit_user": "82d9749d76ddbd2352816d813c4b1f6d92f2222de678b4afe5821090246735c7", - "entry": "6b4405e0c8e4a7d31874659f8835f4e43e01dc3c20686091517ac750196dd70f", - "feed_entries": "ac93cb9a90f93ddd9dd8a67d7e160592ecb9f5e465ee9679bb14eecd8d4caf20", - "feeds": "65b0a47c4438810b9d51c60f3f3b2519690e56ff74029e6296c68626b83a470b", - "history": "abc7ea29f7d54f28f73fe14979bbd03dbc41fa6a7c86f95f56d6e94f7b09b9ba", - "import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f", - "integrations": "3c14d7de904911aad7f3ebec6d1a20b50843287f58125c526e167f429f3d455d", - "login": "7d83c3067c02f1f6aafdd8816c7f97a4eb5a5a4bdaaaa4cc1e2fbb9c17ea65e8", - "sessions": "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf", - "settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9", - "starred": "33dd40d1a24739e9d05f9cc4b66497cfdb8c86a7abb209a66ca65c2fbafc7d87", - "unread": "f4eb7410925e174918f1b55414c9b0b81632f7e13ce649579c8593097bb0f1d7", - "users": "44677e28bb5347799ed0020c90ec785aadec4b1454446d92411cfdaf6e32110b", -} diff --git a/server/ui/controller/about.go b/server/ui/controller/about.go deleted file mode 100644 index d6bfc27..0000000 --- a/server/ui/controller/about.go +++ /dev/null @@ -1,25 +0,0 @@ -// 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/miniflux/server/core" - "github.com/miniflux/miniflux/version" -) - -// AboutPage shows the about page. -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 deleted file mode 100644 index cf378c6..0000000 --- a/server/ui/controller/category.go +++ /dev/null @@ -1,257 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" -) - -// ShowCategories shows the page with all categories. -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.LoggedUser() - categories, err := c.store.CategoriesWithFeedCount(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("categories", args.Merge(tplParams{ - "categories": categories, - "total": len(categories), - "menu": "categories", - })) -} - -// ShowCategoryEntries shows all entries for the given category. -func (c *Controller) ShowCategoryEntries(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - offset := request.QueryIntegerParam("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.NewEntryQueryBuilder(user.ID) - builder.WithCategoryID(category.ID) - builder.WithOrder(model.DefaultSortingOrder) - builder.WithDirection(user.EntryDirection) - builder.WithoutStatus(model.EntryStatusRemoved) - 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.Route("categoryEntries", "categoryID", category.ID), count, offset), - "menu": "categories", - })) -} - -// CreateCategory shows the form to create a new category. -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", - })) -} - -// SaveCategory validate and save the new category into the database. -func (c *Controller) SaveCategory(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - categoryForm := form.NewCategoryForm(request.Request()) - if err := categoryForm.Validate(); err != nil { - response.HTML().Render("create_category", args.Merge(tplParams{ - "errorMessage": err.Error(), - })) - return - } - - duplicateCategory, err := c.store.CategoryByTitle(user.ID, categoryForm.Title) - if err != nil { - response.HTML().ServerError(err) - return - } - - if duplicateCategory != nil { - response.HTML().Render("create_category", args.Merge(tplParams{ - "errorMessage": "This category already exists.", - })) - return - } - - category := model.Category{Title: categoryForm.Title, UserID: user.ID} - err = c.store.CreateCategory(&category) - if err != nil { - logger.Info("[Controller:CreateCategory] %v", err) - response.HTML().Render("create_category", args.Merge(tplParams{ - "errorMessage": "Unable to create this category.", - })) - return - } - - response.Redirect(ctx.Route("categories")) -} - -// EditCategory shows the form to modify a category. -func (c *Controller) EditCategory(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - category, err := c.getCategoryFromURL(ctx, request, response) - if err != nil { - logger.Error("[Controller:EditCategory] %v", err) - return - } - - args, err := c.getCategoryFormTemplateArgs(ctx, user, category, nil) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("edit_category", args) -} - -// UpdateCategory validate and update a category. -func (c *Controller) UpdateCategory(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - category, err := c.getCategoryFromURL(ctx, request, response) - if err != nil { - logger.Error("[Controller:UpdateCategory] %v", err) - return - } - - categoryForm := form.NewCategoryForm(request.Request()) - 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 - } - - if c.store.AnotherCategoryExists(user.ID, category.ID, categoryForm.Title) { - response.HTML().Render("edit_category", args.Merge(tplParams{ - "errorMessage": "This category already exists.", - })) - return - } - - err = c.store.UpdateCategory(categoryForm.Merge(category)) - if err != nil { - logger.Error("[Controller:UpdateCategory] %v", err) - response.HTML().Render("edit_category", args.Merge(tplParams{ - "errorMessage": "Unable to update this category.", - })) - return - } - - response.Redirect(ctx.Route("categories")) -} - -// RemoveCategory delete a category from the database. -func (c *Controller) RemoveCategory(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - 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.Route("categories")) -} - -func (c *Controller) getCategoryFromURL(ctx *core.Context, request *core.Request, response *core.Response) (*model.Category, error) { - categoryID, err := request.IntegerParam("categoryID") - if err != nil { - response.HTML().BadRequest(err) - return nil, err - } - - user := ctx.LoggedUser() - category, err := c.store.Category(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 deleted file mode 100644 index 8555c7b..0000000 --- a/server/ui/controller/controller.go +++ /dev/null @@ -1,66 +0,0 @@ -// 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/miniflux/config" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/reader/feed" - "github.com/miniflux/miniflux/reader/opml" - "github.com/miniflux/miniflux/scheduler" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/storage" -) - -type tplParams map[string]interface{} - -func (t tplParams) Merge(d tplParams) tplParams { - for k, v := range d { - t[k] = v - } - - return t -} - -// Controller contains all HTTP handlers for the user interface. -type Controller struct { - cfg *config.Config - store *storage.Storage - pool *scheduler.WorkerPool - feedHandler *feed.Handler - opmlHandler *opml.Handler -} - -func (c *Controller) getCommonTemplateArgs(ctx *core.Context) (tplParams, error) { - user := ctx.LoggedUser() - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithStatus(model.EntryStatusUnread) - - countUnread, err := builder.CountEntries() - if err != nil { - return nil, err - } - - params := tplParams{ - "menu": "", - "user": user, - "countUnread": countUnread, - "csrf": ctx.CSRF(), - "flashMessage": ctx.FlashMessage(), - "flashErrorMessage": ctx.FlashErrorMessage(), - } - return params, nil -} - -// NewController returns a new Controller. -func NewController(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, opmlHandler *opml.Handler) *Controller { - return &Controller{ - cfg: cfg, - store: store, - pool: pool, - feedHandler: feedHandler, - opmlHandler: opmlHandler, - } -} diff --git a/server/ui/controller/entry.go b/server/ui/controller/entry.go deleted file mode 100644 index ca9f44a..0000000 --- a/server/ui/controller/entry.go +++ /dev/null @@ -1,495 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/reader/sanitizer" - - "github.com/miniflux/miniflux/integration" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/reader/scraper" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/payload" - "github.com/miniflux/miniflux/storage" -) - -// FetchContent downloads the original HTML page and returns relevant contents. -func (c *Controller) FetchContent(ctx *core.Context, request *core.Request, response *core.Response) { - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - user := ctx.LoggedUser() - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - entry, err := builder.GetEntry() - if err != nil { - response.JSON().ServerError(err) - return - } - - if entry == nil { - response.JSON().NotFound(errors.New("Entry not found")) - return - } - - content, err := scraper.Fetch(entry.URL, entry.Feed.ScraperRules) - if err != nil { - response.JSON().ServerError(err) - return - } - - entry.Content = sanitizer.Sanitize(entry.URL, content) - c.store.UpdateEntryContent(entry) - - response.JSON().Created(map[string]string{"content": entry.Content}) -} - -// SaveEntry send the link to external services. -func (c *Controller) SaveEntry(ctx *core.Context, request *core.Request, response *core.Response) { - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - user := ctx.LoggedUser() - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - entry, err := builder.GetEntry() - if err != nil { - response.JSON().ServerError(err) - return - } - - if entry == nil { - response.JSON().NotFound(errors.New("Entry not found")) - return - } - - settings, err := c.store.Integration(user.ID) - if err != nil { - response.JSON().ServerError(err) - return - } - - go func() { - integration.SendEntry(entry, settings) - }() - - response.JSON().Created(map[string]string{"message": "saved"}) -} - -// ShowFeedEntry shows a single feed entry in "feed" mode. -func (c *Controller) ShowFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithFeedID(feedID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - entry, err := builder.GetEntry() - if err != nil { - response.HTML().ServerError(err) - return - } - - if entry == nil { - response.HTML().NotFound() - return - } - - if entry.Status == model.EntryStatusUnread { - err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead) - if err != nil { - logger.Error("[Controller:ShowFeedEntry] %v", err) - response.HTML().ServerError(nil) - return - } - } - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - builder = c.store.NewEntryQueryBuilder(user.ID) - builder.WithFeedID(feedID) - - prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - nextEntryRoute := "" - if nextEntry != nil { - nextEntryRoute = ctx.Route("feedEntry", "feedID", feedID, "entryID", nextEntry.ID) - } - - prevEntryRoute := "" - if prevEntry != nil { - prevEntryRoute = ctx.Route("feedEntry", "feedID", feedID, "entryID", prevEntry.ID) - } - - response.HTML().Render("entry", args.Merge(tplParams{ - "entry": entry, - "prevEntry": prevEntry, - "nextEntry": nextEntry, - "nextEntryRoute": nextEntryRoute, - "prevEntryRoute": prevEntryRoute, - "menu": "feeds", - })) -} - -// ShowCategoryEntry shows a single feed entry in "category" mode. -func (c *Controller) ShowCategoryEntry(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - categoryID, err := request.IntegerParam("categoryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithCategoryID(categoryID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - entry, err := builder.GetEntry() - if err != nil { - response.HTML().ServerError(err) - return - } - - if entry == nil { - response.HTML().NotFound() - return - } - - if entry.Status == model.EntryStatusUnread { - err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead) - if err != nil { - logger.Error("[Controller:ShowCategoryEntry] %v", err) - response.HTML().ServerError(nil) - return - } - } - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - builder = c.store.NewEntryQueryBuilder(user.ID) - builder.WithCategoryID(categoryID) - - prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - nextEntryRoute := "" - if nextEntry != nil { - nextEntryRoute = ctx.Route("categoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID) - } - - prevEntryRoute := "" - if prevEntry != nil { - prevEntryRoute = ctx.Route("categoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID) - } - - response.HTML().Render("entry", args.Merge(tplParams{ - "entry": entry, - "prevEntry": prevEntry, - "nextEntry": nextEntry, - "nextEntryRoute": nextEntryRoute, - "prevEntryRoute": prevEntryRoute, - "menu": "categories", - })) -} - -// ShowUnreadEntry shows a single feed entry in "unread" mode. -func (c *Controller) ShowUnreadEntry(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - 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.NewEntryQueryBuilder(user.ID) - builder.WithStatus(model.EntryStatusUnread) - - prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - nextEntryRoute := "" - if nextEntry != nil { - nextEntryRoute = ctx.Route("unreadEntry", "entryID", nextEntry.ID) - } - - prevEntryRoute := "" - if prevEntry != nil { - prevEntryRoute = ctx.Route("unreadEntry", "entryID", prevEntry.ID) - } - - // We change the status here, otherwise we cannot get the pagination for unread items. - if entry.Status == model.EntryStatusUnread { - err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead) - if err != nil { - logger.Error("[Controller:ShowUnreadEntry] %v", 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", - })) -} - -// ShowReadEntry shows a single feed entry in "history" mode. -func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - 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.NewEntryQueryBuilder(user.ID) - builder.WithStatus(model.EntryStatusRead) - - prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - nextEntryRoute := "" - if nextEntry != nil { - nextEntryRoute = ctx.Route("readEntry", "entryID", nextEntry.ID) - } - - prevEntryRoute := "" - if prevEntry != nil { - prevEntryRoute = ctx.Route("readEntry", "entryID", prevEntry.ID) - } - - response.HTML().Render("entry", args.Merge(tplParams{ - "entry": entry, - "prevEntry": prevEntry, - "nextEntry": nextEntry, - "nextEntryRoute": nextEntryRoute, - "prevEntryRoute": prevEntryRoute, - "menu": "history", - })) -} - -// ShowStarredEntry shows a single feed entry in "starred" mode. -func (c *Controller) ShowStarredEntry(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithEntryID(entryID) - builder.WithoutStatus(model.EntryStatusRemoved) - - entry, err := builder.GetEntry() - if err != nil { - response.HTML().ServerError(err) - return - } - - if entry == nil { - response.HTML().NotFound() - return - } - - if entry.Status == model.EntryStatusUnread { - err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead) - if err != nil { - logger.Error("[Controller:ShowReadEntry] %v", err) - response.HTML().ServerError(nil) - return - } - } - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - builder = c.store.NewEntryQueryBuilder(user.ID) - builder.WithStarred() - - prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - nextEntryRoute := "" - if nextEntry != nil { - nextEntryRoute = ctx.Route("starredEntry", "entryID", nextEntry.ID) - } - - prevEntryRoute := "" - if prevEntry != nil { - prevEntryRoute = ctx.Route("starredEntry", "entryID", prevEntry.ID) - } - - response.HTML().Render("entry", args.Merge(tplParams{ - "entry": entry, - "prevEntry": prevEntry, - "nextEntry": nextEntry, - "nextEntryRoute": nextEntryRoute, - "prevEntryRoute": prevEntryRoute, - "menu": "starred", - })) -} - -// UpdateEntriesStatus handles Ajax request to update the status for a list of entries. -func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - entryIDs, status, err := payload.DecodeEntryStatusPayload(request.Body()) - if err != nil { - logger.Error("[Controller:UpdateEntryStatus] %v", err) - response.JSON().BadRequest(nil) - return - } - - if len(entryIDs) == 0 { - response.JSON().BadRequest(errors.New("The list of entryID is empty")) - return - } - - err = c.store.SetEntriesStatus(user.ID, entryIDs, status) - if err != nil { - logger.Error("[Controller:UpdateEntryStatus] %v", err) - response.JSON().ServerError(nil) - return - } - - response.JSON().Standard("OK") -} - -func (c *Controller) getEntryPrevNext(user *model.User, builder *storage.EntryQueryBuilder, entryID int64) (prev *model.Entry, next *model.Entry, err error) { - builder.WithoutStatus(model.EntryStatusRemoved) - builder.WithOrder(model.DefaultSortingOrder) - builder.WithDirection(user.EntryDirection) - - entries, err := builder.GetEntries() - if err != nil { - return nil, nil, err - } - - n := len(entries) - for i := 0; i < n; i++ { - if entries[i].ID == entryID { - if i-1 >= 0 { - prev = entries[i-1] - } - - if i+1 < n { - next = entries[i+1] - } - } - } - - return prev, next, nil -} diff --git a/server/ui/controller/feed.go b/server/ui/controller/feed.go deleted file mode 100644 index 7dfc56e..0000000 --- a/server/ui/controller/feed.go +++ /dev/null @@ -1,236 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" -) - -// RefreshAllFeeds refresh all feeds in the background for the current user. -func (c *Controller) RefreshAllFeeds(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - jobs, err := c.store.NewUserBatch(user.ID, c.store.CountFeeds(user.ID)) - if err != nil { - response.HTML().ServerError(err) - return - } - - go func() { - c.pool.Push(jobs) - }() - - response.Redirect(ctx.Route("feeds")) -} - -// ShowFeedsPage shows the page with all subscriptions. -func (c *Controller) ShowFeedsPage(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - feeds, err := c.store.Feeds(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("feeds", args.Merge(tplParams{ - "feeds": feeds, - "total": len(feeds), - "menu": "feeds", - })) -} - -// ShowFeedEntries shows all entries for the given feed. -func (c *Controller) ShowFeedEntries(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - offset := request.QueryIntegerParam("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.NewEntryQueryBuilder(user.ID) - builder.WithFeedID(feed.ID) - builder.WithoutStatus(model.EntryStatusRemoved) - builder.WithOrder(model.DefaultSortingOrder) - builder.WithDirection(user.EntryDirection) - 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.Route("feedEntries", "feedID", feed.ID), count, offset), - "menu": "feeds", - })) -} - -// EditFeed shows the form to modify a subscription. -func (c *Controller) EditFeed(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - 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) -} - -// UpdateFeed update a subscription and redirect to the feed entries page. -func (c *Controller) UpdateFeed(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - feed, err := c.getFeedFromURL(request, response, user) - if err != nil { - return - } - - feedForm := form.NewFeedForm(request.Request()) - 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 { - logger.Error("[Controller:EditFeed] %v", err) - response.HTML().Render("edit_feed", args.Merge(tplParams{ - "errorMessage": "Unable to update this feed.", - })) - return - } - - response.Redirect(ctx.Route("feedEntries", "feedID", feed.ID)) -} - -// RemoveFeed delete a subscription from the database and redirect to the list of feeds page. -func (c *Controller) RemoveFeed(ctx *core.Context, request *core.Request, response *core.Response) { - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.HTML().ServerError(err) - return - } - - user := ctx.LoggedUser() - if err := c.store.RemoveFeed(user.ID, feedID); err != nil { - response.HTML().ServerError(err) - return - } - - response.Redirect(ctx.Route("feeds")) -} - -// RefreshFeed refresh a subscription and redirect to the feed entries page. -func (c *Controller) RefreshFeed(ctx *core.Context, request *core.Request, response *core.Response) { - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - user := ctx.LoggedUser() - if err := c.feedHandler.RefreshFeed(user.ID, feedID); err != nil { - logger.Error("[Controller:RefreshFeed] %v", err) - } - - response.Redirect(ctx.Route("feedEntries", "feedID", feedID)) -} - -func (c *Controller) getFeedFromURL(request *core.Request, response *core.Response, user *model.User) (*model.Feed, error) { - feedID, err := request.IntegerParam("feedID") - if err != nil { - response.HTML().BadRequest(err) - return nil, err - } - - feed, err := c.store.FeedByID(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.Categories(user.ID) - if err != nil { - return nil, err - } - - if feedForm == nil { - args["form"] = form.FeedForm{ - SiteURL: feed.SiteURL, - FeedURL: feed.FeedURL, - Title: feed.Title, - ScraperRules: feed.ScraperRules, - RewriteRules: feed.RewriteRules, - Crawler: feed.Crawler, - 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 deleted file mode 100644 index 7347bac..0000000 --- a/server/ui/controller/history.go +++ /dev/null @@ -1,61 +0,0 @@ -// 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/miniflux/model" - "github.com/miniflux/miniflux/server/core" -) - -// ShowHistoryPage renders the page with all read entries. -func (c *Controller) ShowHistoryPage(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - offset := request.QueryIntegerParam("offset", 0) - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithStatus(model.EntryStatusRead) - builder.WithOrder(model.DefaultSortingOrder) - builder.WithDirection(user.EntryDirection) - 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.Route("history"), count, offset), - "menu": "history", - })) -} - -// FlushHistory changes all "read" items to "removed". -func (c *Controller) FlushHistory(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - err := c.store.FlushHistory(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.Redirect(ctx.Route("history")) -} diff --git a/server/ui/controller/icon.go b/server/ui/controller/icon.go deleted file mode 100644 index f5ff1db..0000000 --- a/server/ui/controller/icon.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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 ( - "time" - - "github.com/miniflux/miniflux/server/core" -) - -// ShowIcon shows the feed icon. -func (c *Controller) ShowIcon(ctx *core.Context, request *core.Request, response *core.Response) { - iconID, err := request.IntegerParam("iconID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - icon, err := c.store.IconByID(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/integrations.go b/server/ui/controller/integrations.go deleted file mode 100644 index 9ff4baa..0000000 --- a/server/ui/controller/integrations.go +++ /dev/null @@ -1,84 +0,0 @@ -// 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 ( - "crypto/md5" - "fmt" - - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" -) - -// ShowIntegrations renders the page with all external integrations. -func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - integration, err := c.store.Integration(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("integrations", args.Merge(tplParams{ - "menu": "settings", - "form": form.IntegrationForm{ - PinboardEnabled: integration.PinboardEnabled, - PinboardToken: integration.PinboardToken, - PinboardTags: integration.PinboardTags, - PinboardMarkAsUnread: integration.PinboardMarkAsUnread, - InstapaperEnabled: integration.InstapaperEnabled, - InstapaperUsername: integration.InstapaperUsername, - InstapaperPassword: integration.InstapaperPassword, - FeverEnabled: integration.FeverEnabled, - FeverUsername: integration.FeverUsername, - FeverPassword: integration.FeverPassword, - WallabagEnabled: integration.WallabagEnabled, - WallabagURL: integration.WallabagURL, - WallabagClientID: integration.WallabagClientID, - WallabagClientSecret: integration.WallabagClientSecret, - WallabagUsername: integration.WallabagUsername, - WallabagPassword: integration.WallabagPassword, - }, - })) -} - -// UpdateIntegration updates integration settings. -func (c *Controller) UpdateIntegration(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - integration, err := c.store.Integration(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - integrationForm := form.NewIntegrationForm(request.Request()) - integrationForm.Merge(integration) - - if integration.FeverUsername != "" && c.store.HasDuplicateFeverUsername(user.ID, integration.FeverUsername) { - ctx.SetFlashErrorMessage(ctx.Translate("There is already someone else with the same Fever username!")) - response.Redirect(ctx.Route("integrations")) - return - } - - if integration.FeverEnabled { - integration.FeverToken = fmt.Sprintf("%x", md5.Sum([]byte(integration.FeverUsername+":"+integration.FeverPassword))) - } else { - integration.FeverToken = "" - } - - err = c.store.UpdateIntegration(integration) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.Redirect(ctx.Route("integrations")) -} diff --git a/server/ui/controller/login.go b/server/ui/controller/login.go deleted file mode 100644 index ef99c82..0000000 --- a/server/ui/controller/login.go +++ /dev/null @@ -1,76 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/server/cookie" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" - - "github.com/tomasen/realip" -) - -// ShowLoginPage shows the login form. -func (c *Controller) ShowLoginPage(ctx *core.Context, request *core.Request, response *core.Response) { - if ctx.IsAuthenticated() { - response.Redirect(ctx.Route("unread")) - return - } - - response.HTML().Render("login", tplParams{ - "csrf": ctx.CSRF(), - }) -} - -// CheckLogin validates the username/password and redirects the user to the unread page. -func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, response *core.Response) { - authForm := form.NewAuthForm(request.Request()) - tplParams := tplParams{ - "errorMessage": "Invalid username or password.", - "csrf": ctx.CSRF(), - "form": authForm, - } - - if err := authForm.Validate(); err != nil { - logger.Error("[Controller:CheckLogin] %v", err) - response.HTML().Render("login", tplParams) - return - } - - if err := c.store.CheckPassword(authForm.Username, authForm.Password); err != nil { - logger.Error("[Controller:CheckLogin] %v", err) - response.HTML().Render("login", tplParams) - return - } - - sessionToken, err := c.store.CreateUserSession( - authForm.Username, - request.Request().UserAgent(), - realip.RealIP(request.Request()), - ) - - if err != nil { - response.HTML().ServerError(err) - return - } - - logger.Info("[Controller:CheckLogin] username=%s just logged in", authForm.Username) - - response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS)) - response.Redirect(ctx.Route("unread")) -} - -// Logout destroy the session and redirects the user to the login page. -func (c *Controller) Logout(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - if err := c.store.RemoveUserSessionByToken(user.ID, ctx.UserSessionToken()); err != nil { - logger.Error("[Controller:Logout] %v", err) - } - - response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, c.cfg.IsHTTPS)) - response.Redirect(ctx.Route("login")) -} diff --git a/server/ui/controller/oauth2.go b/server/ui/controller/oauth2.go deleted file mode 100644 index 2aaa5d7..0000000 --- a/server/ui/controller/oauth2.go +++ /dev/null @@ -1,170 +0,0 @@ -// 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/miniflux/config" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/cookie" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/oauth2" - "github.com/tomasen/realip" -) - -// OAuth2Redirect redirects the user to the consent page to ask for permission. -func (c *Controller) OAuth2Redirect(ctx *core.Context, request *core.Request, response *core.Response) { - provider := request.StringParam("provider", "") - if provider == "" { - logger.Error("[OAuth2] Invalid or missing provider: %s", provider) - response.Redirect(ctx.Route("login")) - return - } - - authProvider, err := getOAuth2Manager(c.cfg).Provider(provider) - if err != nil { - logger.Error("[OAuth2] %v", err) - response.Redirect(ctx.Route("login")) - return - } - - response.Redirect(authProvider.GetRedirectURL(ctx.GenerateOAuth2State())) -} - -// OAuth2Callback receives the authorization code and create a new session. -func (c *Controller) OAuth2Callback(ctx *core.Context, request *core.Request, response *core.Response) { - provider := request.StringParam("provider", "") - if provider == "" { - logger.Error("[OAuth2] Invalid or missing provider") - response.Redirect(ctx.Route("login")) - return - } - - code := request.QueryStringParam("code", "") - if code == "" { - logger.Error("[OAuth2] No code received on callback") - response.Redirect(ctx.Route("login")) - return - } - - state := request.QueryStringParam("state", "") - if state == "" || state != ctx.OAuth2State() { - logger.Error(`[OAuth2] Invalid state value: got "%s" instead of "%s"`, state, ctx.OAuth2State()) - response.Redirect(ctx.Route("login")) - return - } - - authProvider, err := getOAuth2Manager(c.cfg).Provider(provider) - if err != nil { - logger.Error("[OAuth2] %v", err) - response.Redirect(ctx.Route("login")) - return - } - - profile, err := authProvider.GetProfile(code) - if err != nil { - logger.Error("[OAuth2] %v", err) - response.Redirect(ctx.Route("login")) - return - } - - if ctx.IsAuthenticated() { - user, err := c.store.UserByExtraField(profile.Key, profile.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - if user != nil { - logger.Error("[OAuth2] User #%d cannot be associated because %s is already associated", ctx.UserID(), user.Username) - ctx.SetFlashErrorMessage(ctx.Translate("There is already someone associated with this provider!")) - response.Redirect(ctx.Route("settings")) - return - } - - user = ctx.LoggedUser() - if err := c.store.UpdateExtraField(user.ID, profile.Key, profile.ID); err != nil { - response.HTML().ServerError(err) - return - } - - ctx.SetFlashMessage(ctx.Translate("Your external account is now linked !")) - response.Redirect(ctx.Route("settings")) - return - } - - user, err := c.store.UserByExtraField(profile.Key, profile.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - if user == nil { - if c.cfg.GetInt("OAUTH2_USER_CREATION", 0) == 0 { - response.HTML().Forbidden() - return - } - - user = model.NewUser() - user.Username = profile.Username - user.IsAdmin = false - user.Extra[profile.Key] = profile.ID - - if err := c.store.CreateUser(user); err != nil { - response.HTML().ServerError(err) - return - } - } - - sessionToken, err := c.store.CreateUserSession( - user.Username, - request.Request().UserAgent(), - realip.RealIP(request.Request()), - ) - - if err != nil { - response.HTML().ServerError(err) - return - } - - logger.Info("[Controller:OAuth2Callback] username=%s just logged in", user.Username) - - response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS)) - response.Redirect(ctx.Route("unread")) -} - -// OAuth2Unlink unlink an account from the external provider. -func (c *Controller) OAuth2Unlink(ctx *core.Context, request *core.Request, response *core.Response) { - provider := request.StringParam("provider", "") - if provider == "" { - logger.Info("[OAuth2] Invalid or missing provider") - response.Redirect(ctx.Route("login")) - return - } - - authProvider, err := getOAuth2Manager(c.cfg).Provider(provider) - if err != nil { - logger.Error("[OAuth2] %v", err) - response.Redirect(ctx.Route("settings")) - return - } - - user := ctx.LoggedUser() - if err := c.store.RemoveExtraField(user.ID, authProvider.GetUserExtraKey()); err != nil { - response.HTML().ServerError(err) - return - } - - response.Redirect(ctx.Route("settings")) - return -} - -func getOAuth2Manager(cfg *config.Config) *oauth2.Manager { - return oauth2.NewManager( - cfg.Get("OAUTH2_CLIENT_ID", ""), - cfg.Get("OAUTH2_CLIENT_SECRET", ""), - cfg.Get("OAUTH2_REDIRECT_URL", ""), - ) -} diff --git a/server/ui/controller/opml.go b/server/ui/controller/opml.go deleted file mode 100644 index d801677..0000000 --- a/server/ui/controller/opml.go +++ /dev/null @@ -1,71 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/server/core" -) - -// Export generates the OPML file. -func (c *Controller) Export(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - opml, err := c.opmlHandler.Export(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.XML().Download("feeds.opml", opml) -} - -// Import shows the import form. -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", - })) -} - -// UploadOPML handles OPML file importation. -func (c *Controller) UploadOPML(ctx *core.Context, request *core.Request, response *core.Response) { - file, fileHeader, err := request.File("file") - if err != nil { - logger.Error("[Controller:UploadOPML] %v", err) - response.Redirect(ctx.Route("import")) - return - } - defer file.Close() - - user := ctx.LoggedUser() - logger.Info( - "[Controller:UploadOPML] User #%d uploaded this file: %s (%d bytes)", - 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, - "menu": "feeds", - })) - - return - } - - response.Redirect(ctx.Route("feeds")) -} diff --git a/server/ui/controller/pagination.go b/server/ui/controller/pagination.go deleted file mode 100644 index 1d61f74..0000000 --- a/server/ui/controller/pagination.go +++ /dev/null @@ -1,46 +0,0 @@ -// 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 deleted file mode 100644 index 6ee52b8..0000000 --- a/server/ui/controller/proxy.go +++ /dev/null @@ -1,56 +0,0 @@ -// 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" - "io/ioutil" - "time" - - "github.com/miniflux/miniflux/crypto" - "github.com/miniflux/miniflux/http" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/server/core" -) - -// ImageProxy fetch an image from a remote server and sent it back to the browser. -func (c *Controller) ImageProxy(ctx *core.Context, request *core.Request, response *core.Response) { - // If we receive a "If-None-Match" header we assume the image in stored in browser cache - if request.Request().Header.Get("If-None-Match") != "" { - response.NotModified() - return - } - - encodedURL := request.StringParam("encodedURL", "") - if encodedURL == "" { - response.HTML().BadRequest(errors.New("No URL provided")) - return - } - - decodedURL, err := base64.URLEncoding.DecodeString(encodedURL) - if err != nil { - response.HTML().BadRequest(errors.New("Unable to decode this URL")) - return - } - - client := http.NewClient(string(decodedURL)) - resp, err := client.Get() - if err != nil { - logger.Error("[Controller:ImageProxy] %v", err) - response.HTML().NotFound() - return - } - - if resp.HasServerFailure() { - response.HTML().NotFound() - return - } - - body, _ := ioutil.ReadAll(resp.Body) - etag := crypto.HashFromBytes(body) - - response.Cache(resp.ContentType, etag, body, 72*time.Hour) -} diff --git a/server/ui/controller/session.go b/server/ui/controller/session.go deleted file mode 100644 index 05cb29e..0000000 --- a/server/ui/controller/session.go +++ /dev/null @@ -1,50 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/server/core" -) - -// ShowSessions shows the list of active user sessions. -func (c *Controller) ShowSessions(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - sessions, err := c.store.UserSessions(user.ID) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("sessions", args.Merge(tplParams{ - "sessions": sessions, - "currentSessionToken": ctx.UserSessionToken(), - "menu": "settings", - })) -} - -// RemoveSession remove a user session. -func (c *Controller) RemoveSession(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - sessionID, err := request.IntegerParam("sessionID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - err = c.store.RemoveUserSessionByID(user.ID, sessionID) - if err != nil { - logger.Error("[Controller:RemoveSession] %v", err) - } - - response.Redirect(ctx.Route("sessions")) -} diff --git a/server/ui/controller/settings.go b/server/ui/controller/settings.go deleted file mode 100644 index feba893..0000000 --- a/server/ui/controller/settings.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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/miniflux/locale" - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" -) - -// ShowSettings shows the settings page. -func (c *Controller) ShowSettings(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - args, err := c.getSettingsFormTemplateArgs(ctx, user, nil) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("settings", args) -} - -// UpdateSettings update the settings. -func (c *Controller) UpdateSettings(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - settingsForm := form.NewSettingsForm(request.Request()) - 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 { - logger.Error("[Controller:UpdateSettings] %v", err) - response.HTML().Render("settings", args.Merge(tplParams{ - "form": settingsForm, - "errorMessage": "Unable to update this user.", - })) - return - } - - ctx.SetFlashMessage(ctx.Translate("Preferences saved!")) - response.Redirect(ctx.Route("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, - EntryDirection: user.EntryDirection, - } - } else { - args["form"] = settingsForm - } - - args["menu"] = "settings" - args["themes"] = model.Themes() - args["languages"] = locale.AvailableLanguages() - args["timezones"], err = c.store.Timezones() - if err != nil { - return args, err - } - - return args, nil -} diff --git a/server/ui/controller/starred.go b/server/ui/controller/starred.go deleted file mode 100644 index e9da241..0000000 --- a/server/ui/controller/starred.go +++ /dev/null @@ -1,68 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/core" -) - -// ShowStarredPage renders the page with all starred entries. -func (c *Controller) ShowStarredPage(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - offset := request.QueryIntegerParam("offset", 0) - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithoutStatus(model.EntryStatusRemoved) - builder.WithStarred() - builder.WithOrder(model.DefaultSortingOrder) - builder.WithDirection(user.EntryDirection) - 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("starred", args.Merge(tplParams{ - "entries": entries, - "total": count, - "pagination": c.getPagination(ctx.Route("starred"), count, offset), - "menu": "starred", - })) -} - -// ToggleBookmark handles Ajax request to toggle bookmark value. -func (c *Controller) ToggleBookmark(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - entryID, err := request.IntegerParam("entryID") - if err != nil { - response.HTML().BadRequest(err) - return - } - - if err := c.store.ToggleBookmark(user.ID, entryID); err != nil { - logger.Error("[Controller:UpdateEntryStatus] %v", err) - response.JSON().ServerError(nil) - return - } - - response.JSON().Standard("OK") -} diff --git a/server/ui/controller/static.go b/server/ui/controller/static.go deleted file mode 100644 index 7cf7a35..0000000 --- a/server/ui/controller/static.go +++ /dev/null @@ -1,97 +0,0 @@ -// 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" - "time" - - "github.com/miniflux/miniflux/logger" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/static" -) - -// Stylesheet renders the CSS. -func (c *Controller) Stylesheet(ctx *core.Context, request *core.Request, response *core.Response) { - stylesheet := request.StringParam("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; charset=utf-8", etag, []byte(body), 48*time.Hour) -} - -// Javascript renders application client side code. -func (c *Controller) Javascript(ctx *core.Context, request *core.Request, response *core.Response) { - response.Cache("text/javascript; charset=utf-8", static.JavascriptChecksums["app"], []byte(static.Javascript["app"]), 48*time.Hour) -} - -// Favicon renders the application favicon. -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 { - logger.Error("[Controller:Favicon] %v", err) - response.HTML().NotFound() - return - } - - response.Cache("image/x-icon", static.BinariesChecksums["favicon.ico"], blob, 48*time.Hour) -} - -// AppIcon returns application icons. -func (c *Controller) AppIcon(ctx *core.Context, request *core.Request, response *core.Response) { - filename := request.StringParam("filename", "favicon.png") - encodedBlob, found := static.Binaries[filename] - if !found { - logger.Info("[Controller:AppIcon] This icon doesn't exists: %s", filename) - response.HTML().NotFound() - return - } - - blob, err := base64.StdEncoding.DecodeString(encodedBlob) - if err != nil { - logger.Error("[Controller:AppIcon] %v", err) - response.HTML().NotFound() - return - } - - response.Cache("image/png", static.BinariesChecksums[filename], blob, 48*time.Hour) -} - -// WebManifest renders web manifest file. -func (c *Controller) WebManifest(ctx *core.Context, request *core.Request, response *core.Response) { - type webManifestIcon struct { - Source string `json:"src"` - Sizes string `json:"sizes"` - Type string `json:"type"` - } - - type webManifest struct { - Name string `json:"name"` - Description string `json:"description"` - ShortName string `json:"short_name"` - StartURL string `json:"start_url"` - Icons []webManifestIcon `json:"icons"` - Display string `json:"display"` - } - - manifest := &webManifest{ - Name: "Miniflux", - ShortName: "Miniflux", - Description: "Minimalist Feed Reader", - Display: "minimal-ui", - StartURL: ctx.Route("unread"), - Icons: []webManifestIcon{ - webManifestIcon{Source: ctx.Route("appIcon", "filename", "touch-icon-ipad-retina.png"), Sizes: "144x144", Type: "image/png"}, - webManifestIcon{Source: ctx.Route("appIcon", "filename", "touch-icon-iphone-retina.png"), Sizes: "114x114", Type: "image/png"}, - }, - } - - response.JSON().Standard(manifest) -} diff --git a/server/ui/controller/subscription.go b/server/ui/controller/subscription.go deleted file mode 100644 index d243f9a..0000000 --- a/server/ui/controller/subscription.go +++ /dev/null @@ -1,145 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/reader/subscription" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" -) - -// Bookmarklet prefill the form to add a subscription from the URL provided by the bookmarklet. -func (c *Controller) Bookmarklet(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - args, err := c.getSubscriptionFormTemplateArgs(ctx, user) - if err != nil { - response.HTML().ServerError(err) - return - } - - bookmarkletURL := request.QueryStringParam("uri", "") - response.HTML().Render("add_subscription", args.Merge(tplParams{ - "form": &form.SubscriptionForm{URL: bookmarkletURL}, - })) -} - -// AddSubscription shows the form to add a new feed. -func (c *Controller) AddSubscription(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - args, err := c.getSubscriptionFormTemplateArgs(ctx, user) - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("add_subscription", args) -} - -// SubmitSubscription try to find a feed from the URL provided by the user. -func (c *Controller) SubmitSubscription(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - args, err := c.getSubscriptionFormTemplateArgs(ctx, user) - if err != nil { - response.HTML().ServerError(err) - return - } - - subscriptionForm := form.NewSubscriptionForm(request.Request()) - 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 { - logger.Error("[Controller:SubmitSubscription] %v", err) - response.HTML().Render("add_subscription", args.Merge(tplParams{ - "form": subscriptionForm, - "errorMessage": err, - })) - return - } - - logger.Info("[UI:SubmitSubscription] %s", 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, subscriptionForm.Crawler) - if err != nil { - response.HTML().Render("add_subscription", args.Merge(tplParams{ - "form": subscriptionForm, - "errorMessage": err, - })) - return - } - - response.Redirect(ctx.Route("feedEntries", "feedID", feed.ID)) - case n > 1: - response.HTML().Render("choose_subscription", args.Merge(tplParams{ - "categoryID": subscriptionForm.CategoryID, - "subscriptions": subscriptions, - })) - } -} - -// ChooseSubscription shows a page to choose a subscription. -func (c *Controller) ChooseSubscription(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - args, err := c.getSubscriptionFormTemplateArgs(ctx, user) - if err != nil { - response.HTML().ServerError(err) - return - } - - subscriptionForm := form.NewSubscriptionForm(request.Request()) - 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, subscriptionForm.Crawler) - if err != nil { - response.HTML().Render("add_subscription", args.Merge(tplParams{ - "form": subscriptionForm, - "errorMessage": err, - })) - return - } - - response.Redirect(ctx.Route("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.Categories(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 deleted file mode 100644 index 1dd7b07..0000000 --- a/server/ui/controller/unread.go +++ /dev/null @@ -1,49 +0,0 @@ -// 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/miniflux/model" - "github.com/miniflux/miniflux/server/core" -) - -// ShowUnreadPage render the page with all unread entries. -func (c *Controller) ShowUnreadPage(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - offset := request.QueryIntegerParam("offset", 0) - - builder := c.store.NewEntryQueryBuilder(user.ID) - builder.WithStatus(model.EntryStatusUnread) - countUnread, err := builder.CountEntries() - if err != nil { - response.HTML().ServerError(err) - return - } - - if offset >= countUnread { - offset = 0 - } - - builder = c.store.NewEntryQueryBuilder(user.ID) - builder.WithStatus(model.EntryStatusUnread) - builder.WithOrder(model.DefaultSortingOrder) - builder.WithDirection(user.EntryDirection) - builder.WithOffset(offset) - builder.WithLimit(nbItemsPerPage) - entries, err := builder.GetEntries() - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("unread", tplParams{ - "user": user, - "countUnread": countUnread, - "entries": entries, - "pagination": c.getPagination(ctx.Route("unread"), countUnread, offset), - "menu": "unread", - "csrf": ctx.CSRF(), - }) -} diff --git a/server/ui/controller/user.go b/server/ui/controller/user.go deleted file mode 100644 index c5d4dba..0000000 --- a/server/ui/controller/user.go +++ /dev/null @@ -1,238 +0,0 @@ -// 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/miniflux/logger" - "github.com/miniflux/miniflux/model" - "github.com/miniflux/miniflux/server/core" - "github.com/miniflux/miniflux/server/ui/form" -) - -// ShowUsers shows the list of users. -func (c *Controller) ShowUsers(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - if !user.IsAdmin { - response.HTML().Forbidden() - return - } - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - users, err := c.store.Users() - if err != nil { - response.HTML().ServerError(err) - return - } - - response.HTML().Render("users", args.Merge(tplParams{ - "users": users, - "menu": "settings", - })) -} - -// CreateUser shows the user creation form. -func (c *Controller) CreateUser(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - 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{}, - })) -} - -// SaveUser validate and save the new user into the database. -func (c *Controller) SaveUser(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - if !user.IsAdmin { - response.HTML().Forbidden() - return - } - - args, err := c.getCommonTemplateArgs(ctx) - if err != nil { - response.HTML().ServerError(err) - return - } - - userForm := form.NewUserForm(request.Request()) - 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 { - logger.Error("[Controller:SaveUser] %v", err) - response.HTML().Render("edit_user", args.Merge(tplParams{ - "menu": "settings", - "form": userForm, - "errorMessage": "Unable to create this user.", - })) - return - } - - response.Redirect(ctx.Route("users")) -} - -// EditUser shows the form to edit a user. -func (c *Controller) EditUser(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - 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, - }, - })) -} - -// UpdateUser validate and update a user. -func (c *Controller) UpdateUser(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - - 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.Request()) - 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 { - logger.Error("[Controller:UpdateUser] %v", 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.Route("users")) -} - -// RemoveUser deletes a user from the database. -func (c *Controller) RemoveUser(ctx *core.Context, request *core.Request, response *core.Response) { - user := ctx.LoggedUser() - 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.Route("users")) -} - -func (c *Controller) getUserFromURL(ctx *core.Context, request *core.Request, response *core.Response) (*model.User, error) { - userID, err := request.IntegerParam("userID") - if err != nil { - response.HTML().BadRequest(err) - return nil, err - } - - user, err := c.store.UserByID(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 deleted file mode 100644 index 12c9da6..0000000 --- a/server/ui/filter/image_proxy_filter.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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" - "strings" - - "github.com/miniflux/miniflux/server/route" - "github.com/miniflux/miniflux/url" - - "github.com/PuerkitoBio/goquery" - "github.com/gorilla/mux" -) - -// ImageProxyFilter rewrites image tag URLs without HTTPS to local proxy URL -func ImageProxyFilter(router *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) { - img.SetAttr("src", Proxify(router, srcAttr)) - } - } - }) - - output, _ := doc.Find("body").First().Html() - return output -} - -// Proxify returns a proxified link. -func Proxify(router *mux.Router, link string) string { - // We use base64 url encoding to avoid slash in the URL. - return route.Path(router, "proxy", "encodedURL", base64.URLEncoding.EncodeToString([]byte(link))) -} diff --git a/server/ui/filter/image_proxy_filter_test.go b/server/ui/filter/image_proxy_filter_test.go deleted file mode 100644 index 992516e..0000000 --- a/server/ui/filter/image_proxy_filter_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 deleted file mode 100644 index c18a0be..0000000 --- a/server/ui/form/auth.go +++ /dev/null @@ -1,34 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/errors" -) - -// AuthForm represents the authentication form. -type AuthForm struct { - Username string - Password string -} - -// Validate makes sure the form values are valid. -func (a AuthForm) Validate() error { - if a.Username == "" || a.Password == "" { - return errors.NewLocalizedError("All fields are mandatory.") - } - - return nil -} - -// NewAuthForm returns a new AuthForm. -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 deleted file mode 100644 index 31b7196..0000000 --- a/server/ui/form/category.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/errors" - "github.com/miniflux/miniflux/model" -) - -// CategoryForm represents a feed form in the UI -type CategoryForm struct { - Title string -} - -// Validate makes sure the form values are valid. -func (c CategoryForm) Validate() error { - if c.Title == "" { - return errors.NewLocalizedError("The title is mandatory.") - } - return nil -} - -// Merge update the given category fields. -func (c CategoryForm) Merge(category *model.Category) *model.Category { - category.Title = c.Title - return category -} - -// NewCategoryForm returns a new CategoryForm. -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 deleted file mode 100644 index 896a6d7..0000000 --- a/server/ui/form/feed.go +++ /dev/null @@ -1,64 +0,0 @@ -// 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 ( - "net/http" - "strconv" - - "github.com/miniflux/miniflux/errors" - "github.com/miniflux/miniflux/model" -) - -// FeedForm represents a feed form in the UI -type FeedForm struct { - FeedURL string - SiteURL string - Title string - ScraperRules string - RewriteRules string - Crawler bool - CategoryID int64 -} - -// ValidateModification validates FeedForm fields -func (f FeedForm) ValidateModification() error { - if f.FeedURL == "" || f.SiteURL == "" || f.Title == "" || f.CategoryID == 0 { - return errors.NewLocalizedError("All fields are mandatory.") - } - return nil -} - -// Merge updates the fields of the given feed. -func (f FeedForm) Merge(feed *model.Feed) *model.Feed { - feed.Category.ID = f.CategoryID - feed.Title = f.Title - feed.SiteURL = f.SiteURL - feed.FeedURL = f.FeedURL - feed.ScraperRules = f.ScraperRules - feed.RewriteRules = f.RewriteRules - feed.Crawler = f.Crawler - 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"), - ScraperRules: r.FormValue("scraper_rules"), - RewriteRules: r.FormValue("rewrite_rules"), - Crawler: r.FormValue("crawler") == "1", - CategoryID: int64(categoryID), - } -} diff --git a/server/ui/form/integration.go b/server/ui/form/integration.go deleted file mode 100644 index 8cc6d35..0000000 --- a/server/ui/form/integration.go +++ /dev/null @@ -1,73 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/model" -) - -// IntegrationForm represents user integration settings form. -type IntegrationForm struct { - PinboardEnabled bool - PinboardToken string - PinboardTags string - PinboardMarkAsUnread bool - InstapaperEnabled bool - InstapaperUsername string - InstapaperPassword string - FeverEnabled bool - FeverUsername string - FeverPassword string - WallabagEnabled bool - WallabagURL string - WallabagClientID string - WallabagClientSecret string - WallabagUsername string - WallabagPassword string -} - -// Merge copy form values to the model. -func (i IntegrationForm) Merge(integration *model.Integration) { - integration.PinboardEnabled = i.PinboardEnabled - integration.PinboardToken = i.PinboardToken - integration.PinboardTags = i.PinboardTags - integration.PinboardMarkAsUnread = i.PinboardMarkAsUnread - integration.InstapaperEnabled = i.InstapaperEnabled - integration.InstapaperUsername = i.InstapaperUsername - integration.InstapaperPassword = i.InstapaperPassword - integration.FeverEnabled = i.FeverEnabled - integration.FeverUsername = i.FeverUsername - integration.FeverPassword = i.FeverPassword - integration.WallabagEnabled = i.WallabagEnabled - integration.WallabagURL = i.WallabagURL - integration.WallabagClientID = i.WallabagClientID - integration.WallabagClientSecret = i.WallabagClientSecret - integration.WallabagUsername = i.WallabagUsername - integration.WallabagPassword = i.WallabagPassword -} - -// NewIntegrationForm returns a new AuthForm. -func NewIntegrationForm(r *http.Request) *IntegrationForm { - return &IntegrationForm{ - PinboardEnabled: r.FormValue("pinboard_enabled") == "1", - PinboardToken: r.FormValue("pinboard_token"), - PinboardTags: r.FormValue("pinboard_tags"), - PinboardMarkAsUnread: r.FormValue("pinboard_mark_as_unread") == "1", - InstapaperEnabled: r.FormValue("instapaper_enabled") == "1", - InstapaperUsername: r.FormValue("instapaper_username"), - InstapaperPassword: r.FormValue("instapaper_password"), - FeverEnabled: r.FormValue("fever_enabled") == "1", - FeverUsername: r.FormValue("fever_username"), - FeverPassword: r.FormValue("fever_password"), - WallabagEnabled: r.FormValue("wallabag_enabled") == "1", - WallabagURL: r.FormValue("wallabag_url"), - WallabagClientID: r.FormValue("wallabag_client_id"), - WallabagClientSecret: r.FormValue("wallabag_client_secret"), - WallabagUsername: r.FormValue("wallabag_username"), - WallabagPassword: r.FormValue("wallabag_password"), - } -} diff --git a/server/ui/form/settings.go b/server/ui/form/settings.go deleted file mode 100644 index e5f6939..0000000 --- a/server/ui/form/settings.go +++ /dev/null @@ -1,70 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/errors" - "github.com/miniflux/miniflux/model" -) - -// SettingsForm represents the settings form. -type SettingsForm struct { - Username string - Password string - Confirmation string - Theme string - Language string - Timezone string - EntryDirection string -} - -// Merge updates the fields of the given user. -func (s *SettingsForm) Merge(user *model.User) *model.User { - user.Username = s.Username - user.Theme = s.Theme - user.Language = s.Language - user.Timezone = s.Timezone - user.EntryDirection = s.EntryDirection - - if s.Password != "" { - user.Password = s.Password - } - - return user -} - -// Validate makes sure the form values are valid. -func (s *SettingsForm) Validate() error { - if s.Username == "" || s.Theme == "" || s.Language == "" || s.Timezone == "" || s.EntryDirection == "" { - return errors.NewLocalizedError("The username, theme, language and timezone fields are mandatory.") - } - - if s.Password != "" { - if s.Password != s.Confirmation { - return errors.NewLocalizedError("Passwords are not the same.") - } - - if len(s.Password) < 6 { - return errors.NewLocalizedError("You must use at least 6 characters") - } - } - - return nil -} - -// NewSettingsForm returns a new SettingsForm. -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"), - EntryDirection: r.FormValue("entry_direction"), - } -} diff --git a/server/ui/form/subscription.go b/server/ui/form/subscription.go deleted file mode 100644 index 7d2caaf..0000000 --- a/server/ui/form/subscription.go +++ /dev/null @@ -1,42 +0,0 @@ -// 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 ( - "net/http" - "strconv" - - "github.com/miniflux/miniflux/errors" -) - -// SubscriptionForm represents the subscription form. -type SubscriptionForm struct { - URL string - CategoryID int64 - Crawler bool -} - -// Validate makes sure the form values are valid. -func (s *SubscriptionForm) Validate() error { - if s.URL == "" || s.CategoryID == 0 { - return errors.NewLocalizedError("The URL and the category are mandatory.") - } - - return nil -} - -// NewSubscriptionForm returns a new SubscriptionForm. -func NewSubscriptionForm(r *http.Request) *SubscriptionForm { - categoryID, err := strconv.Atoi(r.FormValue("category_id")) - if err != nil { - categoryID = 0 - } - - return &SubscriptionForm{ - URL: r.FormValue("url"), - Crawler: r.FormValue("crawler") == "1", - CategoryID: int64(categoryID), - } -} diff --git a/server/ui/form/user.go b/server/ui/form/user.go deleted file mode 100644 index 8b8346e..0000000 --- a/server/ui/form/user.go +++ /dev/null @@ -1,87 +0,0 @@ -// 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 ( - "net/http" - - "github.com/miniflux/miniflux/errors" - "github.com/miniflux/miniflux/model" -) - -// UserForm represents the user form. -type UserForm struct { - Username string - Password string - Confirmation string - IsAdmin bool -} - -// ValidateCreation validates user creation. -func (u UserForm) ValidateCreation() error { - if u.Username == "" || u.Password == "" || u.Confirmation == "" { - return errors.NewLocalizedError("All fields are mandatory.") - } - - if u.Password != u.Confirmation { - return errors.NewLocalizedError("Passwords are not the same.") - } - - if len(u.Password) < 6 { - return errors.NewLocalizedError("You must use at least 6 characters.") - } - - return nil -} - -// ValidateModification validates user modification. -func (u UserForm) ValidateModification() error { - if u.Username == "" { - return errors.NewLocalizedError("The username is mandatory.") - } - - if u.Password != "" { - if u.Password != u.Confirmation { - return errors.NewLocalizedError("Passwords are not the same.") - } - - if len(u.Password) < 6 { - return errors.NewLocalizedError("You must use at least 6 characters.") - } - } - - return nil -} - -// ToUser returns a User from the form values. -func (u UserForm) ToUser() *model.User { - return &model.User{ - Username: u.Username, - Password: u.Password, - IsAdmin: u.IsAdmin, - } -} - -// Merge updates the fields of the given user. -func (u UserForm) Merge(user *model.User) *model.User { - user.Username = u.Username - user.IsAdmin = u.IsAdmin - - if u.Password != "" { - user.Password = u.Password - } - - return user -} - -// NewUserForm returns a new UserForm. -func NewUserForm(r *http.Request) *UserForm { - return &UserForm{ - Username: r.FormValue("username"), - Password: r.FormValue("password"), - Confirmation: r.FormValue("confirmation"), - IsAdmin: r.FormValue("is_admin") == "1", - } -} diff --git a/server/ui/payload/payload.go b/server/ui/payload/payload.go deleted file mode 100644 index d91e34a..0000000 --- a/server/ui/payload/payload.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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" - "io" - - "github.com/miniflux/miniflux/model" -) - -// DecodeEntryStatusPayload unserialize JSON request to update entry statuses. -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 -} |