commit
8ffb773f43
2121 changed files with 1118910 additions and 0 deletions
@ -0,0 +1,2 @@ |
||||
miniflux-linux-amd64 |
||||
miniflux-darwin-amd64 |
@ -0,0 +1,5 @@ |
||||
language: go |
||||
go: |
||||
- 1.9 |
||||
script: |
||||
- go test -cover -race ./... |
@ -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 |
@ -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" |
@ -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 |
@ -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 ./...
|
@ -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 |
@ -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{} |
||||
} |
@ -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} |
||||
} |
@ -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") |
||||
} |
@ -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)) |
||||
} |
@ -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) |
||||
} |
@ -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...) |
||||
} |
@ -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", |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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 |
||||
}, |
||||
} |
@ -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", |
||||
} |
@ -0,0 +1,10 @@ |
||||
{ |
||||
"plural.feed.error_count": [ |
||||
"%d error", |
||||
"%d errors" |
||||
], |
||||
"plural.categories.feed_count": [ |
||||
"There is %d feed.", |
||||
"There are %d feeds." |
||||
] |
||||
} |
@ -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" |
||||
} |
@ -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)} |
||||
} |
@ -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) |
||||
} |
@ -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 |
@ -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 |
@ -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" |
||||
} |
@ -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 |
@ -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"` |
||||
} |
@ -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 |
||||
} |
@ -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 |
@ -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", |
||||
} |
||||
} |
@ -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 |
@ -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 "" |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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} |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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") |
||||
} |
||||
} |
@ -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 <a href="http://howe.iki.rssi.ru/GCTC/gctc_e.htm">Star City</a>.</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") |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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 <a href="http://howe.iki.rssi.ru/GCTC/gctc_e.htm">Star City</a>.</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 <a href="http://science.nasa.gov/headlines/y2003/30may_solareclipse.htm">partial eclipse of the Sun</a> 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) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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} |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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} |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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&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 > Business" description="Find breaking news & business news on Wall Street, media & advertising, international business, banking, interest rates, the stock market, currencies & funds." htmlUrl="http://www.nytimes.com/pages/business/index.html?partner=rssnyt" language="unknown" title="NYT > Business" type="rss" version="RSS2" xmlUrl="http://www.nytimes.com/services/xml/rss/nyt/Business.xml"/> |
||||
<outline text="NYT > Technology" description="" htmlUrl="http://www.nytimes.com/pages/technology/index.html?partner=rssnyt" language="unknown" title="NYT > 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) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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]) |
||||
} |
||||
} |
||||
} |
@ -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 |
@ -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) |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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 |
@ -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" |
||||
} |
@ -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") |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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() |
||||
} |
@ -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} |
||||
} |
@ -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) |
||||
} |
@ -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() |
||||
} |
@ -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) |
||||
} |
@ -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() |
||||
} |
@ -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 |
||||
} |
@ -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} |
||||
} |
@ -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, |
||||
} |
||||
} |
@ -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")) |
||||
} |
@ -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 |
||||
} |
@ -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} |
||||
} |
@ -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} |
||||
} |
@ -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)) |
||||
} |
@ -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} |
||||
} |
@ -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)) |
||||
} |
||||
}) |
||||
} |
@ -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...)} |
||||
} |
@ -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} |
||||
} |
@ -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() |
||||
} |
@ -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 |
||||
} |
@ -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 one or more lines are too long
After Width: | Height: | Size: 17 KiB |
File diff suppressed because one or more lines are too long
@ -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; |
||||
} |
@ -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%; |
||||
} |
@ -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", |
||||
} |
@ -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(); |
||||
}); |
||||
|
||||
})(); |
@ -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", |
||||
} |
@ -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. |
@ -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))) |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
||||
} |
@ -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 have changed in this diff Show More
Loading…
Reference in new issue