1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-07-22 17:18:37 +00:00

First commit

This commit is contained in:
Frédéric Guillot 2017-11-19 21:10:04 -08:00
commit 8ffb773f43
2121 changed files with 1118910 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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