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