mirror of
https://github.com/miniflux/v2.git
synced 2025-07-22 17:18:37 +00:00
First commit
This commit is contained in:
commit
8ffb773f43
2121 changed files with 1118910 additions and 0 deletions
97
server/api/controller/category.go
Normal file
97
server/api/controller/category.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/server/api/payload"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
// CreateCategory is the API handler to create a new category.
|
||||
func (c *Controller) CreateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
category, err := payload.DecodeCategoryPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
category.UserID = ctx.GetUserID()
|
||||
if err := category.ValidateCategoryCreation(); err != nil {
|
||||
response.Json().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.CreateCategory(category)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to create this category"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Created(category)
|
||||
}
|
||||
|
||||
// UpdateCategory is the API handler to update a category.
|
||||
func (c *Controller) UpdateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
categoryID, err := request.GetIntegerParam("categoryID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
category, err := payload.DecodeCategoryPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
category.UserID = ctx.GetUserID()
|
||||
category.ID = categoryID
|
||||
if err := category.ValidateCategoryModification(); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.UpdateCategory(category)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to update this category"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Created(category)
|
||||
}
|
||||
|
||||
// GetCategories is the API handler to get a list of categories for a given user.
|
||||
func (c *Controller) GetCategories(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
categories, err := c.store.GetCategories(ctx.GetUserID())
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch categories"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(categories)
|
||||
}
|
||||
|
||||
// RemoveCategory is the API handler to remove a category.
|
||||
func (c *Controller) RemoveCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
categoryID, err := request.GetIntegerParam("categoryID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.store.CategoryExists(userID, categoryID) {
|
||||
response.Json().NotFound(errors.New("Category not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveCategory(userID, categoryID); err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to remove this category"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().NoContent()
|
||||
}
|
21
server/api/controller/controller.go
Normal file
21
server/api/controller/controller.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/reader/feed"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
)
|
||||
|
||||
// Controller holds all handlers for the API.
|
||||
type Controller struct {
|
||||
store *storage.Storage
|
||||
feedHandler *feed.Handler
|
||||
}
|
||||
|
||||
// NewController creates a new controller.
|
||||
func NewController(store *storage.Storage, feedHandler *feed.Handler) *Controller {
|
||||
return &Controller{store: store, feedHandler: feedHandler}
|
||||
}
|
156
server/api/controller/entry.go
Normal file
156
server/api/controller/entry.go
Normal file
|
@ -0,0 +1,156 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/api/payload"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
// GetEntry is the API handler to get a single feed entry.
|
||||
func (c *Controller) GetEntry(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID, err := request.GetIntegerParam("entryID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.GetUserTimezone())
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch this entry from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.Json().NotFound(errors.New("Entry not found"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(entry)
|
||||
}
|
||||
|
||||
// GetFeedEntries is the API handler to get all feed entries.
|
||||
func (c *Controller) GetFeedEntries(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
status := request.GetQueryStringParam("status", "")
|
||||
if status != "" {
|
||||
if err := model.ValidateEntryStatus(status); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := request.GetQueryStringParam("order", "id")
|
||||
if err := model.ValidateEntryOrder(order); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
direction := request.GetQueryStringParam("direction", "desc")
|
||||
if err := model.ValidateDirection(direction); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.GetQueryIntegerParam("limit", 100)
|
||||
offset := request.GetQueryIntegerParam("offset", 0)
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.GetUserTimezone())
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithStatus(status)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch the list of entries"))
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to count the number of entries"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(&payload.EntriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
// SetEntryStatus is the API handler to change the status of an entry.
|
||||
func (c *Controller) SetEntryStatus(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID, err := request.GetIntegerParam("entryID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := payload.DecodeEntryStatusPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(errors.New("Invalid JSON payload"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.ValidateEntryStatus(status); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.GetUserTimezone())
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch this entry from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.Json().NotFound(errors.New("Entry not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.SetEntriesStatus(userID, []int64{entry.ID}, status); err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to change entry status"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err = builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch this entry from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(entry)
|
||||
}
|
138
server/api/controller/feed.go
Normal file
138
server/api/controller/feed.go
Normal file
|
@ -0,0 +1,138 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/server/api/payload"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
// CreateFeed is the API handler to create a new feed.
|
||||
func (c *Controller) CreateFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedURL, categoryID, err := payload.DecodeFeedCreationPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := c.feedHandler.CreateFeed(userID, categoryID, feedURL)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to create this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Created(feed)
|
||||
}
|
||||
|
||||
// RefreshFeed is the API handler to refresh a feed.
|
||||
func (c *Controller) RefreshFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.feedHandler.RefreshFeed(userID, feedID)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to refresh this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().NoContent()
|
||||
}
|
||||
|
||||
// UpdateFeed is the API handler that is used to update a feed.
|
||||
func (c *Controller) UpdateFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
newFeed, err := payload.DecodeFeedModificationPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed, err := c.store.GetFeedById(userID, feedID)
|
||||
if err != nil {
|
||||
response.Json().NotFound(errors.New("Unable to find this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
if originalFeed == nil {
|
||||
response.Json().NotFound(errors.New("Feed not found"))
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed.Merge(newFeed)
|
||||
if err := c.store.UpdateFeed(originalFeed); err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to update this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Created(originalFeed)
|
||||
}
|
||||
|
||||
// GetFeeds is the API handler that get all feeds that belongs to the given user.
|
||||
func (c *Controller) GetFeeds(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
feeds, err := c.store.GetFeeds(ctx.GetUserID())
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch feeds from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(feeds)
|
||||
}
|
||||
|
||||
// GetFeed is the API handler to get a feed.
|
||||
func (c *Controller) GetFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := c.store.GetFeedById(userID, feedID)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
response.Json().NotFound(errors.New("Feed not found"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(feed)
|
||||
}
|
||||
|
||||
// RemoveFeed is the API handler to remove a feed.
|
||||
func (c *Controller) RemoveFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.GetUserID()
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.store.FeedExists(userID, feedID) {
|
||||
response.Json().NotFound(errors.New("Feed not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveFeed(userID, feedID); err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to remove this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().NoContent()
|
||||
}
|
35
server/api/controller/subscription.go
Normal file
35
server/api/controller/subscription.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/miniflux/miniflux2/reader/subscription"
|
||||
"github.com/miniflux/miniflux2/server/api/payload"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
// GetSubscriptions is the API handler to find subscriptions.
|
||||
func (c *Controller) GetSubscriptions(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
websiteURL, err := payload.DecodeURLPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptions, err := subscription.FindSubscriptions(websiteURL)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to discover subscriptions"))
|
||||
return
|
||||
}
|
||||
|
||||
if subscriptions == nil {
|
||||
response.Json().NotFound(fmt.Errorf("No subscription found"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(subscriptions)
|
||||
}
|
163
server/api/controller/user.go
Normal file
163
server/api/controller/user.go
Normal file
|
@ -0,0 +1,163 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/server/api/payload"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
// CreateUser is the API handler to create a new user.
|
||||
func (c *Controller) CreateUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
if !ctx.IsAdminUser() {
|
||||
response.Json().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := payload.DecodeUserPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.ValidateUserCreation(); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
if c.store.UserExists(user.Username) {
|
||||
response.Json().BadRequest(errors.New("This user already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.CreateUser(user)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to create this user"))
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
response.Json().Created(user)
|
||||
}
|
||||
|
||||
// UpdateUser is the API handler to update the given user.
|
||||
func (c *Controller) UpdateUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
if !ctx.IsAdminUser() {
|
||||
response.Json().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := request.GetIntegerParam("userID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := payload.DecodeUserPayload(request.GetBody())
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.ValidateUserModification(); err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
originalUser, err := c.store.GetUserById(userID)
|
||||
if err != nil {
|
||||
response.Json().BadRequest(errors.New("Unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if originalUser == nil {
|
||||
response.Json().NotFound(errors.New("User not found"))
|
||||
return
|
||||
}
|
||||
|
||||
originalUser.Merge(user)
|
||||
if err = c.store.UpdateUser(originalUser); err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to update this user"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Created(originalUser)
|
||||
}
|
||||
|
||||
// GetUsers is the API handler to get the list of users.
|
||||
func (c *Controller) GetUsers(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
if !ctx.IsAdminUser() {
|
||||
response.Json().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
users, err := c.store.GetUsers()
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch the list of users"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(users)
|
||||
}
|
||||
|
||||
// GetUser is the API handler to fetch the given user.
|
||||
func (c *Controller) GetUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
if !ctx.IsAdminUser() {
|
||||
response.Json().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := request.GetIntegerParam("userID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.store.GetUserById(userID)
|
||||
if err != nil {
|
||||
response.Json().BadRequest(errors.New("Unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.Json().NotFound(errors.New("User not found"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard(user)
|
||||
}
|
||||
|
||||
// RemoveUser is the API handler to remove an existing user.
|
||||
func (c *Controller) RemoveUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
if !ctx.IsAdminUser() {
|
||||
response.Json().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := request.GetIntegerParam("userID")
|
||||
if err != nil {
|
||||
response.Json().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.store.GetUserById(userID)
|
||||
if err != nil {
|
||||
response.Json().ServerError(errors.New("Unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.Json().NotFound(errors.New("User not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveUser(user.ID); err != nil {
|
||||
response.Json().BadRequest(errors.New("Unable to remove this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().NoContent()
|
||||
}
|
93
server/api/payload/payload.go
Normal file
93
server/api/payload/payload.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package payload
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"io"
|
||||
)
|
||||
|
||||
type EntriesResponse struct {
|
||||
Total int `json:"total"`
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
func DecodeUserPayload(data io.Reader) (*model.User, error) {
|
||||
var user model.User
|
||||
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode user JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func DecodeURLPayload(data io.Reader) (string, error) {
|
||||
type payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return p.URL, nil
|
||||
}
|
||||
|
||||
func DecodeEntryStatusPayload(data io.Reader) (string, error) {
|
||||
type payload struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return p.Status, nil
|
||||
}
|
||||
|
||||
func DecodeFeedCreationPayload(data io.Reader) (string, int64, error) {
|
||||
type payload struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return "", 0, fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return p.FeedURL, p.CategoryID, nil
|
||||
}
|
||||
|
||||
func DecodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
|
||||
var feed model.Feed
|
||||
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&feed); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode feed JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &feed, nil
|
||||
}
|
||||
|
||||
func DecodeCategoryPayload(data io.Reader) (*model.Category, error) {
|
||||
var category model.Category
|
||||
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&category); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode category JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &category, nil
|
||||
}
|
99
server/core/context.go
Normal file
99
server/core/context.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/route"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Context contains helper functions related to the current request.
|
||||
type Context struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
user *model.User
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
func (c *Context) IsAdminUser() bool {
|
||||
if v := c.request.Context().Value("IsAdminUser"); v != nil {
|
||||
return v.(bool)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetUserTimezone returns the timezone used by the logged user.
|
||||
func (c *Context) GetUserTimezone() string {
|
||||
if v := c.request.Context().Value("UserTimezone"); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return "UTC"
|
||||
}
|
||||
|
||||
// IsAuthenticated returns a boolean if the user is authenticated.
|
||||
func (c *Context) IsAuthenticated() bool {
|
||||
if v := c.request.Context().Value("IsAuthenticated"); v != nil {
|
||||
return v.(bool)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetUserID returns the UserID of the logged user.
|
||||
func (c *Context) GetUserID() int64 {
|
||||
if v := c.request.Context().Value("UserId"); v != nil {
|
||||
return v.(int64)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetLoggedUser returns all properties related to the logged user.
|
||||
func (c *Context) GetLoggedUser() *model.User {
|
||||
if c.user == nil {
|
||||
var err error
|
||||
c.user, err = c.store.GetUserById(c.GetUserID())
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if c.user == nil {
|
||||
log.Fatalln("Unable to find user from context")
|
||||
}
|
||||
}
|
||||
|
||||
return c.user
|
||||
}
|
||||
|
||||
// GetUserLanguage get the locale used by the current logged user.
|
||||
func (c *Context) GetUserLanguage() string {
|
||||
user := c.GetLoggedUser()
|
||||
return user.Language
|
||||
}
|
||||
|
||||
// GetCsrfToken returns the current CSRF token.
|
||||
func (c *Context) GetCsrfToken() string {
|
||||
if v := c.request.Context().Value("CsrfToken"); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
|
||||
log.Println("No CSRF token in context!")
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRoute returns the path for the given arguments.
|
||||
func (c *Context) GetRoute(name string, args ...interface{}) string {
|
||||
return route.GetRoute(c.router, name, args...)
|
||||
}
|
||||
|
||||
// NewContext creates a new Context.
|
||||
func NewContext(w http.ResponseWriter, r *http.Request, store *storage.Storage, router *mux.Router) *Context {
|
||||
return &Context{writer: w, request: r, store: store, router: router}
|
||||
}
|
57
server/core/handler.go
Normal file
57
server/core/handler.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/helper"
|
||||
"github.com/miniflux/miniflux2/locale"
|
||||
"github.com/miniflux/miniflux2/server/middleware"
|
||||
"github.com/miniflux/miniflux2/server/template"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type HandlerFunc func(ctx *Context, request *Request, response *Response)
|
||||
|
||||
type Handler struct {
|
||||
store *storage.Storage
|
||||
translator *locale.Translator
|
||||
template *template.TemplateEngine
|
||||
router *mux.Router
|
||||
middleware *middleware.MiddlewareChain
|
||||
}
|
||||
|
||||
func (h *Handler) Use(f HandlerFunc) http.Handler {
|
||||
return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer helper.ExecutionTime(time.Now(), r.URL.Path)
|
||||
log.Println(r.Method, r.URL.Path)
|
||||
|
||||
ctx := NewContext(w, r, h.store, h.router)
|
||||
request := NewRequest(w, r)
|
||||
response := NewResponse(w, r, h.template)
|
||||
|
||||
if ctx.IsAuthenticated() {
|
||||
h.template.SetLanguage(ctx.GetUserLanguage())
|
||||
} else {
|
||||
h.template.SetLanguage("en_US")
|
||||
}
|
||||
|
||||
f(ctx, request, response)
|
||||
}))
|
||||
}
|
||||
|
||||
func NewHandler(store *storage.Storage, router *mux.Router, template *template.TemplateEngine, translator *locale.Translator, middleware *middleware.MiddlewareChain) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
translator: translator,
|
||||
router: router,
|
||||
template: template,
|
||||
middleware: middleware,
|
||||
}
|
||||
}
|
58
server/core/html_response.go
Normal file
58
server/core/html_response.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/template"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type HtmlResponse struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
template *template.TemplateEngine
|
||||
}
|
||||
|
||||
func (h *HtmlResponse) Render(template string, args map[string]interface{}) {
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.template.Execute(h.writer, template, args)
|
||||
}
|
||||
|
||||
func (h *HtmlResponse) ServerError(err error) {
|
||||
h.writer.WriteHeader(http.StatusInternalServerError)
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
h.writer.Write([]byte("Internal Server Error: " + err.Error()))
|
||||
} else {
|
||||
h.writer.Write([]byte("Internal Server Error"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HtmlResponse) BadRequest(err error) {
|
||||
h.writer.WriteHeader(http.StatusBadRequest)
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
h.writer.Write([]byte("Bad Request: " + err.Error()))
|
||||
} else {
|
||||
h.writer.Write([]byte("Bad Request"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HtmlResponse) NotFound() {
|
||||
h.writer.WriteHeader(http.StatusNotFound)
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.writer.Write([]byte("Page Not Found"))
|
||||
}
|
||||
|
||||
func (h *HtmlResponse) Forbidden() {
|
||||
h.writer.WriteHeader(http.StatusForbidden)
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.writer.Write([]byte("Access Forbidden"))
|
||||
}
|
94
server/core/json_response.go
Normal file
94
server/core/json_response.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type JsonResponse struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
func (j *JsonResponse) Standard(v interface{}) {
|
||||
j.writer.WriteHeader(http.StatusOK)
|
||||
j.commonHeaders()
|
||||
j.writer.Write(j.toJSON(v))
|
||||
}
|
||||
|
||||
func (j *JsonResponse) Created(v interface{}) {
|
||||
j.writer.WriteHeader(http.StatusCreated)
|
||||
j.commonHeaders()
|
||||
j.writer.Write(j.toJSON(v))
|
||||
}
|
||||
|
||||
func (j *JsonResponse) NoContent() {
|
||||
j.writer.WriteHeader(http.StatusNoContent)
|
||||
j.commonHeaders()
|
||||
}
|
||||
|
||||
func (j *JsonResponse) BadRequest(err error) {
|
||||
log.Println("[API:BadRequest]", err)
|
||||
j.writer.WriteHeader(http.StatusBadRequest)
|
||||
j.commonHeaders()
|
||||
|
||||
if err != nil {
|
||||
j.writer.Write(j.encodeError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JsonResponse) NotFound(err error) {
|
||||
log.Println("[API:NotFound]", err)
|
||||
j.writer.WriteHeader(http.StatusNotFound)
|
||||
j.commonHeaders()
|
||||
j.writer.Write(j.encodeError(err))
|
||||
}
|
||||
|
||||
func (j *JsonResponse) ServerError(err error) {
|
||||
log.Println("[API:ServerError]", err)
|
||||
j.writer.WriteHeader(http.StatusInternalServerError)
|
||||
j.commonHeaders()
|
||||
j.writer.Write(j.encodeError(err))
|
||||
}
|
||||
|
||||
func (j *JsonResponse) Forbidden() {
|
||||
log.Println("[API:Forbidden]")
|
||||
j.writer.WriteHeader(http.StatusForbidden)
|
||||
j.commonHeaders()
|
||||
j.writer.Write(j.encodeError(errors.New("Access Forbidden")))
|
||||
}
|
||||
|
||||
func (j *JsonResponse) commonHeaders() {
|
||||
j.writer.Header().Set("Accept", "application/json")
|
||||
j.writer.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
func (j *JsonResponse) encodeError(err error) []byte {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
tmp := errorMsg{ErrorMessage: err.Error()}
|
||||
data, err := json.Marshal(tmp)
|
||||
if err != nil {
|
||||
log.Println("encodeError:", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (j *JsonResponse) toJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Println("Unable to convert interface to JSON:", err)
|
||||
return []byte("")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
108
server/core/request.go
Normal file
108
server/core/request.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
func (r *Request) GetRequest() *http.Request {
|
||||
return r.request
|
||||
}
|
||||
|
||||
func (r *Request) GetBody() io.ReadCloser {
|
||||
return r.request.Body
|
||||
}
|
||||
|
||||
func (r *Request) GetHeaders() http.Header {
|
||||
return r.request.Header
|
||||
}
|
||||
|
||||
func (r *Request) GetScheme() string {
|
||||
return r.request.URL.Scheme
|
||||
}
|
||||
|
||||
func (r *Request) GetFile(name string) (multipart.File, *multipart.FileHeader, error) {
|
||||
return r.request.FormFile(name)
|
||||
}
|
||||
|
||||
func (r *Request) IsHTTPS() bool {
|
||||
return r.request.URL.Scheme == "https"
|
||||
}
|
||||
|
||||
func (r *Request) GetCookie(name string) string {
|
||||
cookie, err := r.request.Cookie(name)
|
||||
if err == http.ErrNoCookie {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (r *Request) GetIntegerParam(param string) (int64, error) {
|
||||
vars := mux.Vars(r.request)
|
||||
value, err := strconv.Atoi(vars[param])
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return 0, fmt.Errorf("%s parameter is not an integer", param)
|
||||
}
|
||||
|
||||
if value < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return int64(value), nil
|
||||
}
|
||||
|
||||
func (r *Request) GetStringParam(param, defaultValue string) string {
|
||||
vars := mux.Vars(r.request)
|
||||
value := vars[param]
|
||||
if value == "" {
|
||||
value = defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *Request) GetQueryStringParam(param, defaultValue string) string {
|
||||
value := r.request.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
value = defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *Request) GetQueryIntegerParam(param string, defaultValue int) int {
|
||||
value := r.request.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
val, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if val < 0 {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func NewRequest(w http.ResponseWriter, r *http.Request) *Request {
|
||||
return &Request{writer: w, request: r}
|
||||
}
|
63
server/core/response.go
Normal file
63
server/core/response.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
template *template.TemplateEngine
|
||||
}
|
||||
|
||||
func (r *Response) SetCookie(cookie *http.Cookie) {
|
||||
http.SetCookie(r.writer, cookie)
|
||||
}
|
||||
|
||||
func (r *Response) Json() *JsonResponse {
|
||||
r.commonHeaders()
|
||||
return &JsonResponse{writer: r.writer, request: r.request}
|
||||
}
|
||||
|
||||
func (r *Response) Html() *HtmlResponse {
|
||||
r.commonHeaders()
|
||||
return &HtmlResponse{writer: r.writer, request: r.request, template: r.template}
|
||||
}
|
||||
|
||||
func (r *Response) Xml() *XmlResponse {
|
||||
r.commonHeaders()
|
||||
return &XmlResponse{writer: r.writer, request: r.request}
|
||||
}
|
||||
|
||||
func (r *Response) Redirect(path string) {
|
||||
http.Redirect(r.writer, r.request, path, http.StatusFound)
|
||||
}
|
||||
|
||||
func (r *Response) Cache(mime_type, etag string, content []byte, duration time.Duration) {
|
||||
r.writer.Header().Set("Content-Type", mime_type)
|
||||
r.writer.Header().Set("Etag", etag)
|
||||
r.writer.Header().Set("Cache-Control", "public")
|
||||
r.writer.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
|
||||
|
||||
if etag == r.request.Header.Get("If-None-Match") {
|
||||
r.writer.WriteHeader(http.StatusNotModified)
|
||||
} else {
|
||||
r.writer.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Response) commonHeaders() {
|
||||
r.writer.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
r.writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
r.writer.Header().Set("X-Frame-Options", "DENY")
|
||||
}
|
||||
|
||||
func NewResponse(w http.ResponseWriter, r *http.Request, template *template.TemplateEngine) *Response {
|
||||
return &Response{writer: w, request: r, template: template}
|
||||
}
|
21
server/core/xml_response.go
Normal file
21
server/core/xml_response.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type XmlResponse struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
func (x *XmlResponse) Download(filename, data string) {
|
||||
x.writer.Header().Set("Content-Type", "text/xml")
|
||||
x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
x.writer.Write([]byte(data))
|
||||
}
|
61
server/middleware/basic_auth.go
Normal file
61
server/middleware/basic_auth.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type BasicAuthMiddleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
errorResponse := `{"error_message": "Not Authorized"}`
|
||||
|
||||
username, password, authOK := r.BasicAuth()
|
||||
if !authOK {
|
||||
log.Println("[Middleware:BasicAuth] No authentication headers sent")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(errorResponse))
|
||||
return
|
||||
}
|
||||
|
||||
if err := b.store.CheckPassword(username, password); err != nil {
|
||||
log.Println("[Middleware:BasicAuth] Invalid username or password:", username)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(errorResponse))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := b.store.GetUserByUsername(username)
|
||||
if err != nil || user == nil {
|
||||
log.Println("[Middleware:BasicAuth] User not found:", username)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(errorResponse))
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[Middleware:BasicAuth] User authenticated:", username)
|
||||
b.store.SetLastLogin(user.ID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, "UserId", user.ID)
|
||||
ctx = context.WithValue(ctx, "UserTimezone", user.Timezone)
|
||||
ctx = context.WithValue(ctx, "IsAdminUser", user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, "IsAuthenticated", true)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func NewBasicAuthMiddleware(s *storage.Storage) *BasicAuthMiddleware {
|
||||
return &BasicAuthMiddleware{store: s}
|
||||
}
|
48
server/middleware/csrf.go
Normal file
48
server/middleware/csrf.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/miniflux/miniflux2/helper"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Csrf(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var csrfToken string
|
||||
|
||||
csrfCookie, err := r.Cookie("csrfToken")
|
||||
if err == http.ErrNoCookie || csrfCookie.Value == "" {
|
||||
csrfToken = helper.GenerateRandomString(64)
|
||||
cookie := &http.Cookie{
|
||||
Name: "csrfToken",
|
||||
Value: csrfToken,
|
||||
Path: "/",
|
||||
Secure: r.URL.Scheme == "https",
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
} else {
|
||||
csrfToken = csrfCookie.Value
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, "CsrfToken", csrfToken)
|
||||
|
||||
w.Header().Add("Vary", "Cookie")
|
||||
isTokenValid := csrfToken == r.FormValue("csrf") || csrfToken == r.Header.Get("X-Csrf-Token")
|
||||
|
||||
if r.Method == "POST" && !isTokenValid {
|
||||
log.Println("[Middleware:CSRF] Invalid or missing CSRF token!")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid or missing CSRF token!"))
|
||||
} else {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
})
|
||||
}
|
31
server/middleware/middleware.go
Normal file
31
server/middleware/middleware.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
type MiddlewareChain struct {
|
||||
middlewares []Middleware
|
||||
}
|
||||
|
||||
func (m *MiddlewareChain) Wrap(h http.Handler) http.Handler {
|
||||
for i := range m.middlewares {
|
||||
h = m.middlewares[len(m.middlewares)-1-i](h)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *MiddlewareChain) WrapFunc(fn http.HandlerFunc) http.Handler {
|
||||
return m.Wrap(fn)
|
||||
}
|
||||
|
||||
func NewMiddlewareChain(middlewares ...Middleware) *MiddlewareChain {
|
||||
return &MiddlewareChain{append(([]Middleware)(nil), middlewares...)}
|
||||
}
|
72
server/middleware/session.go
Normal file
72
server/middleware/session.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/route"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type SessionMiddleware struct {
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := s.getSessionFromCookie(r)
|
||||
|
||||
if session == nil {
|
||||
log.Println("[Middleware:Session] Session not found")
|
||||
if s.isPublicRoute(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
} else {
|
||||
http.Redirect(w, r, route.GetRoute(s.router, "login"), http.StatusFound)
|
||||
}
|
||||
} else {
|
||||
log.Println("[Middleware:Session]", session)
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, "UserId", session.UserID)
|
||||
ctx = context.WithValue(ctx, "IsAuthenticated", true)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SessionMiddleware) isPublicRoute(r *http.Request) bool {
|
||||
route := mux.CurrentRoute(r)
|
||||
switch route.GetName() {
|
||||
case "login", "checkLogin", "stylesheet", "javascript":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionMiddleware) getSessionFromCookie(r *http.Request) *model.Session {
|
||||
sessionCookie, err := r.Cookie("sessionID")
|
||||
if err == http.ErrNoCookie {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := s.store.GetSessionByToken(sessionCookie.Value)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func NewSessionMiddleware(s *storage.Storage, r *mux.Router) *SessionMiddleware {
|
||||
return &SessionMiddleware{store: s, router: r}
|
||||
}
|
37
server/route/route.go
Normal file
37
server/route/route.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func GetRoute(router *mux.Router, name string, args ...interface{}) string {
|
||||
route := router.Get(name)
|
||||
if route == nil {
|
||||
log.Fatalln("Route not found:", name)
|
||||
}
|
||||
|
||||
var pairs []string
|
||||
for _, param := range args {
|
||||
switch param.(type) {
|
||||
case string:
|
||||
pairs = append(pairs, param.(string))
|
||||
case int64:
|
||||
val := param.(int64)
|
||||
pairs = append(pairs, strconv.FormatInt(val, 10))
|
||||
}
|
||||
}
|
||||
|
||||
result, err := route.URLPath(pairs...)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
132
server/routes.go
Normal file
132
server/routes.go
Normal file
|
@ -0,0 +1,132 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/locale"
|
||||
"github.com/miniflux/miniflux2/reader/feed"
|
||||
"github.com/miniflux/miniflux2/reader/opml"
|
||||
api_controller "github.com/miniflux/miniflux2/server/api/controller"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/middleware"
|
||||
"github.com/miniflux/miniflux2/server/template"
|
||||
ui_controller "github.com/miniflux/miniflux2/server/ui/controller"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func getRoutes(store *storage.Storage, feedHandler *feed.Handler) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
translator := locale.Load()
|
||||
templateEngine := template.NewTemplateEngine(router, translator)
|
||||
|
||||
apiController := api_controller.NewController(store, feedHandler)
|
||||
uiController := ui_controller.NewController(store, feedHandler, opml.NewOpmlHandler(store))
|
||||
|
||||
apiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewMiddlewareChain(
|
||||
middleware.NewBasicAuthMiddleware(store).Handler,
|
||||
))
|
||||
|
||||
uiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewMiddlewareChain(
|
||||
middleware.NewSessionMiddleware(store, router).Handler,
|
||||
middleware.Csrf,
|
||||
))
|
||||
|
||||
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
|
||||
router.Handle("/v1/users", apiHandler.Use(apiController.GetUsers)).Methods("GET")
|
||||
router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.GetUser)).Methods("GET")
|
||||
router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.UpdateUser)).Methods("PUT")
|
||||
router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.RemoveUser)).Methods("DELETE")
|
||||
|
||||
router.Handle("/v1/categories", apiHandler.Use(apiController.CreateCategory)).Methods("POST")
|
||||
router.Handle("/v1/categories", apiHandler.Use(apiController.GetCategories)).Methods("GET")
|
||||
router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.UpdateCategory)).Methods("PUT")
|
||||
router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.RemoveCategory)).Methods("DELETE")
|
||||
|
||||
router.Handle("/v1/discover", apiHandler.Use(apiController.GetSubscriptions)).Methods("POST")
|
||||
|
||||
router.Handle("/v1/feeds", apiHandler.Use(apiController.CreateFeed)).Methods("POST")
|
||||
router.Handle("/v1/feeds", apiHandler.Use(apiController.GetFeeds)).Methods("Get")
|
||||
router.Handle("/v1/feeds/{feedID}/refresh", apiHandler.Use(apiController.RefreshFeed)).Methods("PUT")
|
||||
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.GetFeed)).Methods("GET")
|
||||
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.UpdateFeed)).Methods("PUT")
|
||||
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.RemoveFeed)).Methods("DELETE")
|
||||
|
||||
router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET")
|
||||
router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET")
|
||||
router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.SetEntryStatus)).Methods("PUT")
|
||||
|
||||
router.Handle("/stylesheets/{name}.css", uiHandler.Use(uiController.Stylesheet)).Name("stylesheet").Methods("GET")
|
||||
router.Handle("/js", uiHandler.Use(uiController.Javascript)).Name("javascript").Methods("GET")
|
||||
router.Handle("/favicon.ico", uiHandler.Use(uiController.Favicon)).Name("favicon").Methods("GET")
|
||||
|
||||
router.Handle("/subscribe", uiHandler.Use(uiController.AddSubscription)).Name("addSubscription").Methods("GET")
|
||||
router.Handle("/subscribe", uiHandler.Use(uiController.SubmitSubscription)).Name("submitSubscription").Methods("POST")
|
||||
router.Handle("/subscriptions", uiHandler.Use(uiController.ChooseSubscription)).Name("chooseSubscription").Methods("POST")
|
||||
|
||||
router.Handle("/unread", uiHandler.Use(uiController.ShowUnreadPage)).Name("unread").Methods("GET")
|
||||
router.Handle("/history", uiHandler.Use(uiController.ShowHistoryPage)).Name("history").Methods("GET")
|
||||
|
||||
router.Handle("/feed/{feedID}/refresh", uiHandler.Use(uiController.RefreshFeed)).Name("refreshFeed").Methods("GET")
|
||||
router.Handle("/feed/{feedID}/edit", uiHandler.Use(uiController.EditFeed)).Name("editFeed").Methods("GET")
|
||||
router.Handle("/feed/{feedID}/remove", uiHandler.Use(uiController.RemoveFeed)).Name("removeFeed").Methods("GET")
|
||||
router.Handle("/feed/{feedID}/update", uiHandler.Use(uiController.UpdateFeed)).Name("updateFeed").Methods("POST")
|
||||
router.Handle("/feed/{feedID}/entries", uiHandler.Use(uiController.ShowFeedEntries)).Name("feedEntries").Methods("GET")
|
||||
router.Handle("/feeds", uiHandler.Use(uiController.ShowFeedsPage)).Name("feeds").Methods("GET")
|
||||
|
||||
router.Handle("/unread/entry/{entryID}", uiHandler.Use(uiController.ShowUnreadEntry)).Name("unreadEntry").Methods("GET")
|
||||
router.Handle("/history/entry/{entryID}", uiHandler.Use(uiController.ShowReadEntry)).Name("readEntry").Methods("GET")
|
||||
router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET")
|
||||
router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET")
|
||||
|
||||
router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST")
|
||||
|
||||
router.Handle("/categories", uiHandler.Use(uiController.ShowCategories)).Name("categories").Methods("GET")
|
||||
router.Handle("/category/create", uiHandler.Use(uiController.CreateCategory)).Name("createCategory").Methods("GET")
|
||||
router.Handle("/category/save", uiHandler.Use(uiController.SaveCategory)).Name("saveCategory").Methods("POST")
|
||||
router.Handle("/category/{categoryID}/entries", uiHandler.Use(uiController.ShowCategoryEntries)).Name("categoryEntries").Methods("GET")
|
||||
router.Handle("/category/{categoryID}/edit", uiHandler.Use(uiController.EditCategory)).Name("editCategory").Methods("GET")
|
||||
router.Handle("/category/{categoryID}/update", uiHandler.Use(uiController.UpdateCategory)).Name("updateCategory").Methods("POST")
|
||||
router.Handle("/category/{categoryID}/remove", uiHandler.Use(uiController.RemoveCategory)).Name("removeCategory").Methods("GET")
|
||||
|
||||
router.Handle("/icon/{iconID}", uiHandler.Use(uiController.ShowIcon)).Name("icon").Methods("GET")
|
||||
router.Handle("/proxy/{encodedURL}", uiHandler.Use(uiController.ImageProxy)).Name("proxy").Methods("GET")
|
||||
|
||||
router.Handle("/users", uiHandler.Use(uiController.ShowUsers)).Name("users").Methods("GET")
|
||||
router.Handle("/user/create", uiHandler.Use(uiController.CreateUser)).Name("createUser").Methods("GET")
|
||||
router.Handle("/user/save", uiHandler.Use(uiController.SaveUser)).Name("saveUser").Methods("POST")
|
||||
router.Handle("/users/{userID}/edit", uiHandler.Use(uiController.EditUser)).Name("editUser").Methods("GET")
|
||||
router.Handle("/users/{userID}/update", uiHandler.Use(uiController.UpdateUser)).Name("updateUser").Methods("POST")
|
||||
router.Handle("/users/{userID}/remove", uiHandler.Use(uiController.RemoveUser)).Name("removeUser").Methods("GET")
|
||||
|
||||
router.Handle("/about", uiHandler.Use(uiController.AboutPage)).Name("about").Methods("GET")
|
||||
|
||||
router.Handle("/settings", uiHandler.Use(uiController.ShowSettings)).Name("settings").Methods("GET")
|
||||
router.Handle("/settings", uiHandler.Use(uiController.UpdateSettings)).Name("updateSettings").Methods("POST")
|
||||
|
||||
router.Handle("/sessions", uiHandler.Use(uiController.ShowSessions)).Name("sessions").Methods("GET")
|
||||
router.Handle("/sessions/{sessionID}/remove", uiHandler.Use(uiController.RemoveSession)).Name("removeSession").Methods("GET")
|
||||
|
||||
router.Handle("/export", uiHandler.Use(uiController.Export)).Name("export").Methods("GET")
|
||||
router.Handle("/import", uiHandler.Use(uiController.Import)).Name("import").Methods("GET")
|
||||
router.Handle("/upload", uiHandler.Use(uiController.UploadOPML)).Name("uploadOPML").Methods("POST")
|
||||
|
||||
router.Handle("/login", uiHandler.Use(uiController.CheckLogin)).Name("checkLogin").Methods("POST")
|
||||
router.Handle("/logout", uiHandler.Use(uiController.Logout)).Name("logout").Methods("GET")
|
||||
router.Handle("/", uiHandler.Use(uiController.ShowLoginPage)).Name("login").Methods("GET")
|
||||
|
||||
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("User-agent: *\nDisallow: /"))
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
33
server/server.go
Normal file
33
server/server.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/config"
|
||||
"github.com/miniflux/miniflux2/reader/feed"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler) *http.Server {
|
||||
server := &http.Server{
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Addr: cfg.Get("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||
Handler: getRoutes(store, feedHandler),
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("Listening on %s\n", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return server
|
||||
}
|
12
server/static/bin.go
Normal file
12
server/static/bin.go
Normal file
File diff suppressed because one or more lines are too long
BIN
server/static/bin/favicon.ico
Normal file
BIN
server/static/bin/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
14
server/static/css.go
Normal file
14
server/static/css.go
Normal file
File diff suppressed because one or more lines are too long
197
server/static/css/black.css
Normal file
197
server/static/css/black.css
Normal file
|
@ -0,0 +1,197 @@
|
|||
/* Layout */
|
||||
body {
|
||||
background: #222;
|
||||
color: #efefef;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.header li {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: #ddd;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header .active a {
|
||||
font-weight: 400;
|
||||
color: #9b9494;
|
||||
}
|
||||
|
||||
.header a:focus,
|
||||
.header a:hover {
|
||||
color: rgba(82, 168, 236, 0.85);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.logo a:hover span {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table, th, td {
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #333;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
input[type="url"],
|
||||
input[type="password"],
|
||||
input[type="text"] {
|
||||
border: 1px solid #555;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
input[type="url"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="text"]:focus {
|
||||
color: #efefef;
|
||||
border-color: rgba(82, 168, 236, 0.8);
|
||||
box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button-primary {
|
||||
border-color: #444;
|
||||
background: #333;
|
||||
color: #efefef;
|
||||
}
|
||||
|
||||
.button-primary:hover,
|
||||
.button-primary:focus {
|
||||
border-color: #888;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert,
|
||||
.alert-success,
|
||||
.alert-error,
|
||||
.alert-info,
|
||||
.alert-normal {
|
||||
color: #efefef;
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* Counter */
|
||||
.unread-counter {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Category label */
|
||||
.category {
|
||||
color: #efefef;
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.category a {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.category a:hover,
|
||||
.category a:focus {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination a {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.pagination-bottom {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
/* List view */
|
||||
.item {
|
||||
border-color: #666;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.item.current-item {
|
||||
border-width: 2px;
|
||||
border-color: rgba(82, 168, 236, 0.8);
|
||||
box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
}
|
||||
|
||||
.item-title a {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.item-status-read .item-title a {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-status-read .item-title a:focus,
|
||||
.item-status-read .item-title a:hover {
|
||||
color: rgba(82, 168, 236, 0.6);
|
||||
}
|
||||
|
||||
.item-meta a:hover,
|
||||
.item-meta a:focus {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.item-meta li:after {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* Entry view */
|
||||
.entry header {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.entry header h1 a {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.entry-content,
|
||||
.entry-content p, ul {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.entry-content pre,
|
||||
.entry-content code {
|
||||
color: #fff;
|
||||
background: #555;
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.entry-enclosure {
|
||||
border-color: #333;
|
||||
}
|
654
server/static/css/common.css
Normal file
654
server/static/css/common.css
Normal file
|
@ -0,0 +1,654 @@
|
|||
/* Layout */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3366CC;
|
||||
}
|
||||
|
||||
a:focus {
|
||||
outline: 0;
|
||||
color: red;
|
||||
text-decoration: none;
|
||||
border: 1px dotted #aaa;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header nav ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header li {
|
||||
cursor: pointer;
|
||||
padding-left: 10px;
|
||||
line-height: 2.1em;
|
||||
font-size: 1.2em;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
}
|
||||
|
||||
.header li:hover a {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.header a {
|
||||
font-size: 0.9em;
|
||||
color: #444;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.header .active a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header a:hover,
|
||||
.header a:focus {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-weight: 500;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
}
|
||||
|
||||
.page-header ul {
|
||||
margin-left: 25px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.page-header li {
|
||||
list-style-type: circle;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.logo a:hover {
|
||||
color: #339966;
|
||||
}
|
||||
|
||||
.logo a span {
|
||||
color: #339966;
|
||||
}
|
||||
|
||||
.logo a:hover span {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
body {
|
||||
margin: auto;
|
||||
max-width: 750px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: left;
|
||||
float: left;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.header nav ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header li {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
padding-right: 15px;
|
||||
line-height: normal;
|
||||
font-size: 1.0em;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page-header ul {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.page-header li {
|
||||
display: inline;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #fcfcfc;
|
||||
}
|
||||
|
||||
.table-overflow td {
|
||||
max-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.column-40 {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.column-25 {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.column-20 {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
label {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
line-height: 1.9em;
|
||||
}
|
||||
|
||||
div.radio-group label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
select {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
input[type="url"],
|
||||
input[type="password"],
|
||||
input[type="text"] {
|
||||
border: 1px solid #ccc;
|
||||
padding: 3px;
|
||||
line-height: 15px;
|
||||
width: 250px;
|
||||
font-size: 99%;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input[type="url"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="text"]:focus {
|
||||
color: #000;
|
||||
border-color: rgba(82, 168, 236, 0.8);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
|
||||
}
|
||||
|
||||
::-moz-placeholder,
|
||||
::-ms-input-placeholder,
|
||||
::-webkit-input-placeholder {
|
||||
color: #ddd;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 0.9em;
|
||||
color: brown;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: unset;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
border-color: #3079ed;
|
||||
background: #4d90fe;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-primary:hover,
|
||||
.button-primary:focus {
|
||||
border-color: #2f5bb7;
|
||||
background: #357ae8;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: #b0281a;
|
||||
background: #d14836;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-danger:hover,
|
||||
.button-danger:focus {
|
||||
color: #fff;
|
||||
background: #c53727;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
color: #ccc;
|
||||
background: #f7f7f7;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 8px 35px 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
color: #c09853;
|
||||
background-color: #fcf8e3;
|
||||
border: 1px solid #fbeed5;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.alert h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
color: #468847;
|
||||
background-color: #dff0d8;
|
||||
border-color: #d6e9c6;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
color: #b94a48;
|
||||
background-color: #f2dede;
|
||||
border-color: #eed3d7;
|
||||
}
|
||||
|
||||
.alert-error a {
|
||||
color: #b94a48;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
color: #3a87ad;
|
||||
background-color: #d9edf7;
|
||||
border-color: #bce8f1;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
color: #333;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-weight: 500;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel ul {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
/* Login form */
|
||||
.login-form {
|
||||
margin: auto;
|
||||
margin-top: 50px;
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
/* Counter */
|
||||
.unread-counter {
|
||||
font-size: 0.8em;
|
||||
font-weight: 300;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Category label */
|
||||
.category {
|
||||
font-size: 0.75em;
|
||||
background-color: #fffcd7;
|
||||
border: 1px solid #d5d458;
|
||||
border-radius: 5px;
|
||||
margin-left: 0.25em;
|
||||
padding: 1px 0.4em 1px 0.4em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category a {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.category a:hover,
|
||||
.category a:focus {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.pagination-bottom {
|
||||
border-top: 1px dotted #ddd;
|
||||
margin-bottom: 15px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.pagination > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination-next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pagination-prev:before {
|
||||
content: "« ";
|
||||
}
|
||||
|
||||
.pagination-next:after {
|
||||
content: " »";
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pagination a:hover,
|
||||
.pagination a:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* List view */
|
||||
.item {
|
||||
border: 1px dotted #ddd;
|
||||
margin-bottom: 20px;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item.current-item {
|
||||
border: 3px solid #bce;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.item-title a {
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-status-read .item-title a {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
color: #777;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.item-meta a {
|
||||
color: #777;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.item-meta a:hover,
|
||||
.item-meta a:focus {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-meta ul {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.item-meta li {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.item-meta li:after {
|
||||
content: "|";
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.item-meta li:last-child:after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.hide-read-items .item-status-read {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Entry view */
|
||||
.entry header {
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
}
|
||||
|
||||
.entry header h1 {
|
||||
font-size: 2.0em;
|
||||
line-height: 1.25em;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.entry header h1 a {
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.entry header h1 a:hover,
|
||||
.entry header h1 a:focus {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
font-size: 0.95em;
|
||||
margin: 0 0 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.entry-website img {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.entry-website a {
|
||||
color: #666;
|
||||
vertical-align: top;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entry-website a:hover,
|
||||
.entry-website a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.entry-date {
|
||||
font-size: 0.65em;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
padding-top: 15px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 300;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.entry-content h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.entry-content iframe,
|
||||
.entry-content video,
|
||||
.entry-content img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.entry-content figure img {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.entry-content figcaption {
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.entry-content p {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.entry-content a:visited {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
.entry-content dt {
|
||||
font-weight: 500;
|
||||
margin-top: 15px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.entry-content dd {
|
||||
margin-left: 15px;
|
||||
margin-top: 5px;
|
||||
padding-left: 20px;
|
||||
border-left: 3px solid #ddd;
|
||||
color: #777;
|
||||
font-weight: 300;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.entry-content blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
padding-left: 25px;
|
||||
margin-left: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: #888;
|
||||
line-height: 1.4em;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.entry-content blockquote + p {
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.entry-content q {
|
||||
color: purple;
|
||||
font-family: Georgia, serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.entry-content q:before {
|
||||
content: "“";
|
||||
}
|
||||
|
||||
.entry-content q:after {
|
||||
content: "”";
|
||||
}
|
||||
|
||||
.entry-content pre {
|
||||
padding: 5px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.entry-content ul,
|
||||
.entry-content ol {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.entry-content ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.entry-enclosures h3 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entry-enclosure {
|
||||
border: 1px dotted #ddd;
|
||||
padding: 5px;
|
||||
margin-top: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.entry-enclosure-download {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.enclosure-video video,
|
||||
.enclosure-image img {
|
||||
max-width: 100%;
|
||||
}
|
52
server/static/js.go
Normal file
52
server/static/js.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-11-19 22:01:21.923282889 -0800 PST m=+0.004116032
|
||||
|
||||
package static
|
||||
|
||||
var Javascript = map[string]string{
|
||||
"app": `(function(){'use strict';class KeyboardHandler{constructor(){this.queue=[];this.shortcuts={};}
|
||||
on(combination,callback){this.shortcuts[combination]=callback;}
|
||||
listen(){document.onkeydown=(event)=>{if(this.isEventIgnored(event)){return;}
|
||||
let key=this.getKey(event);this.queue.push(key);for(let combination in this.shortcuts){let keys=combination.split(" ");if(keys.every((value,index)=>value===this.queue[index])){this.queue=[];this.shortcuts[combination]();return;}
|
||||
if(keys.length===1&&key===keys[0]){this.queue=[];this.shortcuts[combination]();return;}}
|
||||
if(this.queue.length>=2){this.queue=[];}};}
|
||||
isEventIgnored(event){return event.target.tagName==="INPUT"||event.target.tagName==="TEXTAREA";}
|
||||
getKey(event){const mapping={'Esc':'Escape','Up':'ArrowUp','Down':'ArrowDown','Left':'ArrowLeft','Right':'ArrowRight'};for(let key in mapping){if(mapping.hasOwnProperty(key)&&key===event.key){return mapping[key];}}
|
||||
return event.key;}}
|
||||
class FormHandler{static handleSubmitButtons(){let elements=document.querySelectorAll("form");elements.forEach(function(element){element.onsubmit=function(){let button=document.querySelector("button");if(button){button.innerHTML=button.dataset.labelLoading;button.disabled=true;}};});}}
|
||||
class MouseHandler{onClick(selector,callback){let elements=document.querySelectorAll(selector);elements.forEach((element)=>{element.onclick=(event)=>{event.preventDefault();callback(event);};});}}
|
||||
class App{run(){FormHandler.handleSubmitButtons();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>this.goToPage("unread"));keyboardHandler.on("g h",()=>this.goToPage("history"));keyboardHandler.on("g f",()=>this.goToPage("feeds"));keyboardHandler.on("g c",()=>this.goToPage("categories"));keyboardHandler.on("g s",()=>this.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>this.goToPrevious());keyboardHandler.on("ArrowRight",()=>this.goToNext());keyboardHandler.on("j",()=>this.goToPrevious());keyboardHandler.on("p",()=>this.goToPrevious());keyboardHandler.on("k",()=>this.goToNext());keyboardHandler.on("n",()=>this.goToNext());keyboardHandler.on("h",()=>this.goToPage("previous"));keyboardHandler.on("l",()=>this.goToPage("next"));keyboardHandler.on("o",()=>this.openSelectedItem());keyboardHandler.on("v",()=>this.openOriginalLink());keyboardHandler.on("m",()=>this.toggleEntryStatus());keyboardHandler.on("A",()=>this.markPageAsRead());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>this.markPageAsRead());if(document.documentElement.clientWidth<600){mouseHandler.onClick(".logo",()=>this.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>this.clickMenuListItem(event));}}
|
||||
clickMenuListItem(event){let element=event.target;console.log(element);if(element.tagName==="A"){window.location.href=element.getAttribute("href");}else{window.location.href=element.querySelector("a").getAttribute("href");}}
|
||||
toggleMainMenu(){let menu=document.querySelector(".header nav ul");if(this.isVisible(menu)){menu.style.display="none";}else{menu.style.display="block";}}
|
||||
updateEntriesStatus(entryIDs,status){let url=document.body.dataset.entriesStatusUrl;let request=new Request(url,{method:"POST",cache:"no-cache",credentials:"include",body:JSON.stringify({entry_ids:entryIDs,status:status}),headers:new Headers({"Content-Type":"application/json","X-Csrf-Token":this.getCsrfToken()})});fetch(request);}
|
||||
markPageAsRead(){let items=this.getVisibleElements(".items .item");let entryIDs=[];items.forEach((element)=>{element.classList.add("item-status-read");entryIDs.push(parseInt(element.dataset.id,10));});if(entryIDs.length>0){this.updateEntriesStatus(entryIDs,"read");}
|
||||
this.goToPage("next");}
|
||||
toggleEntryStatus(){let currentItem=document.querySelector(".current-item");if(currentItem!==null){let entryID=parseInt(currentItem.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(currentItem.classList.contains("item-status-"+currentStatus)){this.goToNextListItem();currentItem.classList.remove("item-status-"+currentStatus);currentItem.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);break;}}}}
|
||||
openOriginalLink(){let entryLink=document.querySelector(".entry h1 a");if(entryLink!==null){this.openNewTab(entryLink.getAttribute("href"));return;}
|
||||
let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){this.openNewTab(currentItemOriginalLink.getAttribute("href"));}}
|
||||
openSelectedItem(){let currentItemLink=document.querySelector(".current-item .item-title a");if(currentItemLink!==null){window.location.href=currentItemLink.getAttribute("href");}}
|
||||
goToPage(page){let element=document.querySelector("a[data-page="+page+"]");if(element){document.location.href=element.href;}}
|
||||
goToPrevious(){if(this.isListView()){this.goToPreviousListItem();}else{this.goToPage("previous");}}
|
||||
goToNext(){if(this.isListView()){this.goToNextListItem();}else{this.goToPage("next");}}
|
||||
goToPreviousListItem(){let items=this.getVisibleElements(".items .item");if(items.length===0){return;}
|
||||
if(document.querySelector(".current-item")===null){items[0].classList.add("current-item");return;}
|
||||
for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i-1>=0){items[i-1].classList.add("current-item");this.scrollPageTo(items[i-1]);}
|
||||
break;}}}
|
||||
goToNextListItem(){let items=this.getVisibleElements(".items .item");if(items.length===0){return;}
|
||||
if(document.querySelector(".current-item")===null){items[0].classList.add("current-item");return;}
|
||||
for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i+1<items.length){items[i+1].classList.add("current-item");this.scrollPageTo(items[i+1]);}
|
||||
break;}}}
|
||||
getVisibleElements(selector){let elements=document.querySelectorAll(selector);let result=[];for(let i=0;i<elements.length;i++){if(this.isVisible(elements[i])){result.push(elements[i]);}}
|
||||
return result;}
|
||||
isListView(){return document.querySelector(".items")!==null;}
|
||||
scrollPageTo(item){let windowScrollPosition=window.pageYOffset;let windowHeight=document.documentElement.clientHeight;let viewportPosition=windowScrollPosition+windowHeight;let itemBottomPosition=item.offsetTop+item.offsetHeight;if(viewportPosition-itemBottomPosition<0||viewportPosition-item.offsetTop>windowHeight){window.scrollTo(0,item.offsetTop-10);}}
|
||||
openNewTab(url){let win=window.open(url,"_blank");win.focus();}
|
||||
isVisible(element){return element.offsetParent!==null;}
|
||||
getCsrfToken(){let element=document.querySelector("meta[name=X-CSRF-Token]");if(element!==null){return element.getAttribute("value");}
|
||||
return "";}}
|
||||
document.addEventListener("DOMContentLoaded",function(){(new App()).run();});})();`,
|
||||
}
|
||||
|
||||
var JavascriptChecksums = map[string]string{
|
||||
"app": "e250c2af19dea14fd75681a81080cf183919a7a589b0886a093586ee894c8282",
|
||||
}
|
351
server/static/js/app.js
Normal file
351
server/static/js/app.js
Normal file
|
@ -0,0 +1,351 @@
|
|||
/*jshint esversion: 6 */
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class KeyboardHandler {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.shortcuts = {};
|
||||
}
|
||||
|
||||
on(combination, callback) {
|
||||
this.shortcuts[combination] = callback;
|
||||
}
|
||||
|
||||
listen() {
|
||||
document.onkeydown = (event) => {
|
||||
if (this.isEventIgnored(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = this.getKey(event);
|
||||
this.queue.push(key);
|
||||
|
||||
for (let combination in this.shortcuts) {
|
||||
let keys = combination.split(" ");
|
||||
|
||||
if (keys.every((value, index) => value === this.queue[index])) {
|
||||
this.queue = [];
|
||||
this.shortcuts[combination]();
|
||||
return;
|
||||
}
|
||||
|
||||
if (keys.length === 1 && key === keys[0]) {
|
||||
this.queue = [];
|
||||
this.shortcuts[combination]();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.queue.length >= 2) {
|
||||
this.queue = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
isEventIgnored(event) {
|
||||
return event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA";
|
||||
}
|
||||
|
||||
getKey(event) {
|
||||
const mapping = {
|
||||
'Esc': 'Escape',
|
||||
'Up': 'ArrowUp',
|
||||
'Down': 'ArrowDown',
|
||||
'Left': 'ArrowLeft',
|
||||
'Right': 'ArrowRight'
|
||||
};
|
||||
|
||||
for (let key in mapping) {
|
||||
if (mapping.hasOwnProperty(key) && key === event.key) {
|
||||
return mapping[key];
|
||||
}
|
||||
}
|
||||
|
||||
return event.key;
|
||||
}
|
||||
}
|
||||
|
||||
class FormHandler {
|
||||
static handleSubmitButtons() {
|
||||
let elements = document.querySelectorAll("form");
|
||||
elements.forEach(function (element) {
|
||||
element.onsubmit = function () {
|
||||
let button = document.querySelector("button");
|
||||
|
||||
if (button) {
|
||||
button.innerHTML = button.dataset.labelLoading;
|
||||
button.disabled = true;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MouseHandler {
|
||||
onClick(selector, callback) {
|
||||
let elements = document.querySelectorAll(selector);
|
||||
elements.forEach((element) => {
|
||||
element.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
callback(event);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class App {
|
||||
run() {
|
||||
FormHandler.handleSubmitButtons();
|
||||
|
||||
let keyboardHandler = new KeyboardHandler();
|
||||
keyboardHandler.on("g u", () => this.goToPage("unread"));
|
||||
keyboardHandler.on("g h", () => this.goToPage("history"));
|
||||
keyboardHandler.on("g f", () => this.goToPage("feeds"));
|
||||
keyboardHandler.on("g c", () => this.goToPage("categories"));
|
||||
keyboardHandler.on("g s", () => this.goToPage("settings"));
|
||||
keyboardHandler.on("ArrowLeft", () => this.goToPrevious());
|
||||
keyboardHandler.on("ArrowRight", () => this.goToNext());
|
||||
keyboardHandler.on("j", () => this.goToPrevious());
|
||||
keyboardHandler.on("p", () => this.goToPrevious());
|
||||
keyboardHandler.on("k", () => this.goToNext());
|
||||
keyboardHandler.on("n", () => this.goToNext());
|
||||
keyboardHandler.on("h", () => this.goToPage("previous"));
|
||||
keyboardHandler.on("l", () => this.goToPage("next"));
|
||||
keyboardHandler.on("o", () => this.openSelectedItem());
|
||||
keyboardHandler.on("v", () => this.openOriginalLink());
|
||||
keyboardHandler.on("m", () => this.toggleEntryStatus());
|
||||
keyboardHandler.on("A", () => this.markPageAsRead());
|
||||
keyboardHandler.listen();
|
||||
|
||||
let mouseHandler = new MouseHandler();
|
||||
mouseHandler.onClick("a[data-on-click=markPageAsRead]", () => this.markPageAsRead());
|
||||
|
||||
if (document.documentElement.clientWidth < 600) {
|
||||
mouseHandler.onClick(".logo", () => this.toggleMainMenu());
|
||||
mouseHandler.onClick(".header nav li", (event) => this.clickMenuListItem(event));
|
||||
}
|
||||
}
|
||||
|
||||
clickMenuListItem(event) {
|
||||
let element = event.target;console.log(element);
|
||||
|
||||
if (element.tagName === "A") {
|
||||
window.location.href = element.getAttribute("href");
|
||||
} else {
|
||||
window.location.href = element.querySelector("a").getAttribute("href");
|
||||
}
|
||||
}
|
||||
|
||||
toggleMainMenu() {
|
||||
let menu = document.querySelector(".header nav ul");
|
||||
if (this.isVisible(menu)) {
|
||||
menu.style.display = "none";
|
||||
} else {
|
||||
menu.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
updateEntriesStatus(entryIDs, status) {
|
||||
let url = document.body.dataset.entriesStatusUrl;
|
||||
let request = new Request(url, {
|
||||
method: "POST",
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
body: JSON.stringify({entry_ids: entryIDs, status: status}),
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
"X-Csrf-Token": this.getCsrfToken()
|
||||
})
|
||||
});
|
||||
|
||||
fetch(request);
|
||||
}
|
||||
|
||||
markPageAsRead() {
|
||||
let items = this.getVisibleElements(".items .item");
|
||||
let entryIDs = [];
|
||||
|
||||
items.forEach((element) => {
|
||||
element.classList.add("item-status-read");
|
||||
entryIDs.push(parseInt(element.dataset.id, 10));
|
||||
});
|
||||
|
||||
if (entryIDs.length > 0) {
|
||||
this.updateEntriesStatus(entryIDs, "read");
|
||||
}
|
||||
|
||||
this.goToPage("next");
|
||||
}
|
||||
|
||||
toggleEntryStatus() {
|
||||
let currentItem = document.querySelector(".current-item");
|
||||
if (currentItem !== null) {
|
||||
let entryID = parseInt(currentItem.dataset.id, 10);
|
||||
let statuses = {read: "unread", unread: "read"};
|
||||
|
||||
for (let currentStatus in statuses) {
|
||||
let newStatus = statuses[currentStatus];
|
||||
|
||||
if (currentItem.classList.contains("item-status-" + currentStatus)) {
|
||||
this.goToNextListItem();
|
||||
|
||||
currentItem.classList.remove("item-status-" + currentStatus);
|
||||
currentItem.classList.add("item-status-" + newStatus);
|
||||
|
||||
this.updateEntriesStatus([entryID], newStatus);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openOriginalLink() {
|
||||
let entryLink = document.querySelector(".entry h1 a");
|
||||
if (entryLink !== null) {
|
||||
this.openNewTab(entryLink.getAttribute("href"));
|
||||
return;
|
||||
}
|
||||
|
||||
let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]");
|
||||
if (currentItemOriginalLink !== null) {
|
||||
this.openNewTab(currentItemOriginalLink.getAttribute("href"));
|
||||
}
|
||||
}
|
||||
|
||||
openSelectedItem() {
|
||||
let currentItemLink = document.querySelector(".current-item .item-title a");
|
||||
if (currentItemLink !== null) {
|
||||
window.location.href = currentItemLink.getAttribute("href");
|
||||
}
|
||||
}
|
||||
|
||||
goToPage(page) {
|
||||
let element = document.querySelector("a[data-page=" + page + "]");
|
||||
|
||||
if (element) {
|
||||
document.location.href = element.href;
|
||||
}
|
||||
}
|
||||
|
||||
goToPrevious() {
|
||||
if (this.isListView()) {
|
||||
this.goToPreviousListItem();
|
||||
} else {
|
||||
this.goToPage("previous");
|
||||
}
|
||||
}
|
||||
|
||||
goToNext() {
|
||||
if (this.isListView()) {
|
||||
this.goToNextListItem();
|
||||
} else {
|
||||
this.goToPage("next");
|
||||
}
|
||||
}
|
||||
|
||||
goToPreviousListItem() {
|
||||
let items = this.getVisibleElements(".items .item");
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.querySelector(".current-item") === null) {
|
||||
items[0].classList.add("current-item");
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].classList.contains("current-item")) {
|
||||
items[i].classList.remove("current-item");
|
||||
|
||||
if (i - 1 >= 0) {
|
||||
items[i - 1].classList.add("current-item");
|
||||
this.scrollPageTo(items[i - 1]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goToNextListItem() {
|
||||
let items = this.getVisibleElements(".items .item");
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.querySelector(".current-item") === null) {
|
||||
items[0].classList.add("current-item");
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].classList.contains("current-item")) {
|
||||
items[i].classList.remove("current-item");
|
||||
|
||||
if (i + 1 < items.length) {
|
||||
items[i + 1].classList.add("current-item");
|
||||
this.scrollPageTo(items[i + 1]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVisibleElements(selector) {
|
||||
let elements = document.querySelectorAll(selector);
|
||||
let result = [];
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (this.isVisible(elements[i])) {
|
||||
result.push(elements[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isListView() {
|
||||
return document.querySelector(".items") !== null;
|
||||
}
|
||||
|
||||
scrollPageTo(item) {
|
||||
let windowScrollPosition = window.pageYOffset;
|
||||
let windowHeight = document.documentElement.clientHeight;
|
||||
let viewportPosition = windowScrollPosition + windowHeight;
|
||||
let itemBottomPosition = item.offsetTop + item.offsetHeight;
|
||||
|
||||
if (viewportPosition - itemBottomPosition < 0 || viewportPosition - item.offsetTop > windowHeight) {
|
||||
window.scrollTo(0, item.offsetTop - 10);
|
||||
}
|
||||
}
|
||||
|
||||
openNewTab(url) {
|
||||
let win = window.open(url, "_blank");
|
||||
win.focus();
|
||||
}
|
||||
|
||||
isVisible(element) {
|
||||
return element.offsetParent !== null;
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
let element = document.querySelector("meta[name=X-CSRF-Token]");
|
||||
|
||||
if (element !== null) {
|
||||
return element.getAttribute("value");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
(new App()).run();
|
||||
});
|
||||
|
||||
})();
|
111
server/template/common.go
Normal file
111
server/template/common.go
Normal file
|
@ -0,0 +1,111 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-11-19 22:01:21.924938666 -0800 PST m=+0.005771809
|
||||
|
||||
package template
|
||||
|
||||
var templateCommonMap = map[string]string{
|
||||
"entry_pagination": `{{ define "entry_pagination" }}
|
||||
<div class="pagination">
|
||||
<div class="pagination-prev">
|
||||
{{ if .prevEntry }}
|
||||
<a href="{{ .prevEntryRoute }}" title="{{ .prevEntry.Title }}" data-page="previous">{{ t "Previous" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Previous" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="pagination-next">
|
||||
{{ if .nextEntry }}
|
||||
<a href="{{ .nextEntryRoute }}" title="{{ .nextEntry.Title }}" data-page="next">{{ t "Next" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Next" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}`,
|
||||
"layout": `{{ define "base" }}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{{ if .csrf }}
|
||||
<meta name="X-CSRF-Token" value="{{ .csrf }}">
|
||||
{{ end }}
|
||||
<title>{{template "title" .}} - Miniflux</title>
|
||||
{{ if .user }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .user.Theme }}">
|
||||
{{ else }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" "white" }}">
|
||||
{{ end }}
|
||||
<script type="text/javascript" src="{{ route "javascript" }}" defer></script>
|
||||
</head>
|
||||
<body data-entries-status-url="{{ route "updateEntriesStatus" }}">
|
||||
{{ if .user }}
|
||||
<header class="header">
|
||||
<nav>
|
||||
<div class="logo">
|
||||
<a href="{{ route "unread" }}">Mini<span>flux</span></a>
|
||||
</div>
|
||||
<ul>
|
||||
<li {{ if eq .menu "unread" }}class="active"{{ end }}>
|
||||
<a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
|
||||
{{ if gt .countUnread 0 }}
|
||||
<span class="unread-counter" title="Unread articles">({{ .countUnread }})</span>
|
||||
{{ end }}
|
||||
</li>
|
||||
<li {{ if eq .menu "history" }}class="active"{{ end }}>
|
||||
<a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a>
|
||||
</li>
|
||||
<li {{ if eq .menu "feeds" }}class="active"{{ end }}>
|
||||
<a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li {{ if eq .menu "categories" }}class="active"{{ end }}>
|
||||
<a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a>
|
||||
</li>
|
||||
<li {{ if eq .menu "settings" }}class="active"{{ end }}>
|
||||
<a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "logout" }}" title="Logged as {{ .user.Username }}">{{ t "Logout" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
{{ end }}
|
||||
<section class="main">
|
||||
{{template "content" .}}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}`,
|
||||
"pagination": `{{ define "pagination" }}
|
||||
<div class="pagination">
|
||||
<div class="pagination-prev">
|
||||
{{ if .ShowPrev }}
|
||||
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ end }}" data-page="previous">{{ t "Previous" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Previous" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="pagination-next">
|
||||
{{ if .ShowNext }}
|
||||
<a href="{{ .Route }}?offset={{ .NextOffset }}" data-page="next">{{ t "Next" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Next" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
`,
|
||||
}
|
||||
|
||||
var templateCommonMapChecksums = map[string]string{
|
||||
"entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
|
||||
"layout": "8be69cc93fdc99eb36841ae645f58488bd675670507dcdb2de0e593602893178",
|
||||
"pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924",
|
||||
}
|
21
server/template/helper/LICENSE
Normal file
21
server/template/helper/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Hervé GOUCHET
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
61
server/template/helper/elapsed.go
Normal file
61
server/template/helper/elapsed.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
// Copyright (c) 2017 Hervé Gouchet. All rights reserved.
|
||||
// Use of this source code is governed by the MIT License
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/locale"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Texts to be translated if necessary.
|
||||
var (
|
||||
NotYet = `not yet`
|
||||
JustNow = `just now`
|
||||
LastMinute = `1 minute ago`
|
||||
Minutes = `%d minutes ago`
|
||||
LastHour = `1 hour ago`
|
||||
Hours = `%d hours ago`
|
||||
Yesterday = `yesterday`
|
||||
Days = `%d days ago`
|
||||
Weeks = `%d weeks ago`
|
||||
Months = `%d months ago`
|
||||
Years = `%d years ago`
|
||||
)
|
||||
|
||||
// GetElapsedTime returns in a human readable format the elapsed time
|
||||
// since the given datetime.
|
||||
func GetElapsedTime(translator *locale.Language, t time.Time) string {
|
||||
if t.IsZero() || time.Now().Before(t) {
|
||||
return translator.Get(NotYet)
|
||||
}
|
||||
diff := time.Since(t)
|
||||
// Duration in seconds
|
||||
s := diff.Seconds()
|
||||
// Duration in days
|
||||
d := int(s / 86400)
|
||||
switch {
|
||||
case s < 60:
|
||||
return translator.Get(JustNow)
|
||||
case s < 120:
|
||||
return translator.Get(LastMinute)
|
||||
case s < 3600:
|
||||
return translator.Get(Minutes, int(diff.Minutes()))
|
||||
case s < 7200:
|
||||
return translator.Get(LastHour)
|
||||
case s < 86400:
|
||||
return translator.Get(Hours, int(diff.Hours()))
|
||||
case d == 1:
|
||||
return translator.Get(Yesterday)
|
||||
case d < 7:
|
||||
return translator.Get(Days, d)
|
||||
case d < 31:
|
||||
return translator.Get(Weeks, int(math.Ceil(float64(d)/7)))
|
||||
case d < 365:
|
||||
return translator.Get(Months, int(math.Ceil(float64(d)/30)))
|
||||
default:
|
||||
return translator.Get(Years, int(math.Ceil(float64(d)/365)))
|
||||
}
|
||||
}
|
37
server/template/helper/elapsed_test.go
Normal file
37
server/template/helper/elapsed_test.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
// Copyright (c) 2017 Hervé Gouchet. All rights reserved.
|
||||
// Use of this source code is governed by the MIT License
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/miniflux/miniflux2/locale"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestElapsedTime(t *testing.T) {
|
||||
var dt = []struct {
|
||||
in time.Time
|
||||
out string
|
||||
}{
|
||||
{time.Time{}, NotYet},
|
||||
{time.Now().Add(time.Hour), NotYet},
|
||||
{time.Now(), JustNow},
|
||||
{time.Now().Add(-time.Minute), LastMinute},
|
||||
{time.Now().Add(-time.Minute * 40), fmt.Sprintf(Minutes, 40)},
|
||||
{time.Now().Add(-time.Hour), LastHour},
|
||||
{time.Now().Add(-time.Hour * 3), fmt.Sprintf(Hours, 3)},
|
||||
{time.Now().Add(-time.Hour * 32), Yesterday},
|
||||
{time.Now().Add(-time.Hour * 24 * 3), fmt.Sprintf(Days, 3)},
|
||||
{time.Now().Add(-time.Hour * 24 * 14), fmt.Sprintf(Weeks, 2)},
|
||||
{time.Now().Add(-time.Hour * 24 * 60), fmt.Sprintf(Months, 2)},
|
||||
{time.Now().Add(-time.Hour * 24 * 365 * 3), fmt.Sprintf(Years, 3)},
|
||||
}
|
||||
for i, tt := range dt {
|
||||
if out := GetElapsedTime(&locale.Language{}, tt.in); out != tt.out {
|
||||
t.Errorf("%d. content mismatch for %v:exp=%q got=%q", i, tt.in, tt.out, out)
|
||||
}
|
||||
}
|
||||
}
|
37
server/template/html/about.html
Normal file
37
server/template/html/about.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{{ define "title"}}{{ t "About" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "About" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
{{ if .user.IsAdmin }}
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="panel">
|
||||
<h3>{{ t "Version" }}</h3>
|
||||
<ul>
|
||||
<li><strong>{{ t "Version:" }}</strong> {{ .version }}</li>
|
||||
<li><strong>{{ t "Build Date:" }}</strong> {{ .build_date }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>{{ t "Authors" }}</h3>
|
||||
<ul>
|
||||
<li><strong>{{ t "Author:" }}</strong> Frédéric Guillot</li>
|
||||
<li><strong>{{ t "License:" }}</strong> Apache 2.0</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
45
server/template/html/add_subscription.html
Normal file
45
server/template/html/add_subscription.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{{ define "title"}}{{ t "New Subscription" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New Subscription" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .categories }}
|
||||
<p class="alert alert-error">{{ t "There is no category. You must have at least one category." }}</p>
|
||||
{{ else }}
|
||||
<form action="{{ route "submitSubscription" }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-url">{{ t "URL" }}</label>
|
||||
<input type="url" name="url" id="form-url" placeholder="https://domain.tld/" value="{{ .form.URL }}" required autofocus>
|
||||
|
||||
<label for="form-category">{{ t "Category" }}</label>
|
||||
<select id="form-category" name="category_id">
|
||||
{{ range .categories }}
|
||||
<option value="{{ .ID }}">{{ .Title }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Find a subscription" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
50
server/template/html/categories.html
Normal file
50
server/template/html/categories.html
Normal file
|
@ -0,0 +1,50 @@
|
|||
{{ define "title"}}{{ t "Categories" }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Categories" }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .categories }}
|
||||
<p class="alert alert-error">{{ t "There is no category." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .categories }}
|
||||
<article class="item">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
{{ if eq .FeedCount 0 }}
|
||||
{{ t "No feed." }}
|
||||
{{ else }}
|
||||
{{ plural "plural.categories.feed_count" .FeedCount .FeedCount }}
|
||||
{{ end }}
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "editCategory" "categoryID" .ID }}">{{ t "Edit" }}</a>
|
||||
</li>
|
||||
{{ if eq .FeedCount 0 }}
|
||||
<li>
|
||||
<a href="{{ route "removeCategory" "categoryID" .ID }}">{{ t "Remove" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
47
server/template/html/category_entries.html
Normal file
47
server/template/html/category_entries.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
{{ define "title"}}{{ .category.Title }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ .category.Title }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .entries }}
|
||||
<p class="alert">{{ t "There is no article in this category." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
36
server/template/html/choose_subscription.html
Normal file
36
server/template/html/choose_subscription.html
Normal file
|
@ -0,0 +1,36 @@
|
|||
{{ define "title"}}{{ t "Choose a Subscription" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New Subscription" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "chooseSubscription" }}" method="POST">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
<input type="hidden" name="category_id" value="{{ .categoryID }}">
|
||||
|
||||
<h3>{{ t "Choose a Subscription" }}</h3>
|
||||
|
||||
{{ range .subscriptions }}
|
||||
<div class="radio-group">
|
||||
<label title="{{ .URL }}"><input type="radio" name="url" value="{{ .URL }}"> {{ .Title }}</label> ({{ .Type }})
|
||||
<small title="Type = {{ .Type }}"><a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL }}</a></small>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Subscribe" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
19
server/template/html/common/entry_pagination.html
Normal file
19
server/template/html/common/entry_pagination.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{{ define "entry_pagination" }}
|
||||
<div class="pagination">
|
||||
<div class="pagination-prev">
|
||||
{{ if .prevEntry }}
|
||||
<a href="{{ .prevEntryRoute }}" title="{{ .prevEntry.Title }}" data-page="previous">{{ t "Previous" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Previous" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="pagination-next">
|
||||
{{ if .nextEntry }}
|
||||
<a href="{{ .nextEntryRoute }}" title="{{ .nextEntry.Title }}" data-page="next">{{ t "Next" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Next" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
59
server/template/html/common/layout.html
Normal file
59
server/template/html/common/layout.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{{ define "base" }}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{{ if .csrf }}
|
||||
<meta name="X-CSRF-Token" value="{{ .csrf }}">
|
||||
{{ end }}
|
||||
<title>{{template "title" .}} - Miniflux</title>
|
||||
{{ if .user }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .user.Theme }}">
|
||||
{{ else }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" "white" }}">
|
||||
{{ end }}
|
||||
<script type="text/javascript" src="{{ route "javascript" }}" defer></script>
|
||||
</head>
|
||||
<body data-entries-status-url="{{ route "updateEntriesStatus" }}">
|
||||
{{ if .user }}
|
||||
<header class="header">
|
||||
<nav>
|
||||
<div class="logo">
|
||||
<a href="{{ route "unread" }}">Mini<span>flux</span></a>
|
||||
</div>
|
||||
<ul>
|
||||
<li {{ if eq .menu "unread" }}class="active"{{ end }}>
|
||||
<a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
|
||||
{{ if gt .countUnread 0 }}
|
||||
<span class="unread-counter" title="Unread articles">({{ .countUnread }})</span>
|
||||
{{ end }}
|
||||
</li>
|
||||
<li {{ if eq .menu "history" }}class="active"{{ end }}>
|
||||
<a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a>
|
||||
</li>
|
||||
<li {{ if eq .menu "feeds" }}class="active"{{ end }}>
|
||||
<a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li {{ if eq .menu "categories" }}class="active"{{ end }}>
|
||||
<a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a>
|
||||
</li>
|
||||
<li {{ if eq .menu "settings" }}class="active"{{ end }}>
|
||||
<a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "logout" }}" title="Logged as {{ .user.Username }}">{{ t "Logout" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
{{ end }}
|
||||
<section class="main">
|
||||
{{template "content" .}}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
19
server/template/html/common/pagination.html
Normal file
19
server/template/html/common/pagination.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{{ define "pagination" }}
|
||||
<div class="pagination">
|
||||
<div class="pagination-prev">
|
||||
{{ if .ShowPrev }}
|
||||
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ end }}" data-page="previous">{{ t "Previous" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Previous" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="pagination-next">
|
||||
{{ if .ShowNext }}
|
||||
<a href="{{ .Route }}?offset={{ .NextOffset }}" data-page="next">{{ t "Next" }}</a>
|
||||
{{ else }}
|
||||
{{ t "Next" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
27
server/template/html/create_category.html
Normal file
27
server/template/html/create_category.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
{{ define "title"}}{{ t "New Category" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New Category" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "categories" }}">{{ t "Categories" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "saveCategory" }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "Title" }}</label>
|
||||
<input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
41
server/template/html/create_user.html
Normal file
41
server/template/html/create_user.html
Normal file
|
@ -0,0 +1,41 @@
|
|||
{{ define "title"}}{{ t "New User" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New User" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "saveUser" }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" value="{{ .form.Password }}" required>
|
||||
|
||||
<label for="form-confirmation">{{ t "Confirmation" }}</label>
|
||||
<input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" required>
|
||||
|
||||
<label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
30
server/template/html/edit_category.html
Normal file
30
server/template/html/edit_category.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{{ define "title"}}{{ t "Edit Category: %s" .category.Title }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Edit Category: %s" .category.Title }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "categories" }}">{{ t "Categories" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "updateCategory" "categoryID" .category.ID }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "Title" }}</label>
|
||||
<input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
61
server/template/html/edit_feed.html
Normal file
61
server/template/html/edit_feed.html
Normal file
|
@ -0,0 +1,61 @@
|
|||
{{ define "title"}}{{ t "Edit Feed: %s" .feed.Title }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ .feed.Title }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .categories }}
|
||||
<p class="alert alert-error">{{ t "There is no category!" }}</p>
|
||||
{{ else }}
|
||||
{{ if ne .feed.ParsingErrorCount 0 }}
|
||||
<div class="alert alert-error">
|
||||
<h3>{{ t "Last Parsing Error" }}</h3>
|
||||
{{ .feed.ParsingErrorMsg }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<form action="{{ route "updateFeed" "feedID" .feed.ID }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "Title" }}</label>
|
||||
<input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
|
||||
|
||||
<label for="form-site-url">{{ t "Site URL" }}</label>
|
||||
<input type="url" name="site_url" id="form-site-url" placeholder="https://domain.tld/" value="{{ .form.SiteURL }}" required>
|
||||
|
||||
<label for="form-feed-url">{{ t "Feed URL" }}</label>
|
||||
<input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" required>
|
||||
|
||||
<label for="form-category">{{ t "Category" }}</label>
|
||||
<select id="form-category" name="category_id">
|
||||
{{ range .categories }}
|
||||
<option value="{{ .ID }}" {{ if eq .ID $.form.CategoryID }}selected="selected"{{ end }}>{{ .Title }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "feeds" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
44
server/template/html/edit_user.html
Normal file
44
server/template/html/edit_user.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
{{ define "title"}}{{ t "Edit user: %s" .selected_user.Username }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Edit user %s" .selected_user.Username }}"</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "updateUser" "userID" .selected_user.ID }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" value="{{ .form.Password }}">
|
||||
|
||||
<label for="form-confirmation">{{ t "Confirmation" }}</label>
|
||||
<input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}">
|
||||
|
||||
<label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
75
server/template/html/entry.html
Normal file
75
server/template/html/entry.html
Normal file
|
@ -0,0 +1,75 @@
|
|||
{{ define "title"}}{{ .entry.Title }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="entry">
|
||||
<header class="entry-header">
|
||||
<h1>
|
||||
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
|
||||
</h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-website">
|
||||
{{ if ne .entry.Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .entry.Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
|
||||
</span>
|
||||
{{ if .entry.Author }}
|
||||
<span class="entry-author">
|
||||
{{ if contains .entry.Author "@" }}
|
||||
- <a href="mailto:{{ .entry.Author }}">{{ .entry.Author }}</a>
|
||||
{{ else }}
|
||||
– <em>{{ .entry.Author }}</em>
|
||||
{{ end }}
|
||||
</span>
|
||||
{{ end }}
|
||||
<span class="category">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="entry-date">
|
||||
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
|
||||
</div>
|
||||
</header>
|
||||
<div class="pagination-top">
|
||||
{{ template "entry_pagination" . }}
|
||||
</div>
|
||||
<article class="entry-content">
|
||||
{{ noescape (proxyFilter .entry.Content) }}
|
||||
</article>
|
||||
{{ if .entry.Enclosures }}
|
||||
<aside class="entry-enclosures">
|
||||
<h3>{{ t "Attachments" }}</h3>
|
||||
{{ range .entry.Enclosures }}
|
||||
<div class="entry-enclosure">
|
||||
{{ if hasPrefix .MimeType "audio/" }}
|
||||
<div class="enclosure-audio">
|
||||
<audio controls preload="metadata">
|
||||
<source src="{{ .URL }}" type="{{ .MimeType }}">
|
||||
</audio>
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata">
|
||||
<source src="{{ .URL }}" type="{{ .MimeType }}">
|
||||
</video>
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "image/" }}
|
||||
<div class="enclosure-image">
|
||||
<img src="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" alt="{{ .URL }} ({{ .MimeType }})">
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="entry-enclosure-download">
|
||||
<a href="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Download" }}</a>
|
||||
<small>({{ .URL }})</small>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</aside>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
<div class="pagination-bottom">
|
||||
{{ template "entry_pagination" . }}
|
||||
</div>
|
||||
{{ end }}
|
58
server/template/html/feed_entries.html
Normal file
58
server/template/html/feed_entries.html
Normal file
|
@ -0,0 +1,58 @@
|
|||
{{ define "title"}}{{ .feed.Title }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ .feed.Title }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "refreshFeed" "feedID" .feed.ID }}">{{ t "Refresh" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "editFeed" "feedID" .feed.ID }}">{{ t "Edit" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if ne .feed.ParsingErrorCount 0 }}
|
||||
<div class="alert alert-error">
|
||||
<h3>{{ t "There is a problem with this feed" }}</h3>
|
||||
{{ .feed.ParsingErrorMsg }}
|
||||
</div>
|
||||
{{ else if not .entries }}
|
||||
<p class="alert">{{ t "There is no article for this feed." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
65
server/template/html/feeds.html
Normal file
65
server/template/html/feeds.html
Normal file
|
@ -0,0 +1,65 @@
|
|||
{{ define "title"}}{{ t "Feeds" }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Feeds" }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .feeds }}
|
||||
<p class="alert">{{ t "You don't have any subscription." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .feeds }}
|
||||
<article class="item">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "feedEntries" "feedID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .Category.ID }}">{{ .Category.Title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
|
||||
</li>
|
||||
{{ if ne .ParsingErrorCount 0 }}
|
||||
<li><strong title="{{ .ParsingErrorMsg }}">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "refreshFeed" "feedID" .ID }}">{{ t "Refresh" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "editFeed" "feedID" .ID }}">{{ t "Edit" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "removeFeed" "feedID" .ID }}">{{ t "Remove" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
42
server/template/html/history.html
Normal file
42
server/template/html/history.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "History" }} ({{ .total }})</h1>
|
||||
</section>
|
||||
|
||||
{{ if not .entries }}
|
||||
<p class="alert alert-info">{{ t "There is no history at the moment." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "readEntry" "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
34
server/template/html/import.html
Normal file
34
server/template/html/import.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{{ define "title"}}{{ t "Import" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Import" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "uploadOPML" }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-file">{{ t "OPML file" }}</label>
|
||||
<input type="file" name="file" id="form-file">
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Import" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{ end }}
|
23
server/template/html/login.html
Normal file
23
server/template/html/login.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{{ define "title"}}{{ t "Sign In" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="login-form">
|
||||
<form action="{{ route "checkLogin" }}" method="post">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" required autofocus>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" required>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
42
server/template/html/sessions.html
Normal file
42
server/template/html/sessions.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{{ define "title"}}{{ t "Sessions" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Sessions" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<table class="table-overflow">
|
||||
<tr>
|
||||
<th>{{ t "Date" }}</th>
|
||||
<th>{{ t "IP Address" }}</th>
|
||||
<th>{{ t "User Agent" }}</th>
|
||||
<th>{{ t "Actions" }}</th>
|
||||
</tr>
|
||||
{{ range .sessions }}
|
||||
<tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
|
||||
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
|
||||
<td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
|
||||
<td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
|
||||
<td class="column-20">
|
||||
{{ if eq .Token $.currentSessionToken }}
|
||||
{{ t "Current session" }}
|
||||
{{ else }}
|
||||
<a href="{{ route "removeSession" "sessionID" .ID }}">{{ t "Remove" }}</a>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
|
||||
{{ end }}
|
63
server/template/html/settings.html
Normal file
63
server/template/html/settings.html
Normal file
|
@ -0,0 +1,63 @@
|
|||
{{ define "title"}}{{ t "Settings" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Settings" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
{{ if .user.IsAdmin }}
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li>
|
||||
<a href="{{ route "about" }}">{{ t "About" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form method="post" autocomplete="off" action="{{ route "updateSettings" }}">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" value="{{ .form.Username }}" required>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" value="{{ .form.Password }}" autocomplete="off">
|
||||
|
||||
<label for="form-confirmation">{{ t "Confirmation" }}</label>
|
||||
<input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" autocomplete="off">
|
||||
|
||||
<label for="form-language">{{ t "Language" }}</label>
|
||||
<select id="form-language" name="language">
|
||||
{{ range $key, $value := .languages }}
|
||||
<option value="{{ $key }}" {{ if eq $key $.form.Language }}selected="selected"{{ end }}>{{ $value }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<label for="form-timezone">{{ t "Timezone" }}</label>
|
||||
<select id="form-timezone" name="timezone">
|
||||
{{ range $key, $value := .timezones }}
|
||||
<option value="{{ $key }}" {{ if eq $key $.form.Timezone }}selected="selected"{{ end }}>{{ $value }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<label for="form-theme">{{ t "Theme" }}</label>
|
||||
<select id="form-theme" name="theme">
|
||||
{{ range $key, $value := .themes }}
|
||||
<option value="{{ $key }}" {{ if eq $key $.form.Theme }}selected="selected"{{ end }}>{{ $value }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{ end }}
|
47
server/template/html/unread.html
Normal file
47
server/template/html/unread.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Unread" }} ({{ .countUnread }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .entries }}
|
||||
<p class="alert">{{ t "There is no unread article." }}</p>
|
||||
{{ else }}
|
||||
<div class="items hide-read-items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "unreadEntry" "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
51
server/template/html/users.html
Normal file
51
server/template/html/users.html
Normal file
|
@ -0,0 +1,51 @@
|
|||
{{ define "title"}}{{ t "Users" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Users" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if eq (len .users) 1 }}
|
||||
<p class="alert">{{ t "You are the only user." }}</p>
|
||||
{{ else }}
|
||||
<table>
|
||||
<tr>
|
||||
<th class="column-20">{{ t "Username" }}</th>
|
||||
<th>{{ t "Administrator" }}</th>
|
||||
<th>{{ t "Last Login" }}</th>
|
||||
<th>{{ t "Actions" }}</th>
|
||||
</tr>
|
||||
{{ range .users }}
|
||||
{{ if ne .ID $.user.ID }}
|
||||
<tr>
|
||||
<td>{{ .Username }}</td>
|
||||
<td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
|
||||
<td>
|
||||
{{ if .LastLoginAt }}
|
||||
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
|
||||
{{ else }}
|
||||
{{ t "Never" }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route "editUser" "userID" .ID }}">{{ t "Edit" }}</a>,
|
||||
<a href="{{ route "removeUser" "userID" .ID }}">{{ t "Remove" }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</table>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
117
server/template/template.go
Normal file
117
server/template/template.go
Normal file
|
@ -0,0 +1,117 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/miniflux/miniflux2/errors"
|
||||
"github.com/miniflux/miniflux2/locale"
|
||||
"github.com/miniflux/miniflux2/server/route"
|
||||
"github.com/miniflux/miniflux2/server/template/helper"
|
||||
"github.com/miniflux/miniflux2/server/ui/filter"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type TemplateEngine struct {
|
||||
templates map[string]*template.Template
|
||||
router *mux.Router
|
||||
translator *locale.Translator
|
||||
currentLocale *locale.Language
|
||||
}
|
||||
|
||||
func (t *TemplateEngine) ParseAll() {
|
||||
funcMap := template.FuncMap{
|
||||
"route": func(name string, args ...interface{}) string {
|
||||
return route.GetRoute(t.router, name, args...)
|
||||
},
|
||||
"noescape": func(str string) template.HTML {
|
||||
return template.HTML(str)
|
||||
},
|
||||
"proxyFilter": func(data string) string {
|
||||
return filter.ImageProxyFilter(t.router, data)
|
||||
},
|
||||
"domain": func(websiteURL string) string {
|
||||
parsedURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return websiteURL
|
||||
}
|
||||
|
||||
return parsedURL.Host
|
||||
},
|
||||
"hasPrefix": func(str, prefix string) bool {
|
||||
return strings.HasPrefix(str, prefix)
|
||||
},
|
||||
"contains": func(str, substr string) bool {
|
||||
return strings.Contains(str, substr)
|
||||
},
|
||||
"isodate": func(ts time.Time) string {
|
||||
return ts.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"elapsed": func(ts time.Time) string {
|
||||
return helper.GetElapsedTime(t.currentLocale, ts)
|
||||
},
|
||||
"t": func(key interface{}, args ...interface{}) string {
|
||||
switch key.(type) {
|
||||
case string, error:
|
||||
return t.currentLocale.Get(key.(string), args...)
|
||||
case errors.LocalizedError:
|
||||
err := key.(errors.LocalizedError)
|
||||
return err.Localize(t.currentLocale)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
},
|
||||
"plural": func(key string, n int, args ...interface{}) string {
|
||||
return t.currentLocale.Plural(key, n, args...)
|
||||
},
|
||||
}
|
||||
|
||||
commonTemplates := ""
|
||||
for _, content := range templateCommonMap {
|
||||
commonTemplates += content
|
||||
}
|
||||
|
||||
for name, content := range templateViewsMap {
|
||||
log.Println("Parsing template:", name)
|
||||
t.templates[name] = template.Must(template.New("main").Funcs(funcMap).Parse(commonTemplates + content))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TemplateEngine) SetLanguage(language string) {
|
||||
t.currentLocale = t.translator.GetLanguage(language)
|
||||
}
|
||||
|
||||
func (t *TemplateEngine) Execute(w io.Writer, name string, data interface{}) {
|
||||
tpl, ok := t.templates[name]
|
||||
if !ok {
|
||||
log.Fatalf("The template %s does not exists.\n", name)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
err := tpl.ExecuteTemplate(&b, "base", data)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to render template: %v\n", err)
|
||||
}
|
||||
|
||||
b.WriteTo(w)
|
||||
}
|
||||
|
||||
func NewTemplateEngine(router *mux.Router, translator *locale.Translator) *TemplateEngine {
|
||||
tpl := &TemplateEngine{
|
||||
templates: make(map[string]*template.Template),
|
||||
router: router,
|
||||
translator: translator,
|
||||
}
|
||||
|
||||
tpl.ParseAll()
|
||||
return tpl
|
||||
}
|
966
server/template/views.go
Normal file
966
server/template/views.go
Normal file
|
@ -0,0 +1,966 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-11-19 22:01:21.923713128 -0800 PST m=+0.004546271
|
||||
|
||||
package template
|
||||
|
||||
var templateViewsMap = map[string]string{
|
||||
"about": `{{ define "title"}}{{ t "About" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "About" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
{{ if .user.IsAdmin }}
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="panel">
|
||||
<h3>{{ t "Version" }}</h3>
|
||||
<ul>
|
||||
<li><strong>{{ t "Version:" }}</strong> {{ .version }}</li>
|
||||
<li><strong>{{ t "Build Date:" }}</strong> {{ .build_date }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>{{ t "Authors" }}</h3>
|
||||
<ul>
|
||||
<li><strong>{{ t "Author:" }}</strong> Frédéric Guillot</li>
|
||||
<li><strong>{{ t "License:" }}</strong> Apache 2.0</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"add_subscription": `{{ define "title"}}{{ t "New Subscription" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New Subscription" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .categories }}
|
||||
<p class="alert alert-error">{{ t "There is no category. You must have at least one category." }}</p>
|
||||
{{ else }}
|
||||
<form action="{{ route "submitSubscription" }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-url">{{ t "URL" }}</label>
|
||||
<input type="url" name="url" id="form-url" placeholder="https://domain.tld/" value="{{ .form.URL }}" required autofocus>
|
||||
|
||||
<label for="form-category">{{ t "Category" }}</label>
|
||||
<select id="form-category" name="category_id">
|
||||
{{ range .categories }}
|
||||
<option value="{{ .ID }}">{{ .Title }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Find a subscription" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"categories": `{{ define "title"}}{{ t "Categories" }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Categories" }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .categories }}
|
||||
<p class="alert alert-error">{{ t "There is no category." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .categories }}
|
||||
<article class="item">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
{{ if eq .FeedCount 0 }}
|
||||
{{ t "No feed." }}
|
||||
{{ else }}
|
||||
{{ plural "plural.categories.feed_count" .FeedCount .FeedCount }}
|
||||
{{ end }}
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "editCategory" "categoryID" .ID }}">{{ t "Edit" }}</a>
|
||||
</li>
|
||||
{{ if eq .FeedCount 0 }}
|
||||
<li>
|
||||
<a href="{{ route "removeCategory" "categoryID" .ID }}">{{ t "Remove" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"category_entries": `{{ define "title"}}{{ .category.Title }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ .category.Title }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .entries }}
|
||||
<p class="alert">{{ t "There is no article in this category." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"choose_subscription": `{{ define "title"}}{{ t "Choose a Subscription" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New Subscription" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "chooseSubscription" }}" method="POST">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
<input type="hidden" name="category_id" value="{{ .categoryID }}">
|
||||
|
||||
<h3>{{ t "Choose a Subscription" }}</h3>
|
||||
|
||||
{{ range .subscriptions }}
|
||||
<div class="radio-group">
|
||||
<label title="{{ .URL }}"><input type="radio" name="url" value="{{ .URL }}"> {{ .Title }}</label> ({{ .Type }})
|
||||
<small title="Type = {{ .Type }}"><a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL }}</a></small>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Subscribe" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
`,
|
||||
"create_category": `{{ define "title"}}{{ t "New Category" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New Category" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "categories" }}">{{ t "Categories" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "saveCategory" }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "Title" }}</label>
|
||||
<input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
`,
|
||||
"create_user": `{{ define "title"}}{{ t "New User" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "New User" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "saveUser" }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" value="{{ .form.Password }}" required>
|
||||
|
||||
<label for="form-confirmation">{{ t "Confirmation" }}</label>
|
||||
<input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" required>
|
||||
|
||||
<label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Save" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
`,
|
||||
"edit_category": `{{ define "title"}}{{ t "Edit Category: %s" .category.Title }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Edit Category: %s" .category.Title }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "categories" }}">{{ t "Categories" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createCategory" }}">{{ t "Create a category" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "updateCategory" "categoryID" .category.ID }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "Title" }}</label>
|
||||
<input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "categories" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
`,
|
||||
"edit_feed": `{{ define "title"}}{{ t "Edit Feed: %s" .feed.Title }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ .feed.Title }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .categories }}
|
||||
<p class="alert alert-error">{{ t "There is no category!" }}</p>
|
||||
{{ else }}
|
||||
{{ if ne .feed.ParsingErrorCount 0 }}
|
||||
<div class="alert alert-error">
|
||||
<h3>{{ t "Last Parsing Error" }}</h3>
|
||||
{{ .feed.ParsingErrorMsg }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<form action="{{ route "updateFeed" "feedID" .feed.ID }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "Title" }}</label>
|
||||
<input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
|
||||
|
||||
<label for="form-site-url">{{ t "Site URL" }}</label>
|
||||
<input type="url" name="site_url" id="form-site-url" placeholder="https://domain.tld/" value="{{ .form.SiteURL }}" required>
|
||||
|
||||
<label for="form-feed-url">{{ t "Feed URL" }}</label>
|
||||
<input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" required>
|
||||
|
||||
<label for="form-category">{{ t "Category" }}</label>
|
||||
<select id="form-category" name="category_id">
|
||||
{{ range .categories }}
|
||||
<option value="{{ .ID }}" {{ if eq .ID $.form.CategoryID }}selected="selected"{{ end }}>{{ .Title }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "feeds" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}`,
|
||||
"edit_user": `{{ define "title"}}{{ t "Edit user: %s" .selected_user.Username }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Edit user %s" .selected_user.Username }}"</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "updateUser" "userID" .selected_user.ID }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" value="{{ .form.Username }}" required autofocus>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" value="{{ .form.Password }}">
|
||||
|
||||
<label for="form-confirmation">{{ t "Confirmation" }}</label>
|
||||
<input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}">
|
||||
|
||||
<label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked="checked"{{ end }}> {{ t "Administrator" }}</label>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button> {{ t "or" }} <a href="{{ route "users" }}">{{ t "cancel" }}</a>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
`,
|
||||
"entry": `{{ define "title"}}{{ .entry.Title }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="entry">
|
||||
<header class="entry-header">
|
||||
<h1>
|
||||
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
|
||||
</h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-website">
|
||||
{{ if ne .entry.Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .entry.Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
|
||||
</span>
|
||||
{{ if .entry.Author }}
|
||||
<span class="entry-author">
|
||||
{{ if contains .entry.Author "@" }}
|
||||
- <a href="mailto:{{ .entry.Author }}">{{ .entry.Author }}</a>
|
||||
{{ else }}
|
||||
– <em>{{ .entry.Author }}</em>
|
||||
{{ end }}
|
||||
</span>
|
||||
{{ end }}
|
||||
<span class="category">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="entry-date">
|
||||
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
|
||||
</div>
|
||||
</header>
|
||||
<div class="pagination-top">
|
||||
{{ template "entry_pagination" . }}
|
||||
</div>
|
||||
<article class="entry-content">
|
||||
{{ noescape (proxyFilter .entry.Content) }}
|
||||
</article>
|
||||
{{ if .entry.Enclosures }}
|
||||
<aside class="entry-enclosures">
|
||||
<h3>{{ t "Attachments" }}</h3>
|
||||
{{ range .entry.Enclosures }}
|
||||
<div class="entry-enclosure">
|
||||
{{ if hasPrefix .MimeType "audio/" }}
|
||||
<div class="enclosure-audio">
|
||||
<audio controls preload="metadata">
|
||||
<source src="{{ .URL }}" type="{{ .MimeType }}">
|
||||
</audio>
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata">
|
||||
<source src="{{ .URL }}" type="{{ .MimeType }}">
|
||||
</video>
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "image/" }}
|
||||
<div class="enclosure-image">
|
||||
<img src="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" alt="{{ .URL }} ({{ .MimeType }})">
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="entry-enclosure-download">
|
||||
<a href="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Download" }}</a>
|
||||
<small>({{ .URL }})</small>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</aside>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
<div class="pagination-bottom">
|
||||
{{ template "entry_pagination" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
`,
|
||||
"feed_entries": `{{ define "title"}}{{ .feed.Title }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ .feed.Title }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "refreshFeed" "feedID" .feed.ID }}">{{ t "Refresh" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "editFeed" "feedID" .feed.ID }}">{{ t "Edit" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if ne .feed.ParsingErrorCount 0 }}
|
||||
<div class="alert alert-error">
|
||||
<h3>{{ t "There is a problem with this feed" }}</h3>
|
||||
{{ .feed.ParsingErrorMsg }}
|
||||
</div>
|
||||
{{ else if not .entries }}
|
||||
<p class="alert">{{ t "There is no article for this feed." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"feeds": `{{ define "title"}}{{ t "Feeds" }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Feeds" }} ({{ .total }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "import" }}">{{ t "Import" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .feeds }}
|
||||
<p class="alert">{{ t "You don't have any subscription." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .feeds }}
|
||||
<article class="item">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "feedEntries" "feedID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .Category.ID }}">{{ .Category.Title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
|
||||
</li>
|
||||
{{ if ne .ParsingErrorCount 0 }}
|
||||
<li><strong title="{{ .ParsingErrorMsg }}">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "refreshFeed" "feedID" .ID }}">{{ t "Refresh" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "editFeed" "feedID" .ID }}">{{ t "Edit" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "removeFeed" "feedID" .ID }}">{{ t "Remove" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"history": `{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "History" }} ({{ .total }})</h1>
|
||||
</section>
|
||||
|
||||
{{ if not .entries }}
|
||||
<p class="alert alert-info">{{ t "There is no history at the moment." }}</p>
|
||||
{{ else }}
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "readEntry" "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"import": `{{ define "title"}}{{ t "Import" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Import" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feeds" }}">{{ t "Feeds" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "addSubscription" }}">{{ t "Add subscription" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "export" }}">{{ t "Export" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "uploadOPML" }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-file">{{ t "OPML file" }}</label>
|
||||
<input type="file" name="file" id="form-file">
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Import" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"login": `{{ define "title"}}{{ t "Sign In" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="login-form">
|
||||
<form action="{{ route "checkLogin" }}" method="post">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" required autofocus>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" required>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
`,
|
||||
"sessions": `{{ define "title"}}{{ t "Sessions" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Sessions" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<table class="table-overflow">
|
||||
<tr>
|
||||
<th>{{ t "Date" }}</th>
|
||||
<th>{{ t "IP Address" }}</th>
|
||||
<th>{{ t "User Agent" }}</th>
|
||||
<th>{{ t "Actions" }}</th>
|
||||
</tr>
|
||||
{{ range .sessions }}
|
||||
<tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
|
||||
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
|
||||
<td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
|
||||
<td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
|
||||
<td class="column-20">
|
||||
{{ if eq .Token $.currentSessionToken }}
|
||||
{{ t "Current session" }}
|
||||
{{ else }}
|
||||
<a href="{{ route "removeSession" "sessionID" .ID }}">{{ t "Remove" }}</a>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"settings": `{{ define "title"}}{{ t "Settings" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Settings" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
{{ if .user.IsAdmin }}
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li>
|
||||
<a href="{{ route "about" }}">{{ t "About" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form method="post" autocomplete="off" action="{{ route "updateSettings" }}">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-username">{{ t "Username" }}</label>
|
||||
<input type="text" name="username" id="form-username" value="{{ .form.Username }}" required>
|
||||
|
||||
<label for="form-password">{{ t "Password" }}</label>
|
||||
<input type="password" name="password" id="form-password" value="{{ .form.Password }}" autocomplete="off">
|
||||
|
||||
<label for="form-confirmation">{{ t "Confirmation" }}</label>
|
||||
<input type="password" name="confirmation" id="form-confirmation" value="{{ .form.Confirmation }}" autocomplete="off">
|
||||
|
||||
<label for="form-language">{{ t "Language" }}</label>
|
||||
<select id="form-language" name="language">
|
||||
{{ range $key, $value := .languages }}
|
||||
<option value="{{ $key }}" {{ if eq $key $.form.Language }}selected="selected"{{ end }}>{{ $value }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<label for="form-timezone">{{ t "Timezone" }}</label>
|
||||
<select id="form-timezone" name="timezone">
|
||||
{{ range $key, $value := .timezones }}
|
||||
<option value="{{ $key }}" {{ if eq $key $.form.Timezone }}selected="selected"{{ end }}>{{ $value }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<label for="form-theme">{{ t "Theme" }}</label>
|
||||
<select id="form-theme" name="theme">
|
||||
{{ range $key, $value := .themes }}
|
||||
<option value="{{ $key }}" {{ if eq $key $.form.Theme }}selected="selected"{{ end }}>{{ $value }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
"unread": `{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Unread" }} ({{ .countUnread }})</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#" data-on-click="markPageAsRead">{{ t "Mark this page as read" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if not .entries }}
|
||||
<p class="alert">{{ t "There is no unread article." }}</p>
|
||||
{{ else }}
|
||||
<div class="items hide-read-items">
|
||||
{{ range .entries }}
|
||||
<article class="item item-status-{{ .Status }}" data-id="{{ .ID }}">
|
||||
<div class="item-header">
|
||||
<span class="item-title">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
|
||||
{{ end }}
|
||||
<a href="{{ route "unreadEntry" "entryID" .ID }}">{{ .Title }}</a>
|
||||
</span>
|
||||
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "pagination" .pagination }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}`,
|
||||
"users": `{{ define "title"}}{{ t "Users" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "Users" }}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "sessions" }}">{{ t "Sessions" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{ if eq (len .users) 1 }}
|
||||
<p class="alert">{{ t "You are the only user." }}</p>
|
||||
{{ else }}
|
||||
<table>
|
||||
<tr>
|
||||
<th class="column-20">{{ t "Username" }}</th>
|
||||
<th>{{ t "Administrator" }}</th>
|
||||
<th>{{ t "Last Login" }}</th>
|
||||
<th>{{ t "Actions" }}</th>
|
||||
</tr>
|
||||
{{ range .users }}
|
||||
{{ if ne .ID $.user.ID }}
|
||||
<tr>
|
||||
<td>{{ .Username }}</td>
|
||||
<td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
|
||||
<td>
|
||||
{{ if .LastLoginAt }}
|
||||
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
|
||||
{{ else }}
|
||||
{{ t "Never" }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route "editUser" "userID" .ID }}">{{ t "Edit" }}</a>,
|
||||
<a href="{{ route "removeUser" "userID" .ID }}">{{ t "Remove" }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</table>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
`,
|
||||
}
|
||||
|
||||
var templateViewsMapChecksums = map[string]string{
|
||||
"about": "56f1d45d8b9944306c66be0712320527e739a0ce4fccbd97a4c414c8f9cfab04",
|
||||
"add_subscription": "098ea9e492e18242bd414b22c4d8638006d113f728e5ae78c9186663f60ae3f1",
|
||||
"categories": "721b6bae6aa6461f4e020d667707fabe53c94b399f7d74febef2de5eb9f15071",
|
||||
"category_entries": "0bdcf28ef29b976b78d1add431896a8c56791476abd7a4240998d52c3efe1f35",
|
||||
"choose_subscription": "d37682743d8bbd84738a964e238103db2651f95fa340c6e285ffe2e12548d673",
|
||||
"create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275",
|
||||
"create_user": "966b31d0414e0d0a547ef9ada428cbd24a91100bfed491f780c0461892a2489b",
|
||||
"edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5",
|
||||
"edit_feed": "c5bc4c22bf7e8348d880395250545595d21fb8c8e723fc5d7cca68e25d250884",
|
||||
"edit_user": "f0f79704983de3ca7858bd8cda7a372c3999f5e4e0cf951fba5fa2c1752f9111",
|
||||
"entry": "32e605edd6d43773ac31329d247ebd81d38d974cd43689d91de79fffec7fe04b",
|
||||
"feed_entries": "9aff923b6c7452dec1514feada7e0d2bbc1ec21c6f5e9f48b2de41d1b731ffe4",
|
||||
"feeds": "ddcf12a47c850e6a1f3b85c9ab6566b4e45adfcd7a3546381a0c3a7a54f2b7d4",
|
||||
"history": "439000d0be8fd716f3b89860af4d721e05baef0c2ccd2325ba020c940d6aa847",
|
||||
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
|
||||
"login": "568f2f69f248048f3e55e9bbc719077a74ae23fe18f237aa40e3de37e97b7a41",
|
||||
"sessions": "7fcd3bb794d4ad01eb9fa515660f04c8e79e1568970fd541cc7b2de8a76e1542",
|
||||
"settings": "9c89bfd70ff288b4256e5205be78a7645450b364db1df51d10fee3cb915b2c6b",
|
||||
"unread": "b6f9be1a72188947c75a6fdcac6ff7878db7745f9efa46318e0433102892a722",
|
||||
"users": "5bd535de3e46d9b14667d8159a5ec1478d6e028a77bf306c89d7b55813eeb625",
|
||||
}
|
24
server/ui/controller/about.go
Normal file
24
server/ui/controller/about.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/version"
|
||||
)
|
||||
|
||||
func (c *Controller) AboutPage(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("about", args.Merge(tplParams{
|
||||
"version": version.Version,
|
||||
"build_date": version.BuildDate,
|
||||
"menu": "settings",
|
||||
}))
|
||||
}
|
228
server/ui/controller/category.go
Normal file
228
server/ui/controller/category.go
Normal file
|
@ -0,0 +1,228 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/form"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowCategories(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
user := ctx.GetLoggedUser()
|
||||
categories, err := c.store.GetCategoriesWithFeedCount(user.ID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("categories", args.Merge(tplParams{
|
||||
"categories": categories,
|
||||
"total": len(categories),
|
||||
"menu": "categories",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) ShowCategoryEntries(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
offset := request.GetQueryIntegerParam("offset", 0)
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
category, err := c.getCategoryFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(NbItemsPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("category_entries", args.Merge(tplParams{
|
||||
"category": category,
|
||||
"entries": entries,
|
||||
"total": count,
|
||||
"pagination": c.getPagination(ctx.GetRoute("categoryEntries", "categoryID", category.ID), count, offset),
|
||||
"menu": "categories",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) CreateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("create_category", args.Merge(tplParams{
|
||||
"menu": "categories",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) SaveCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryForm := form.NewCategoryForm(request.GetRequest())
|
||||
if err := categoryForm.Validate(); err != nil {
|
||||
response.Html().Render("create_category", args.Merge(tplParams{
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
category := model.Category{Title: categoryForm.Title, UserID: user.ID}
|
||||
err = c.store.CreateCategory(&category)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("create_category", args.Merge(tplParams{
|
||||
"errorMessage": "Unable to create this category.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("categories"))
|
||||
}
|
||||
|
||||
func (c *Controller) EditCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
category, err := c.getCategoryFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCategoryFormTemplateArgs(ctx, user, category, nil)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("edit_category", args)
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
category, err := c.getCategoryFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryForm := form.NewCategoryForm(request.GetRequest())
|
||||
args, err := c.getCategoryFormTemplateArgs(ctx, user, category, categoryForm)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := categoryForm.Validate(); err != nil {
|
||||
response.Html().Render("edit_category", args.Merge(tplParams{
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.UpdateCategory(categoryForm.Merge(category))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("edit_category", args.Merge(tplParams{
|
||||
"errorMessage": "Unable to update this category.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("categories"))
|
||||
}
|
||||
|
||||
func (c *Controller) RemoveCategory(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
category, err := c.getCategoryFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveCategory(user.ID, category.ID); err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("categories"))
|
||||
}
|
||||
|
||||
func (c *Controller) getCategoryFromURL(ctx *core.Context, request *core.Request, response *core.Response) (*model.Category, error) {
|
||||
categoryID, err := request.GetIntegerParam("categoryID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := ctx.GetLoggedUser()
|
||||
category, err := c.store.GetCategory(user.ID, categoryID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
response.Html().NotFound()
|
||||
return nil, errors.New("Category not found")
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func (c *Controller) getCategoryFormTemplateArgs(ctx *core.Context, user *model.User, category *model.Category, categoryForm *form.CategoryForm) (tplParams, error) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if categoryForm == nil {
|
||||
args["form"] = form.CategoryForm{
|
||||
Title: category.Title,
|
||||
}
|
||||
} else {
|
||||
args["form"] = categoryForm
|
||||
}
|
||||
|
||||
args["category"] = category
|
||||
args["menu"] = "categories"
|
||||
return args, nil
|
||||
}
|
56
server/ui/controller/controller.go
Normal file
56
server/ui/controller/controller.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/reader/feed"
|
||||
"github.com/miniflux/miniflux2/reader/opml"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
)
|
||||
|
||||
type tplParams map[string]interface{}
|
||||
|
||||
func (t tplParams) Merge(d tplParams) tplParams {
|
||||
for k, v := range d {
|
||||
t[k] = v
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
type Controller struct {
|
||||
store *storage.Storage
|
||||
feedHandler *feed.Handler
|
||||
opmlHandler *opml.OpmlHandler
|
||||
}
|
||||
|
||||
func (c *Controller) getCommonTemplateArgs(ctx *core.Context) (tplParams, error) {
|
||||
user := ctx.GetLoggedUser()
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
|
||||
countUnread, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := tplParams{
|
||||
"menu": "",
|
||||
"user": user,
|
||||
"countUnread": countUnread,
|
||||
"csrf": ctx.GetCsrfToken(),
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func NewController(store *storage.Storage, feedHandler *feed.Handler, opmlHandler *opml.OpmlHandler) *Controller {
|
||||
return &Controller{
|
||||
store: store,
|
||||
feedHandler: feedHandler,
|
||||
opmlHandler: opmlHandler,
|
||||
}
|
||||
}
|
375
server/ui/controller/entry.go
Normal file
375
server/ui/controller/entry.go
Normal file
|
@ -0,0 +1,375 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/payload"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
sortingDirection := model.DefaultSortingDirection
|
||||
|
||||
entryID, err := request.GetIntegerParam("entryID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", "<=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
nextEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", ">=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.GetOppositeDirection(sortingDirection))
|
||||
prevEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = ctx.GetRoute("feedEntry", "feedID", feedID, "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = ctx.GetRoute("feedEntry", "feedID", feedID, "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
if entry.Status == model.EntryStatusUnread {
|
||||
err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().ServerError(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Html().Render("entry", args.Merge(tplParams{
|
||||
"entry": entry,
|
||||
"prevEntry": prevEntry,
|
||||
"nextEntry": nextEntry,
|
||||
"nextEntryRoute": nextEntryRoute,
|
||||
"prevEntryRoute": prevEntryRoute,
|
||||
"menu": "feeds",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) ShowCategoryEntry(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
sortingDirection := model.DefaultSortingDirection
|
||||
|
||||
categoryID, err := request.GetIntegerParam("categoryID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID, err := request.GetIntegerParam("entryID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", "<=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(sortingDirection)
|
||||
nextEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", ">=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.GetOppositeDirection(sortingDirection))
|
||||
prevEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = ctx.GetRoute("categoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = ctx.GetRoute("categoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
if entry.Status == model.EntryStatusUnread {
|
||||
err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().ServerError(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Html().Render("entry", args.Merge(tplParams{
|
||||
"entry": entry,
|
||||
"prevEntry": prevEntry,
|
||||
"nextEntry": nextEntry,
|
||||
"nextEntryRoute": nextEntryRoute,
|
||||
"prevEntryRoute": prevEntryRoute,
|
||||
"menu": "categories",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) ShowUnreadEntry(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
sortingDirection := model.DefaultSortingDirection
|
||||
|
||||
entryID, err := request.GetIntegerParam("entryID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", "<=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(sortingDirection)
|
||||
nextEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", ">=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.GetOppositeDirection(sortingDirection))
|
||||
prevEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = ctx.GetRoute("unreadEntry", "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = ctx.GetRoute("unreadEntry", "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
if entry.Status == model.EntryStatusUnread {
|
||||
err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().ServerError(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Html().Render("entry", args.Merge(tplParams{
|
||||
"entry": entry,
|
||||
"prevEntry": prevEntry,
|
||||
"nextEntry": nextEntry,
|
||||
"nextEntryRoute": nextEntryRoute,
|
||||
"prevEntryRoute": prevEntryRoute,
|
||||
"menu": "unread",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
sortingDirection := model.DefaultSortingDirection
|
||||
|
||||
entryID, err := request.GetIntegerParam("entryID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", "<=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(sortingDirection)
|
||||
nextEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
builder.WithCondition("e.id", "!=", entryID)
|
||||
builder.WithCondition("e.published_at", ">=", entry.Date)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.GetOppositeDirection(sortingDirection))
|
||||
prevEntry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = ctx.GetRoute("readEntry", "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = ctx.GetRoute("readEntry", "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
response.Html().Render("entry", args.Merge(tplParams{
|
||||
"entry": entry,
|
||||
"prevEntry": prevEntry,
|
||||
"nextEntry": nextEntry,
|
||||
"nextEntryRoute": nextEntryRoute,
|
||||
"prevEntryRoute": prevEntryRoute,
|
||||
"menu": "history",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
entryIDs, status, err := payload.DecodeEntryStatusPayload(request.GetBody())
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Json().BadRequest(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(entryIDs) == 0 {
|
||||
response.Html().BadRequest(errors.New("The list of entryID is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.SetEntriesStatus(user.ID, entryIDs, status)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().ServerError(nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Json().Standard("OK")
|
||||
}
|
209
server/ui/controller/feed.go
Normal file
209
server/ui/controller/feed.go
Normal file
|
@ -0,0 +1,209 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/form"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowFeedsPage(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := c.store.GetFeeds(user.ID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("feeds", args.Merge(tplParams{
|
||||
"feeds": feeds,
|
||||
"total": len(feeds),
|
||||
"menu": "feeds",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) ShowFeedEntries(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
offset := request.GetQueryIntegerParam("offset", 0)
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := c.getFeedFromURL(request, response, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithFeedID(feed.ID)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(NbItemsPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("feed_entries", args.Merge(tplParams{
|
||||
"feed": feed,
|
||||
"entries": entries,
|
||||
"total": count,
|
||||
"pagination": c.getPagination(ctx.GetRoute("feedEntries", "feedID", feed.ID), count, offset),
|
||||
"menu": "feeds",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) EditFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
feed, err := c.getFeedFromURL(request, response, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getFeedFormTemplateArgs(ctx, user, feed, nil)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("edit_feed", args)
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
feed, err := c.getFeedFromURL(request, response, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
feedForm := form.NewFeedForm(request.GetRequest())
|
||||
args, err := c.getFeedFormTemplateArgs(ctx, user, feed, feedForm)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := feedForm.ValidateModification(); err != nil {
|
||||
response.Html().Render("edit_feed", args.Merge(tplParams{
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.UpdateFeed(feedForm.Merge(feed))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("edit_feed", args.Merge(tplParams{
|
||||
"errorMessage": "Unable to update this feed.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("feeds"))
|
||||
}
|
||||
|
||||
func (c *Controller) RemoveFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
user := ctx.GetLoggedUser()
|
||||
if err := c.store.RemoveFeed(user.ID, feedID); err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("feeds"))
|
||||
}
|
||||
|
||||
func (c *Controller) RefreshFeed(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
user := ctx.GetLoggedUser()
|
||||
if err := c.feedHandler.RefreshFeed(user.ID, feedID); err != nil {
|
||||
log.Println("[UI:RefreshFeed]", err)
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("feedEntries", "feedID", feedID))
|
||||
}
|
||||
|
||||
func (c *Controller) getFeedFromURL(request *core.Request, response *core.Response, user *model.User) (*model.Feed, error) {
|
||||
feedID, err := request.GetIntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, err := c.store.GetFeedById(user.ID, feedID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
response.Html().NotFound()
|
||||
return nil, errors.New("Feed not found")
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
func (c *Controller) getFeedFormTemplateArgs(ctx *core.Context, user *model.User, feed *model.Feed, feedForm *form.FeedForm) (tplParams, error) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categories, err := c.store.GetCategories(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if feedForm == nil {
|
||||
args["form"] = form.FeedForm{
|
||||
SiteURL: feed.SiteURL,
|
||||
FeedURL: feed.FeedURL,
|
||||
Title: feed.Title,
|
||||
CategoryID: feed.Category.ID,
|
||||
}
|
||||
} else {
|
||||
args["form"] = feedForm
|
||||
}
|
||||
|
||||
args["categories"] = categories
|
||||
args["feed"] = feed
|
||||
args["menu"] = "feeds"
|
||||
return args, nil
|
||||
}
|
47
server/ui/controller/history.go
Normal file
47
server/ui/controller/history.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowHistoryPage(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
offset := request.GetQueryIntegerParam("offset", 0)
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(NbItemsPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("history", args.Merge(tplParams{
|
||||
"entries": entries,
|
||||
"total": count,
|
||||
"pagination": c.getPagination(ctx.GetRoute("history"), count, offset),
|
||||
"menu": "history",
|
||||
}))
|
||||
}
|
31
server/ui/controller/icon.go
Normal file
31
server/ui/controller/icon.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowIcon(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
iconID, err := request.GetIntegerParam("iconID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := c.store.GetIconByID(iconID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
response.Cache(icon.MimeType, icon.Hash, icon.Content, 72*time.Hour)
|
||||
}
|
91
server/ui/controller/login.go
Normal file
91
server/ui/controller/login.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/form"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tomasen/realip"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowLoginPage(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
if ctx.IsAuthenticated() {
|
||||
response.Redirect(ctx.GetRoute("unread"))
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("login", tplParams{
|
||||
"csrf": ctx.GetCsrfToken(),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
authForm := form.NewAuthForm(request.GetRequest())
|
||||
tplParams := tplParams{
|
||||
"errorMessage": "Invalid username or password.",
|
||||
"csrf": ctx.GetCsrfToken(),
|
||||
}
|
||||
|
||||
if err := authForm.Validate(); err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("login", tplParams)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.CheckPassword(authForm.Username, authForm.Password); err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("login", tplParams)
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken, err := c.store.CreateSession(
|
||||
authForm.Username,
|
||||
request.GetHeaders().Get("User-Agent"),
|
||||
realip.RealIP(request.GetRequest()),
|
||||
)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[UI:CheckLogin] username=%s just logged in\n", authForm.Username)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: "sessionID",
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
Secure: request.IsHTTPS(),
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
response.SetCookie(cookie)
|
||||
response.Redirect(ctx.GetRoute("unread"))
|
||||
}
|
||||
|
||||
func (c *Controller) Logout(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
sessionCookie := request.GetCookie("sessionID")
|
||||
if err := c.store.RemoveSessionByToken(user.ID, sessionCookie); err != nil {
|
||||
log.Printf("[UI:Logout] %v", err)
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: "sessionID",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: request.IsHTTPS(),
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
response.SetCookie(cookie)
|
||||
response.Redirect(ctx.GetRoute("login"))
|
||||
}
|
63
server/ui/controller/opml.go
Normal file
63
server/ui/controller/opml.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) Export(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
opml, err := c.opmlHandler.Export(user.ID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Xml().Download("feeds.opml", opml)
|
||||
}
|
||||
|
||||
func (c *Controller) Import(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("import", args.Merge(tplParams{
|
||||
"menu": "feeds",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) UploadOPML(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
file, fileHeader, err := request.GetFile("file")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Redirect(ctx.GetRoute("import"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
user := ctx.GetLoggedUser()
|
||||
log.Printf("[UI:UploadOPML] User #%d uploaded this file: %s (%d bytes)\n", user.ID, fileHeader.Filename, fileHeader.Size)
|
||||
|
||||
if impErr := c.opmlHandler.Import(user.ID, file); impErr != nil {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("import", args.Merge(tplParams{
|
||||
"errorMessage": impErr.Error(),
|
||||
"menu": "feeds",
|
||||
}))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("feeds"))
|
||||
}
|
46
server/ui/controller/pagination.go
Normal file
46
server/ui/controller/pagination.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
const (
|
||||
NbItemsPerPage = 100
|
||||
)
|
||||
|
||||
type Pagination struct {
|
||||
Route string
|
||||
Total int
|
||||
Offset int
|
||||
ItemsPerPage int
|
||||
ShowNext bool
|
||||
ShowPrev bool
|
||||
NextOffset int
|
||||
PrevOffset int
|
||||
}
|
||||
|
||||
func (c *Controller) getPagination(route string, total, offset int) Pagination {
|
||||
nextOffset := 0
|
||||
prevOffset := 0
|
||||
showNext := (total - offset) > NbItemsPerPage
|
||||
showPrev := offset > 0
|
||||
|
||||
if showNext {
|
||||
nextOffset = offset + NbItemsPerPage
|
||||
}
|
||||
|
||||
if showPrev {
|
||||
prevOffset = offset - NbItemsPerPage
|
||||
}
|
||||
|
||||
return Pagination{
|
||||
Route: route,
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
ItemsPerPage: NbItemsPerPage,
|
||||
ShowNext: showNext,
|
||||
NextOffset: nextOffset,
|
||||
ShowPrev: showPrev,
|
||||
PrevOffset: prevOffset,
|
||||
}
|
||||
}
|
49
server/ui/controller/proxy.go
Normal file
49
server/ui/controller/proxy.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/helper"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Controller) ImageProxy(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
encodedURL := request.GetStringParam("encodedURL", "")
|
||||
if encodedURL == "" {
|
||||
response.Html().BadRequest(errors.New("No URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
decodedURL, err := base64.StdEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
response.Html().BadRequest(errors.New("Unable to decode this URL"))
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Get(string(decodedURL))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
etag := helper.HashFromBytes(body)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
response.Cache(contentType, etag, body, 72*time.Hour)
|
||||
}
|
49
server/ui/controller/session.go
Normal file
49
server/ui/controller/session.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowSessions(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := c.store.GetSessions(user.ID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sessionCookie := request.GetCookie("sessionID")
|
||||
response.Html().Render("sessions", args.Merge(tplParams{
|
||||
"sessions": sessions,
|
||||
"currentSessionToken": sessionCookie,
|
||||
"menu": "settings",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) RemoveSession(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
sessionID, err := request.GetIntegerParam("sessionID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.RemoveSessionByID(user.ID, sessionID)
|
||||
if err != nil {
|
||||
log.Println("[UI:RemoveSession]", err)
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("sessions"))
|
||||
}
|
92
server/ui/controller/settings.go
Normal file
92
server/ui/controller/settings.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/locale"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/form"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowSettings(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
args, err := c.getSettingsFormTemplateArgs(ctx, user, nil)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("settings", args)
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateSettings(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
settingsForm := form.NewSettingsForm(request.GetRequest())
|
||||
args, err := c.getSettingsFormTemplateArgs(ctx, user, settingsForm)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := settingsForm.Validate(); err != nil {
|
||||
response.Html().Render("settings", args.Merge(tplParams{
|
||||
"form": settingsForm,
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if c.store.AnotherUserExists(user.ID, settingsForm.Username) {
|
||||
response.Html().Render("settings", args.Merge(tplParams{
|
||||
"form": settingsForm,
|
||||
"errorMessage": "This user already exists.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.UpdateUser(settingsForm.Merge(user))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("settings", args.Merge(tplParams{
|
||||
"form": settingsForm,
|
||||
"errorMessage": "Unable to update this user.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("settings"))
|
||||
}
|
||||
|
||||
func (c *Controller) getSettingsFormTemplateArgs(ctx *core.Context, user *model.User, settingsForm *form.SettingsForm) (tplParams, error) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
return args, err
|
||||
}
|
||||
|
||||
if settingsForm == nil {
|
||||
args["form"] = form.SettingsForm{
|
||||
Username: user.Username,
|
||||
Theme: user.Theme,
|
||||
Language: user.Language,
|
||||
Timezone: user.Timezone,
|
||||
}
|
||||
} else {
|
||||
args["form"] = settingsForm
|
||||
}
|
||||
|
||||
args["menu"] = "settings"
|
||||
args["themes"] = model.GetThemes()
|
||||
args["languages"] = locale.GetAvailableLanguages()
|
||||
args["timezones"], err = c.store.GetTimezones()
|
||||
if err != nil {
|
||||
return args, err
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
41
server/ui/controller/static.go
Normal file
41
server/ui/controller/static.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/static"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Controller) Stylesheet(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
stylesheet := request.GetStringParam("name", "white")
|
||||
body := static.Stylesheets["common"]
|
||||
etag := static.StylesheetsChecksums["common"]
|
||||
|
||||
if theme, found := static.Stylesheets[stylesheet]; found {
|
||||
body += theme
|
||||
etag += static.StylesheetsChecksums[stylesheet]
|
||||
}
|
||||
|
||||
response.Cache("text/css", etag, []byte(body), 48*time.Hour)
|
||||
}
|
||||
|
||||
func (c *Controller) Javascript(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
response.Cache("text/javascript", static.JavascriptChecksums["app"], []byte(static.Javascript["app"]), 48*time.Hour)
|
||||
}
|
||||
|
||||
func (c *Controller) Favicon(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
blob, err := base64.StdEncoding.DecodeString(static.Binaries["favicon.ico"])
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
response.Cache("image/x-icon", static.BinariesChecksums["favicon.ico"], blob, 48*time.Hour)
|
||||
}
|
127
server/ui/controller/subscription.go
Normal file
127
server/ui/controller/subscription.go
Normal file
|
@ -0,0 +1,127 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/reader/subscription"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/form"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) AddSubscription(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("add_subscription", args)
|
||||
}
|
||||
|
||||
func (c *Controller) SubmitSubscription(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionForm := form.NewSubscriptionForm(request.GetRequest())
|
||||
if err := subscriptionForm.Validate(); err != nil {
|
||||
response.Html().Render("add_subscription", args.Merge(tplParams{
|
||||
"form": subscriptionForm,
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
subscriptions, err := subscription.FindSubscriptions(subscriptionForm.URL)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("add_subscription", args.Merge(tplParams{
|
||||
"form": subscriptionForm,
|
||||
"errorMessage": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[UI:SubmitSubscription]", subscriptions)
|
||||
|
||||
n := len(subscriptions)
|
||||
switch {
|
||||
case n == 0:
|
||||
response.Html().Render("add_subscription", args.Merge(tplParams{
|
||||
"form": subscriptionForm,
|
||||
"errorMessage": "Unable to find any subscription.",
|
||||
}))
|
||||
case n == 1:
|
||||
feed, err := c.feedHandler.CreateFeed(user.ID, subscriptionForm.CategoryID, subscriptions[0].URL)
|
||||
if err != nil {
|
||||
response.Html().Render("add_subscription", args.Merge(tplParams{
|
||||
"form": subscriptionForm,
|
||||
"errorMessage": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("feedEntries", "feedID", feed.ID))
|
||||
case n > 1:
|
||||
response.Html().Render("choose_subscription", args.Merge(tplParams{
|
||||
"categoryID": subscriptionForm.CategoryID,
|
||||
"subscriptions": subscriptions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) ChooseSubscription(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionForm := form.NewSubscriptionForm(request.GetRequest())
|
||||
if err := subscriptionForm.Validate(); err != nil {
|
||||
response.Html().Render("add_subscription", args.Merge(tplParams{
|
||||
"form": subscriptionForm,
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := c.feedHandler.CreateFeed(user.ID, subscriptionForm.CategoryID, subscriptionForm.URL)
|
||||
if err != nil {
|
||||
response.Html().Render("add_subscription", args.Merge(tplParams{
|
||||
"form": subscriptionForm,
|
||||
"errorMessage": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("feedEntries", "feedID", feed.ID))
|
||||
}
|
||||
|
||||
func (c *Controller) getSubscriptionFormTemplateArgs(ctx *core.Context, user *model.User) (tplParams, error) {
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categories, err := c.store.GetCategories(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args["categories"] = categories
|
||||
args["menu"] = "feeds"
|
||||
return args, nil
|
||||
}
|
43
server/ui/controller/unread.go
Normal file
43
server/ui/controller/unread.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowUnreadPage(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
offset := request.GetQueryIntegerParam("offset", 0)
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithOrder(model.DefaultSortingOrder)
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(NbItemsPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
countUnread, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("unread", tplParams{
|
||||
"user": user,
|
||||
"countUnread": countUnread,
|
||||
"entries": entries,
|
||||
"pagination": c.getPagination(ctx.GetRoute("unread"), countUnread, offset),
|
||||
"menu": "unread",
|
||||
"csrf": ctx.GetCsrfToken(),
|
||||
})
|
||||
}
|
231
server/ui/controller/user.go
Normal file
231
server/ui/controller/user.go
Normal file
|
@ -0,0 +1,231 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/ui/form"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (c *Controller) ShowUsers(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
if !user.IsAdmin {
|
||||
response.Html().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := c.store.GetUsers()
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("users", args.Merge(tplParams{
|
||||
"users": users,
|
||||
"menu": "settings",
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) CreateUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
if !user.IsAdmin {
|
||||
response.Html().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("create_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"form": &form.UserForm{},
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) SaveUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
if !user.IsAdmin {
|
||||
response.Html().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
userForm := form.NewUserForm(request.GetRequest())
|
||||
if err := userForm.ValidateCreation(); err != nil {
|
||||
response.Html().Render("create_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"form": userForm,
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if c.store.UserExists(userForm.Username) {
|
||||
response.Html().Render("create_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"form": userForm,
|
||||
"errorMessage": "This user already exists.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
newUser := userForm.ToUser()
|
||||
if err := c.store.CreateUser(newUser); err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("edit_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"form": userForm,
|
||||
"errorMessage": "Unable to create this user.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("users"))
|
||||
}
|
||||
|
||||
func (c *Controller) EditUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
if !user.IsAdmin {
|
||||
response.Html().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
selectedUser, err := c.getUserFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response.Html().Render("edit_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"selected_user": selectedUser,
|
||||
"form": &form.UserForm{
|
||||
Username: selectedUser.Username,
|
||||
IsAdmin: selectedUser.IsAdmin,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
|
||||
if !user.IsAdmin {
|
||||
response.Html().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
args, err := c.getCommonTemplateArgs(ctx)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
selectedUser, err := c.getUserFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
userForm := form.NewUserForm(request.GetRequest())
|
||||
if err := userForm.ValidateModification(); err != nil {
|
||||
response.Html().Render("edit_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"selected_user": selectedUser,
|
||||
"form": userForm,
|
||||
"errorMessage": err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if c.store.AnotherUserExists(selectedUser.ID, userForm.Username) {
|
||||
response.Html().Render("edit_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"selected_user": selectedUser,
|
||||
"form": userForm,
|
||||
"errorMessage": "This user already exists.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
userForm.Merge(selectedUser)
|
||||
if err := c.store.UpdateUser(selectedUser); err != nil {
|
||||
log.Println(err)
|
||||
response.Html().Render("edit_user", args.Merge(tplParams{
|
||||
"menu": "settings",
|
||||
"selected_user": selectedUser,
|
||||
"form": userForm,
|
||||
"errorMessage": "Unable to update this user.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("users"))
|
||||
}
|
||||
|
||||
func (c *Controller) RemoveUser(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
user := ctx.GetLoggedUser()
|
||||
if !user.IsAdmin {
|
||||
response.Html().Forbidden()
|
||||
return
|
||||
}
|
||||
|
||||
selectedUser, err := c.getUserFromURL(ctx, request, response)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveUser(selectedUser.ID); err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Redirect(ctx.GetRoute("users"))
|
||||
}
|
||||
|
||||
func (c *Controller) getUserFromURL(ctx *core.Context, request *core.Request, response *core.Response) (*model.User, error) {
|
||||
userID, err := request.GetIntegerParam("userID")
|
||||
if err != nil {
|
||||
response.Html().BadRequest(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := c.store.GetUserById(userID)
|
||||
if err != nil {
|
||||
response.Html().ServerError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.Html().NotFound()
|
||||
return nil, errors.New("User not found")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
35
server/ui/filter/image_proxy_filter.go
Normal file
35
server/ui/filter/image_proxy_filter.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package filter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"github.com/miniflux/miniflux2/reader/url"
|
||||
"github.com/miniflux/miniflux2/server/route"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ImageProxyFilter rewrites image tag URLs without HTTPS to local proxy URL
|
||||
func ImageProxyFilter(r *mux.Router, data string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
|
||||
doc.Find("img").Each(func(i int, img *goquery.Selection) {
|
||||
if srcAttr, ok := img.Attr("src"); ok {
|
||||
if !url.IsHTTPS(srcAttr) {
|
||||
path := route.GetRoute(r, "proxy", "encodedURL", base64.StdEncoding.EncodeToString([]byte(srcAttr)))
|
||||
img.SetAttr("src", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
return output
|
||||
}
|
38
server/ui/filter/image_proxy_filter_test.go
Normal file
38
server/ui/filter/image_proxy_filter_test.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package filter
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestProxyFilterWithHttp(t *testing.T) {
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||
output := ImageProxyFilter(r, input)
|
||||
expected := `<p><img src="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFilterWithHttps(t *testing.T) {
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := ImageProxyFilter(r, input)
|
||||
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
||||
}
|
||||
}
|
30
server/ui/form/auth.go
Normal file
30
server/ui/form/auth.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AuthForm struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (a AuthForm) Validate() error {
|
||||
if a.Username == "" || a.Password == "" {
|
||||
return errors.New("All fields are mandatory.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAuthForm(r *http.Request) *AuthForm {
|
||||
return &AuthForm{
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
}
|
||||
}
|
34
server/ui/form/category.go
Normal file
34
server/ui/form/category.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CategoryForm represents a feed form in the UI
|
||||
type CategoryForm struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func (c CategoryForm) Validate() error {
|
||||
if c.Title == "" {
|
||||
return errors.New("The title is mandatory.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c CategoryForm) Merge(category *model.Category) *model.Category {
|
||||
category.Title = c.Title
|
||||
return category
|
||||
}
|
||||
|
||||
func NewCategoryForm(r *http.Request) *CategoryForm {
|
||||
return &CategoryForm{
|
||||
Title: r.FormValue("title"),
|
||||
}
|
||||
}
|
53
server/ui/form/feed.go
Normal file
53
server/ui/form/feed.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// FeedForm represents a feed form in the UI
|
||||
type FeedForm struct {
|
||||
FeedURL string
|
||||
SiteURL string
|
||||
Title string
|
||||
CategoryID int64
|
||||
}
|
||||
|
||||
// ValidateModification validates FeedForm fields
|
||||
func (f FeedForm) ValidateModification() error {
|
||||
if f.FeedURL == "" || f.SiteURL == "" || f.Title == "" || f.CategoryID == 0 {
|
||||
return errors.New("All fields are mandatory.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
|
||||
feed.Category.ID = f.CategoryID
|
||||
feed.Title = f.Title
|
||||
feed.SiteURL = f.SiteURL
|
||||
feed.FeedURL = f.FeedURL
|
||||
feed.ParsingErrorCount = 0
|
||||
feed.ParsingErrorMsg = ""
|
||||
return feed
|
||||
}
|
||||
|
||||
// NewFeedForm parses the HTTP request and returns a FeedForm
|
||||
func NewFeedForm(r *http.Request) *FeedForm {
|
||||
categoryID, err := strconv.Atoi(r.FormValue("category_id"))
|
||||
if err != nil {
|
||||
categoryID = 0
|
||||
}
|
||||
|
||||
return &FeedForm{
|
||||
FeedURL: r.FormValue("feed_url"),
|
||||
SiteURL: r.FormValue("site_url"),
|
||||
Title: r.FormValue("title"),
|
||||
CategoryID: int64(categoryID),
|
||||
}
|
||||
}
|
62
server/ui/form/settings.go
Normal file
62
server/ui/form/settings.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SettingsForm struct {
|
||||
Username string
|
||||
Password string
|
||||
Confirmation string
|
||||
Theme string
|
||||
Language string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
func (s *SettingsForm) Merge(user *model.User) *model.User {
|
||||
user.Username = s.Username
|
||||
user.Theme = s.Theme
|
||||
user.Language = s.Language
|
||||
user.Timezone = s.Timezone
|
||||
|
||||
if s.Password != "" {
|
||||
user.Password = s.Password
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *SettingsForm) Validate() error {
|
||||
if s.Username == "" || s.Theme == "" || s.Language == "" || s.Timezone == "" {
|
||||
return errors.New("The username, theme, language and timezone fields are mandatory.")
|
||||
}
|
||||
|
||||
if s.Password != "" {
|
||||
if s.Password != s.Confirmation {
|
||||
return errors.New("Passwords are not the same.")
|
||||
}
|
||||
|
||||
if len(s.Password) < 6 {
|
||||
return errors.New("You must use at least 6 characters")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSettingsForm(r *http.Request) *SettingsForm {
|
||||
return &SettingsForm{
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
Confirmation: r.FormValue("confirmation"),
|
||||
Theme: r.FormValue("theme"),
|
||||
Language: r.FormValue("language"),
|
||||
Timezone: r.FormValue("timezone"),
|
||||
}
|
||||
}
|
36
server/ui/form/subscription.go
Normal file
36
server/ui/form/subscription.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type SubscriptionForm struct {
|
||||
URL string
|
||||
CategoryID int64
|
||||
}
|
||||
|
||||
func (s *SubscriptionForm) Validate() error {
|
||||
if s.URL == "" || s.CategoryID == 0 {
|
||||
return errors.New("The URL and the category are mandatory.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSubscriptionForm(r *http.Request) *SubscriptionForm {
|
||||
categoryID, err := strconv.Atoi(r.FormValue("category_id"))
|
||||
if err != nil {
|
||||
categoryID = 0
|
||||
}
|
||||
|
||||
return &SubscriptionForm{
|
||||
URL: r.FormValue("url"),
|
||||
CategoryID: int64(categoryID),
|
||||
}
|
||||
}
|
80
server/ui/form/user.go
Normal file
80
server/ui/form/user.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type UserForm struct {
|
||||
Username string
|
||||
Password string
|
||||
Confirmation string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func (u UserForm) ValidateCreation() error {
|
||||
if u.Username == "" || u.Password == "" || u.Confirmation == "" {
|
||||
return errors.New("All fields are mandatory.")
|
||||
}
|
||||
|
||||
if u.Password != u.Confirmation {
|
||||
return errors.New("Passwords are not the same.")
|
||||
}
|
||||
|
||||
if len(u.Password) < 6 {
|
||||
return errors.New("You must use at least 6 characters.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u UserForm) ValidateModification() error {
|
||||
if u.Username == "" {
|
||||
return errors.New("The username is mandatory.")
|
||||
}
|
||||
|
||||
if u.Password != "" {
|
||||
if u.Password != u.Confirmation {
|
||||
return errors.New("Passwords are not the same.")
|
||||
}
|
||||
|
||||
if len(u.Password) < 6 {
|
||||
return errors.New("You must use at least 6 characters.")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u UserForm) ToUser() *model.User {
|
||||
return &model.User{
|
||||
Username: u.Username,
|
||||
Password: u.Password,
|
||||
IsAdmin: u.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func (u UserForm) Merge(user *model.User) *model.User {
|
||||
user.Username = u.Username
|
||||
user.IsAdmin = u.IsAdmin
|
||||
|
||||
if u.Password != "" {
|
||||
user.Password = u.Password
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func NewUserForm(r *http.Request) *UserForm {
|
||||
return &UserForm{
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
Confirmation: r.FormValue("confirmation"),
|
||||
IsAdmin: r.FormValue("is_admin") == "1",
|
||||
}
|
||||
}
|
31
server/ui/payload/payload.go
Normal file
31
server/ui/payload/payload.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package payload
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"io"
|
||||
)
|
||||
|
||||
func DecodeEntryStatusPayload(data io.Reader) (entryIDs []int64, status string, err error) {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(data)
|
||||
if err = decoder.Decode(&p); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
if err := model.ValidateEntryStatus(p.Status); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return p.EntryIDs, p.Status, nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue