Browse Source

First commit

Frédéric Guillot 8 years ago
commit
8ffb773f43
100 changed files with 8960 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 5 0
      .travis.yml
  3. 81 0
      Gopkg.lock
  4. 54 0
      Gopkg.toml
  5. 177 0
      LICENSE
  6. 25 0
      Makefile
  7. 38 0
      README.md
  8. 36 0
      config/config.go
  9. 27 0
      errors/errors.go
  10. 120 0
      generate.go
  11. 38 0
      helper/crypto.go
  12. 16 0
      helper/time.go
  13. 47 0
      locale/language.go
  14. 30 0
      locale/locale.go
  15. 103 0
      locale/locale_test.go
  16. 101 0
      locale/plurals.go
  17. 136 0
      locale/translations.go
  18. 10 0
      locale/translations/en_US.json
  19. 113 0
      locale/translations/fr_FR.json
  20. 40 0
      locale/translator.go
  21. 124 0
      main.go
  22. 51 0
      model/category.go
  23. 18 0
      model/enclosure.go
  24. 71 0
      model/entry.go
  25. 66 0
      model/feed.go
  26. 19 0
      model/icon.go
  27. 10 0
      model/job.go
  28. 23 0
      model/session.go
  29. 13 0
      model/theme.go
  30. 96 0
      model/user.go
  31. 214 0
      reader/feed/atom/atom.go
  32. 28 0
      reader/feed/atom/parser.go
  33. 319 0
      reader/feed/atom/parser_test.go
  34. 203 0
      reader/feed/date/parser.go
  35. 152 0
      reader/feed/handler.go
  36. 170 0
      reader/feed/json/json.go
  37. 23 0
      reader/feed/json/parser.go
  38. 345 0
      reader/feed/json/parser_test.go
  39. 82 0
      reader/feed/parser.go
  40. 169 0
      reader/feed/parser_test.go
  41. 28 0
      reader/feed/rss/parser.go
  42. 466 0
      reader/feed/rss/parser_test.go
  43. 207 0
      reader/feed/rss/rss.go
  44. 95 0
      reader/http/client.go
  45. 32 0
      reader/http/response.go
  46. 109 0
      reader/icon/finder.go
  47. 94 0
      reader/opml/handler.go
  48. 82 0
      reader/opml/opml.go
  49. 26 0
      reader/opml/parser.go
  50. 138 0
      reader/opml/parser_test.go
  51. 58 0
      reader/opml/serializer.go
  52. 31 0
      reader/opml/serializer_test.go
  53. 18 0
      reader/opml/subscription.go
  54. 15 0
      reader/processor/processor.go
  55. 47 0
      reader/rewrite/rewriter.go
  56. 34 0
      reader/rewrite/rewriter_test.go
  57. 360 0
      reader/sanitizer/sanitizer.go
  58. 144 0
      reader/sanitizer/sanitizer_test.go
  59. 35 0
      reader/sanitizer/strip_tags.go
  60. 17 0
      reader/sanitizer/strip_tags_test.go
  61. 96 0
      reader/subscription/finder.go
  62. 21 0
      reader/subscription/subscription.go
  63. 61 0
      reader/url/url.go
  64. 107 0
      reader/url/url_test.go
  65. 24 0
      scheduler/scheduler.go
  66. 35 0
      scheduler/worker.go
  67. 34 0
      scheduler/worker_pool.go
  68. 97 0
      server/api/controller/category.go
  69. 21 0
      server/api/controller/controller.go
  70. 156 0
      server/api/controller/entry.go
  71. 138 0
      server/api/controller/feed.go
  72. 35 0
      server/api/controller/subscription.go
  73. 163 0
      server/api/controller/user.go
  74. 93 0
      server/api/payload/payload.go
  75. 99 0
      server/core/context.go
  76. 57 0
      server/core/handler.go
  77. 58 0
      server/core/html_response.go
  78. 94 0
      server/core/json_response.go
  79. 108 0
      server/core/request.go
  80. 63 0
      server/core/response.go
  81. 21 0
      server/core/xml_response.go
  82. 61 0
      server/middleware/basic_auth.go
  83. 48 0
      server/middleware/csrf.go
  84. 31 0
      server/middleware/middleware.go
  85. 72 0
      server/middleware/session.go
  86. 37 0
      server/route/route.go
  87. 132 0
      server/routes.go
  88. 33 0
      server/server.go
  89. 6 0
      server/static/bin.go
  90. BIN
      server/static/bin/favicon.ico
  91. 7 0
      server/static/css.go
  92. 197 0
      server/static/css/black.css
  93. 654 0
      server/static/css/common.css
  94. 52 0
      server/static/js.go
  95. 351 0
      server/static/js/app.js
  96. 111 0
      server/template/common.go
  97. 21 0
      server/template/helper/LICENSE
  98. 61 0
      server/template/helper/elapsed.go
  99. 37 0
      server/template/helper/elapsed_test.go
  100. 37 0
      server/template/html/about.html

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+miniflux-linux-amd64
+miniflux-darwin-amd64

+ 5 - 0
.travis.yml

@@ -0,0 +1,5 @@
+language: go
+go:
+  - 1.9
+script:
+  - go test -cover -race ./...

+ 81 - 0
Gopkg.lock

@@ -0,0 +1,81 @@
+# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
+
+
+[[projects]]
+  name = "github.com/PuerkitoBio/goquery"
+  packages = ["."]
+  revision = "e1271ee34c6a305e38566ecd27ae374944907ee9"
+  version = "v1.1.0"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/andybalholm/cascadia"
+  packages = ["."]
+  revision = "349dd0209470eabd9514242c688c403c0926d266"
+
+[[projects]]
+  name = "github.com/gorilla/context"
+  packages = ["."]
+  revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
+  version = "v1.1"
+
+[[projects]]
+  name = "github.com/gorilla/mux"
+  packages = ["."]
+  revision = "7f08801859139f86dfafd1c296e2cba9a80d292e"
+  version = "v1.6.0"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/lib/pq"
+  packages = [".","oid"]
+  revision = "8c6ee72f3e6bcb1542298dd5f76cb74af9742cec"
+
+[[projects]]
+  name = "github.com/tdewolff/minify"
+  packages = [".","css","js"]
+  revision = "90df1aae5028a7cbb441bde86e86a55df6b5aa34"
+  version = "v2.3.3"
+
+[[projects]]
+  name = "github.com/tdewolff/parse"
+  packages = [".","buffer","css","js","strconv"]
+  revision = "bace4cf682c41e03b154044b561575ff541b83e8"
+  version = "v2.3.1"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/tomasen/realip"
+  packages = ["."]
+  revision = "15489afd3be348430f5f67467d2bb6b2f9b757ed"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/crypto"
+  packages = ["bcrypt","blowfish","ssh/terminal"]
+  revision = "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/net"
+  packages = ["html","html/atom","html/charset"]
+  revision = "9dfe39835686865bff950a07b394c12a98ddc811"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/sys"
+  packages = ["unix","windows"]
+  revision = "0dd5e194bbf5eb84a39666eb4c98a4d007e4203a"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/text"
+  packages = ["encoding","encoding/charmap","encoding/htmlindex","encoding/internal","encoding/internal/identifier","encoding/japanese","encoding/korean","encoding/simplifiedchinese","encoding/traditionalchinese","encoding/unicode","internal/gen","internal/tag","internal/utf8internal","language","runes","transform","unicode/cldr"]
+  revision = "88f656faf3f37f690df1a32515b479415e1a6769"
+
+[solve-meta]
+  analyzer-name = "dep"
+  analyzer-version = 1
+  inputs-digest = "27a0ca12f5a709bb76b9c90f6720b6824ac8fc81b2fc66f059f212366443ff5d"
+  solver-name = "gps-cdcl"
+  solver-version = 1

+ 54 - 0
Gopkg.toml

@@ -0,0 +1,54 @@
+
+# Gopkg.toml example
+#
+# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
+# for detailed Gopkg.toml documentation.
+#
+# required = ["github.com/user/thing/cmd/thing"]
+# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
+#
+# [[constraint]]
+#   name = "github.com/user/project"
+#   version = "1.0.0"
+#
+# [[constraint]]
+#   name = "github.com/user/project2"
+#   branch = "dev"
+#   source = "github.com/myfork/project2"
+#
+# [[override]]
+#  name = "github.com/x/y"
+#  version = "2.4.0"
+
+
+[[constraint]]
+  name = "github.com/PuerkitoBio/goquery"
+  version = "1.1.0"
+
+[[constraint]]
+  name = "github.com/gorilla/mux"
+  version = "1.6.0"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/lib/pq"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/rvflash/elapsed"
+
+[[constraint]]
+  name = "github.com/tdewolff/minify"
+  version = "2.3.3"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/tomasen/realip"
+
+[[constraint]]
+  branch = "master"
+  name = "golang.org/x/crypto"
+
+[[constraint]]
+  branch = "master"
+  name = "golang.org/x/net"

+ 177 - 0
LICENSE

@@ -0,0 +1,177 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS

+ 25 - 0
Makefile

@@ -0,0 +1,25 @@
+APP = miniflux
+VERSION = $(shell git rev-parse --short HEAD)
+BUILD_DATE = `date +%FT%T%z`
+
+.PHONY: build-linux build-darwin build run clean test
+
+build-linux:
+	@ go generate
+	@ GOOS=linux GOARCH=amd64 go build -ldflags="-X 'miniflux/version.Version=$(VERSION)' -X 'miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
+
+build-darwin:
+	@ go generate
+	@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'miniflux/version.Version=$(VERSION)' -X 'miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
+
+build: build-linux build-darwin
+
+run:
+	@ go generate
+	@ go run main.go
+
+clean:
+	@ rm -f $(APP)-*
+
+test:
+	go test -cover -race ./...

+ 38 - 0
README.md

@@ -0,0 +1,38 @@
+Miniflux 2
+==========
+[![Build Status](https://travis-ci.org/miniflux/miniflux2.svg?branch=master)](https://travis-ci.org/miniflux/miniflux2)
+
+Miniflux is a minimalist and opinionated feed reader:
+
+- Written in Go (Golang)
+- Works only with Postgresql
+- Doesn't use any ORM
+- Doesn't use any complicated framework
+- The number of features is volountary limited
+
+It's simple, fast, lightweight and super easy to install.
+
+Miniflux 2 is a rewrite of Miniflux 1.x in Golang.
+
+Notes
+-----
+
+Miniflux 2 still in development and **it's not ready to use**.
+
+TODO
+----
+
+- [ ] Custom entries sorting
+- [ ] Webpage scraper (Readability)
+- [ ] Bookmarklet
+- [ ] External integrations (Pinboard, Wallabag...)
+- [ ] Gzip compression
+- [ ] Integration tests
+- [ ] Flush history
+- [ ] OAuth2
+
+Credits
+-------
+
+- Author: Frédéric Guillot
+- Distributed under Apache 2.0 License

+ 36 - 0
config/config.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 config
+
+import (
+	"os"
+	"strconv"
+)
+
+type Config struct {
+}
+
+func (c *Config) Get(key, fallback string) string {
+	value := os.Getenv(key)
+	if value == "" {
+		return fallback
+	}
+
+	return value
+}
+
+func (c *Config) GetInt(key string, fallback int) int {
+	value := os.Getenv(key)
+	if value == "" {
+		return fallback
+	}
+
+	v, _ := strconv.Atoi(value)
+	return v
+}
+
+func NewConfig() *Config {
+	return &Config{}
+}

+ 27 - 0
errors/errors.go

@@ -0,0 +1,27 @@
+// 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 errors
+
+import (
+	"fmt"
+	"github.com/miniflux/miniflux2/locale"
+)
+
+type LocalizedError struct {
+	message string
+	args    []interface{}
+}
+
+func (l LocalizedError) Error() string {
+	return fmt.Sprintf(l.message, l.args...)
+}
+
+func (l LocalizedError) Localize(translation *locale.Language) string {
+	return translation.Get(l.message, l.args...)
+}
+
+func NewLocalizedError(message string, args ...interface{}) LocalizedError {
+	return LocalizedError{message: message, args: args}
+}

+ 120 - 0
generate.go

@@ -0,0 +1,120 @@
+// 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.
+
+// +build ignore
+
+package main
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/tdewolff/minify"
+	"github.com/tdewolff/minify/css"
+	"github.com/tdewolff/minify/js"
+)
+
+const tpl = `// Code generated by go generate; DO NOT EDIT.
+// {{ .Timestamp }}
+
+package {{ .Package }}
+
+var {{ .Map }} = map[string]string{
+{{ range $constant, $content := .Files }}` + "\t" + `"{{ $constant }}": ` + "`{{ $content }}`" + `,
+{{ end }}}
+
+var {{ .Map }}Checksums = map[string]string{
+{{ range $constant, $content := .Checksums }}` + "\t" + `"{{ $constant }}": "{{ $content }}",
+{{ end }}}
+`
+
+var generatedTpl = template.Must(template.New("").Parse(tpl))
+
+type GeneratedFile struct {
+	Package, Map string
+	Timestamp    time.Time
+	Files        map[string]string
+	Checksums    map[string]string
+}
+
+func normalizeBasename(filename string) string {
+	filename = strings.TrimSuffix(filename, filepath.Ext(filename))
+	return strings.Replace(filename, " ", "_", -1)
+}
+
+func generateFile(serializer, pkg, mapName, pattern, output string) {
+	generatedFile := &GeneratedFile{
+		Package:   pkg,
+		Map:       mapName,
+		Timestamp: time.Now(),
+		Files:     make(map[string]string),
+		Checksums: make(map[string]string),
+	}
+
+	files, _ := filepath.Glob(pattern)
+	for _, file := range files {
+		basename := path.Base(file)
+		content, err := ioutil.ReadFile(file)
+		if err != nil {
+			panic(err)
+		}
+
+		switch serializer {
+		case "css":
+			m := minify.New()
+			m.AddFunc("text/css", css.Minify)
+			content, err = m.Bytes("text/css", content)
+			if err != nil {
+				panic(err)
+			}
+
+			basename = normalizeBasename(basename)
+			generatedFile.Files[basename] = string(content)
+		case "js":
+			m := minify.New()
+			m.AddFunc("text/javascript", js.Minify)
+			content, err = m.Bytes("text/javascript", content)
+			if err != nil {
+				panic(err)
+			}
+
+			basename = normalizeBasename(basename)
+			generatedFile.Files[basename] = string(content)
+		case "base64":
+			encodedContent := base64.StdEncoding.EncodeToString(content)
+			generatedFile.Files[basename] = encodedContent
+		default:
+			basename = normalizeBasename(basename)
+			generatedFile.Files[basename] = string(content)
+		}
+
+		generatedFile.Checksums[basename] = fmt.Sprintf("%x", sha256.Sum256(content))
+	}
+
+	f, err := os.Create(output)
+	if err != nil {
+		panic(err)
+	}
+	defer f.Close()
+
+	generatedTpl.Execute(f, generatedFile)
+}
+
+func main() {
+	generateFile("none", "sql", "SqlMap", "sql/*.sql", "sql/sql.go")
+	generateFile("base64", "static", "Binaries", "server/static/bin/*", "server/static/bin.go")
+	generateFile("css", "static", "Stylesheets", "server/static/css/*.css", "server/static/css.go")
+	generateFile("js", "static", "Javascript", "server/static/js/*.js", "server/static/js.go")
+	generateFile("none", "template", "templateViewsMap", "server/template/html/*.html", "server/template/views.go")
+	generateFile("none", "template", "templateCommonMap", "server/template/html/common/*.html", "server/template/common.go")
+	generateFile("none", "locale", "Translations", "locale/translations/*.json", "locale/translations.go")
+}

+ 38 - 0
helper/crypto.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 helper
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+)
+
+// HashFromBytes returns a SHA-256 checksum of the input.
+func HashFromBytes(value []byte) string {
+	sum := sha256.Sum256(value)
+	return fmt.Sprintf("%x", sum)
+}
+
+// Hash returns a SHA-256 checksum of a string.
+func Hash(value string) string {
+	return HashFromBytes([]byte(value))
+}
+
+// GenerateRandomBytes returns random bytes.
+func GenerateRandomBytes(size int) []byte {
+	b := make([]byte, size)
+	if _, err := rand.Read(b); err != nil {
+		panic(fmt.Errorf("Unable to generate random string: %v", err))
+	}
+
+	return b
+}
+
+// GenerateRandomString returns a random string.
+func GenerateRandomString(size int) string {
+	return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
+}

+ 16 - 0
helper/time.go

@@ -0,0 +1,16 @@
+// 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 helper
+
+import (
+	"log"
+	"time"
+)
+
+// ExecutionTime returns the elapsed time of a block of code.
+func ExecutionTime(start time.Time, name string) {
+	elapsed := time.Since(start)
+	log.Printf("%s took %s", name, elapsed)
+}

+ 47 - 0
locale/language.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 locale
+
+import "fmt"
+
+type Language struct {
+	language     string
+	translations Translation
+}
+
+func (l *Language) Get(key string, args ...interface{}) string {
+	var translation string
+
+	str, found := l.translations[key]
+	if !found {
+		translation = key
+	} else {
+		translation = str.(string)
+	}
+
+	return fmt.Sprintf(translation, args...)
+}
+
+func (l *Language) Plural(key string, n int, args ...interface{}) string {
+	translation := key
+	slices, found := l.translations[key]
+	if found {
+
+		pluralForm, found := pluralForms[l.language]
+		if !found {
+			pluralForm = pluralForms["default"]
+		}
+
+		index := pluralForm(n)
+		translations := slices.([]interface{})
+		translation = key
+
+		if len(translations) > index {
+			translation = translations[index].(string)
+		}
+	}
+
+	return fmt.Sprintf(translation, args...)
+}

+ 30 - 0
locale/locale.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 locale
+
+import "log"
+
+type Translation map[string]interface{}
+
+type Locales map[string]Translation
+
+func Load() *Translator {
+	translator := NewTranslator()
+
+	for language, translations := range Translations {
+		log.Println("Loading translation:", language)
+		translator.AddLanguage(language, translations)
+	}
+
+	return translator
+}
+
+// GetAvailableLanguages returns the list of available languages.
+func GetAvailableLanguages() map[string]string {
+	return map[string]string{
+		"en_US": "English",
+		"fr_FR": "Français",
+	}
+}

+ 103 - 0
locale/locale_test.go

@@ -0,0 +1,103 @@
+// 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 locale
+
+import "testing"
+
+func TestTranslateWithMissingLanguage(t *testing.T) {
+	translator := NewTranslator()
+	translation := translator.GetLanguage("en_US").Get("auth.username")
+
+	if translation != "auth.username" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslateWithExistingKey(t *testing.T) {
+	data := `{"auth.username": "Username"}`
+	translator := NewTranslator()
+	translator.AddLanguage("en_US", data)
+	translation := translator.GetLanguage("en_US").Get("auth.username")
+
+	if translation != "Username" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslateWithMissingKey(t *testing.T) {
+	data := `{"auth.username": "Username"}`
+	translator := NewTranslator()
+	translator.AddLanguage("en_US", data)
+	translation := translator.GetLanguage("en_US").Get("auth.password")
+
+	if translation != "auth.password" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
+	translator := NewTranslator()
+	translator.AddLanguage("fr_FR", "")
+	translation := translator.GetLanguage("fr_FR").Get("Status: %s", "ok")
+
+	if translation != "Status: ok" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslatePluralWithDefaultRule(t *testing.T) {
+	data := `{"number_of_users": ["Il y a %d utilisateur (%s)", "Il y a %d utilisateurs (%s)"]}`
+	translator := NewTranslator()
+	translator.AddLanguage("fr_FR", data)
+	language := translator.GetLanguage("fr_FR")
+
+	translation := language.Plural("number_of_users", 1, 1, "some text")
+	expected := "Il y a 1 utilisateur (some text)"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+
+	translation = language.Plural("number_of_users", 2, 2, "some text")
+	expected = "Il y a 2 utilisateurs (some text)"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+}
+
+func TestTranslatePluralWithRussianRule(t *testing.T) {
+	data := `{"key": ["из %d книги за %d день", "из %d книг за %d дня", "из %d книг за %d дней"]}`
+	translator := NewTranslator()
+	translator.AddLanguage("ru_RU", data)
+	language := translator.GetLanguage("ru_RU")
+
+	translation := language.Plural("key", 1, 1, 1)
+	expected := "из 1 книги за 1 день"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+
+	translation = language.Plural("key", 2, 2, 2)
+	expected = "из 2 книг за 2 дня"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+
+	translation = language.Plural("key", 5, 5, 5)
+	expected = "из 5 книг за 5 дней"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+}
+
+func TestTranslatePluralWithMissingTranslation(t *testing.T) {
+	translator := NewTranslator()
+	translator.AddLanguage("fr_FR", "")
+	language := translator.GetLanguage("fr_FR")
+
+	translation := language.Plural("number_of_users", 2)
+	expected := "number_of_users"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+}

+ 101 - 0
locale/plurals.go

@@ -0,0 +1,101 @@
+// 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 locale
+
+// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
+// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
+var pluralForms = map[string]func(n int) int{
+	// nplurals=2; plural=(n != 1);
+	"default": func(n int) int {
+		if n != 1 {
+			return 1
+		}
+
+		return 0
+	},
+	// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
+	"ar_AR": func(n int) int {
+		if n == 0 {
+			return 0
+		}
+
+		if n == 1 {
+			return 1
+		}
+
+		if n == 2 {
+			return 2
+		}
+
+		if n%100 >= 3 && n%100 <= 10 {
+			return 3
+		}
+
+		if n%100 >= 11 {
+			return 4
+		}
+
+		return 5
+	},
+	// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
+	"cs_CZ": func(n int) int {
+		if n == 1 {
+			return 0
+		}
+
+		if n >= 2 && n <= 4 {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+	"pl_PL": func(n int) int {
+		if n == 1 {
+			return 0
+		}
+
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=2; plural=(n > 1);
+	"pt_BR": func(n int) int {
+		if n > 1 {
+			return 1
+		}
+		return 0
+	},
+	// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+	"ru_RU": func(n int) int {
+		if n%10 == 1 && n%100 != 11 {
+			return 0
+		}
+
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+	"sr_RS": func(n int) int {
+		if n%10 == 1 && n%100 != 11 {
+			return 0
+		}
+
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=1; plural=0;
+	"zh_CN": func(n int) int {
+		return 0
+	},
+}

+ 136 - 0
locale/translations.go

@@ -0,0 +1,136 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.925268372 -0800 PST m=+0.006101515
+
+package locale
+
+var Translations = map[string]string{
+	"en_US": `{
+    "plural.feed.error_count": [
+        "%d error",
+        "%d errors"
+    ],
+    "plural.categories.feed_count": [
+        "There is %d feed.",
+        "There are %d feeds."
+    ]
+}`,
+	"fr_FR": `{
+    "plural.feed.error_count": [
+        "%d erreur",
+        "%d erreurs"
+    ],
+    "plural.categories.feed_count": [
+        "Il y %d abonnement.",
+        "Il y %d abonnements."
+    ],
+    "Username": "Nom d'utilisateur",
+    "Password": "Mot de passe",
+    "Unread": "Non lus",
+    "History": "Historique",
+    "Feeds": "Abonnements",
+    "Categories": "Catégories",
+    "Settings": "Réglages",
+    "Logout": "Se déconnecter",
+    "Next": "Suivant",
+    "Previous": "Précédent",
+    "New Subscription": "Nouvel Abonnment",
+    "Import": "Importation",
+    "Export": "Exportation",
+    "There is no category. You must have at least one category.": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
+    "URL": "URL",
+    "Category": "Catégorie",
+    "Find a subscription": "Trouver un abonnement",
+    "Loading...": "Chargement...",
+    "Create a category": "Créer une catégorie",
+    "There is no category.": "Il n'y a aucune catégorie.",
+    "Edit": "Modifier",
+    "Remove": "Supprimer",
+    "No feed.": "Aucun abonnement.",
+    "There is no article in this category.": "Il n'y a aucun article dans cette catégorie.",
+    "Original": "Original",
+    "Mark this page as read": "Marquer cette page comme lu",
+    "not yet": "pas encore",
+    "just now": "à l'instant",
+    "1 minute ago": "il y a une minute",
+    "%d minutes ago": "il y a %d minutes",
+    "1 hour ago": "il y a une heure",
+    "%d hours ago": "il y a %d heures",
+    "yesterday": "hier",
+    "%d days ago": "il y a %d jours",
+    "%d weeks ago": "il y a %d semaines",
+    "%d months ago": "il y a %d mois",
+    "%d years ago": "il y a %d années",
+    "Date": "Date",
+    "IP Address": "Adresse IP",
+    "User Agent": "Navigateur Web",
+    "Actions": "Actions",
+    "Current session": "Session actuelle",
+    "Sessions": "Sessions",
+    "Users": "Utilisateurs",
+    "Add user": "Ajouter un utilisateur",
+    "Choose a Subscription": "Choisissez un abonnement",
+    "Subscribe": "S'abonner",
+    "New Category": "Nouvelle Catégorie",
+    "Title": "Titre",
+    "Save": "Sauvegarder",
+    "or": "ou",
+    "cancel": "annuler",
+    "New User": "Nouvel Utilisateur",
+    "Confirmation": "Confirmation",
+    "Administrator": "Administrateur",
+    "Edit Category: %s": "Modification de la catégorie : %s",
+    "Update": "Mettre à jour",
+    "Edit Feed: %s": "Modification de l'abonnement : %s",
+    "There is no category!": "Il n'y a aucune catégorie !",
+    "Edit user: %s": "Modification de l'utilisateur : %s",
+    "There is no article for this feed.": "Il n'y a aucun article pour cet abonnement.",
+    "Add subscription": "Ajouter un abonnement",
+    "You don't have any subscription.": "Vous n'avez aucun abonnement",
+    "Last check:": "Dernière vérification :",
+    "Refresh": "Actualiser",
+    "There is no history at the moment.": "Il n'y a aucun historique pour le moment.",
+    "OPML file": "Fichier OPML",
+    "Sign In": "Connexion",
+    "Sign in": "Connexion",
+    "Theme": "Thème",
+    "Timezone": "Fuseau horaire",
+    "Language": "Langue",
+    "There is no unread article.": "Il n'y a rien de nouveau à lire.",
+    "You are the only user.": "Vous êtes le seul utilisateur.",
+    "Last Login": "Dernière connexion",
+    "Yes": "Oui",
+    "No": "Non",
+    "This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
+    "Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
+    "Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
+    "Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
+    "Unable to find any subscription.": "Impossible de trouver un abonnement.",
+    "The URL and the category are mandatory.": "L'URL et la catégorie sont obligatoire.",
+    "All fields are mandatory.": "Tous les champs sont obligatoire.",
+    "Passwords are not the same.": "Les mots de passe ne sont pas les mêmes.",
+    "You must use at least 6 characters.": "Vous devez utiliser au moins 6 caractères.",
+    "The username is mandatory.": "Le nom d'utilisateur est obligatoire.",
+    "The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
+    "The title is mandatory.": "Le titre est obligatoire.",
+    "About": "A propos",
+    "version": "Version",
+    "Version:": "Version :",
+    "Build Date:": "Date de la compilation :",
+    "Author:": "Auteur :",
+    "Authors": "Auteurs",
+    "License:": "Licence :",
+    "Attachments": "Pièces jointes",
+    "Download": "Télécharger",
+    "Invalid username or password.": "Mauvais identifiant ou mot de passe.",
+    "Never": "Jamais",
+    "Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
+    "Last Parsing Error": "Dernière erreur d'analyse",
+    "There is a problem with this feed": "Il y a un problème avec cet abonnement"
+}
+`,
+}
+
+var TranslationsChecksums = map[string]string{
+	"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
+	"fr_FR": "1f75e5a4b581755f7f84687126bc5b96aaf0109a2f83a72a8770c2ad3ddb7ba3",
+}

+ 10 - 0
locale/translations/en_US.json

@@ -0,0 +1,10 @@
+{
+    "plural.feed.error_count": [
+        "%d error",
+        "%d errors"
+    ],
+    "plural.categories.feed_count": [
+        "There is %d feed.",
+        "There are %d feeds."
+    ]
+}

+ 113 - 0
locale/translations/fr_FR.json

@@ -0,0 +1,113 @@
+{
+    "plural.feed.error_count": [
+        "%d erreur",
+        "%d erreurs"
+    ],
+    "plural.categories.feed_count": [
+        "Il y %d abonnement.",
+        "Il y %d abonnements."
+    ],
+    "Username": "Nom d'utilisateur",
+    "Password": "Mot de passe",
+    "Unread": "Non lus",
+    "History": "Historique",
+    "Feeds": "Abonnements",
+    "Categories": "Catégories",
+    "Settings": "Réglages",
+    "Logout": "Se déconnecter",
+    "Next": "Suivant",
+    "Previous": "Précédent",
+    "New Subscription": "Nouvel Abonnment",
+    "Import": "Importation",
+    "Export": "Exportation",
+    "There is no category. You must have at least one category.": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
+    "URL": "URL",
+    "Category": "Catégorie",
+    "Find a subscription": "Trouver un abonnement",
+    "Loading...": "Chargement...",
+    "Create a category": "Créer une catégorie",
+    "There is no category.": "Il n'y a aucune catégorie.",
+    "Edit": "Modifier",
+    "Remove": "Supprimer",
+    "No feed.": "Aucun abonnement.",
+    "There is no article in this category.": "Il n'y a aucun article dans cette catégorie.",
+    "Original": "Original",
+    "Mark this page as read": "Marquer cette page comme lu",
+    "not yet": "pas encore",
+    "just now": "à l'instant",
+    "1 minute ago": "il y a une minute",
+    "%d minutes ago": "il y a %d minutes",
+    "1 hour ago": "il y a une heure",
+    "%d hours ago": "il y a %d heures",
+    "yesterday": "hier",
+    "%d days ago": "il y a %d jours",
+    "%d weeks ago": "il y a %d semaines",
+    "%d months ago": "il y a %d mois",
+    "%d years ago": "il y a %d années",
+    "Date": "Date",
+    "IP Address": "Adresse IP",
+    "User Agent": "Navigateur Web",
+    "Actions": "Actions",
+    "Current session": "Session actuelle",
+    "Sessions": "Sessions",
+    "Users": "Utilisateurs",
+    "Add user": "Ajouter un utilisateur",
+    "Choose a Subscription": "Choisissez un abonnement",
+    "Subscribe": "S'abonner",
+    "New Category": "Nouvelle Catégorie",
+    "Title": "Titre",
+    "Save": "Sauvegarder",
+    "or": "ou",
+    "cancel": "annuler",
+    "New User": "Nouvel Utilisateur",
+    "Confirmation": "Confirmation",
+    "Administrator": "Administrateur",
+    "Edit Category: %s": "Modification de la catégorie : %s",
+    "Update": "Mettre à jour",
+    "Edit Feed: %s": "Modification de l'abonnement : %s",
+    "There is no category!": "Il n'y a aucune catégorie !",
+    "Edit user: %s": "Modification de l'utilisateur : %s",
+    "There is no article for this feed.": "Il n'y a aucun article pour cet abonnement.",
+    "Add subscription": "Ajouter un abonnement",
+    "You don't have any subscription.": "Vous n'avez aucun abonnement",
+    "Last check:": "Dernière vérification :",
+    "Refresh": "Actualiser",
+    "There is no history at the moment.": "Il n'y a aucun historique pour le moment.",
+    "OPML file": "Fichier OPML",
+    "Sign In": "Connexion",
+    "Sign in": "Connexion",
+    "Theme": "Thème",
+    "Timezone": "Fuseau horaire",
+    "Language": "Langue",
+    "There is no unread article.": "Il n'y a rien de nouveau à lire.",
+    "You are the only user.": "Vous êtes le seul utilisateur.",
+    "Last Login": "Dernière connexion",
+    "Yes": "Oui",
+    "No": "Non",
+    "This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
+    "Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
+    "Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
+    "Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
+    "Unable to find any subscription.": "Impossible de trouver un abonnement.",
+    "The URL and the category are mandatory.": "L'URL et la catégorie sont obligatoire.",
+    "All fields are mandatory.": "Tous les champs sont obligatoire.",
+    "Passwords are not the same.": "Les mots de passe ne sont pas les mêmes.",
+    "You must use at least 6 characters.": "Vous devez utiliser au moins 6 caractères.",
+    "The username is mandatory.": "Le nom d'utilisateur est obligatoire.",
+    "The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
+    "The title is mandatory.": "Le titre est obligatoire.",
+    "About": "A propos",
+    "version": "Version",
+    "Version:": "Version :",
+    "Build Date:": "Date de la compilation :",
+    "Author:": "Auteur :",
+    "Authors": "Auteurs",
+    "License:": "Licence :",
+    "Attachments": "Pièces jointes",
+    "Download": "Télécharger",
+    "Invalid username or password.": "Mauvais identifiant ou mot de passe.",
+    "Never": "Jamais",
+    "Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
+    "Last Parsing Error": "Dernière erreur d'analyse",
+    "There is a problem with this feed": "Il y a un problème avec cet abonnement"
+}

+ 40 - 0
locale/translator.go

@@ -0,0 +1,40 @@
+// 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 locale
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+type Translator struct {
+	Locales Locales
+}
+
+func (t *Translator) AddLanguage(language, translations string) error {
+	var decodedTranslations Translation
+
+	decoder := json.NewDecoder(strings.NewReader(translations))
+	if err := decoder.Decode(&decodedTranslations); err != nil {
+		return fmt.Errorf("Invalid JSON file: %v", err)
+	}
+
+	t.Locales[language] = decodedTranslations
+	return nil
+}
+
+func (t *Translator) GetLanguage(language string) *Language {
+	translations, found := t.Locales[language]
+	if !found {
+		return &Language{language: language}
+	}
+
+	return &Language{language: language, translations: translations}
+}
+
+func NewTranslator() *Translator {
+	return &Translator{Locales: make(Locales)}
+}

+ 124 - 0
main.go

@@ -0,0 +1,124 @@
+// 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 main
+
+//go:generate go run generate.go
+
+import (
+	"bufio"
+	"context"
+	"flag"
+	"fmt"
+	"github.com/miniflux/miniflux2/config"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed"
+	"github.com/miniflux/miniflux2/scheduler"
+	"github.com/miniflux/miniflux2/server"
+	"github.com/miniflux/miniflux2/storage"
+	"github.com/miniflux/miniflux2/version"
+	"log"
+	"os"
+	"os/signal"
+	"runtime"
+	"strings"
+	"time"
+
+	_ "github.com/lib/pq"
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+func run(cfg *config.Config, store *storage.Storage) {
+	log.Println("Starting Miniflux...")
+
+	stop := make(chan os.Signal, 1)
+	signal.Notify(stop, os.Interrupt)
+
+	feedHandler := feed.NewFeedHandler(store)
+	server := server.NewServer(cfg, store, feedHandler)
+
+	go func() {
+		pool := scheduler.NewWorkerPool(feedHandler, cfg.GetInt("WORKER_POOL_SIZE", 5))
+		scheduler.NewScheduler(store, pool, cfg.GetInt("POLLING_FREQUENCY", 30), cfg.GetInt("BATCH_SIZE", 10))
+	}()
+
+	<-stop
+	log.Println("Shutting down the server...")
+	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
+	server.Shutdown(ctx)
+	store.Close()
+	log.Println("Server gracefully stopped")
+}
+
+func askCredentials() (string, string) {
+	reader := bufio.NewReader(os.Stdin)
+
+	fmt.Print("Enter Username: ")
+	username, _ := reader.ReadString('\n')
+
+	fmt.Print("Enter Password: ")
+	bytePassword, _ := terminal.ReadPassword(0)
+
+	fmt.Printf("\n")
+	return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
+}
+
+func main() {
+	flagInfo := flag.Bool("info", false, "Show application information")
+	flagVersion := flag.Bool("version", false, "Show application version")
+	flagMigrate := flag.Bool("migrate", false, "Migrate database schema")
+	flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
+	flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
+	flag.Parse()
+
+	cfg := config.NewConfig()
+	store := storage.NewStorage(
+		cfg.Get("DATABASE_URL", "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"),
+		cfg.GetInt("DATABASE_MAX_CONNS", 20),
+	)
+
+	if *flagInfo {
+		fmt.Println("Version:", version.Version)
+		fmt.Println("Build Date:", version.BuildDate)
+		fmt.Println("Go Version:", runtime.Version())
+		return
+	}
+
+	if *flagVersion {
+		fmt.Println(version.Version)
+		return
+	}
+
+	if *flagMigrate {
+		store.Migrate()
+		return
+	}
+
+	if *flagFlushSessions {
+		fmt.Println("Flushing all sessions (disconnect users)")
+		if err := store.FlushAllSessions(); err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+		return
+	}
+
+	if *flagCreateAdmin {
+		user := &model.User{IsAdmin: true}
+		user.Username, user.Password = askCredentials()
+		if err := user.ValidateUserCreation(); err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+
+		if err := store.CreateUser(user); err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+
+		return
+	}
+
+	run(cfg, store)
+}

+ 51 - 0
model/category.go

@@ -0,0 +1,51 @@
+// 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 model
+
+import (
+	"errors"
+	"fmt"
+)
+
+type Category struct {
+	ID        int64  `json:"id,omitempty"`
+	Title     string `json:"title,omitempty"`
+	UserID    int64  `json:"user_id,omitempty"`
+	FeedCount int    `json:"nb_feeds,omitempty"`
+}
+
+func (c *Category) String() string {
+	return fmt.Sprintf("ID=%d, UserID=%d, Title=%s", c.ID, c.UserID, c.Title)
+}
+
+func (c Category) ValidateCategoryCreation() error {
+	if c.Title == "" {
+		return errors.New("The title is mandatory")
+	}
+
+	if c.UserID == 0 {
+		return errors.New("The userID is mandatory")
+	}
+
+	return nil
+}
+
+func (c Category) ValidateCategoryModification() error {
+	if c.Title == "" {
+		return errors.New("The title is mandatory")
+	}
+
+	if c.UserID == 0 {
+		return errors.New("The userID is mandatory")
+	}
+
+	if c.ID == 0 {
+		return errors.New("The ID is mandatory")
+	}
+
+	return nil
+}
+
+type Categories []*Category

+ 18 - 0
model/enclosure.go

@@ -0,0 +1,18 @@
+// 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 model
+
+// Enclosure represents an attachment.
+type Enclosure struct {
+	ID       int64  `json:"id"`
+	UserID   int64  `json:"user_id"`
+	EntryID  int64  `json:"entry_id"`
+	URL      string `json:"url"`
+	MimeType string `json:"mime_type"`
+	Size     int    `json:"size"`
+}
+
+// EnclosureList represents a list of attachments.
+type EnclosureList []*Enclosure

+ 71 - 0
model/entry.go

@@ -0,0 +1,71 @@
+// 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 model
+
+import (
+	"fmt"
+	"time"
+)
+
+const (
+	EntryStatusUnread       = "unread"
+	EntryStatusRead         = "read"
+	EntryStatusRemoved      = "removed"
+	DefaultSortingOrder     = "published_at"
+	DefaultSortingDirection = "desc"
+)
+
+type Entry struct {
+	ID         int64         `json:"id"`
+	UserID     int64         `json:"user_id"`
+	FeedID     int64         `json:"feed_id"`
+	Status     string        `json:"status"`
+	Hash       string        `json:"hash"`
+	Title      string        `json:"title"`
+	URL        string        `json:"url"`
+	Date       time.Time     `json:"published_at"`
+	Content    string        `json:"content"`
+	Author     string        `json:"author"`
+	Enclosures EnclosureList `json:"enclosures,omitempty"`
+	Feed       *Feed         `json:"feed,omitempty"`
+	Category   *Category     `json:"category,omitempty"`
+}
+
+type Entries []*Entry
+
+func ValidateEntryStatus(status string) error {
+	switch status {
+	case EntryStatusRead, EntryStatusUnread, EntryStatusRemoved:
+		return nil
+	}
+
+	return fmt.Errorf(`Invalid entry status, valid status values are: "%s", "%s" and "%s"`, EntryStatusRead, EntryStatusUnread, EntryStatusRemoved)
+}
+
+func ValidateEntryOrder(order string) error {
+	switch order {
+	case "id", "status", "published_at", "category_title", "category_id":
+		return nil
+	}
+
+	return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "published_at", "category_title", "category_id"`)
+}
+
+func ValidateDirection(direction string) error {
+	switch direction {
+	case "asc", "desc":
+		return nil
+	}
+
+	return fmt.Errorf(`Invalid direction, valid direction values are: "asc" or "desc"`)
+}
+
+func GetOppositeDirection(direction string) string {
+	if direction == "asc" {
+		return "desc"
+	}
+
+	return "asc"
+}

+ 66 - 0
model/feed.go

@@ -0,0 +1,66 @@
+// 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 model
+
+import (
+	"fmt"
+	"reflect"
+	"time"
+)
+
+// Feed represents a feed in the database
+type Feed struct {
+	ID                 int64     `json:"id"`
+	UserID             int64     `json:"user_id"`
+	FeedURL            string    `json:"feed_url"`
+	SiteURL            string    `json:"site_url"`
+	Title              string    `json:"title"`
+	CheckedAt          time.Time `json:"checked_at,omitempty"`
+	EtagHeader         string    `json:"etag_header,omitempty"`
+	LastModifiedHeader string    `json:"last_modified_header,omitempty"`
+	ParsingErrorMsg    string    `json:"parsing_error_message,omitempty"`
+	ParsingErrorCount  int       `json:"parsing_error_count,omitempty"`
+	Category           *Category `json:"category,omitempty"`
+	Entries            Entries   `json:"entries,omitempty"`
+	Icon               *FeedIcon `json:"icon,omitempty"`
+}
+
+func (f *Feed) String() string {
+	return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}",
+		f.ID,
+		f.UserID,
+		f.FeedURL,
+		f.SiteURL,
+		f.Title,
+		f.Category,
+	)
+}
+
+// Merge combine src to the current struct
+func (f *Feed) Merge(src *Feed) {
+	src.ID = f.ID
+	src.UserID = f.UserID
+
+	new := reflect.ValueOf(src).Elem()
+	for i := 0; i < new.NumField(); i++ {
+		field := new.Field(i)
+
+		switch field.Interface().(type) {
+		case int64:
+			value := field.Int()
+			if value != 0 {
+				reflect.ValueOf(f).Elem().Field(i).SetInt(value)
+			}
+		case string:
+			value := field.String()
+			if value != "" {
+				reflect.ValueOf(f).Elem().Field(i).SetString(value)
+			}
+		}
+	}
+}
+
+// Feeds is a list of feed
+type Feeds []*Feed

+ 19 - 0
model/icon.go

@@ -0,0 +1,19 @@
+// 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 model
+
+// Icon represents a website icon (favicon)
+type Icon struct {
+	ID       int64  `json:"id"`
+	Hash     string `json:"hash"`
+	MimeType string `json:"mime_type"`
+	Content  []byte `json:"content"`
+}
+
+// FeedIcon is a jonction table between feeds and icons
+type FeedIcon struct {
+	FeedID int64 `json:"feed_id"`
+	IconID int64 `json:"icon_id"`
+}

+ 10 - 0
model/job.go

@@ -0,0 +1,10 @@
+// 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 model
+
+type Job struct {
+	UserID int64
+	FeedID int64
+}

+ 23 - 0
model/session.go

@@ -0,0 +1,23 @@
+// 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 model
+
+import "time"
+import "fmt"
+
+type Session struct {
+	ID        int64
+	UserID    int64
+	Token     string
+	CreatedAt time.Time
+	UserAgent string
+	IP        string
+}
+
+func (s *Session) String() string {
+	return fmt.Sprintf("ID=%d, UserID=%d, IP=%s", s.ID, s.UserID, s.IP)
+}
+
+type Sessions []*Session

+ 13 - 0
model/theme.go

@@ -0,0 +1,13 @@
+// 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 model
+
+// GetThemes returns the list of available themes.
+func GetThemes() map[string]string {
+	return map[string]string{
+		"default": "Default",
+		"black":   "Black",
+	}
+}

+ 96 - 0
model/user.go

@@ -0,0 +1,96 @@
+// 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 model
+
+import (
+	"errors"
+	"time"
+)
+
+// User represents a user in the system.
+type User struct {
+	ID          int64      `json:"id"`
+	Username    string     `json:"username"`
+	Password    string     `json:"password,omitempty"`
+	IsAdmin     bool       `json:"is_admin"`
+	Theme       string     `json:"theme"`
+	Language    string     `json:"language"`
+	Timezone    string     `json:"timezone"`
+	LastLoginAt *time.Time `json:"last_login_at"`
+}
+
+func (u User) ValidateUserCreation() error {
+	if err := u.ValidateUserLogin(); err != nil {
+		return err
+	}
+
+	if err := u.ValidatePassword(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (u User) ValidateUserModification() error {
+	if u.Username == "" {
+		return errors.New("The username is mandatory")
+	}
+
+	if err := u.ValidatePassword(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (u User) ValidateUserLogin() error {
+	if u.Username == "" {
+		return errors.New("The username is mandatory")
+	}
+
+	if u.Password == "" {
+		return errors.New("The password is mandatory")
+	}
+
+	return nil
+}
+
+func (u User) ValidatePassword() error {
+	if u.Password != "" && len(u.Password) < 6 {
+		return errors.New("The password must have at least 6 characters")
+	}
+
+	return nil
+}
+
+// Merge update the current user with another user.
+func (u *User) Merge(override *User) {
+	if u.Username != override.Username {
+		u.Username = override.Username
+	}
+
+	if u.Password != override.Password {
+		u.Password = override.Password
+	}
+
+	if u.IsAdmin != override.IsAdmin {
+		u.IsAdmin = override.IsAdmin
+	}
+
+	if u.Theme != override.Theme {
+		u.Theme = override.Theme
+	}
+
+	if u.Language != override.Language {
+		u.Language = override.Language
+	}
+
+	if u.Timezone != override.Timezone {
+		u.Timezone = override.Timezone
+	}
+}
+
+// Users represents a list of users.
+type Users []*User

+ 214 - 0
reader/feed/atom/atom.go

@@ -0,0 +1,214 @@
+// 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 atom
+
+import (
+	"encoding/xml"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed/date"
+	"github.com/miniflux/miniflux2/reader/processor"
+	"github.com/miniflux/miniflux2/reader/sanitizer"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type AtomFeed struct {
+	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
+	ID      string      `xml:"id"`
+	Title   string      `xml:"title"`
+	Author  Author      `xml:"author"`
+	Links   []Link      `xml:"link"`
+	Entries []AtomEntry `xml:"entry"`
+}
+
+type AtomEntry struct {
+	ID         string     `xml:"id"`
+	Title      string     `xml:"title"`
+	Updated    string     `xml:"updated"`
+	Links      []Link     `xml:"link"`
+	Summary    string     `xml:"summary"`
+	Content    Content    `xml:"content"`
+	MediaGroup MediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
+	Author     Author     `xml:"author"`
+}
+
+type Author struct {
+	Name  string `xml:"name"`
+	Email string `xml:"email"`
+}
+
+type Link struct {
+	Url    string `xml:"href,attr"`
+	Type   string `xml:"type,attr"`
+	Rel    string `xml:"rel,attr"`
+	Length string `xml:"length,attr"`
+}
+
+type Content struct {
+	Type string `xml:"type,attr"`
+	Data string `xml:",chardata"`
+	Xml  string `xml:",innerxml"`
+}
+
+type MediaGroup struct {
+	Description string `xml:"http://search.yahoo.com/mrss/ description"`
+}
+
+func (a *AtomFeed) getSiteURL() string {
+	for _, link := range a.Links {
+		if strings.ToLower(link.Rel) == "alternate" {
+			return link.Url
+		}
+
+		if link.Rel == "" && link.Type == "" {
+			return link.Url
+		}
+	}
+
+	return ""
+}
+
+func (a *AtomFeed) getFeedURL() string {
+	for _, link := range a.Links {
+		if strings.ToLower(link.Rel) == "self" {
+			return link.Url
+		}
+	}
+
+	return ""
+}
+
+func (a *AtomFeed) Transform() *model.Feed {
+	feed := new(model.Feed)
+	feed.FeedURL = a.getFeedURL()
+	feed.SiteURL = a.getSiteURL()
+	feed.Title = sanitizer.StripTags(a.Title)
+
+	if feed.Title == "" {
+		feed.Title = feed.SiteURL
+	}
+
+	for _, entry := range a.Entries {
+		item := entry.Transform()
+		if item.Author == "" {
+			item.Author = a.GetAuthor()
+		}
+
+		feed.Entries = append(feed.Entries, item)
+	}
+
+	return feed
+}
+
+func (a *AtomFeed) GetAuthor() string {
+	return getAuthor(a.Author)
+}
+
+func (e *AtomEntry) GetDate() time.Time {
+	if e.Updated != "" {
+		result, err := date.Parse(e.Updated)
+		if err != nil {
+			log.Println(err)
+			return time.Now()
+		}
+
+		return result
+	}
+
+	return time.Now()
+}
+
+func (e *AtomEntry) GetURL() string {
+	for _, link := range e.Links {
+		if strings.ToLower(link.Rel) == "alternate" {
+			return link.Url
+		}
+
+		if link.Rel == "" && link.Type == "" {
+			return link.Url
+		}
+	}
+
+	return ""
+}
+
+func (e *AtomEntry) GetAuthor() string {
+	return getAuthor(e.Author)
+}
+
+func (e *AtomEntry) GetHash() string {
+	for _, value := range []string{e.ID, e.GetURL()} {
+		if value != "" {
+			return helper.Hash(value)
+		}
+	}
+
+	return ""
+}
+
+func (e *AtomEntry) GetContent() string {
+	if e.Content.Type == "html" || e.Content.Type == "text" {
+		return e.Content.Data
+	}
+
+	if e.Content.Type == "xhtml" {
+		return e.Content.Xml
+	}
+
+	if e.Summary != "" {
+		return e.Summary
+	}
+
+	if e.MediaGroup.Description != "" {
+		return e.MediaGroup.Description
+	}
+
+	return ""
+}
+
+func (e *AtomEntry) GetEnclosures() model.EnclosureList {
+	enclosures := make(model.EnclosureList, 0)
+
+	for _, link := range e.Links {
+		if strings.ToLower(link.Rel) == "enclosure" {
+			length, _ := strconv.Atoi(link.Length)
+			enclosures = append(enclosures, &model.Enclosure{URL: link.Url, MimeType: link.Type, Size: length})
+		}
+	}
+
+	return enclosures
+}
+
+func (e *AtomEntry) Transform() *model.Entry {
+	entry := new(model.Entry)
+	entry.URL = e.GetURL()
+	entry.Date = e.GetDate()
+	entry.Author = sanitizer.StripTags(e.GetAuthor())
+	entry.Hash = e.GetHash()
+	entry.Content = processor.ItemContentProcessor(entry.URL, e.GetContent())
+	entry.Title = sanitizer.StripTags(strings.Trim(e.Title, " \n\t"))
+	entry.Enclosures = e.GetEnclosures()
+
+	if entry.Title == "" {
+		entry.Title = entry.URL
+	}
+
+	return entry
+}
+
+func getAuthor(author Author) string {
+	if author.Name != "" {
+		return author.Name
+	}
+
+	if author.Email != "" {
+		return author.Email
+	}
+
+	return ""
+}

+ 28 - 0
reader/feed/atom/parser.go

@@ -0,0 +1,28 @@
+// 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 atom
+
+import (
+	"encoding/xml"
+	"fmt"
+	"github.com/miniflux/miniflux2/model"
+	"io"
+
+	"golang.org/x/net/html/charset"
+)
+
+// Parse returns a normalized feed struct.
+func Parse(data io.Reader) (*model.Feed, error) {
+	atomFeed := new(AtomFeed)
+	decoder := xml.NewDecoder(data)
+	decoder.CharsetReader = charset.NewReaderLabel
+
+	err := decoder.Decode(atomFeed)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to parse Atom feed: %v\n", err)
+	}
+
+	return atomFeed.Transform(), nil
+}

+ 319 - 0
reader/feed/atom/parser_test.go

@@ -0,0 +1,319 @@
+// 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 atom
+
+import (
+	"bytes"
+	"testing"
+	"time"
+)
+
+func TestParseAtomSample(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	  <author>
+		<name>John Doe</name>
+	  </author>
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+	  <entry>
+		<title>Atom-Powered Robots Run Amok</title>
+		<link href="http://example.org/2003/12/13/atom03"/>
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<summary>Some text.</summary>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "Example Feed" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+
+	if feed.FeedURL != "" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+
+	if feed.SiteURL != "http://example.org/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 18, 30, 2, 0, time.UTC)) {
+		t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
+	}
+
+	if feed.Entries[0].Hash != "3841e5cf232f5111fc5841e9eba5f4b26d95e7d7124902e0f7272729d65601a6" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
+	}
+
+	if feed.Entries[0].URL != "http://example.org/2003/12/13/atom03" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Title != "Atom-Powered Robots Run Amok" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+
+	if feed.Entries[0].Content != "Some text." {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
+	}
+
+	if feed.Entries[0].Author != "John Doe" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseFeedWithoutTitle(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<feed xmlns="http://www.w3.org/2005/Atom">
+			<link rel="alternate" type="text/html" href="https://example.org/"/>
+			<link rel="self" type="application/atom+xml" href="https://example.org/feed"/>
+			<updated>2003-12-13T18:30:02Z</updated>
+		</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "https://example.org/" {
+		t.Errorf("Incorrect feed title, got: %s", feed.Title)
+	}
+}
+
+func TestParseEntryWithoutTitle(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	  <author>
+		<name>John Doe</name>
+	  </author>
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+	  <entry>
+		<link href="http://example.org/2003/12/13/atom03"/>
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<summary>Some text.</summary>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Title != "http://example.org/2003/12/13/atom03" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}
+
+func TestParseFeedURL(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+	  <title>Example Feed</title>
+	  <link rel="alternate" type="text/html" href="https://example.org/"/>
+	  <link rel="self" type="application/atom+xml" href="https://example.org/feed"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.SiteURL != "https://example.org/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if feed.FeedURL != "https://example.org/feed" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+}
+
+func TestParseEntryTitleWithWhitespaces(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+
+	  <entry>
+		<title>
+			Some Title
+		</title>
+		<link href="http://example.org/2003/12/13/atom03"/>
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<summary>Some text.</summary>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Title != "Some Title" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}
+
+func TestParseEntryWithAuthorName(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+
+	  <entry>
+		<link href="http://example.org/2003/12/13/atom03"/>
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<summary>Some text.</summary>
+		<author>
+			<name>Me</name>
+			<email>me@localhost</email>
+		</author>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "Me" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithoutAuthorName(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+
+	  <entry>
+		<link href="http://example.org/2003/12/13/atom03"/>
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<summary>Some text.</summary>
+		<author>
+			<name/>
+			<email>me@localhost</email>
+		</author>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "me@localhost" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithEnclosures(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+		<id>http://www.example.org/myfeed</id>
+		<title>My Podcast Feed</title>
+		<updated>2005-07-15T12:00:00Z</updated>
+		<author>
+		<name>John Doe</name>
+		</author>
+		<link href="http://example.org" />
+		<link rel="self" href="http://example.org/myfeed" />
+		<entry>
+			<id>http://www.example.org/entries/1</id>
+			<title>Atom 1.0</title>
+			<updated>2005-07-15T12:00:00Z</updated>
+			<link href="http://www.example.org/entries/1" />
+			<summary>An overview of Atom 1.0</summary>
+			<link rel="enclosure"
+					type="audio/mpeg"
+					title="MP3"
+					href="http://www.example.org/myaudiofile.mp3"
+					length="1234" />
+			<link rel="enclosure"
+					type="application/x-bittorrent"
+					title="BitTorrent"
+					href="http://www.example.org/myaudiofile.torrent"
+					length="4567" />
+			<content type="xhtml">
+				<div xmlns="http://www.w3.org/1999/xhtml">
+				<h1>Show Notes</h1>
+				<ul>
+					<li>00:01:00 -- Introduction</li>
+					<li>00:15:00 -- Talking about Atom 1.0</li>
+					<li>00:30:00 -- Wrapping up</li>
+				</ul>
+				</div>
+			</content>
+		</entry>
+  	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 2 {
+		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
+	}
+
+	if feed.Entries[0].Enclosures[0].MimeType != "audio/mpeg" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[0].Size != 1234 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
+	}
+
+	if feed.Entries[0].Enclosures[1].URL != "http://www.example.org/myaudiofile.torrent" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[1].URL)
+	}
+
+	if feed.Entries[0].Enclosures[1].MimeType != "application/x-bittorrent" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[1].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[1].Size != 4567 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[1].Size)
+	}
+}

+ 203 - 0
reader/feed/date/parser.go

@@ -0,0 +1,203 @@
+// 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 date
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// DateFormats taken from github.com/mjibson/goread
+var dateFormats = []string{
+	time.RFC822,  // RSS
+	time.RFC822Z, // RSS
+	time.RFC3339, // Atom
+	time.UnixDate,
+	time.RubyDate,
+	time.RFC850,
+	time.RFC1123Z,
+	time.RFC1123,
+	time.ANSIC,
+	"Mon, January 2 2006 15:04:05 -0700",
+	"Mon, January 02, 2006, 15:04:05 MST",
+	"Mon, January 02, 2006 15:04:05 MST",
+	"Mon, Jan 2, 2006 15:04 MST",
+	"Mon, Jan 2 2006 15:04 MST",
+	"Mon, Jan 2, 2006 15:04:05 MST",
+	"Mon, Jan 2 2006 15:04:05 -700",
+	"Mon, Jan 2 2006 15:04:05 -0700",
+	"Mon Jan 2 15:04 2006",
+	"Mon Jan 2 15:04:05 2006 MST",
+	"Mon Jan 02, 2006 3:04 pm",
+	"Mon, Jan 02,2006 15:04:05 MST",
+	"Mon Jan 02 2006 15:04:05 -0700",
+	"Monday, January 2, 2006 15:04:05 MST",
+	"Monday, January 2, 2006 03:04 PM",
+	"Monday, January 2, 2006",
+	"Monday, January 02, 2006",
+	"Monday, 2 January 2006 15:04:05 MST",
+	"Monday, 2 January 2006 15:04:05 -0700",
+	"Monday, 2 Jan 2006 15:04:05 MST",
+	"Monday, 2 Jan 2006 15:04:05 -0700",
+	"Monday, 02 January 2006 15:04:05 MST",
+	"Monday, 02 January 2006 15:04:05 -0700",
+	"Monday, 02 January 2006 15:04:05",
+	"Mon, 2 January 2006 15:04 MST",
+	"Mon, 2 January 2006, 15:04 -0700",
+	"Mon, 2 January 2006, 15:04:05 MST",
+	"Mon, 2 January 2006 15:04:05 MST",
+	"Mon, 2 January 2006 15:04:05 -0700",
+	"Mon, 2 January 2006",
+	"Mon, 2 Jan 2006 3:04:05 PM -0700",
+	"Mon, 2 Jan 2006 15:4:5 MST",
+	"Mon, 2 Jan 2006 15:4:5 -0700 GMT",
+	"Mon, 2, Jan 2006 15:4",
+	"Mon, 2 Jan 2006 15:04 MST",
+	"Mon, 2 Jan 2006, 15:04 -0700",
+	"Mon, 2 Jan 2006 15:04 -0700",
+	"Mon, 2 Jan 2006 15:04:05 UT",
+	"Mon, 2 Jan 2006 15:04:05MST",
+	"Mon, 2 Jan 2006 15:04:05 MST",
+	"Mon 2 Jan 2006 15:04:05 MST",
+	"mon,2 Jan 2006 15:04:05 MST",
+	"Mon, 2 Jan 2006 15:04:05 -0700 MST",
+	"Mon, 2 Jan 2006 15:04:05-0700",
+	"Mon, 2 Jan 2006 15:04:05 -0700",
+	"Mon, 2 Jan 2006 15:04:05",
+	"Mon, 2 Jan 2006 15:04",
+	"Mon,2 Jan 2006",
+	"Mon, 2 Jan 2006",
+	"Mon, 2 Jan 15:04:05 MST",
+	"Mon, 2 Jan 06 15:04:05 MST",
+	"Mon, 2 Jan 06 15:04:05 -0700",
+	"Mon, 2006-01-02 15:04",
+	"Mon,02 January 2006 14:04:05 MST",
+	"Mon, 02 January 2006",
+	"Mon, 02 Jan 2006 3:04:05 PM MST",
+	"Mon, 02 Jan 2006 15 -0700",
+	"Mon,02 Jan 2006 15:04 MST",
+	"Mon, 02 Jan 2006 15:04 MST",
+	"Mon, 02 Jan 2006 15:04 -0700",
+	"Mon, 02 Jan 2006 15:04:05 Z",
+	"Mon, 02 Jan 2006 15:04:05 UT",
+	"Mon, 02 Jan 2006 15:04:05 MST-07:00",
+	"Mon, 02 Jan 2006 15:04:05 MST -0700",
+	"Mon, 02 Jan 2006, 15:04:05 MST",
+	"Mon, 02 Jan 2006 15:04:05MST",
+	"Mon, 02 Jan 2006 15:04:05 MST",
+	"Mon , 02 Jan 2006 15:04:05 MST",
+	"Mon, 02 Jan 2006 15:04:05 GMT-0700",
+	"Mon,02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -07:00",
+	"Mon, 02 Jan 2006 15:04:05 --0700",
+	"Mon 02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -07",
+	"Mon, 02 Jan 2006 15:04:05 00",
+	"Mon, 02 Jan 2006 15:04:05",
+	"Mon, 02 Jan 2006",
+	"Mon, 02 Jan 06 15:04:05 MST",
+	"January 2, 2006 3:04 PM",
+	"January 2, 2006, 3:04 p.m.",
+	"January 2, 2006 15:04:05 MST",
+	"January 2, 2006 15:04:05",
+	"January 2, 2006 03:04 PM",
+	"January 2, 2006",
+	"January 02, 2006 15:04:05 MST",
+	"January 02, 2006 15:04",
+	"January 02, 2006 03:04 PM",
+	"January 02, 2006",
+	"Jan 2, 2006 3:04:05 PM MST",
+	"Jan 2, 2006 3:04:05 PM",
+	"Jan 2, 2006 15:04:05 MST",
+	"Jan 2, 2006",
+	"Jan 02 2006 03:04:05PM",
+	"Jan 02, 2006",
+	"6/1/2 15:04",
+	"6-1-2 15:04",
+	"2 January 2006 15:04:05 MST",
+	"2 January 2006 15:04:05 -0700",
+	"2 January 2006",
+	"2 Jan 2006 15:04:05 Z",
+	"2 Jan 2006 15:04:05 MST",
+	"2 Jan 2006 15:04:05 -0700",
+	"2 Jan 2006",
+	"2.1.2006 15:04:05",
+	"2/1/2006",
+	"2-1-2006",
+	"2006 January 02",
+	"2006-1-2T15:04:05Z",
+	"2006-1-2 15:04:05",
+	"2006-1-2",
+	"2006-1-02T15:04:05Z",
+	"2006-01-02T15:04Z",
+	"2006-01-02T15:04-07:00",
+	"2006-01-02T15:04:05Z",
+	"2006-01-02T15:04:05-07:00:00",
+	"2006-01-02T15:04:05:-0700",
+	"2006-01-02T15:04:05-0700",
+	"2006-01-02T15:04:05-07:00",
+	"2006-01-02T15:04:05 -0700",
+	"2006-01-02T15:04:05:00",
+	"2006-01-02T15:04:05",
+	"2006-01-02 at 15:04:05",
+	"2006-01-02 15:04:05Z",
+	"2006-01-02 15:04:05 MST",
+	"2006-01-02 15:04:05-0700",
+	"2006-01-02 15:04:05-07:00",
+	"2006-01-02 15:04:05 -0700",
+	"2006-01-02 15:04",
+	"2006-01-02 00:00:00.0 15:04:05.0 -0700",
+	"2006/01/02",
+	"2006-01-02",
+	"15:04 02.01.2006 -0700",
+	"1/2/2006 3:04 PM MST",
+	"1/2/2006 3:04:05 PM MST",
+	"1/2/2006 3:04:05 PM",
+	"1/2/2006 15:04:05 MST",
+	"1/2/2006",
+	"06/1/2 15:04",
+	"06-1-2 15:04",
+	"02 Monday, Jan 2006 15:04",
+	"02 Jan 2006 15:04 MST",
+	"02 Jan 2006 15:04:05 UT",
+	"02 Jan 2006 15:04:05 MST",
+	"02 Jan 2006 15:04:05 -0700",
+	"02 Jan 2006 15:04:05",
+	"02 Jan 2006",
+	"02/01/2006 15:04 MST",
+	"02-01-2006 15:04:05 MST",
+	"02.01.2006 15:04:05",
+	"02/01/2006 15:04:05",
+	"02.01.2006 15:04",
+	"02/01/2006 - 15:04",
+	"02.01.2006 -0700",
+	"02/01/2006",
+	"02-01-2006",
+	"01/02/2006 3:04 PM",
+	"01/02/2006 15:04:05 MST",
+	"01/02/2006 - 15:04",
+	"01/02/2006",
+	"01-02-2006",
+}
+
+// Parse parses a given date string using a large
+// list of commonly found feed date formats.
+func Parse(ds string) (t time.Time, err error) {
+	d := strings.TrimSpace(ds)
+	if d == "" {
+		return t, fmt.Errorf("Date string is empty")
+	}
+
+	for _, f := range dateFormats {
+		if t, err = time.Parse(f, d); err == nil {
+			return
+		}
+	}
+
+	err = fmt.Errorf("Failed to parse date: %s", ds)
+	return
+}

+ 152 - 0
reader/feed/handler.go

@@ -0,0 +1,152 @@
+// 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 feed
+
+import (
+	"fmt"
+	"github.com/miniflux/miniflux2/errors"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/http"
+	"github.com/miniflux/miniflux2/reader/icon"
+	"github.com/miniflux/miniflux2/storage"
+	"log"
+	"time"
+)
+
+var (
+	errRequestFailed = "Unable to execute request: %v"
+	errServerFailure = "Unable to fetch feed (statusCode=%d)."
+	errDuplicate     = "This feed already exists (%s)."
+	errNotFound      = "Feed %d not found"
+)
+
+// Handler contains all the logic to create and refresh feeds.
+type Handler struct {
+	store *storage.Storage
+}
+
+// CreateFeed fetch, parse and store a new feed.
+func (h *Handler) CreateFeed(userID, categoryID int64, url string) (*model.Feed, error) {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:CreateFeed] feedUrl=%s", url))
+
+	client := http.NewHttpClient(url)
+	response, err := client.Get()
+	if err != nil {
+		return nil, errors.NewLocalizedError(errRequestFailed, err)
+	}
+
+	if response.HasServerFailure() {
+		return nil, errors.NewLocalizedError(errServerFailure, response.StatusCode)
+	}
+
+	if h.store.FeedURLExists(userID, response.EffectiveURL) {
+		return nil, errors.NewLocalizedError(errDuplicate, response.EffectiveURL)
+	}
+
+	subscription, err := parseFeed(response.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	subscription.Category = &model.Category{ID: categoryID}
+	subscription.EtagHeader = response.ETag
+	subscription.LastModifiedHeader = response.LastModified
+	subscription.FeedURL = response.EffectiveURL
+	subscription.UserID = userID
+
+	err = h.store.CreateFeed(subscription)
+	if err != nil {
+		return nil, err
+	}
+
+	log.Println("[Handler:CreateFeed] Feed saved with ID:", subscription.ID)
+
+	icon, err := icon.FindIcon(subscription.SiteURL)
+	if err != nil {
+		log.Println(err)
+	} else if icon == nil {
+		log.Printf("No icon found for feedID=%d\n", subscription.ID)
+	} else {
+		h.store.CreateFeedIcon(subscription, icon)
+	}
+
+	return subscription, nil
+}
+
+// RefreshFeed fetch and update a feed if necessary.
+func (h *Handler) RefreshFeed(userID, feedID int64) error {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:RefreshFeed] feedID=%d", feedID))
+
+	originalFeed, err := h.store.GetFeedById(userID, feedID)
+	if err != nil {
+		return err
+	}
+
+	if originalFeed == nil {
+		return errors.NewLocalizedError(errNotFound, feedID)
+	}
+
+	client := http.NewHttpClientWithCacheHeaders(originalFeed.FeedURL, originalFeed.EtagHeader, originalFeed.LastModifiedHeader)
+	response, err := client.Get()
+	if err != nil {
+		customErr := errors.NewLocalizedError(errRequestFailed, err)
+		originalFeed.ParsingErrorCount++
+		originalFeed.ParsingErrorMsg = customErr.Error()
+		h.store.UpdateFeed(originalFeed)
+		return customErr
+	}
+
+	originalFeed.CheckedAt = time.Now()
+
+	if response.HasServerFailure() {
+		err := errors.NewLocalizedError(errServerFailure, response.StatusCode)
+		originalFeed.ParsingErrorCount++
+		originalFeed.ParsingErrorMsg = err.Error()
+		h.store.UpdateFeed(originalFeed)
+		return err
+	}
+
+	if response.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
+		log.Printf("[Handler:RefreshFeed] Feed #%d has been modified\n", feedID)
+
+		subscription, err := parseFeed(response.Body)
+		if err != nil {
+			originalFeed.ParsingErrorCount++
+			originalFeed.ParsingErrorMsg = err.Error()
+			h.store.UpdateFeed(originalFeed)
+			return err
+		}
+
+		originalFeed.EtagHeader = response.ETag
+		originalFeed.LastModifiedHeader = response.LastModified
+
+		if err := h.store.UpdateEntries(originalFeed.UserID, originalFeed.ID, subscription.Entries); err != nil {
+			return err
+		}
+
+		if !h.store.HasIcon(originalFeed.ID) {
+			log.Println("[Handler:RefreshFeed] Looking for feed icon")
+			icon, err := icon.FindIcon(originalFeed.SiteURL)
+			if err != nil {
+				log.Println("[Handler:RefreshFeed]", err)
+			} else {
+				h.store.CreateFeedIcon(originalFeed, icon)
+			}
+		}
+	} else {
+		log.Printf("[Handler:RefreshFeed] Feed #%d not modified\n", feedID)
+	}
+
+	originalFeed.ParsingErrorCount = 0
+	originalFeed.ParsingErrorMsg = ""
+
+	return h.store.UpdateFeed(originalFeed)
+}
+
+// NewFeedHandler returns a feed handler.
+func NewFeedHandler(store *storage.Storage) *Handler {
+	return &Handler{store: store}
+}

+ 170 - 0
reader/feed/json/json.go

@@ -0,0 +1,170 @@
+// 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 json
+
+import (
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed/date"
+	"github.com/miniflux/miniflux2/reader/processor"
+	"github.com/miniflux/miniflux2/reader/sanitizer"
+	"log"
+	"strings"
+	"time"
+)
+
+type JsonFeed struct {
+	Version string     `json:"version"`
+	Title   string     `json:"title"`
+	SiteURL string     `json:"home_page_url"`
+	FeedURL string     `json:"feed_url"`
+	Author  JsonAuthor `json:"author"`
+	Items   []JsonItem `json:"items"`
+}
+
+type JsonAuthor struct {
+	Name string `json:"name"`
+	URL  string `json:"url"`
+}
+
+type JsonItem struct {
+	ID            string           `json:"id"`
+	URL           string           `json:"url"`
+	Title         string           `json:"title"`
+	Summary       string           `json:"summary"`
+	Text          string           `json:"content_text"`
+	Html          string           `json:"content_html"`
+	DatePublished string           `json:"date_published"`
+	DateModified  string           `json:"date_modified"`
+	Author        JsonAuthor       `json:"author"`
+	Attachments   []JsonAttachment `json:"attachments"`
+}
+
+type JsonAttachment struct {
+	URL      string `json:"url"`
+	MimeType string `json:"mime_type"`
+	Title    string `json:"title"`
+	Size     int    `json:"size_in_bytes"`
+	Duration int    `json:"duration_in_seconds"`
+}
+
+func (j *JsonFeed) GetAuthor() string {
+	return getAuthor(j.Author)
+}
+
+func (j *JsonFeed) Transform() *model.Feed {
+	feed := new(model.Feed)
+	feed.FeedURL = j.FeedURL
+	feed.SiteURL = j.SiteURL
+	feed.Title = sanitizer.StripTags(j.Title)
+
+	if feed.Title == "" {
+		feed.Title = feed.SiteURL
+	}
+
+	for _, item := range j.Items {
+		entry := item.Transform()
+		if entry.Author == "" {
+			entry.Author = j.GetAuthor()
+		}
+
+		feed.Entries = append(feed.Entries, entry)
+	}
+
+	return feed
+}
+
+func (j *JsonItem) GetDate() time.Time {
+	for _, value := range []string{j.DatePublished, j.DateModified} {
+		if value != "" {
+			d, err := date.Parse(value)
+			if err != nil {
+				log.Println(err)
+				return time.Now()
+			}
+
+			return d
+		}
+	}
+
+	return time.Now()
+}
+
+func (j *JsonItem) GetAuthor() string {
+	return getAuthor(j.Author)
+}
+
+func (j *JsonItem) GetHash() string {
+	for _, value := range []string{j.ID, j.URL, j.Text + j.Html + j.Summary} {
+		if value != "" {
+			return helper.Hash(value)
+		}
+	}
+
+	return ""
+}
+
+func (j *JsonItem) GetTitle() string {
+	for _, value := range []string{j.Title, j.Summary, j.Text, j.Html} {
+		if value != "" {
+			return truncate(value)
+		}
+	}
+
+	return j.URL
+}
+
+func (j *JsonItem) GetContent() string {
+	for _, value := range []string{j.Html, j.Text, j.Summary} {
+		if value != "" {
+			return value
+		}
+	}
+
+	return ""
+}
+
+func (j *JsonItem) GetEnclosures() model.EnclosureList {
+	enclosures := make(model.EnclosureList, 0)
+
+	for _, attachment := range j.Attachments {
+		enclosures = append(enclosures, &model.Enclosure{
+			URL:      attachment.URL,
+			MimeType: attachment.MimeType,
+			Size:     attachment.Size,
+		})
+	}
+
+	return enclosures
+}
+
+func (j *JsonItem) Transform() *model.Entry {
+	entry := new(model.Entry)
+	entry.URL = j.URL
+	entry.Date = j.GetDate()
+	entry.Author = sanitizer.StripTags(j.GetAuthor())
+	entry.Hash = j.GetHash()
+	entry.Content = processor.ItemContentProcessor(entry.URL, j.GetContent())
+	entry.Title = sanitizer.StripTags(strings.Trim(j.GetTitle(), " \n\t"))
+	entry.Enclosures = j.GetEnclosures()
+	return entry
+}
+
+func getAuthor(author JsonAuthor) string {
+	if author.Name != "" {
+		return author.Name
+	}
+
+	return ""
+}
+
+func truncate(str string) string {
+	max := 100
+	if len(str) > max {
+		return str[:max] + "..."
+	}
+
+	return str
+}

+ 23 - 0
reader/feed/json/parser.go

@@ -0,0 +1,23 @@
+// 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 json
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/miniflux/miniflux2/model"
+	"io"
+)
+
+// Parse returns a normalized feed struct.
+func Parse(data io.Reader) (*model.Feed, error) {
+	jsonFeed := new(JsonFeed)
+	decoder := json.NewDecoder(data)
+	if err := decoder.Decode(&jsonFeed); err != nil {
+		return nil, fmt.Errorf("Unable to parse JSON Feed: %v", err)
+	}
+
+	return jsonFeed.Transform(), nil
+}

+ 345 - 0
reader/feed/json/parser_test.go

@@ -0,0 +1,345 @@
+// 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 json
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestParseJsonFeed(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"title": "My Example Feed",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"id": "2",
+				"content_text": "This is a second item.",
+				"url": "https://example.org/second-item"
+			},
+			{
+				"id": "1",
+				"content_html": "<p>Hello, world!</p>",
+				"url": "https://example.org/initial-post"
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "My Example Feed" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+
+	if feed.FeedURL != "https://example.org/feed.json" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+
+	if feed.SiteURL != "https://example.org/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if len(feed.Entries) != 2 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Hash != "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
+	}
+
+	if feed.Entries[0].URL != "https://example.org/second-item" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Title != "This is a second item." {
+		t.Errorf(`Incorrect entry title, got: "%s"`, feed.Entries[0].Title)
+	}
+
+	if feed.Entries[0].Content != "This is a second item." {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
+	}
+
+	if feed.Entries[1].Hash != "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[1].Hash)
+	}
+
+	if feed.Entries[1].URL != "https://example.org/initial-post" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[1].URL)
+	}
+
+	if feed.Entries[1].Title != "Hello, world!" {
+		t.Errorf(`Incorrect entry title, got: "%s"`, feed.Entries[1].Title)
+	}
+
+	if feed.Entries[1].Content != "<p>Hello, world!</p>" {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[1].Content)
+	}
+}
+
+func TestParsePodcast(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"user_comment": "This is a podcast feed. You can add this feed to your podcast client using the following URL: http://therecord.co/feed.json",
+		"title": "The Record",
+		"home_page_url": "http://therecord.co/",
+		"feed_url": "http://therecord.co/feed.json",
+		"items": [
+			{
+				"id": "http://therecord.co/chris-parrish",
+				"title": "Special #1 - Chris Parrish",
+				"url": "http://therecord.co/chris-parrish",
+				"content_text": "Chris has worked at Adobe and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped Napkin, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on Bainbridge Island, a quick ferry ride from Seattle.",
+				"content_html": "Chris has worked at <a href=\"http://adobe.com/\">Adobe</a> and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped <a href=\"http://aged-and-distilled.com/napkin/\">Napkin</a>, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on <a href=\"http://www.ci.bainbridge-isl.wa.us/\">Bainbridge Island</a>, a quick ferry ride from Seattle.",
+				"summary": "Brent interviews Chris Parrish, co-host of The Record and one-half of Aged & Distilled.",
+				"date_published": "2014-05-09T14:04:00-07:00",
+				"attachments": [
+					{
+						"url": "http://therecord.co/downloads/The-Record-sp1e1-ChrisParrish.m4a",
+						"mime_type": "audio/x-m4a",
+						"size_in_bytes": 89970236,
+						"duration_in_seconds": 6629
+					}
+				]
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "The Record" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+
+	if feed.FeedURL != "http://therecord.co/feed.json" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+
+	if feed.SiteURL != "http://therecord.co/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Hash != "6b678e57962a1b001e4e873756563cdc08bbd06ca561e764e0baa9a382485797" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
+	}
+
+	if feed.Entries[0].URL != "http://therecord.co/chris-parrish" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Title != "Special #1 - Chris Parrish" {
+		t.Errorf(`Incorrect entry title, got: "%s"`, feed.Entries[0].Title)
+	}
+
+	if feed.Entries[0].Content != `Chris has worked at <a href="http://adobe.com/" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">Adobe</a> and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped <a href="http://aged-and-distilled.com/napkin/" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">Napkin</a>, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on <a href="http://www.ci.bainbridge-isl.wa.us/" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">Bainbridge Island</a>, a quick ferry ride from Seattle.` {
+		t.Errorf(`Incorrect entry content, got: "%s"`, feed.Entries[0].Content)
+	}
+
+	location, _ := time.LoadLocation("America/Vancouver")
+	if !feed.Entries[0].Date.Equal(time.Date(2014, time.May, 9, 14, 4, 0, 0, location)) {
+		t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 1 {
+		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	if feed.Entries[0].Enclosures[0].URL != "http://therecord.co/downloads/The-Record-sp1e1-ChrisParrish.m4a" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
+	}
+
+	if feed.Entries[0].Enclosures[0].MimeType != "audio/x-m4a" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[0].Size != 89970236 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
+	}
+}
+
+func TestParseAuthor(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
+		"title": "Brent Simmons’s Microblog",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"author": {
+			"name": "Brent Simmons",
+			"url": "http://example.org/",
+			"avatar": "https://example.org/avatar.png"
+		},
+		"items": [
+			{
+				"id": "2347259",
+				"url": "https://example.org/2347259",
+				"content_text": "Cats are neat. \n\nhttps://example.org/cats",
+				"date_published": "2016-02-09T14:22:00-07:00"
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Author != "Brent Simmons" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseFeedWithoutTitle(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"id": "2347259",
+				"url": "https://example.org/2347259",
+				"content_text": "Cats are neat. \n\nhttps://example.org/cats",
+				"date_published": "2016-02-09T14:22:00-07:00"
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "https://example.org/" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+}
+
+func TestParseFeedItemWithInvalidDate(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"title": "My Example Feed",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"id": "2347259",
+				"url": "https://example.org/2347259",
+				"content_text": "Cats are neat. \n\nhttps://example.org/cats",
+				"date_published": "Tomorrow"
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if !feed.Entries[0].Date.Before(time.Now()) {
+		t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
+	}
+}
+
+func TestParseFeedItemWithoutID(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"title": "My Example Feed",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"content_text": "Some text."
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Hash != "13b4c5aecd1b6d749afcee968fbf9c80f1ed1bbdbe1aaf25cb34ebd01144bbe9" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
+	}
+}
+
+func TestParseFeedItemWithoutTitle(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"title": "My Example Feed",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"url": "https://example.org/item"
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Title != "https://example.org/item" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}
+
+func TestParseTruncateItemTitle(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"title": "My Example Feed",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"title": "` + strings.Repeat("a", 200) + `"
+			}
+		]
+	}`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if len(feed.Entries[0].Title) != 103 {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}

+ 82 - 0
reader/feed/parser.go

@@ -0,0 +1,82 @@
+// 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 feed
+
+import (
+	"bytes"
+	"encoding/xml"
+	"errors"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed/atom"
+	"github.com/miniflux/miniflux2/reader/feed/json"
+	"github.com/miniflux/miniflux2/reader/feed/rss"
+	"io"
+	"strings"
+	"time"
+
+	"golang.org/x/net/html/charset"
+)
+
+const (
+	FormatRss     = "rss"
+	FormatAtom    = "atom"
+	FormatJson    = "json"
+	FormatUnknown = "unknown"
+)
+
+func DetectFeedFormat(data io.Reader) string {
+	defer helper.ExecutionTime(time.Now(), "[Feed:DetectFeedFormat]")
+
+	var buffer bytes.Buffer
+	tee := io.TeeReader(data, &buffer)
+
+	decoder := xml.NewDecoder(tee)
+	decoder.CharsetReader = charset.NewReaderLabel
+
+	for {
+		token, _ := decoder.Token()
+		if token == nil {
+			break
+		}
+
+		if element, ok := token.(xml.StartElement); ok {
+			switch element.Name.Local {
+			case "rss":
+				return FormatRss
+			case "feed":
+				return FormatAtom
+			}
+		}
+	}
+
+	if strings.HasPrefix(strings.TrimSpace(buffer.String()), "{") {
+		return FormatJson
+	}
+
+	return FormatUnknown
+}
+
+func parseFeed(data io.Reader) (*model.Feed, error) {
+	defer helper.ExecutionTime(time.Now(), "[Feed:ParseFeed]")
+
+	var buffer bytes.Buffer
+	io.Copy(&buffer, data)
+
+	reader := bytes.NewReader(buffer.Bytes())
+	format := DetectFeedFormat(reader)
+	reader.Seek(0, io.SeekStart)
+
+	switch format {
+	case FormatAtom:
+		return atom.Parse(reader)
+	case FormatRss:
+		return rss.Parse(reader)
+	case FormatJson:
+		return json.Parse(reader)
+	default:
+		return nil, errors.New("Unsupported feed format")
+	}
+}

+ 169 - 0
reader/feed/parser_test.go

@@ -0,0 +1,169 @@
+// 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 feed
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestDetectRSS(t *testing.T) {
+	data := `<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`
+	format := DetectFeedFormat(bytes.NewBufferString(data))
+
+	if format != FormatRss {
+		t.Errorf("Wrong format detected: %s instead of %s", format, FormatRss)
+	}
+}
+
+func TestDetectAtom(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
+	format := DetectFeedFormat(bytes.NewBufferString(data))
+
+	if format != FormatAtom {
+		t.Errorf("Wrong format detected: %s instead of %s", format, FormatAtom)
+	}
+}
+
+func TestDetectAtomWithISOCharset(t *testing.T) {
+	data := `<?xml version="1.0" encoding="ISO-8859-15"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
+	format := DetectFeedFormat(bytes.NewBufferString(data))
+
+	if format != FormatAtom {
+		t.Errorf("Wrong format detected: %s instead of %s", format, FormatAtom)
+	}
+}
+
+func TestDetectJSON(t *testing.T) {
+	data := `
+	{
+		"version" : "https://jsonfeed.org/version/1",
+		"title" : "Example"
+	}
+	`
+	format := DetectFeedFormat(bytes.NewBufferString(data))
+
+	if format != FormatJson {
+		t.Errorf("Wrong format detected: %s instead of %s", format, FormatJson)
+	}
+}
+
+func TestDetectUnknown(t *testing.T) {
+	data := `
+	<!DOCTYPE html> <html> </html>
+	`
+	format := DetectFeedFormat(bytes.NewBufferString(data))
+
+	if format != FormatUnknown {
+		t.Errorf("Wrong format detected: %s instead of %s", format, FormatUnknown)
+	}
+}
+
+func TestParseAtom(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	  <author>
+		<name>John Doe</name>
+	  </author>
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+	  <entry>
+		<title>Atom-Powered Robots Run Amok</title>
+		<link href="http://example.org/2003/12/13/atom03"/>
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<summary>Some text.</summary>
+	  </entry>
+
+	</feed>`
+
+	feed, err := parseFeed(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "Example Feed" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+}
+
+func TestParseRss(t *testing.T) {
+	data := `<?xml version="1.0"?>
+	<rss version="2.0">
+	<channel>
+		<title>Liftoff News</title>
+		<link>http://liftoff.msfc.nasa.gov/</link>
+		<item>
+			<title>Star City</title>
+			<link>http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</link>
+			<description>How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's &lt;a href="http://howe.iki.rssi.ru/GCTC/gctc_e.htm"&gt;Star City&lt;/a&gt;.</description>
+			<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>
+			<guid>http://liftoff.msfc.nasa.gov/2003/06/03.html#item573</guid>
+		</item>
+	</channel>
+	</rss>`
+
+	feed, err := parseFeed(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "Liftoff News" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+}
+
+func TestParseJson(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1",
+		"title": "My Example Feed",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": [
+			{
+				"id": "2",
+				"content_text": "This is a second item.",
+				"url": "https://example.org/second-item"
+			},
+			{
+				"id": "1",
+				"content_html": "<p>Hello, world!</p>",
+				"url": "https://example.org/initial-post"
+			}
+		]
+	}`
+
+	feed, err := parseFeed(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "My Example Feed" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+}
+
+func TestParseUnknownFeed(t *testing.T) {
+	data := `
+		<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+		<html xmlns="http://www.w3.org/1999/xhtml">
+			<head>
+				<title>Title of document</title>
+			</head>
+			<body>
+				some content
+			</body>
+		</html>
+	`
+
+	_, err := parseFeed(bytes.NewBufferString(data))
+	if err == nil {
+		t.Error("ParseFeed must returns an error")
+	}
+}

+ 28 - 0
reader/feed/rss/parser.go

@@ -0,0 +1,28 @@
+// 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 rss
+
+import (
+	"encoding/xml"
+	"fmt"
+	"github.com/miniflux/miniflux2/model"
+	"io"
+
+	"golang.org/x/net/html/charset"
+)
+
+// Parse returns a normalized feed struct.
+func Parse(data io.Reader) (*model.Feed, error) {
+	rssFeed := new(RssFeed)
+	decoder := xml.NewDecoder(data)
+	decoder.CharsetReader = charset.NewReaderLabel
+
+	err := decoder.Decode(rssFeed)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to parse RSS feed: %v", err)
+	}
+
+	return rssFeed.Transform(), nil
+}

+ 466 - 0
reader/feed/rss/parser_test.go

@@ -0,0 +1,466 @@
+// 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 rss
+
+import (
+	"bytes"
+	"testing"
+	"time"
+)
+
+func TestParseRss2Sample(t *testing.T) {
+	data := `
+		<?xml version="1.0"?>
+		<rss version="2.0">
+		<channel>
+			<title>Liftoff News</title>
+			<link>http://liftoff.msfc.nasa.gov/</link>
+			<description>Liftoff to Space Exploration.</description>
+			<language>en-us</language>
+			<pubDate>Tue, 10 Jun 2003 04:00:00 GMT</pubDate>
+			<lastBuildDate>Tue, 10 Jun 2003 09:41:01 GMT</lastBuildDate>
+			<docs>http://blogs.law.harvard.edu/tech/rss</docs>
+			<generator>Weblog Editor 2.0</generator>
+			<managingEditor>editor@example.com</managingEditor>
+			<webMaster>webmaster@example.com</webMaster>
+			<item>
+				<title>Star City</title>
+				<link>http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</link>
+				<description>How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's &lt;a href="http://howe.iki.rssi.ru/GCTC/gctc_e.htm"&gt;Star City&lt;/a&gt;.</description>
+				<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>
+				<guid>http://liftoff.msfc.nasa.gov/2003/06/03.html#item573</guid>
+			</item>
+			<item>
+				<description>Sky watchers in Europe, Asia, and parts of Alaska and Canada will experience a &lt;a href="http://science.nasa.gov/headlines/y2003/30may_solareclipse.htm"&gt;partial eclipse of the Sun&lt;/a&gt; on Saturday, May 31st.</description>
+				<pubDate>Fri, 30 May 2003 11:06:42 GMT</pubDate>
+				<guid>http://liftoff.msfc.nasa.gov/2003/05/30.html#item572</guid>
+			</item>
+			<item>
+				<title>The Engine That Does More</title>
+				<link>http://liftoff.msfc.nasa.gov/news/2003/news-VASIMR.asp</link>
+				<description>Before man travels to Mars, NASA hopes to design new engines that will let us fly through the Solar System more quickly.  The proposed VASIMR engine would do that.</description>
+				<pubDate>Tue, 27 May 2003 08:37:32 GMT</pubDate>
+				<guid>http://liftoff.msfc.nasa.gov/2003/05/27.html#item571</guid>
+			</item>
+			<item>
+				<title>Astronauts' Dirty Laundry</title>
+				<link>http://liftoff.msfc.nasa.gov/news/2003/news-laundry.asp</link>
+				<description>Compared to earlier spacecraft, the International Space Station has many luxuries, but laundry facilities are not one of them.  Instead, astronauts have other options.</description>
+				<pubDate>Tue, 20 May 2003 08:56:02 GMT</pubDate>
+				<guid>http://liftoff.msfc.nasa.gov/2003/05/20.html#item570</guid>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "Liftoff News" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+
+	if feed.FeedURL != "" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+
+	if feed.SiteURL != "http://liftoff.msfc.nasa.gov/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if len(feed.Entries) != 4 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	expectedDate := time.Date(2003, time.June, 3, 9, 39, 21, 0, time.UTC)
+	if !feed.Entries[0].Date.Equal(expectedDate) {
+		t.Errorf("Incorrect entry date, got: %v, want: %v", feed.Entries[0].Date, expectedDate)
+	}
+
+	if feed.Entries[0].Hash != "5b2b4ac2fe1786ddf0fd2da2f1b07f64e691264f41f2db3ea360f31bb6d9152b" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
+	}
+
+	if feed.Entries[0].URL != "http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Title != "Star City" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+
+	if feed.Entries[0].Content != `How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's <a href="http://howe.iki.rssi.ru/GCTC/gctc_e.htm" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">Star City</a>.` {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
+	}
+}
+
+func TestParseFeedWithoutTitle(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0">
+		<channel>
+			<link>https://example.org/</link>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "https://example.org/" {
+		t.Errorf("Incorrect feed title, got: %s", feed.Title)
+	}
+}
+
+func TestParseEntryWithoutTitle(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0">
+		<channel>
+			<link>https://example.org/</link>
+			<item>
+				<link>https://example.org/item</link>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Title != "https://example.org/item" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}
+
+func TestParseFeedURLWithAtomLink(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.FeedURL != "https://example.org/rss" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+
+	if feed.SiteURL != "https://example.org/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+}
+
+func TestParseEntryWithAtomAuthor(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
+			<item>
+				<title>Test</title>
+				<link>https://example.org/item</link>
+				<author xmlns:author="http://www.w3.org/2005/Atom">
+					<name>Foo Bar</name>
+					<title>Vice President</title>
+					<department/>
+					<company>FooBar Inc.</company>
+				</author>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "Foo Bar" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithDublinCoreAuthor(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<item>
+				<title>Test</title>
+				<link>https://example.org/item</link>
+				<dc:creator>Me (me@example.com)</dc:creator>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "Me (me@example.com)" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithItunesAuthor(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<item>
+				<title>Test</title>
+				<link>https://example.org/item</link>
+				<itunes:author>Someone</itunes:author>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "Someone" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseFeedWithItunesAuthor(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<itunes:author>Someone</itunes:author>
+			<item>
+				<title>Test</title>
+				<link>https://example.org/item</link>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "Someone" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithDublinCoreDate(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+				<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
+				<channel>
+					<title>Example</title>
+					<link>http://example.org/</link>
+					<item>
+						<title>Item 1</title>
+						<link>http://example.org/item1</link>
+						<description>Description.</description>
+						<guid isPermaLink="false">UUID</guid>
+						<dc:date>2002-09-29T23:40:06-05:00</dc:date>
+					</item>
+				</channel>
+			</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	location, _ := time.LoadLocation("EST")
+	expectedDate := time.Date(2002, time.September, 29, 23, 40, 06, 0, location)
+	if !feed.Entries[0].Date.Equal(expectedDate) {
+		t.Errorf("Incorrect entry date, got: %v, want: %v", feed.Entries[0].Date, expectedDate)
+	}
+}
+
+func TestParseEntryWithContentEncoded(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+		<channel>
+			<title>Example</title>
+			<link>http://example.org/</link>
+			<item>
+				<title>Item 1</title>
+				<link>http://example.org/item1</link>
+				<description>Description.</description>
+				<guid isPermaLink="false">UUID</guid>
+				<content:encoded><![CDATA[<p><a href="http://www.example.org/">Example</a>.</p>]]></content:encoded>
+			</item>
+		</channel>
+	</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Content != `<p><a href="http://www.example.org/" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">Example</a>.</p>` {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
+	}
+}
+
+func TestParseEntryWithFeedBurnerLink(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0">
+		<channel>
+			<title>Example</title>
+			<link>http://example.org/</link>
+			<item>
+				<title>Item 1</title>
+				<link>http://example.org/item1</link>
+				<feedburner:origLink>http://example.org/original</feedburner:origLink>
+			</item>
+		</channel>
+	</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].URL != "http://example.org/original" {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].URL)
+	}
+}
+
+func TestParseEntryTitleWithWhitespaces(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<rss version="2.0">
+	<channel>
+		<title>Example</title>
+		<link>http://example.org</link>
+		<item>
+			<title>
+				Some Title
+			</title>
+			<link>http://www.example.org/entries/1</link>
+			<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>
+		</item>
+	</channel>
+	</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Title != "Some Title" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}
+
+func TestParseEntryWithEnclosures(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0">
+		<channel>
+		<title>My Podcast Feed</title>
+		<link>http://example.org</link>
+		<author>some.email@example.org</author>
+		<item>
+			<title>Podcasting with RSS</title>
+			<link>http://www.example.org/entries/1</link>
+			<description>An overview of RSS podcasting</description>
+			<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>
+			<guid isPermaLink="true">http://www.example.org/entries/1</guid>
+			<enclosure url="http://www.example.org/myaudiofile.mp3"
+					length="12345"
+					type="audio/mpeg" />
+		</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 1 {
+		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
+	}
+
+	if feed.Entries[0].Enclosures[0].MimeType != "audio/mpeg" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[0].Size != 12345 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
+	}
+}
+
+func TestParseEntryWithFeedBurnerEnclosures(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0">
+		<channel>
+		<title>My Example Feed</title>
+		<link>http://example.org</link>
+		<author>some.email@example.org</author>
+		<item>
+			<title>Example Item</title>
+			<link>http://www.example.org/entries/1</link>
+			<enclosure
+				url="http://feedproxy.google.com/~r/example/~5/lpMyFSCvubs/File.mp3"
+				length="76192460"
+				type="audio/mpeg" />
+			<feedburner:origEnclosureLink>http://example.org/67ca416c-f22a-4228-a681-68fc9998ec10/File.mp3</feedburner:origEnclosureLink>
+		</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 1 {
+		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	if feed.Entries[0].Enclosures[0].URL != "http://example.org/67ca416c-f22a-4228-a681-68fc9998ec10/File.mp3" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
+	}
+
+	if feed.Entries[0].Enclosures[0].MimeType != "audio/mpeg" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[0].Size != 76192460 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
+	}
+}

+ 207 - 0
reader/feed/rss/rss.go

@@ -0,0 +1,207 @@
+// 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 rss
+
+import (
+	"encoding/xml"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed/date"
+	"github.com/miniflux/miniflux2/reader/processor"
+	"github.com/miniflux/miniflux2/reader/sanitizer"
+	"log"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type RssLink struct {
+	XMLName xml.Name
+	Data    string `xml:",chardata"`
+	Href    string `xml:"href,attr"`
+}
+
+type RssFeed struct {
+	XMLName      xml.Name  `xml:"rss"`
+	Version      string    `xml:"version,attr"`
+	Title        string    `xml:"channel>title"`
+	Links        []RssLink `xml:"channel>link"`
+	Language     string    `xml:"channel>language"`
+	Description  string    `xml:"channel>description"`
+	PubDate      string    `xml:"channel>pubDate"`
+	ItunesAuthor string    `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>author"`
+	Items        []RssItem `xml:"channel>item"`
+}
+
+type RssItem struct {
+	Guid              string         `xml:"guid"`
+	Title             string         `xml:"title"`
+	Link              string         `xml:"link"`
+	OriginalLink      string         `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
+	Description       string         `xml:"description"`
+	Content           string         `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
+	PubDate           string         `xml:"pubDate"`
+	Date              string         `xml:"http://purl.org/dc/elements/1.1/ date"`
+	Authors           []RssAuthor    `xml:"author"`
+	Creator           string         `xml:"http://purl.org/dc/elements/1.1/ creator"`
+	Enclosures        []RssEnclosure `xml:"enclosure"`
+	OrigEnclosureLink string         `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
+}
+
+type RssAuthor struct {
+	XMLName xml.Name
+	Data    string `xml:",chardata"`
+	Name    string `xml:"name"`
+}
+
+type RssEnclosure struct {
+	Url    string `xml:"url,attr"`
+	Type   string `xml:"type,attr"`
+	Length string `xml:"length,attr"`
+}
+
+func (r *RssFeed) GetSiteURL() string {
+	for _, elem := range r.Links {
+		if elem.XMLName.Space == "" {
+			return elem.Data
+		}
+	}
+
+	return ""
+}
+
+func (r *RssFeed) GetFeedURL() string {
+	for _, elem := range r.Links {
+		if elem.XMLName.Space == "http://www.w3.org/2005/Atom" {
+			return elem.Href
+		}
+	}
+
+	return ""
+}
+
+func (r *RssFeed) Transform() *model.Feed {
+	feed := new(model.Feed)
+	feed.SiteURL = r.GetSiteURL()
+	feed.FeedURL = r.GetFeedURL()
+	feed.Title = sanitizer.StripTags(r.Title)
+
+	if feed.Title == "" {
+		feed.Title = feed.SiteURL
+	}
+
+	for _, item := range r.Items {
+		entry := item.Transform()
+
+		if entry.Author == "" && r.ItunesAuthor != "" {
+			entry.Author = r.ItunesAuthor
+		}
+		entry.Author = sanitizer.StripTags(entry.Author)
+
+		feed.Entries = append(feed.Entries, entry)
+	}
+
+	return feed
+}
+func (i *RssItem) GetDate() time.Time {
+	value := i.PubDate
+	if i.Date != "" {
+		value = i.Date
+	}
+
+	if value != "" {
+		result, err := date.Parse(value)
+		if err != nil {
+			log.Println(err)
+			return time.Now()
+		}
+
+		return result
+	}
+
+	return time.Now()
+}
+
+func (i *RssItem) GetAuthor() string {
+	for _, element := range i.Authors {
+		if element.Name != "" {
+			return element.Name
+		}
+
+		if element.Data != "" {
+			return element.Data
+		}
+	}
+
+	return i.Creator
+}
+
+func (i *RssItem) GetHash() string {
+	for _, value := range []string{i.Guid, i.Link} {
+		if value != "" {
+			return helper.Hash(value)
+		}
+	}
+
+	return ""
+}
+
+func (i *RssItem) GetContent() string {
+	if i.Content != "" {
+		return i.Content
+	}
+
+	return i.Description
+}
+
+func (i *RssItem) GetURL() string {
+	if i.OriginalLink != "" {
+		return i.OriginalLink
+	}
+
+	return i.Link
+}
+
+func (i *RssItem) GetEnclosures() model.EnclosureList {
+	enclosures := make(model.EnclosureList, 0)
+
+	for _, enclosure := range i.Enclosures {
+		length, _ := strconv.Atoi(enclosure.Length)
+		enclosureURL := enclosure.Url
+
+		if i.OrigEnclosureLink != "" {
+			filename := path.Base(i.OrigEnclosureLink)
+			if strings.Contains(enclosureURL, filename) {
+				enclosureURL = i.OrigEnclosureLink
+			}
+		}
+
+		enclosures = append(enclosures, &model.Enclosure{
+			URL:      enclosureURL,
+			MimeType: enclosure.Type,
+			Size:     length,
+		})
+	}
+
+	return enclosures
+}
+
+func (i *RssItem) Transform() *model.Entry {
+	entry := new(model.Entry)
+	entry.URL = i.GetURL()
+	entry.Date = i.GetDate()
+	entry.Author = i.GetAuthor()
+	entry.Hash = i.GetHash()
+	entry.Content = processor.ItemContentProcessor(entry.URL, i.GetContent())
+	entry.Title = sanitizer.StripTags(strings.Trim(i.Title, " \n\t"))
+	entry.Enclosures = i.GetEnclosures()
+
+	if entry.Title == "" {
+		entry.Title = entry.URL
+	}
+
+	return entry
+}

+ 95 - 0
reader/http/client.go

@@ -0,0 +1,95 @@
+// 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 http
+
+import (
+	"crypto/tls"
+	"fmt"
+	"github.com/miniflux/miniflux2/helper"
+	"log"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+const HTTP_USER_AGENT = "Miniflux <https://miniflux.net/>"
+
+type HttpClient struct {
+	url                string
+	etagHeader         string
+	lastModifiedHeader string
+	Insecure           bool
+}
+
+func (h *HttpClient) Get() (*ServerResponse, error) {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient:Get] url=%s", h.url))
+	u, _ := url.Parse(h.url)
+
+	req := &http.Request{
+		URL:    u,
+		Method: "GET",
+		Header: h.buildHeaders(),
+	}
+
+	client := h.buildClient()
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	response := &ServerResponse{
+		Body:         resp.Body,
+		StatusCode:   resp.StatusCode,
+		EffectiveURL: resp.Request.URL.String(),
+		LastModified: resp.Header.Get("Last-Modified"),
+		ETag:         resp.Header.Get("ETag"),
+		ContentType:  resp.Header.Get("Content-Type"),
+	}
+
+	log.Println("[HttpClient:Get]",
+		"OriginalURL:", h.url,
+		"StatusCode:", response.StatusCode,
+		"ETag:", response.ETag,
+		"LastModified:", response.LastModified,
+		"EffectiveURL:", response.EffectiveURL,
+	)
+
+	return response, err
+}
+
+func (h *HttpClient) buildClient() http.Client {
+	if h.Insecure {
+		transport := &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		}
+
+		return http.Client{Transport: transport}
+	}
+
+	return http.Client{}
+}
+
+func (h *HttpClient) buildHeaders() http.Header {
+	headers := make(http.Header)
+	headers.Add("User-Agent", HTTP_USER_AGENT)
+
+	if h.etagHeader != "" {
+		headers.Add("If-None-Match", h.etagHeader)
+	}
+
+	if h.lastModifiedHeader != "" {
+		headers.Add("If-Modified-Since", h.lastModifiedHeader)
+	}
+
+	return headers
+}
+
+func NewHttpClient(url string) *HttpClient {
+	return &HttpClient{url: url, Insecure: false}
+}
+
+func NewHttpClientWithCacheHeaders(url, etagHeader, lastModifiedHeader string) *HttpClient {
+	return &HttpClient{url: url, etagHeader: etagHeader, lastModifiedHeader: lastModifiedHeader, Insecure: false}
+}

+ 32 - 0
reader/http/response.go

@@ -0,0 +1,32 @@
+// 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 http
+
+import "io"
+
+type ServerResponse struct {
+	Body         io.Reader
+	StatusCode   int
+	EffectiveURL string
+	LastModified string
+	ETag         string
+	ContentType  string
+}
+
+func (s *ServerResponse) HasServerFailure() bool {
+	return s.StatusCode >= 400
+}
+
+func (s *ServerResponse) IsModified(etag, lastModified string) bool {
+	if s.StatusCode == 304 {
+		return false
+	}
+
+	if s.ETag != "" && s.LastModified != "" && (s.ETag == etag || s.LastModified == lastModified) {
+		return false
+	}
+
+	return true
+}

+ 109 - 0
reader/icon/finder.go

@@ -0,0 +1,109 @@
+// 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 icon
+
+import (
+	"fmt"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/http"
+	"github.com/miniflux/miniflux2/reader/url"
+	"io"
+	"io/ioutil"
+	"log"
+
+	"github.com/PuerkitoBio/goquery"
+)
+
+// FindIcon try to find the website's icon.
+func FindIcon(websiteURL string) (*model.Icon, error) {
+	rootURL := url.GetRootURL(websiteURL)
+	client := http.NewHttpClient(rootURL)
+	response, err := client.Get()
+	if err != nil {
+		return nil, fmt.Errorf("unable to download website index page: %v", err)
+	}
+
+	if response.HasServerFailure() {
+		return nil, fmt.Errorf("unable to download website index page: status=%d", response.StatusCode)
+	}
+
+	iconURL, err := parseDocument(rootURL, response.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	log.Println("[FindIcon] Fetching icon =>", iconURL)
+	icon, err := downloadIcon(iconURL)
+	if err != nil {
+		return nil, err
+	}
+
+	return icon, nil
+}
+
+func parseDocument(websiteURL string, data io.Reader) (string, error) {
+	queries := []string{
+		"link[rel='shortcut icon']",
+		"link[rel='Shortcut Icon']",
+		"link[rel='icon shortcut']",
+		"link[rel='icon']",
+	}
+
+	doc, err := goquery.NewDocumentFromReader(data)
+	if err != nil {
+		return "", fmt.Errorf("unable to read document: %v", err)
+	}
+
+	var iconURL string
+	for _, query := range queries {
+		doc.Find(query).Each(func(i int, s *goquery.Selection) {
+			if href, exists := s.Attr("href"); exists {
+				iconURL = href
+			}
+		})
+
+		if iconURL != "" {
+			break
+		}
+	}
+
+	if iconURL == "" {
+		iconURL = url.GetRootURL(websiteURL) + "favicon.ico"
+	} else {
+		iconURL, _ = url.GetAbsoluteURL(websiteURL, iconURL)
+	}
+
+	return iconURL, nil
+}
+
+func downloadIcon(iconURL string) (*model.Icon, error) {
+	client := http.NewHttpClient(iconURL)
+	response, err := client.Get()
+	if err != nil {
+		return nil, fmt.Errorf("unable to download iconURL: %v", err)
+	}
+
+	if response.HasServerFailure() {
+		return nil, fmt.Errorf("unable to download icon: status=%d", response.StatusCode)
+	}
+
+	body, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		return nil, fmt.Errorf("unable to read downloaded icon: %v", err)
+	}
+
+	if len(body) == 0 {
+		return nil, fmt.Errorf("downloaded icon is empty, iconURL=%s", iconURL)
+	}
+
+	icon := &model.Icon{
+		Hash:     helper.HashFromBytes(body),
+		MimeType: response.ContentType,
+		Content:  body,
+	}
+
+	return icon, nil
+}

+ 94 - 0
reader/opml/handler.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 opml
+
+import (
+	"errors"
+	"fmt"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/storage"
+	"io"
+	"log"
+)
+
+type OpmlHandler struct {
+	store *storage.Storage
+}
+
+func (o *OpmlHandler) Export(userID int64) (string, error) {
+	feeds, err := o.store.GetFeeds(userID)
+	if err != nil {
+		log.Println(err)
+		return "", errors.New("Unable to fetch feeds.")
+	}
+
+	var subscriptions SubcriptionList
+	for _, feed := range feeds {
+		subscriptions = append(subscriptions, &Subcription{
+			Title:        feed.Title,
+			FeedURL:      feed.FeedURL,
+			SiteURL:      feed.SiteURL,
+			CategoryName: feed.Category.Title,
+		})
+	}
+
+	return Serialize(subscriptions), nil
+}
+
+func (o *OpmlHandler) Import(userID int64, data io.Reader) (err error) {
+	subscriptions, err := Parse(data)
+	if err != nil {
+		return err
+	}
+
+	for _, subscription := range subscriptions {
+		if !o.store.FeedURLExists(userID, subscription.FeedURL) {
+			var category *model.Category
+
+			if subscription.CategoryName == "" {
+				category, err = o.store.GetFirstCategory(userID)
+				if err != nil {
+					log.Println(err)
+					return errors.New("Unable to find first category.")
+				}
+			} else {
+				category, err = o.store.GetCategoryByTitle(userID, subscription.CategoryName)
+				if err != nil {
+					log.Println(err)
+					return errors.New("Unable to search category by title.")
+				}
+
+				if category == nil {
+					category = &model.Category{
+						UserID: userID,
+						Title:  subscription.CategoryName,
+					}
+
+					err := o.store.CreateCategory(category)
+					if err != nil {
+						log.Println(err)
+						return fmt.Errorf(`Unable to create this category: "%s".`, subscription.CategoryName)
+					}
+				}
+			}
+
+			feed := &model.Feed{
+				UserID:   userID,
+				Title:    subscription.Title,
+				FeedURL:  subscription.FeedURL,
+				SiteURL:  subscription.SiteURL,
+				Category: category,
+			}
+
+			o.store.CreateFeed(feed)
+		}
+	}
+
+	return nil
+}
+
+func NewOpmlHandler(store *storage.Storage) *OpmlHandler {
+	return &OpmlHandler{store: store}
+}

+ 82 - 0
reader/opml/opml.go

@@ -0,0 +1,82 @@
+// 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 opml
+
+import "encoding/xml"
+
+type Opml struct {
+	XMLName  xml.Name  `xml:"opml"`
+	Version  string    `xml:"version,attr"`
+	Outlines []Outline `xml:"body>outline"`
+}
+
+type Outline struct {
+	Title    string    `xml:"title,attr,omitempty"`
+	Text     string    `xml:"text,attr"`
+	FeedURL  string    `xml:"xmlUrl,attr,omitempty"`
+	SiteURL  string    `xml:"htmlUrl,attr,omitempty"`
+	Outlines []Outline `xml:"outline,omitempty"`
+}
+
+func (o *Outline) GetTitle() string {
+	if o.Title != "" {
+		return o.Title
+	}
+
+	if o.Text != "" {
+		return o.Text
+	}
+
+	if o.SiteURL != "" {
+		return o.SiteURL
+	}
+
+	if o.FeedURL != "" {
+		return o.FeedURL
+	}
+
+	return ""
+}
+
+func (o *Outline) GetSiteURL() string {
+	if o.SiteURL != "" {
+		return o.SiteURL
+	}
+
+	return o.FeedURL
+}
+
+func (o *Outline) IsCategory() bool {
+	return o.Text != "" && o.SiteURL == "" && o.FeedURL == ""
+}
+
+func (o *Outline) Append(subscriptions SubcriptionList, category string) SubcriptionList {
+	if o.FeedURL != "" {
+		subscriptions = append(subscriptions, &Subcription{
+			Title:        o.GetTitle(),
+			FeedURL:      o.FeedURL,
+			SiteURL:      o.GetSiteURL(),
+			CategoryName: category,
+		})
+	}
+
+	return subscriptions
+}
+
+func (o *Opml) Transform() SubcriptionList {
+	var subscriptions SubcriptionList
+
+	for _, outline := range o.Outlines {
+		if outline.IsCategory() {
+			for _, element := range outline.Outlines {
+				subscriptions = element.Append(subscriptions, outline.Text)
+			}
+		} else {
+			subscriptions = outline.Append(subscriptions, "")
+		}
+	}
+
+	return subscriptions
+}

+ 26 - 0
reader/opml/parser.go

@@ -0,0 +1,26 @@
+// 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 opml
+
+import (
+	"encoding/xml"
+	"fmt"
+	"io"
+
+	"golang.org/x/net/html/charset"
+)
+
+func Parse(data io.Reader) (SubcriptionList, error) {
+	opml := new(Opml)
+	decoder := xml.NewDecoder(data)
+	decoder.CharsetReader = charset.NewReaderLabel
+
+	err := decoder.Decode(opml)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to parse OPML file: %v\n", err)
+	}
+
+	return opml.Transform(), nil
+}

+ 138 - 0
reader/opml/parser_test.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 opml
+
+import "testing"
+import "bytes"
+
+func TestParseOpmlWithoutCategories(t *testing.T) {
+	data := `<?xml version="1.0" encoding="ISO-8859-1"?>
+	<opml version="2.0">
+		<head>
+			<title>mySubscriptions.opml</title>
+		</head>
+		<body>
+			<outline text="CNET News.com" description="Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media." htmlUrl="http://news.com.com/" language="unknown" title="CNET News.com" type="rss" version="RSS2" xmlUrl="http://news.com.com/2547-1_3-0-5.xml"/>
+			<outline text="washingtonpost.com - Politics" description="Politics" htmlUrl="http://www.washingtonpost.com/wp-dyn/politics?nav=rss_politics" language="unknown" title="washingtonpost.com - Politics" type="rss" version="RSS2" xmlUrl="http://www.washingtonpost.com/wp-srv/politics/rssheadlines.xml"/>
+			<outline text="Scobleizer: Microsoft Geek Blogger" description="Robert Scoble's look at geek and Microsoft life." htmlUrl="http://radio.weblogs.com/0001011/" language="unknown" title="Scobleizer: Microsoft Geek Blogger" type="rss" version="RSS2" xmlUrl="http://radio.weblogs.com/0001011/rss.xml"/>
+			<outline text="Yahoo! News: Technology" description="Technology" htmlUrl="http://news.yahoo.com/news?tmpl=index&amp;cid=738" language="unknown" title="Yahoo! News: Technology" type="rss" version="RSS2" xmlUrl="http://rss.news.yahoo.com/rss/tech"/>
+			<outline text="Workbench" description="Programming and publishing news and comment" htmlUrl="http://www.cadenhead.org/workbench/" language="unknown" title="Workbench" type="rss" version="RSS2" xmlUrl="http://www.cadenhead.org/workbench/rss.xml"/>
+			<outline text="Christian Science Monitor | Top Stories" description="Read the front page stories of csmonitor.com." htmlUrl="http://csmonitor.com" language="unknown" title="Christian Science Monitor | Top Stories" type="rss" version="RSS" xmlUrl="http://www.csmonitor.com/rss/top.rss"/>
+			<outline text="Dictionary.com Word of the Day" description="A new word is presented every day with its definition and example sentences from actual published works." htmlUrl="http://dictionary.reference.com/wordoftheday/" language="unknown" title="Dictionary.com Word of the Day" type="rss" version="RSS" xmlUrl="http://www.dictionary.com/wordoftheday/wotd.rss"/>
+			<outline text="The Motley Fool" description="To Educate, Amuse, and Enrich" htmlUrl="http://www.fool.com" language="unknown" title="The Motley Fool" type="rss" version="RSS" xmlUrl="http://www.fool.com/xml/foolnews_rss091.xml"/>
+			<outline text="InfoWorld: Top News" description="The latest on Top News from InfoWorld" htmlUrl="http://www.infoworld.com/news/index.html" language="unknown" title="InfoWorld: Top News" type="rss" version="RSS2" xmlUrl="http://www.infoworld.com/rss/news.xml"/>
+			<outline text="NYT &gt; Business" description="Find breaking news &amp; business news on Wall Street, media &amp; advertising, international business, banking, interest rates, the stock market, currencies &amp; funds." htmlUrl="http://www.nytimes.com/pages/business/index.html?partner=rssnyt" language="unknown" title="NYT &gt; Business" type="rss" version="RSS2" xmlUrl="http://www.nytimes.com/services/xml/rss/nyt/Business.xml"/>
+			<outline text="NYT &gt; Technology" description="" htmlUrl="http://www.nytimes.com/pages/technology/index.html?partner=rssnyt" language="unknown" title="NYT &gt; Technology" type="rss" version="RSS2" xmlUrl="http://www.nytimes.com/services/xml/rss/nyt/Technology.xml"/>
+			<outline text="Scripting News" description="It's even worse than it appears." htmlUrl="http://www.scripting.com/" language="unknown" title="Scripting News" type="rss" version="RSS2" xmlUrl="http://www.scripting.com/rss.xml"/>
+			<outline text="Wired News" description="Technology, and the way we do business, is changing the world we know. Wired News is a technology - and business-oriented news service feeding an intelligent, discerning audience. What role does technology play in the day-to-day living of your life? Wired News tells you. How has evolving technology changed the face of the international business world? Wired News puts you in the picture." htmlUrl="http://www.wired.com/" language="unknown" title="Wired News" type="rss" version="RSS" xmlUrl="http://www.wired.com/news_drop/netcenter/netcenter.rdf"/>
+		</body>
+	</opml>
+	`
+
+	var expected SubcriptionList
+	expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/"})
+
+	subscriptions, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(subscriptions) != 13 {
+		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
+	}
+
+	if !subscriptions[0].Equals(expected[0]) {
+		t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[0], expected[0])
+	}
+}
+
+func TestParseOpmlWithCategories(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<opml version="2.0">
+		<head>
+			<title>mySubscriptions.opml</title>
+		</head>
+		<body>
+			<outline text="My Category 1">
+				<outline text="Feed 1" xmlUrl="http://example.org/feed1/" htmlUrl="http://example.org/1"/>
+				<outline text="Feed 2" xmlUrl="http://example.org/feed2/" htmlUrl="http://example.org/2"/>
+			</outline>
+			<outline text="My Category 2">
+			<outline text="Feed 3" xmlUrl="http://example.org/feed3/" htmlUrl="http://example.org/3"/>
+		</outline>
+		</body>
+	</opml>
+	`
+
+	var expected SubcriptionList
+	expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "My Category 1"})
+	expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "My Category 1"})
+	expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryName: "My Category 2"})
+
+	subscriptions, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(subscriptions) != 3 {
+		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
+	}
+
+	for i := 0; i < len(subscriptions); i++ {
+		if !subscriptions[i].Equals(expected[i]) {
+			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+		}
+	}
+}
+
+func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
+	data := `<?xml version="1.0" encoding="ISO-8859-1"?>
+	<opml version="2.0">
+	<head>
+	<title>mySubscriptions.opml</title>
+	</head>
+	<body>
+		<outline xmlUrl="http://example.org/feed1/" htmlUrl="http://example.org/1"/>
+		<outline xmlUrl="http://example.org/feed2/"/>
+	</body>
+	</opml>
+	`
+
+	var expected SubcriptionList
+	expected = append(expected, &Subcription{Title: "http://example.org/1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: ""})
+	expected = append(expected, &Subcription{Title: "http://example.org/feed2/", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/feed2/", CategoryName: ""})
+
+	subscriptions, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(subscriptions) != 2 {
+		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
+	}
+
+	for i := 0; i < len(subscriptions); i++ {
+		if !subscriptions[i].Equals(expected[i]) {
+			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+		}
+	}
+}
+
+func TestParseInvalidXML(t *testing.T) {
+	data := `<?xml version="1.0" encoding="ISO-8859-1"?>
+	<opml version="2.0">
+	<head>
+	</head>
+	<body>
+		<outline
+	</body>
+	</opml>
+	`
+
+	_, err := Parse(bytes.NewBufferString(data))
+	if err == nil {
+		t.Error(err)
+	}
+}

+ 58 - 0
reader/opml/serializer.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 opml
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/xml"
+	"log"
+)
+
+func Serialize(subscriptions SubcriptionList) string {
+	var b bytes.Buffer
+	writer := bufio.NewWriter(&b)
+	writer.WriteString(xml.Header)
+
+	opml := new(Opml)
+	opml.Version = "2.0"
+	for categoryName, subs := range groupSubscriptionsByFeed(subscriptions) {
+		outline := Outline{Text: categoryName}
+
+		for _, subscription := range subs {
+			outline.Outlines = append(outline.Outlines, Outline{
+				Title:   subscription.Title,
+				Text:    subscription.Title,
+				FeedURL: subscription.FeedURL,
+				SiteURL: subscription.SiteURL,
+			})
+		}
+
+		opml.Outlines = append(opml.Outlines, outline)
+	}
+
+	encoder := xml.NewEncoder(writer)
+	encoder.Indent("  ", "    ")
+	if err := encoder.Encode(opml); err != nil {
+		log.Println(err)
+		return ""
+	}
+
+	return b.String()
+}
+
+func groupSubscriptionsByFeed(subscriptions SubcriptionList) map[string]SubcriptionList {
+	groups := make(map[string]SubcriptionList)
+
+	for _, subscription := range subscriptions {
+		// if subs, ok := groups[subscription.CategoryName]; !ok {
+		// groups[subscription.CategoryName] = SubcriptionList{}
+		// }
+
+		groups[subscription.CategoryName] = append(groups[subscription.CategoryName], subscription)
+	}
+
+	return groups
+}

+ 31 - 0
reader/opml/serializer_test.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 opml
+
+import "testing"
+import "bytes"
+
+func TestSerialize(t *testing.T) {
+	var subscriptions SubcriptionList
+	subscriptions = append(subscriptions, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed/1", SiteURL: "http://example.org/1", CategoryName: "Category 1"})
+	subscriptions = append(subscriptions, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed/2", SiteURL: "http://example.org/2", CategoryName: "Category 1"})
+	subscriptions = append(subscriptions, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed/3", SiteURL: "http://example.org/3", CategoryName: "Category 2"})
+
+	output := Serialize(subscriptions)
+	feeds, err := Parse(bytes.NewBufferString(output))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feeds) != 3 {
+		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(feeds), 3)
+	}
+
+	for i := 0; i < len(feeds); i++ {
+		if !feeds[i].Equals(subscriptions[i]) {
+			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], feeds[i])
+		}
+	}
+}

+ 18 - 0
reader/opml/subscription.go

@@ -0,0 +1,18 @@
+// 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 opml
+
+type Subcription struct {
+	Title        string
+	SiteURL      string
+	FeedURL      string
+	CategoryName string
+}
+
+func (s Subcription) Equals(subscription *Subcription) bool {
+	return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL && s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName
+}
+
+type SubcriptionList []*Subcription

+ 15 - 0
reader/processor/processor.go

@@ -0,0 +1,15 @@
+// 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 processor
+
+import (
+	"github.com/miniflux/miniflux2/reader/rewrite"
+	"github.com/miniflux/miniflux2/reader/sanitizer"
+)
+
+func ItemContentProcessor(url, content string) string {
+	content = sanitizer.Sanitize(url, content)
+	return rewrite.Rewriter(url, content)
+}

+ 47 - 0
reader/rewrite/rewriter.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 rewrite
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+)
+
+var rewriteRules = []func(string, string) string{
+	func(url, content string) string {
+		re := regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
+		matches := re.FindStringSubmatch(url)
+
+		if len(matches) == 2 {
+			video := `<iframe width="650" height="350" frameborder="0" src="https://www.youtube-nocookie.com/embed/` + matches[1] + `" allowfullscreen></iframe>`
+			return video + "<p>" + content + "</p>"
+		}
+		return content
+	},
+	func(url, content string) string {
+		if strings.HasPrefix(url, "https://xkcd.com") {
+			doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
+			if err != nil {
+				return content
+			}
+
+			imgTag := doc.Find("img").First()
+			if titleAttr, found := imgTag.Attr("title"); found {
+				return content + `<blockquote cite="` + url + `">` + titleAttr + "</blockquote>"
+			}
+		}
+		return content
+	},
+}
+
+func Rewriter(url, content string) string {
+	for _, rewriteRule := range rewriteRules {
+		content = rewriteRule(url, content)
+	}
+
+	return content
+}

+ 34 - 0
reader/rewrite/rewriter_test.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 rewrite
+
+import "testing"
+
+func TestRewriteWithNoMatchingRule(t *testing.T) {
+	output := Rewriter("https://example.org/article", `Some text.`)
+	expected := `Some text.`
+
+	if expected != output {
+		t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestRewriteWithYoutubeLink(t *testing.T) {
+	output := Rewriter("https://www.youtube.com/watch?v=1234", `Video Description`)
+	expected := `<iframe width="650" height="350" frameborder="0" src="https://www.youtube-nocookie.com/embed/1234" allowfullscreen></iframe><p>Video Description</p>`
+
+	if expected != output {
+		t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestRewriteWithXkcdLink(t *testing.T) {
+	description := `<img src="https://imgs.xkcd.com/comics/thermostat.png" title="Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you." alt="Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you." />`
+	output := Rewriter("https://xkcd.com/1912/", description)
+	expected := description + `<blockquote cite="https://xkcd.com/1912/">Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.</blockquote>`
+	if expected != output {
+		t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
+	}
+}

+ 360 - 0
reader/sanitizer/sanitizer.go

@@ -0,0 +1,360 @@
+// 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 sanitizer
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/miniflux/miniflux2/reader/url"
+	"io"
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+// Sanitize returns safe HTML.
+func Sanitize(baseURL, input string) string {
+	tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
+	var buffer bytes.Buffer
+	var tagStack []string
+
+	for {
+		if tokenizer.Next() == html.ErrorToken {
+			err := tokenizer.Err()
+			if err == io.EOF {
+				return buffer.String()
+			}
+
+			return ""
+		}
+
+		token := tokenizer.Token()
+		switch token.Type {
+		case html.TextToken:
+			buffer.WriteString(token.Data)
+		case html.StartTagToken:
+			tagName := token.DataAtom.String()
+
+			if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
+				attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
+
+				if hasRequiredAttributes(tagName, attrNames) {
+					if len(attrNames) > 0 {
+						buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
+					} else {
+						buffer.WriteString("<" + tagName + ">")
+					}
+
+					tagStack = append(tagStack, tagName)
+				}
+			}
+		case html.EndTagToken:
+			tagName := token.DataAtom.String()
+			if isValidTag(tagName) && inList(tagName, tagStack) {
+				buffer.WriteString(fmt.Sprintf("</%s>", tagName))
+			}
+		case html.SelfClosingTagToken:
+			tagName := token.DataAtom.String()
+			if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
+				attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
+
+				if hasRequiredAttributes(tagName, attrNames) {
+					if len(attrNames) > 0 {
+						buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
+					} else {
+						buffer.WriteString("<" + tagName + "/>")
+					}
+				}
+			}
+		}
+	}
+}
+
+func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) (attrNames []string, html string) {
+	var htmlAttrs []string
+	var err error
+
+	for _, attribute := range attributes {
+		value := attribute.Val
+
+		if !isValidAttribute(tagName, attribute.Key) {
+			continue
+		}
+
+		if isExternalResourceAttribute(attribute.Key) {
+			if tagName == "iframe" && !isValidIframeSource(attribute.Val) {
+				continue
+			} else {
+				value, err = url.GetAbsoluteURL(baseURL, value)
+				if err != nil {
+					continue
+				}
+
+				if !hasValidScheme(value) || isBlacklistedResource(value) {
+					continue
+				}
+			}
+		}
+
+		attrNames = append(attrNames, attribute.Key)
+		htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, value))
+	}
+
+	extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
+	if len(extraAttrNames) > 0 {
+		attrNames = append(attrNames, extraAttrNames...)
+		htmlAttrs = append(htmlAttrs, extraHTMLAttributes...)
+	}
+
+	return attrNames, strings.Join(htmlAttrs, " ")
+}
+
+func getExtraAttributes(tagName string) ([]string, []string) {
+	if tagName == "a" {
+		return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
+	}
+
+	if tagName == "video" || tagName == "audio" {
+		return []string{"controls"}, []string{"controls"}
+	}
+
+	return nil, nil
+}
+
+func isValidTag(tagName string) bool {
+	for element := range getTagWhitelist() {
+		if tagName == element {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isValidAttribute(tagName, attributeName string) bool {
+	for element, attributes := range getTagWhitelist() {
+		if tagName == element {
+			if inList(attributeName, attributes) {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+func isExternalResourceAttribute(attribute string) bool {
+	switch attribute {
+	case "src", "href", "poster", "cite":
+		return true
+	default:
+		return false
+	}
+}
+
+func isPixelTracker(tagName string, attributes []html.Attribute) bool {
+	if tagName == "img" {
+		hasHeight := false
+		hasWidth := false
+
+		for _, attribute := range attributes {
+			if attribute.Key == "height" && attribute.Val == "1" {
+				hasHeight = true
+			}
+
+			if attribute.Key == "width" && attribute.Val == "1" {
+				hasWidth = true
+			}
+		}
+
+		return hasHeight && hasWidth
+	}
+
+	return false
+}
+
+func hasRequiredAttributes(tagName string, attributes []string) bool {
+	elements := make(map[string][]string)
+	elements["a"] = []string{"href"}
+	elements["iframe"] = []string{"src"}
+	elements["img"] = []string{"src"}
+	elements["source"] = []string{"src"}
+
+	for element, attrs := range elements {
+		if tagName == element {
+			for _, attribute := range attributes {
+				for _, attr := range attrs {
+					if attr == attribute {
+						return true
+					}
+				}
+			}
+
+			return false
+		}
+	}
+
+	return true
+}
+
+func hasValidScheme(src string) bool {
+	// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
+	whitelist := []string{
+		"apt://",
+		"bitcoin://",
+		"callto://",
+		"ed2k://",
+		"facetime://",
+		"feed://",
+		"ftp://",
+		"geo://",
+		"gopher://",
+		"git://",
+		"http://",
+		"https://",
+		"irc://",
+		"irc6://",
+		"ircs://",
+		"itms://",
+		"jabber://",
+		"magnet://",
+		"mailto://",
+		"maps://",
+		"news://",
+		"nfs://",
+		"nntp://",
+		"rtmp://",
+		"sip://",
+		"sips://",
+		"skype://",
+		"smb://",
+		"sms://",
+		"spotify://",
+		"ssh://",
+		"sftp://",
+		"steam://",
+		"svn://",
+		"tel://",
+		"webcal://",
+		"xmpp://",
+	}
+
+	for _, prefix := range whitelist {
+		if strings.HasPrefix(src, prefix) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isBlacklistedResource(src string) bool {
+	blacklist := []string{
+		"feedsportal.com",
+		"api.flattr.com",
+		"stats.wordpress.com",
+		"plus.google.com/share",
+		"twitter.com/share",
+		"feeds.feedburner.com",
+	}
+
+	for _, element := range blacklist {
+		if strings.Contains(src, element) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isValidIframeSource(src string) bool {
+	whitelist := []string{
+		"http://www.youtube.com",
+		"https://www.youtube.com",
+		"http://player.vimeo.com",
+		"https://player.vimeo.com",
+		"http://www.dailymotion.com",
+		"https://www.dailymotion.com",
+		"http://vk.com",
+		"https://vk.com",
+	}
+
+	for _, prefix := range whitelist {
+		if strings.HasPrefix(src, prefix) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func getTagWhitelist() map[string][]string {
+	whitelist := make(map[string][]string)
+	whitelist["img"] = []string{"alt", "title", "src"}
+	whitelist["audio"] = []string{"src"}
+	whitelist["video"] = []string{"poster", "height", "width", "src"}
+	whitelist["source"] = []string{"src", "type"}
+	whitelist["dt"] = []string{}
+	whitelist["dd"] = []string{}
+	whitelist["dl"] = []string{}
+	whitelist["table"] = []string{}
+	whitelist["caption"] = []string{}
+	whitelist["thead"] = []string{}
+	whitelist["tfooter"] = []string{}
+	whitelist["tr"] = []string{}
+	whitelist["td"] = []string{"rowspan", "colspan"}
+	whitelist["th"] = []string{"rowspan", "colspan"}
+	whitelist["h1"] = []string{}
+	whitelist["h2"] = []string{}
+	whitelist["h3"] = []string{}
+	whitelist["h4"] = []string{}
+	whitelist["h5"] = []string{}
+	whitelist["h6"] = []string{}
+	whitelist["strong"] = []string{}
+	whitelist["em"] = []string{}
+	whitelist["code"] = []string{}
+	whitelist["pre"] = []string{}
+	whitelist["blockquote"] = []string{}
+	whitelist["q"] = []string{"cite"}
+	whitelist["p"] = []string{}
+	whitelist["ul"] = []string{}
+	whitelist["li"] = []string{}
+	whitelist["ol"] = []string{}
+	whitelist["br"] = []string{}
+	whitelist["del"] = []string{}
+	whitelist["a"] = []string{"href", "title"}
+	whitelist["figure"] = []string{}
+	whitelist["figcaption"] = []string{}
+	whitelist["cite"] = []string{}
+	whitelist["time"] = []string{"datetime"}
+	whitelist["abbr"] = []string{"title"}
+	whitelist["acronym"] = []string{"title"}
+	whitelist["wbr"] = []string{}
+	whitelist["dfn"] = []string{}
+	whitelist["sub"] = []string{}
+	whitelist["sup"] = []string{}
+	whitelist["var"] = []string{}
+	whitelist["samp"] = []string{}
+	whitelist["s"] = []string{}
+	whitelist["del"] = []string{}
+	whitelist["ins"] = []string{}
+	whitelist["kbd"] = []string{}
+	whitelist["rp"] = []string{}
+	whitelist["rt"] = []string{}
+	whitelist["rtc"] = []string{}
+	whitelist["ruby"] = []string{}
+	whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
+	return whitelist
+}
+
+func inList(needle string, haystack []string) bool {
+	for _, element := range haystack {
+		if element == needle {
+			return true
+		}
+	}
+
+	return false
+}

+ 144 - 0
reader/sanitizer/sanitizer_test.go

@@ -0,0 +1,144 @@
+// 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 sanitizer
+
+import "testing"
+
+func TestValidInput(t *testing.T) {
+	input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test">.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if input != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
+	}
+}
+
+func TestSelfClosingTags(t *testing.T) {
+	input := `<p>This <br> is a <strong>text</strong> <br/>with an image: <img src="http://example.org/" alt="Test"/>.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if input != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
+	}
+}
+
+func TestTable(t *testing.T) {
+	input := `<table><tr><th>A</th><th colspan="2">B</th></tr><tr><td>C</td><td>D</td><td>E</td></tr></table>`
+	output := Sanitize("http://example.org/", input)
+
+	if input != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
+	}
+}
+
+func TestRelativeURL(t *testing.T) {
+	input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png"/>`
+	expected := `This <a href="http://example.org/test.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a> and this image: <img src="http://example.org/folder/image.png"/>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestProtocolRelativeURL(t *testing.T) {
+	input := `This <a href="//static.example.org/index.html">link is relative</a>.`
+	expected := `This <a href="https://static.example.org/index.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a>.`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestInvalidTag(t *testing.T) {
+	input := `<p>My invalid <b>tag</b>.</p>`
+	expected := `<p>My invalid tag.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestVideoTag(t *testing.T) {
+	input := `<p>My valid <video src="videofile.webm" autoplay poster="posterimage.jpg">fallback</video>.</p>`
+	expected := `<p>My valid <video src="http://example.org/videofile.webm" poster="http://example.org/posterimage.jpg" controls>fallback</video>.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestAudioAndSourceTag(t *testing.T) {
+	input := `<p>My music <audio controls="controls"><source src="foo.wav" type="audio/wav"></audio>.</p>`
+	expected := `<p>My music <audio controls><source src="http://example.org/foo.wav" type="audio/wav"></audio>.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestUnknownTag(t *testing.T) {
+	input := `<p>My invalid <unknown>tag</unknown>.</p>`
+	expected := `<p>My invalid tag.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestInvalidNestedTag(t *testing.T) {
+	input := `<p>My invalid <b>tag with some <em>valid</em> tag</b>.</p>`
+	expected := `<p>My invalid tag with some <em>valid</em> tag.</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestInvalidIFrame(t *testing.T) {
+	input := `<iframe src="http://example.org/"></iframe>`
+	expected := ``
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestInvalidURLScheme(t *testing.T) {
+	input := `<p>This link is <a src="file:///etc/passwd">not valid</a></p>`
+	expected := `<p>This link is not valid</p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestBlacklistedLink(t *testing.T) {
+	input := `<p>This image is not valid <img src="https://stats.wordpress.com/some-tracker"></p>`
+	expected := `<p>This image is not valid </p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}
+
+func TestPixelTracker(t *testing.T) {
+	input := `<p><img src="https://tracker1.example.org/" height="1" width="1"> and <img src="https://tracker2.example.org/" height="1" width="1"/></p>`
+	expected := `<p> and </p>`
+	output := Sanitize("http://example.org/", input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}

+ 35 - 0
reader/sanitizer/strip_tags.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 sanitizer
+
+import (
+	"bytes"
+	"io"
+
+	"golang.org/x/net/html"
+)
+
+// StripTags removes all HTML/XML tags from the input string.
+func StripTags(input string) string {
+	tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
+	var buffer bytes.Buffer
+
+	for {
+		if tokenizer.Next() == html.ErrorToken {
+			err := tokenizer.Err()
+			if err == io.EOF {
+				return buffer.String()
+			}
+
+			return ""
+		}
+
+		token := tokenizer.Token()
+		switch token.Type {
+		case html.TextToken:
+			buffer.WriteString(token.Data)
+		}
+	}
+}

+ 17 - 0
reader/sanitizer/strip_tags_test.go

@@ -0,0 +1,17 @@
+// 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 sanitizer
+
+import "testing"
+
+func TestStripTags(t *testing.T) {
+	input := `This <a href="/test.html">link is relative</a> and <strong>this</strong> image: <img src="../folder/image.png"/>`
+	expected := `This link is relative and this image: `
+	output := StripTags(input)
+
+	if expected != output {
+		t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
+	}
+}

+ 96 - 0
reader/subscription/finder.go

@@ -0,0 +1,96 @@
+// 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 subscription
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/miniflux/miniflux2/errors"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/reader/feed"
+	"github.com/miniflux/miniflux2/reader/http"
+	"github.com/miniflux/miniflux2/reader/url"
+	"io"
+	"log"
+	"time"
+
+	"github.com/PuerkitoBio/goquery"
+)
+
+var (
+	errConnectionFailure = "Unable to open this link: %v"
+	errUnreadableDoc     = "Unable to analyze this page: %v"
+)
+
+// FindSubscriptions downloads and try to find one or more subscriptions from an URL.
+func FindSubscriptions(websiteURL string) (Subscriptions, error) {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[FindSubscriptions] url=%s", websiteURL))
+
+	client := http.NewHttpClient(websiteURL)
+	response, err := client.Get()
+	if err != nil {
+		return nil, errors.NewLocalizedError(errConnectionFailure, err)
+	}
+
+	var buffer bytes.Buffer
+	io.Copy(&buffer, response.Body)
+	reader := bytes.NewReader(buffer.Bytes())
+
+	if format := feed.DetectFeedFormat(reader); format != feed.FormatUnknown {
+		var subscriptions Subscriptions
+		subscriptions = append(subscriptions, &Subscription{
+			Title: response.EffectiveURL,
+			URL:   response.EffectiveURL,
+			Type:  format,
+		})
+
+		return subscriptions, nil
+	}
+
+	reader.Seek(0, io.SeekStart)
+	return parseDocument(response.EffectiveURL, bytes.NewReader(buffer.Bytes()))
+}
+
+func parseDocument(websiteURL string, data io.Reader) (Subscriptions, error) {
+	var subscriptions Subscriptions
+	queries := map[string]string{
+		"link[type='application/rss+xml']":  "rss",
+		"link[type='application/atom+xml']": "atom",
+		"link[type='application/json']":     "json",
+	}
+
+	doc, err := goquery.NewDocumentFromReader(data)
+	if err != nil {
+		return nil, errors.NewLocalizedError(errUnreadableDoc, err)
+	}
+
+	for query, kind := range queries {
+		doc.Find(query).Each(func(i int, s *goquery.Selection) {
+			subscription := new(Subscription)
+			subscription.Type = kind
+
+			if title, exists := s.Attr("title"); exists {
+				subscription.Title = title
+			} else {
+				subscription.Title = "Feed"
+			}
+
+			if feedURL, exists := s.Attr("href"); exists {
+				subscription.URL, _ = url.GetAbsoluteURL(websiteURL, feedURL)
+			}
+
+			if subscription.Title == "" {
+				subscription.Title = subscription.URL
+			}
+
+			if subscription.URL != "" {
+				log.Println("[FindSubscriptions]", subscription)
+				subscriptions = append(subscriptions, subscription)
+			}
+		})
+	}
+
+	return subscriptions, nil
+}

+ 21 - 0
reader/subscription/subscription.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 subscription
+
+import "fmt"
+
+// Subscription represents a feed subscription.
+type Subscription struct {
+	Title string `json:"title"`
+	URL   string `json:"url"`
+	Type  string `json:"type"`
+}
+
+func (s Subscription) String() string {
+	return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
+}
+
+// Subscriptions represents a list of subscription.
+type Subscriptions []*Subscription

+ 61 - 0
reader/url/url.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 url
+
+import "net/url"
+import "fmt"
+import "strings"
+
+// GetAbsoluteURL converts the input URL as absolute URL if necessary.
+func GetAbsoluteURL(baseURL, input string) (string, error) {
+	if strings.HasPrefix(input, "//") {
+		input = "https://" + input[2:]
+	}
+
+	u, err := url.Parse(input)
+	if err != nil {
+		return "", fmt.Errorf("unable to parse input URL: %v", err)
+	}
+
+	if u.IsAbs() {
+		return u.String(), nil
+	}
+
+	base, err := url.Parse(baseURL)
+	if err != nil {
+		return "", fmt.Errorf("unable to parse base URL: %v", err)
+	}
+
+	return base.ResolveReference(u).String(), nil
+}
+
+// GetRootURL returns absolute URL without the path.
+func GetRootURL(websiteURL string) string {
+	if strings.HasPrefix(websiteURL, "//") {
+		websiteURL = "https://" + websiteURL[2:]
+	}
+
+	absoluteURL, err := GetAbsoluteURL(websiteURL, "")
+	if err != nil {
+		return websiteURL
+	}
+
+	u, err := url.Parse(absoluteURL)
+	if err != nil {
+		return absoluteURL
+	}
+
+	return u.Scheme + "://" + u.Host + "/"
+}
+
+// IsHTTPS returns true if the URL is using HTTPS.
+func IsHTTPS(websiteURL string) bool {
+	parsedURL, err := url.Parse(websiteURL)
+	if err != nil {
+		return false
+	}
+
+	return strings.ToLower(parsedURL.Scheme) == "https"
+}

+ 107 - 0
reader/url/url_test.go

@@ -0,0 +1,107 @@
+package url
+
+import "testing"
+
+func TestGetAbsoluteURLWithAbsolutePath(t *testing.T) {
+	expected := `https://example.org/path/file.ext`
+	input := `/path/file.ext`
+	output, err := GetAbsoluteURL("https://example.org/folder/", input)
+
+	if err != nil {
+		t.Error(err)
+	}
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestGetAbsoluteURLWithRelativePath(t *testing.T) {
+	expected := `https://example.org/folder/path/file.ext`
+	input := `path/file.ext`
+	output, err := GetAbsoluteURL("https://example.org/folder/", input)
+
+	if err != nil {
+		t.Error(err)
+	}
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestGetAbsoluteURLWithRelativePaths(t *testing.T) {
+	expected := `https://example.org/path/file.ext`
+	input := `path/file.ext`
+	output, err := GetAbsoluteURL("https://example.org/folder", input)
+
+	if err != nil {
+		t.Error(err)
+	}
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestWhenInputIsAlreadyAbsolute(t *testing.T) {
+	expected := `https://example.org/path/file.ext`
+	input := `https://example.org/path/file.ext`
+	output, err := GetAbsoluteURL("https://example.org/folder/", input)
+
+	if err != nil {
+		t.Error(err)
+	}
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestGetAbsoluteURLWithProtocolRelative(t *testing.T) {
+	expected := `https://static.example.org/path/file.ext`
+	input := `//static.example.org/path/file.ext`
+	output, err := GetAbsoluteURL("https://www.example.org/", input)
+
+	if err != nil {
+		t.Error(err)
+	}
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestGetRootURL(t *testing.T) {
+	expected := `https://example.org/`
+	input := `https://example.org/path/file.ext`
+	output := GetRootURL(input)
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestGetRootURLWithProtocolRelativePath(t *testing.T) {
+	expected := `https://static.example.org/`
+	input := `//static.example.org/path/file.ext`
+	output := GetRootURL(input)
+
+	if expected != output {
+		t.Errorf(`Unexpected output, got "%s" instead of "%s"`, output, expected)
+	}
+}
+
+func TestIsHTTPS(t *testing.T) {
+	if !IsHTTPS("https://example.org/") {
+		t.Error("Unable to recognize HTTPS URL")
+	}
+
+	if IsHTTPS("http://example.org/") {
+		t.Error("Unable to recognize HTTP URL")
+	}
+
+	if IsHTTPS("") {
+		t.Error("Unable to recognize malformed URL")
+	}
+}

+ 24 - 0
scheduler/scheduler.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 scheduler
+
+import (
+	"github.com/miniflux/miniflux2/storage"
+	"log"
+	"time"
+)
+
+// NewScheduler starts a new scheduler to push jobs to a pool of workers.
+func NewScheduler(store *storage.Storage, workerPool *WorkerPool, frequency, batchSize int) {
+	c := time.Tick(time.Duration(frequency) * time.Minute)
+	for now := range c {
+		jobs := store.GetJobs(batchSize)
+		log.Printf("[Scheduler:%v] => Pushing %d jobs\n", now, len(jobs))
+
+		for _, job := range jobs {
+			workerPool.Push(job)
+		}
+	}
+}

+ 35 - 0
scheduler/worker.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 scheduler
+
+import (
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed"
+	"log"
+	"time"
+)
+
+// A Worker refresh a feed in the background.
+type Worker struct {
+	id          int
+	feedHandler *feed.Handler
+}
+
+// Run wait for a job and refresh the given feed.
+func (w *Worker) Run(c chan model.Job) {
+	log.Printf("[Worker] #%d started\n", w.id)
+
+	for {
+		job := <-c
+		log.Printf("[Worker #%d] got userID=%d, feedID=%d\n", w.id, job.UserID, job.FeedID)
+
+		err := w.feedHandler.RefreshFeed(job.UserID, job.FeedID)
+		if err != nil {
+			log.Println("Worker:", err)
+		}
+
+		time.Sleep(time.Millisecond * 1000)
+	}
+}

+ 34 - 0
scheduler/worker_pool.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 scheduler
+
+import (
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed"
+)
+
+// WorkerPool handle a pool of workers.
+type WorkerPool struct {
+	queue chan model.Job
+}
+
+// Push send a job on the queue.
+func (w *WorkerPool) Push(job model.Job) {
+	w.queue <- job
+}
+
+// NewWorkerPool creates a pool of background workers.
+func NewWorkerPool(feedHandler *feed.Handler, nbWorkers int) *WorkerPool {
+	workerPool := &WorkerPool{
+		queue: make(chan model.Job),
+	}
+
+	for i := 0; i < nbWorkers; i++ {
+		worker := &Worker{id: i, feedHandler: feedHandler}
+		go worker.Run(workerPool.queue)
+	}
+
+	return workerPool
+}

+ 97 - 0
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()
+}

+ 21 - 0
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}
+}

+ 156 - 0
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)
+}

+ 138 - 0
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()
+}

+ 35 - 0
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)
+}

+ 163 - 0
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()
+}

+ 93 - 0
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
+}

+ 99 - 0
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}
+}

+ 57 - 0
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,
+	}
+}

+ 58 - 0
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"))
+}

+ 94 - 0
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
+}

+ 108 - 0
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}
+}

+ 63 - 0
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}
+}

+ 21 - 0
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))
+}

+ 61 - 0
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}
+}

+ 48 - 0
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))
+		}
+	})
+}

+ 31 - 0
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...)}
+}

+ 72 - 0
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}
+}

+ 37 - 0
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()
+}

+ 132 - 0
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
+}

+ 33 - 0
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
+}

File diff suppressed because it is too large
+ 6 - 0
server/static/bin.go


BIN
server/static/bin/favicon.ico


File diff suppressed because it is too large
+ 7 - 0
server/static/css.go


+ 197 - 0
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;
+}

+ 654 - 0
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%;
+}

+ 52 - 0
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",
+}

+ 351 - 0
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();
+});
+
+})();

+ 111 - 0
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",
+}

+ 21 - 0
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.

+ 61 - 0
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)))
+	}
+}

+ 37 - 0
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)
+		}
+	}
+}

+ 37 - 0
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 }}

Some files were not shown because too many files changed in this diff