Compare commits

...

32 Commits

Author SHA1 Message Date
Frédéric Guillot ccb7306dcb Update ChangeLog 2018-02-05 20:29:42 -08:00
Frédéric Guillot 12ff562d31 Add support for Let's Encrypt http-01 challenge 2018-02-04 18:05:45 -08:00
Frédéric Guillot 3884a33b36 Move template functions outside engine (refactoring) 2018-02-04 15:45:07 -08:00
Frédéric Guillot b5b1930599 Take timezone into consideration when calculating relative time 2018-02-04 14:37:42 -08:00
Frédéric Guillot 61bc012a62 Add support for HTTP Strict Transport Security header 2018-02-03 15:54:02 -08:00
Frédéric Guillot 9c42997209 Add support for base URLs with subfolders 2018-02-03 15:33:17 -08:00
stratmaster 78385a351e Update German translation 2018-02-01 20:46:53 -08:00
Frédéric Guillot 32cec5bad0 Add missing about menu in settings 2018-01-31 22:03:21 -08:00
Frédéric Guillot b78172033f Show API URL endpoints in user interface 2018-01-31 21:57:20 -08:00
Frédéric Guillot b0442e0277 Do not update entry date while refreshing a feed
We do not update the published date because some feeds do not contains any date,
in this case it default to time.Now(), which could change the order of items on
the history page.
2018-01-29 21:07:55 -08:00
Frédéric Guillot bca717873e Enable debug mode for integration tests 2018-01-29 20:58:11 -08:00
Frédéric Guillot 23d2d9109f Add the possiblity to enable debug mode with an environment variable 2018-01-29 20:54:38 -08:00
Frédéric Guillot c8138351c9 Keep code base tidy 2018-01-29 20:47:09 -08:00
Rogier Lommers 24a2f472ec Add flag to enable debug logging 2018-01-29 20:34:00 -08:00
Mahendra Kalkura 40eb1b10fe Improve unread counter updates 2018-01-25 20:13:38 -08:00
Frédéric Guillot 26af3f98d2 Add ChangeLog 2018-01-22 21:03:46 -08:00
Frédéric Guillot 577bcddb2a Change user agent 2018-01-22 20:43:03 -08:00
Frédéric Guillot da5c8c2edb Move environment variables to config package 2018-01-20 16:49:47 -08:00
Frédéric Guillot ffabb009b8 Do not override existing entries when the crawler is enabled 2018-01-20 14:04:19 -08:00
Frédéric Guillot 208cf9892d Compile all ARM architectures 2018-01-20 13:25:21 -08:00
Frédéric Guillot 713b38e34c Handle more encoding edge cases
- Feeds with charset specified only in Content-Type header and not in XML document
- Feeds with charset specified in both places
- Feeds with charset specified only in XML document and not in HTTP header
2018-01-20 13:25:21 -08:00
Frédéric Guillot 3b62f904d6 Do not crawl existing entry URLs 2018-01-20 13:25:20 -08:00
stratmaster 09785df07f Update German translation strings 2018-01-20 13:25:20 -08:00
Frédéric Guillot bdcc4134fa Store language in session to show the login page translated 2018-01-20 13:25:20 -08:00
Frédéric Guillot 6302c3b219 Add mark as read/unread link on list items 2018-01-20 13:25:05 -08:00
stratmaster 68508ff800 Updated Strings for Keyboard Shortcuts & added missing strings
- added missing String for "Pages Navigation", "Logged as" and "Unread Items"
- fixed some other strings
2018-01-17 22:05:25 -08:00
stratmaster 45f0d3bed3 Update German translation
Since "Next/Previous" are used for switching between single articles and whole pages, the translation has to be more universal. Furthermore unification for Star/Favorites as "Lesezeichen" and items/articels as "Artikel". Some other small fixes.
2018-01-17 11:08:57 -08:00
stratmaster 13db40f6bb Update German translation 2018-01-16 18:01:24 -08:00
Frédéric Guillot c2fd2e747a Add functions to get config values 2018-01-15 18:08:30 -08:00
stratmaster d7f66ffa5c Add German translation 2018-01-15 17:23:47 -08:00
Frédéric Guillot 631e0a2e20 Add integration test for export API endpoint 2018-01-12 18:16:51 -08:00
Rogier Lommers 4aec2453f4 Add API endpoint for OPML export 2018-01-12 13:42:36 -08:00
107 changed files with 3727 additions and 1039 deletions
+2 -3
View File
@@ -1,5 +1,4 @@
miniflux-linux-amd64
miniflux-linux-arm64
miniflux-linux-arm
miniflux-linux-arm*
miniflux-darwin-amd64
miniflux-test
miniflux-test
+34
View File
@@ -0,0 +1,34 @@
Version 2.0.2 (Feb 5, 2018)
---------------------------
* Add support for Let's Encrypt http-01 challenge
* Move template functions outside engine (refactoring)
* Take timezone into consideration when calculating relative time
* Add support for HTTP Strict Transport Security header
* Add support for base URLs with subfolders
* Add missing about menu in settings
* Show API URL endpoints in user interface
* Do not update entry date while refreshing a feed
* Add flag to toggle debug logging
* Improve unread counter updates
Version 2.0.1 (Jan 22, 2018)
----------------------------
* Change user agent (People are blocking the crawler with mod_security)
* Move environment variables to config package (refactoring)
* Add build targets for all ARM architectures
* Do not crawl existing entry URLs
* Show translated login page in user language when logged out
* Handle more encoding edge cases:
- Feeds with charset specified only in Content-Type header and not in XML document
- Feeds with charset specified in both places
- Feeds with charset specified only in XML document and not in HTTP header
* Add German translation
* Add mark as read/unread link on list items
* Add API endpoint for OPML export
Version 2.0.0 (Jan 11, 2018)
----------------------------
* Initial release of Miniflux 2.
Generated
+69 -11
View File
@@ -34,24 +34,38 @@
[[projects]]
branch = "master"
name = "github.com/lib/pq"
packages = [".","hstore","oid"]
packages = [
".",
"hstore",
"oid"
]
revision = "83612a56d3dd153a94a629cd64925371c9adad78"
[[projects]]
branch = "master"
name = "github.com/miniflux/miniflux-go"
packages = ["."]
revision = "3d654932d84b6afdbd5e66b34b08392f62229e61"
revision = "887ba3b062946784f0e64edb1734f435beb204f9"
[[projects]]
name = "github.com/tdewolff/minify"
packages = [".","css","js"]
packages = [
".",
"css",
"js"
]
revision = "222672169d634c440a73abc47685074e1a9daa60"
version = "v2.3.4"
[[projects]]
name = "github.com/tdewolff/parse"
packages = [".","buffer","css","js","strconv"]
packages = [
".",
"buffer",
"css",
"js",
"strconv"
]
revision = "639f6272aec6b52094db77b9ec488214b0b4b1a1"
version = "v2.3.2"
@@ -64,36 +78,80 @@
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["acme","acme/autocert","bcrypt","blowfish","ssh/terminal"]
revision = "94eea52f7b742c7cbe0b03b22f0c4c8631ece122"
packages = [
"acme",
"acme/autocert",
"bcrypt",
"blowfish",
"ssh/terminal"
]
revision = "1875d0a70c90e57f11972aefd42276df65e895b9"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = ["context","context/ctxhttp","html","html/atom","html/charset"]
packages = [
"context",
"context/ctxhttp",
"html",
"html/atom",
"html/charset"
]
revision = "d866cfc389cec985d6fda2859936a575a55a3ab6"
[[projects]]
branch = "master"
name = "golang.org/x/oauth2"
packages = [".","internal"]
packages = [
".",
"internal"
]
revision = "462316686f20eb6df426961c1c131bdaa5dfa68e"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix","windows"]
packages = [
"unix",
"windows"
]
revision = "571f7bbbe08da2a8955aed9d4db316e78630e9a3"
[[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"]
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 = "d5a9226ed7dd70cade6ccae9d37517fe14dd9fee"
[[projects]]
name = "google.golang.org/appengine"
packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"]
packages = [
"internal",
"internal/base",
"internal/datastore",
"internal/log",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
+6 -4
View File
@@ -12,8 +12,10 @@ linux:
linux-arm:
@ go generate
@ GOOS=linux GOARCH=arm64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-arm64 main.go
@ GOOS=linux GOARCH=arm go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-arm main.go
@ GOOS=linux GOARCH=arm64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv8 main.go
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv7 main.go
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv6 main.go
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv5 main.go
darwin:
@ go generate
@@ -23,7 +25,7 @@ build: linux linux-arm darwin
run:
@ go generate
@ go run main.go
@ go run main.go -debug
clean:
@ rm -f $(APP)-*
@@ -40,7 +42,7 @@ integration-test:
DATABASE_URL=$(DB_URL) go run main.go -migrate
DATABASE_URL=$(DB_URL) ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go -create-admin
go build -o miniflux-test main.go
DATABASE_URL=$(DB_URL) ./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
DATABASE_URL=$(DB_URL) ./miniflux-test -debug >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! echo exit | nc localhost 8080; do sleep 1; done >/dev/null
go test -v -tags=integration || cat /tmp/miniflux.log
+14
View File
@@ -7,6 +7,8 @@ package api
import (
"errors"
"github.com/miniflux/miniflux/reader/opml"
"github.com/miniflux/miniflux/http/handler"
)
@@ -132,6 +134,18 @@ func (c *Controller) GetFeeds(ctx *handler.Context, request *handler.Request, re
response.JSON().Standard(feeds)
}
// Export is the API handler that incoves an OPML export.
func (c *Controller) Export(ctx *handler.Context, request *handler.Request, response *handler.Response) {
opmlHandler := opml.NewHandler(c.store)
opml, err := opmlHandler.Export(ctx.LoggedUser().ID)
if err != nil {
response.JSON().ServerError(errors.New("unable to export feeds to OPML"))
}
response.XML().Serve(opml)
}
// GetFeed is the API handler to get a feed.
func (c *Controller) GetFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
+9 -2
View File
@@ -8,6 +8,8 @@ import (
"flag"
"fmt"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/daemon"
"github.com/miniflux/miniflux/storage"
@@ -22,12 +24,13 @@ func Parse() {
flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
flagResetPassword := flag.Bool("reset-password", false, "Reset user password")
flagDebugMode := flag.Bool("debug", false, "Enable debug mode (more verbose output)")
flag.Parse()
cfg := config.NewConfig()
store := storage.NewStorage(
cfg.Get("DATABASE_URL", config.DefaultDatabaseURL),
cfg.GetInt("DATABASE_MAX_CONNS", config.DefaultDatabaseMaxConns),
cfg.DatabaseURL(),
cfg.DatabaseMaxConnections(),
)
if *flagInfo {
@@ -60,5 +63,9 @@ func Parse() {
return
}
if *flagDebugMode || cfg.HasDebugMode() {
logger.EnableDebug()
}
daemon.Run(cfg, store)
}
+139 -18
View File
@@ -5,33 +5,35 @@
package config
import (
"net/url"
"os"
"strconv"
)
// Default config parameters values
const (
DefaultBaseURL = "http://localhost"
DefaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
DefaultWorkerPoolSize = 5
DefaultPollingFrequency = 60
DefaultBatchSize = 10
DefaultDatabaseMaxConns = 20
DefaultListenAddr = "127.0.0.1:8080"
DefaultCertFile = ""
DefaultKeyFile = ""
DefaultCertDomain = ""
DefaultCertCache = "/tmp/cert_cache"
DefaultSessionCleanupFrequency = 24
defaultBaseURL = "http://localhost"
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultDatabaseMaxConns = 20
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultSessionCleanupFrequency = 24
)
// Config manages configuration parameters.
type Config struct {
IsHTTPS bool
IsHTTPS bool
baseURL string
rootURL string
basePath string
}
// Get returns a config parameter value.
func (c *Config) Get(key, fallback string) string {
func (c *Config) get(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
@@ -40,8 +42,7 @@ func (c *Config) Get(key, fallback string) string {
return value
}
// GetInt returns a config parameter as integer.
func (c *Config) GetInt(key string, fallback int) int {
func (c *Config) getInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
@@ -51,6 +52,126 @@ func (c *Config) GetInt(key string, fallback int) int {
return v
}
// HasDebugMode returns true if debug mode is enabled.
func (c *Config) HasDebugMode() bool {
return c.get("DEBUG", "") != ""
}
// BaseURL returns the application base URL with path.
func (c *Config) BaseURL() string {
if c.baseURL == "" {
c.baseURL = c.get("BASE_URL", defaultBaseURL)
if c.baseURL[len(c.baseURL)-1:] == "/" {
c.baseURL = c.baseURL[:len(c.baseURL)-1]
}
}
return c.baseURL
}
// RootURL returns the base URL without path.
func (c *Config) RootURL() string {
if c.rootURL == "" {
u, _ := url.Parse(c.BaseURL())
u.Path = ""
c.rootURL = u.String()
}
return c.rootURL
}
// BasePath returns the application base path according to the base URL.
func (c *Config) BasePath() string {
if c.basePath == "" {
u, _ := url.Parse(c.BaseURL())
c.basePath = u.Path
}
return c.basePath
}
// DatabaseURL returns the database URL.
func (c *Config) DatabaseURL() string {
return c.get("DATABASE_URL", defaultDatabaseURL)
}
// DatabaseMaxConnections returns the number of maximum database connections.
func (c *Config) DatabaseMaxConnections() int {
return c.getInt("DATABASE_MAX_CONNS", defaultDatabaseMaxConns)
}
// ListenAddr returns the listen address for the HTTP server.
func (c *Config) ListenAddr() string {
return c.get("LISTEN_ADDR", defaultListenAddr)
}
// CertFile returns the SSL certificate filename if any.
func (c *Config) CertFile() string {
return c.get("CERT_FILE", defaultCertFile)
}
// KeyFile returns the private key filename for custom SSL certificate.
func (c *Config) KeyFile() string {
return c.get("KEY_FILE", defaultKeyFile)
}
// CertDomain returns the domain to use for Let's Encrypt certificate.
func (c *Config) CertDomain() string {
return c.get("CERT_DOMAIN", defaultCertDomain)
}
// CertCache returns the directory to use for Let's Encrypt session cache.
func (c *Config) CertCache() string {
return c.get("CERT_CACHE", defaultCertCache)
}
// SessionCleanupFrequency returns the interval for session cleanup.
func (c *Config) SessionCleanupFrequency() int {
return c.getInt("SESSION_CLEANUP_FREQUENCY", defaultSessionCleanupFrequency)
}
// WorkerPoolSize returns the number of background worker.
func (c *Config) WorkerPoolSize() int {
return c.getInt("WORKER_POOL_SIZE", defaultWorkerPoolSize)
}
// PollingFrequency returns the interval to refresh feeds in the background.
func (c *Config) PollingFrequency() int {
return c.getInt("POLLING_FREQUENCY", defaultPollingFrequency)
}
// BatchSize returns the number of feeds to send for background processing.
func (c *Config) BatchSize() int {
return c.getInt("BATCH_SIZE", defaultBatchSize)
}
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
func (c *Config) IsOAuth2UserCreationAllowed() bool {
return c.getInt("OAUTH2_USER_CREATION", 0) == 1
}
// OAuth2ClientID returns the OAuth2 Client ID.
func (c *Config) OAuth2ClientID() string {
return c.get("OAUTH2_CLIENT_ID", "")
}
// OAuth2ClientSecret returns the OAuth2 client secret.
func (c *Config) OAuth2ClientSecret() string {
return c.get("OAUTH2_CLIENT_SECRET", "")
}
// OAuth2RedirectURL returns the OAuth2 redirect URL.
func (c *Config) OAuth2RedirectURL() string {
return c.get("OAUTH2_REDIRECT_URL", "")
}
// OAuth2Provider returns the name of the OAuth2 provider configured.
func (c *Config) OAuth2Provider() string {
return c.get("OAUTH2_PROVIDER", "")
}
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (c *Config) HasHSTS() bool {
return c.get("DISABLE_HSTS", "") == ""
}
// NewConfig returns a new Config.
func NewConfig() *Config {
return &Config{IsHTTPS: os.Getenv("HTTPS") != ""}
+101
View File
@@ -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 config
import (
"os"
"testing"
)
func TestDebugModeOn(t *testing.T) {
os.Clearenv()
os.Setenv("DEBUG", "1")
cfg := NewConfig()
if !cfg.HasDebugMode() {
t.Fatalf(`Unexpected debug mode value, got "%v"`, cfg.HasDebugMode())
}
}
func TestDebugModeOff(t *testing.T) {
os.Clearenv()
cfg := NewConfig()
if cfg.HasDebugMode() {
t.Fatalf(`Unexpected debug mode value, got "%v"`, cfg.HasDebugMode())
}
}
func TestCustomBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org")
cfg := NewConfig()
if cfg.BaseURL() != "http://example.org" {
t.Fatalf(`Unexpected base URL, got "%s"`, cfg.BaseURL())
}
if cfg.RootURL() != "http://example.org" {
t.Fatalf(`Unexpected root URL, got "%s"`, cfg.RootURL())
}
if cfg.BasePath() != "" {
t.Fatalf(`Unexpected base path, got "%s"`, cfg.BasePath())
}
}
func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org/folder/")
cfg := NewConfig()
if cfg.BaseURL() != "http://example.org/folder" {
t.Fatalf(`Unexpected base URL, got "%s"`, cfg.BaseURL())
}
if cfg.RootURL() != "http://example.org" {
t.Fatalf(`Unexpected root URL, got "%s"`, cfg.BaseURL())
}
if cfg.BasePath() != "/folder" {
t.Fatalf(`Unexpected base path, got "%s"`, cfg.BasePath())
}
}
func TestDefaultBaseURL(t *testing.T) {
os.Clearenv()
cfg := NewConfig()
if cfg.BaseURL() != "http://localhost" {
t.Fatalf(`Unexpected base URL, got "%s"`, cfg.BaseURL())
}
if cfg.RootURL() != "http://localhost" {
t.Fatalf(`Unexpected root URL, got "%s"`, cfg.RootURL())
}
if cfg.BasePath() != "" {
t.Fatalf(`Unexpected base path, got "%s"`, cfg.BasePath())
}
}
func TestHSTSOn(t *testing.T) {
os.Clearenv()
cfg := NewConfig()
if !cfg.HasHSTS() {
t.Fatalf(`Unexpected HSTS value, got "%v"`, cfg.HasHSTS())
}
}
func TestHSTSOff(t *testing.T) {
os.Clearenv()
os.Setenv("DISABLE_HSTS", "1")
cfg := NewConfig()
if cfg.HasHSTS() {
t.Fatalf(`Unexpected HSTS value, got "%v"`, cfg.HasHSTS())
}
}
+4 -4
View File
@@ -27,17 +27,17 @@ func Run(cfg *config.Config, store *storage.Storage) {
signal.Notify(stop, syscall.SIGTERM)
feedHandler := feed.NewFeedHandler(store)
pool := scheduler.NewWorkerPool(feedHandler, cfg.GetInt("WORKER_POOL_SIZE", config.DefaultWorkerPoolSize))
pool := scheduler.NewWorkerPool(feedHandler, cfg.WorkerPoolSize())
server := newServer(cfg, store, pool, feedHandler)
scheduler.NewFeedScheduler(
store,
pool,
cfg.GetInt("POLLING_FREQUENCY", config.DefaultPollingFrequency),
cfg.GetInt("BATCH_SIZE", config.DefaultBatchSize),
cfg.PollingFrequency(),
cfg.BatchSize(),
)
scheduler.NewSessionScheduler(store, config.DefaultSessionCleanupFrequency)
scheduler.NewSessionScheduler(store, cfg.SessionCleanupFrequency())
<-stop
logger.Info("Shutting down the server...")
+6 -1
View File
@@ -45,7 +45,11 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
middleware.NewSessionMiddleware(cfg, store).Handler,
))
router.Handle("/fever/", feverHandler.Use(feverController.Handler))
if cfg.BasePath() != "" {
router = router.PathPrefix(cfg.BasePath()).Subrouter()
}
router.Handle("/fever/", feverHandler.Use(feverController.Handler)).Name("feverEndpoint")
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
router.Handle("/v1/users", apiHandler.Use(apiController.Users)).Methods("GET")
@@ -68,6 +72,7 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
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}/icon", apiHandler.Use(apiController.FeedIcon)).Methods("GET")
router.Handle("/v1/export", apiHandler.Use(apiController.Export)).Methods("GET")
router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET")
router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetFeedEntry)).Methods("GET")
+12 -5
View File
@@ -19,15 +19,15 @@ import (
)
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler) *http.Server {
certFile := cfg.Get("CERT_FILE", config.DefaultCertFile)
keyFile := cfg.Get("KEY_FILE", config.DefaultKeyFile)
certDomain := cfg.Get("CERT_DOMAIN", config.DefaultCertDomain)
certCache := cfg.Get("CERT_CACHE", config.DefaultCertCache)
certFile := cfg.CertFile()
keyFile := cfg.KeyFile()
certDomain := cfg.CertDomain()
certCache := cfg.CertCache()
server := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
Addr: cfg.Get("LISTEN_ADDR", config.DefaultListenAddr),
Addr: cfg.ListenAddr(),
Handler: routes(cfg, store, feedHandler, pool),
}
@@ -40,6 +40,13 @@ func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.Worke
HostPolicy: autocert.HostWhitelist(certDomain),
}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
go func() {
logger.Info(`Listening on "%s" by using auto-configured certificate for "%s"`, server.Addr, certDomain)
logger.Fatal(server.Serve(certManager.Listener()).Error())
-21
View File
@@ -1,21 +0,0 @@
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.
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
/*
Package duration implements helpers to calculate time duration.
*/
package duration
-62
View File
@@ -1,62 +0,0 @@
// 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 duration
import (
"math"
"time"
"github.com/miniflux/miniflux/locale"
)
// 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`
)
// ElapsedTime returns in a human readable format the elapsed time
// since the given datetime.
func ElapsedTime(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)))
}
}
+4 -4
View File
@@ -17,10 +17,9 @@ import (
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/timer"
"github.com/miniflux/miniflux/version"
)
// Note: Some websites have a user agent filter.
const userAgent = "Mozilla/5.0 (like Gecko, like Safari, like Chrome) - Miniflux <https://miniflux.net/>"
const requestTimeout = 300
const maxBodySize = 1024 * 1024 * 15
@@ -95,11 +94,12 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
ContentLength: resp.ContentLength,
}
logger.Debug("[HttpClient:%s] OriginalURL=%s, StatusCode=%d, ContentLength=%d, ETag=%s, LastModified=%s, EffectiveURL=%s",
logger.Debug("[HttpClient:%s] OriginalURL=%s, StatusCode=%d, ContentLength=%d, ContentType=%s, ETag=%s, LastModified=%s, EffectiveURL=%s",
request.Method,
c.url,
response.StatusCode,
resp.ContentLength,
response.ContentType,
response.ETag,
response.LastModified,
response.EffectiveURL,
@@ -135,7 +135,7 @@ func (c *Client) buildClient() http.Client {
func (c *Client) buildHeaders() http.Header {
headers := make(http.Header)
headers.Add("User-Agent", userAgent)
headers.Add("User-Agent", "Mozilla/5.0 (compatible; Miniflux/"+version.Version+"; +https://miniflux.net)")
headers.Add("Accept", "*/*")
if c.etagHeader != "" {
+11 -4
View File
@@ -19,11 +19,11 @@ const (
)
// New creates a new cookie.
func New(name, value string, isHTTPS bool) *http.Cookie {
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
@@ -31,14 +31,21 @@ func New(name, value string, isHTTPS bool) *http.Cookie {
}
// Expired returns an expired cookie.
func Expired(name string, isHTTPS bool) *http.Cookie {
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
MaxAge: -1,
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
}
}
func basePath(path string) string {
if path == "" {
return "/"
}
return path
}
+6 -3
View File
@@ -80,8 +80,12 @@ func (c *Context) LoggedUser() *model.User {
// UserLanguage get the locale used by the current logged user.
func (c *Context) UserLanguage() string {
user := c.LoggedUser()
return user.Language
if c.IsAuthenticated() {
user := c.LoggedUser()
return user.Language
}
return c.getContextStringValue(middleware.UserLanguageContextKey)
}
// Translate translates a message in the current language.
@@ -145,7 +149,6 @@ func (c *Context) getContextStringValue(key *middleware.ContextKey) string {
return v.(string)
}
logger.Error("[Core:Context] Missing key: %s", key)
return ""
}
+4 -3
View File
@@ -45,10 +45,11 @@ func (h *Handler) Use(f ControllerFunc) http.Handler {
ctx := NewContext(r, h.store, h.router, h.translator)
request := NewRequest(r)
response := NewResponse(w, r, h.template)
response := NewResponse(h.cfg, w, r, h.template)
language := ctx.UserLanguage()
if ctx.IsAuthenticated() {
h.template.SetLanguage(ctx.UserLanguage())
if language != "" {
h.template.SetLanguage(language)
} else {
h.template.SetLanguage("en_US")
}
+8 -2
View File
@@ -8,11 +8,13 @@ import (
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/template"
)
// Response handles HTTP responses.
type Response struct {
cfg *config.Config
writer http.ResponseWriter
request *http.Request
template *template.Engine
@@ -74,9 +76,13 @@ func (r *Response) commonHeaders() {
// Even if the directive "frame-src" has been deprecated in Firefox,
// we keep it to stay compatible with other browsers.
r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
if r.cfg.IsHTTPS && r.cfg.HasHSTS() {
r.writer.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
}
// NewResponse returns a new Response.
func NewResponse(w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
return &Response{writer: w, request: r, template: template}
func NewResponse(cfg *config.Config, w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
return &Response{cfg: cfg, writer: w, request: r, template: template}
}
+6
View File
@@ -21,3 +21,9 @@ func (x *XMLResponse) Download(filename, data string) {
x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
x.writer.Write([]byte(data))
}
// Serve forces the XML to be sent to browser.
func (x *XMLResponse) Serve(data string) {
x.writer.Header().Set("Content-Type", "text/xml")
x.writer.Write([]byte(data))
}
+3
View File
@@ -29,6 +29,9 @@ var (
// UserSessionTokenContextKey is the context key used to store the user session ID.
UserSessionTokenContextKey = &ContextKey{"UserSessionToken"}
// UserLanguageContextKey is the context key to store user language.
UserLanguageContextKey = &ContextKey{"UserLanguageContextKey"}
// SessionIDContextKey is the context key used to store the session ID.
SessionIDContextKey = &ContextKey{"SessionID"}
+2 -1
View File
@@ -36,7 +36,7 @@ func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
return
}
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS))
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS, s.cfg.BasePath()))
} else {
logger.Debug("[Middleware:Session] %s", session)
}
@@ -59,6 +59,7 @@ func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State)
ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage)
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+9 -1
View File
@@ -50,7 +50,15 @@ func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
route := mux.CurrentRoute(r)
switch route.GetName() {
case "login", "checkLogin", "stylesheet", "javascript", "oauth2Redirect", "oauth2Callback", "appIcon", "favicon", "webManifest":
case "login",
"checkLogin",
"stylesheet",
"javascript",
"oauth2Redirect",
"oauth2Callback",
"appIcon",
"favicon",
"webManifest":
return true
default:
return false
+17 -2
View File
@@ -6,8 +6,10 @@ package http
import (
"io"
"mime"
"strings"
"github.com/miniflux/miniflux/logger"
"golang.org/x/net/html/charset"
)
@@ -45,9 +47,22 @@ func (r *Response) IsModified(etag, lastModified string) bool {
}
// NormalizeBodyEncoding make sure the body is encoded in UTF-8.
//
// If a charset other than UTF-8 is detected, we convert the document to UTF-8.
// This is used by the scraper and feed readers.
//
// Do not forget edge cases:
// - Some non-utf8 feeds specify encoding only in Content-Type, not in XML document.
func (r *Response) NormalizeBodyEncoding() (io.Reader, error) {
if strings.Contains(r.ContentType, "charset=") {
return charset.NewReader(r.Body, r.ContentType)
_, params, err := mime.ParseMediaType(r.ContentType)
if err == nil {
if enc, found := params["charset"]; found {
enc = strings.ToLower(enc)
if enc != "utf-8" && enc != "utf8" && enc != "" {
logger.Debug("[NormalizeBodyEncoding] Convert body to UTF-8 from %s", enc)
return charset.NewReader(r.Body, r.ContentType)
}
}
}
return r.Body, nil
}
+19
View File
@@ -634,6 +634,25 @@ func TestCreateFeedWithInexistingCategory(t *testing.T) {
}
}
func TestExport(t *testing.T) {
username := getRandomUsername()
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
_, err := client.CreateUser(username, testStandardPassword, false)
if err != nil {
t.Fatal(err)
}
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
output, err := client.Export()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(output), "<?xml") {
t.Fatalf(`Invalid OPML export, got "%s"`, string(output))
}
}
func TestUpdateFeed(t *testing.T) {
username := getRandomUsername()
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
+1
View File
@@ -29,5 +29,6 @@ func AvailableLanguages() map[string]string {
return map[string]string{
"en_US": "English",
"fr_FR": "Français",
"de_DE": "Deutsch",
}
}
+229 -3
View File
@@ -1,9 +1,223 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-04 18:29:14.775838828 -0800 PST m=+0.007352060
// 2018-02-01 14:52:01.163022 +0100 CET m=+0.026333224
package locale
var translations = map[string]string{
"de_DE": `{
"plural.feed.error_count": [
"%d Error",
"%d Errors"
],
"plural.categories.feed_count": [
"Es gibt %d Abonnement.",
"Es gibt %d Abonnements."
],
"Username": "Benutzername",
"Password": "Passwort",
"Unread": "Ungelesen",
"History": "Verlauf",
"Feeds": "Abonnements",
"Categories": "Kategorien",
"Settings": "Einstellungen",
"Logout": "Abmelden",
"Next": "Nächste",
"Previous": "Vorherige",
"New Subscription": "Neues Abonnement",
"Import": "Importieren",
"Export": "Exportieren",
"There is no category. You must have at least one category.": "Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.",
"URL": "URL",
"Category": "Kategorie",
"Find a subscription": "Abonnement suchen",
"Loading...": "Lade...",
"Create a category": "Kategorie anlegen",
"There is no category.": "Es ist keine Kategorie vorhanden.",
"Edit": "Bearbeiten",
"Remove": "Entfernen",
"No feed.": "Kein Abonnement.",
"There is no article in this category.": "Es befindet sich kein Artikel in dieser Kategorie.",
"Original": "Original-Artikel",
"Mark this page as read": "Diese Seite als gelesen markieren",
"not yet": "noch nicht",
"just now": "gerade",
"1 minute ago": "vor einer Minute",
"%d minutes ago": "vor %d Minuten",
"1 hour ago": "vor einer Stunde",
"%d hours ago": "vor %d Stunden",
"yesterday": "gestern",
"%d days ago": "vor %d Tagen",
"%d weeks ago": "vor %d Wochen",
"%d months ago": "vor %d Monaten",
"%d years ago": "vor %d Jahren",
"Date": "Datum",
"IP Address": "IP Adresse",
"User Agent": "Benutzeragent",
"Actions": "Aktionen",
"Current session": "Aktuelle Sitzung",
"Sessions": "Sitzungen",
"Users": "Benutzer",
"Add user": "Benutzer anlegen",
"Choose a Subscription": "Abonnement auswählen",
"Subscribe": "Abonnieren",
"New Category": "Neue Kategorie",
"Title": "Titel",
"Save": "Speichern",
"or": "oder",
"cancel": "abbrechen",
"New User": "Neuer Benutzer",
"Confirmation": "Bestätigung",
"Administrator": "Administrator",
"Edit Category: %s": "Kategorie bearbeiten : %s",
"Update": "Aktualisieren",
"Edit Feed: %s": "Abonnement bearbeiten : %s",
"There is no category!": "Es ist keine Kategorie vorhanden!",
"Edit user: %s": "Benutzer bearbeiten: %s",
"There is no article for this feed.": "Es existiert kein Artikel für dieses Abonnement.",
"Add subscription": "Abonnement hinzufügen",
"You don't have any subscription.": "Es sind keine Abonnements vorhanden",
"Last check:": "Letzte Aktualisierung:",
"Refresh": "Aktualisieren",
"There is no history at the moment.": "Es exisitiert zur Zeit kein Verlauf.",
"OPML file": "OPML Datei",
"Sign In": "Anmeldung",
"Sign in": "Anmelden",
"Theme": "Thema",
"Timezone": "Zeitzone",
"Language": "Sprache",
"There is no unread article.": "Es existiert kein ungelesener Artikel.",
"You are the only user.": "Sie sind der einzige Benutzer.",
"Last Login": "Letzte Anmeldung",
"Yes": "Ja",
"No": "Nein",
"This feed already exists (%s).": "Diese Abonnement existiert bereits (%s).",
"Unable to fetch feed (statusCode=%d).": "Abonnement konnte nicht abgerufen werden (code=%d).",
"Unable to open this link: %v": "Dieser Link konnte nicht geöffnet werden: %v",
"Unable to analyze this page: %v": "Diese Seite konnte nicht analysiert werden: %v",
"Unable to find any subscription.": "Es wurden keine Abonnements gefunden.",
"The URL and the category are mandatory.": "Die URL und die Kategorie sind obligatorisch.",
"All fields are mandatory.": "Alle Felder sind obligatorisch.",
"Passwords are not the same.": "Passwörter stimmen nicht überein.",
"You must use at least 6 characters.": "Wenigstens 6 Zeichen müssen genutzt werden.",
"The username is mandatory.": "Der Benutzername ist obligatorisch.",
"The username, theme, language and timezone fields are mandatory.": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"The title is mandatory.": "Der Titel ist obligatorisch.",
"About": "Über",
"version": "Version",
"Version:": "Version :",
"Build Date:": "Datum der Kompilierung:",
"Author:": "Autor:",
"Authors": "Autoren",
"License:": "Lizenz:",
"Attachments": "Anhänge",
"Download": "Herunterladen",
"Invalid username or password.": "Benutzername oder Passwort ungültig.",
"Never": "Niemals",
"Unable to execute request: %v": "Diese Anfrage konnte nicht ausgeführt werden: %v",
"Last Parsing Error": "Letzter Analyse Error",
"There is a problem with this feed": "Es gibt ein Problem mit diesem Abonnement",
"Unable to parse OPML file: %v.": "OPML Datei konnte nicht gelesen werden: %v.",
"Unable to parse RSS feed: %v.": "RSS Feed konnte nicht gelesen werden: %v.",
"Unable to parse Atom feed: %v.": "Atom Feed konnte nicht gelesen werden: %v.",
"Unable to parse JSON feed: %v.": "JSON Feed konnte nicht gelesen werden: %v.",
"Unable to parse RDF feed: %v.": "RDF Feed konnte nicht gelesen werden: %v.",
"Unable to normalize encoding: %v.": "Zeichenkodierung konnte nicht normalisiert werden: %v.",
"Unable to create this category.": "Diese Kategorie konnte nicht angelegt werden.",
"yes": "ja",
"no": "nein",
"Are you sure?": "Sind Sie sicher?",
"Work in progress...": "In Arbeit...",
"This user already exists.": "Dieser Benutzer existiert bereits.",
"This category already exists.": "Diese Kategorie existiert bereits.",
"Unable to update this category.": "Diese Kategorie konnte nicht aktualisiert werden.",
"Integrations": "Dienste",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Ziehen Sie diesen Link in Ihre Lesezeichen.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Dieser spezielle Link ermöglicht es, eine Webseite direkt über ein Lesezeichen im Browser zu abonnieren.",
"Add to Miniflux": "Mit Miniflux abonnieren",
"Refresh all feeds in background": "Alle Abonnements im Hintergrund aktualisieren",
"Sign in with Google": "Anmeldung mit Google",
"Unlink my Google account": "Google Konto abmelden",
"Link my Google account": "Google Konto verknüpfen",
"Category not found for this user.": "Diese Kategorie existiert nicht für diesen Benutzer.",
"Invalid theme.": "Dieses Thema ist fehlerhaft.",
"Entry Sorting": "Sortierung der Artikel",
"Older entries first": "Älteste Artikel zuerst",
"Recent entries first": "Neueste Artikel zuerst",
"Saving...": "Speichern...",
"Done!": "Erledigt!",
"Save this article": "Diesen Artikel speichern",
"Mark bookmark as unread": "Lesezeichen als ungelesen markieren",
"Pinboard Tags": "Pinboard Tags",
"Pinboard API Token": "Pinboard API Token",
"Save articles to Pinboard": "Artikel in Pinboard speichern",
"Save articles to Instapaper": "Artikel in Instapaper speichern",
"Instapaper Username": "Instapaper Benutzername",
"Instapaper Password": "Instapaper Passwort",
"Activate Fever API": "Fever API aktivieren",
"Fever Username": "Fever Benutzername",
"Fever Password": "Fever Passwort",
"Fetch original content": "Inhalt herunterladen",
"Scraper Rules": "Extraktionsregeln",
"Rewrite Rules": "Umschreiberegeln",
"Preferences saved!": "Einstellungen gespeichert!",
"Your external account is now linked !": "Ihr externes Konto wurde verlinkt!",
"Save articles to Wallabag": "Artikel in Wallabag speichern",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag Benutzername",
"Wallabag Password": "Wallabag Passwort",
"Keyboard Shortcut: %s": "Tastenkürzel: %s",
"Favorites": "Lesezeichen",
"Star": "Lesezeichen hinzufügen",
"Unstar": "Lesezeichen entfernen",
"Starred": "Lesezeichen",
"There is no bookmark at the moment.": "Es existiert derzeit kein Lesezeichen.",
"Last checked:": "Zuletzt geprüft:",
"ETag header:": "ETag-Kopfzeile:",
"LastModified header:": "Zuletzt geändert:",
"None": "Nicht verfügbar",
"Keyboard Shortcuts": "Tastenkürzel",
"Sections Navigation": "Navigation zwischen den Menüpunkten",
"Go to unread": "Zu den ungelesenen Artikeln gehen",
"Go to bookmarks": "Zu den Lesezeichen gehen",
"Go to history": "Zum Verlauf gehen",
"Go to feeds": "Zu den Abonnements gehen",
"Go to categories": "Zu den Kategorien gehen",
"Go to settings": "Zu den Einstellungen gehen",
"Show keyboard shortcuts": "Liste der Tastenkürzel anzeigen",
"Items Navigation": "Navigation zwischen den Artikeln",
"Go to previous item": "Zum vorherigen Artikel gehen",
"Go to next item": "Zum nächsten Artikel gehen",
"Pages Navigation": "Navigation zwischen den Seiten",
"Go to previous page": "Zur vorherigen Seite gehen",
"Go to next page": "Zur nächsten Seite gehen",
"Open selected item": "Gewählten Artikel öffnen",
"Open original link": "Original-Artikel öffnen",
"Toggle read/unread": "Gewählten Artikel als gelesen/ungelesen markieren",
"Mark current page as read": "Aktuelle Seite als gelesen markieren",
"Download original content": "Vollständigen Inhalt herunterladen",
"Toggle bookmark": "Lesezeichen hinzufügen/entfernen",
"Close modal dialog": "Liste der Tastenkürzel schließen",
"Save article": "Artikel speichern",
"There is already someone associated with this provider!": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
"There is already someone else with the same Fever username!": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
"Mark all as read": "Alle als gelesen markieren",
"This feed is empty": "Dieses Abonnement ist leer",
"Flush history": "Verlauf leeren",
"Site URL": "Webseite-URL",
"Feed URL": "Feed-URL",
"Logged as %s": "Angemeldet als %s",
"Unread Items": "Ungelesen",
"Change entry status": "Status des Artikels ändern",
"Read": "Gelesen",
"Fever API endpoint:": "Fever API Endpunkt:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpunkt",
"Your account password": "Ihr Konto Passwort"
}
`,
"en_US": `{
"plural.feed.error_count": [
"%d error",
@@ -213,12 +427,24 @@ var translations = map[string]string{
"There is already someone associated with this provider!": "Il y a déjà quelqu'un d'associé avec ce provider !",
"There is already someone else with the same Fever username!": "Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Fever !",
"Mark all as read": "Tout marquer comme lu",
"This feed is empty": "Cet abonnement est vide"
"This feed is empty": "Cet abonnement est vide",
"Flush history": "Supprimer l'historique",
"Site URL": "URL du site web",
"Feed URL": "URL du flux",
"Logged as %s": "Connecté en tant que %s",
"Unread Items": "Éléments non lus",
"Change entry status": "Changer le statut de l'élément",
"Read": "Lu",
"Fever API endpoint:": "Point de terminaison de l'API Fever :",
"Miniflux API": "API de Miniflux",
"API Endpoint": "Point de terminaison de l'API",
"Your account password": "Le mot de passe de votre compte"
}
`,
}
var translationsChecksums = map[string]string{
"de_DE": "f4a66bffedb7bf99294281da5a1fa78901509db67a9fba15a160b656feb1425a",
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
"fr_FR": "888baaeba8cee020f0cf77db7bad9960e9b640277d042029cafb41e72e13566e",
"fr_FR": "e6305fd54508a4f54d630e3ef231a8eadc2335ed0ffac7b5266b2ec751824e28",
}
+213
View File
@@ -0,0 +1,213 @@
{
"plural.feed.error_count": [
"%d Error",
"%d Errors"
],
"plural.categories.feed_count": [
"Es gibt %d Abonnement.",
"Es gibt %d Abonnements."
],
"Username": "Benutzername",
"Password": "Passwort",
"Unread": "Ungelesen",
"History": "Verlauf",
"Feeds": "Abonnements",
"Categories": "Kategorien",
"Settings": "Einstellungen",
"Logout": "Abmelden",
"Next": "Nächste",
"Previous": "Vorherige",
"New Subscription": "Neues Abonnement",
"Import": "Importieren",
"Export": "Exportieren",
"There is no category. You must have at least one category.": "Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.",
"URL": "URL",
"Category": "Kategorie",
"Find a subscription": "Abonnement suchen",
"Loading...": "Lade...",
"Create a category": "Kategorie anlegen",
"There is no category.": "Es ist keine Kategorie vorhanden.",
"Edit": "Bearbeiten",
"Remove": "Entfernen",
"No feed.": "Kein Abonnement.",
"There is no article in this category.": "Es befindet sich kein Artikel in dieser Kategorie.",
"Original": "Original-Artikel",
"Mark this page as read": "Diese Seite als gelesen markieren",
"not yet": "noch nicht",
"just now": "gerade",
"1 minute ago": "vor einer Minute",
"%d minutes ago": "vor %d Minuten",
"1 hour ago": "vor einer Stunde",
"%d hours ago": "vor %d Stunden",
"yesterday": "gestern",
"%d days ago": "vor %d Tagen",
"%d weeks ago": "vor %d Wochen",
"%d months ago": "vor %d Monaten",
"%d years ago": "vor %d Jahren",
"Date": "Datum",
"IP Address": "IP Adresse",
"User Agent": "Benutzeragent",
"Actions": "Aktionen",
"Current session": "Aktuelle Sitzung",
"Sessions": "Sitzungen",
"Users": "Benutzer",
"Add user": "Benutzer anlegen",
"Choose a Subscription": "Abonnement auswählen",
"Subscribe": "Abonnieren",
"New Category": "Neue Kategorie",
"Title": "Titel",
"Save": "Speichern",
"or": "oder",
"cancel": "abbrechen",
"New User": "Neuer Benutzer",
"Confirmation": "Bestätigung",
"Administrator": "Administrator",
"Edit Category: %s": "Kategorie bearbeiten : %s",
"Update": "Aktualisieren",
"Edit Feed: %s": "Abonnement bearbeiten : %s",
"There is no category!": "Es ist keine Kategorie vorhanden!",
"Edit user: %s": "Benutzer bearbeiten: %s",
"There is no article for this feed.": "Es existiert kein Artikel für dieses Abonnement.",
"Add subscription": "Abonnement hinzufügen",
"You don't have any subscription.": "Es sind keine Abonnements vorhanden",
"Last check:": "Letzte Aktualisierung:",
"Refresh": "Aktualisieren",
"There is no history at the moment.": "Es exisitiert zur Zeit kein Verlauf.",
"OPML file": "OPML Datei",
"Sign In": "Anmeldung",
"Sign in": "Anmelden",
"Theme": "Thema",
"Timezone": "Zeitzone",
"Language": "Sprache",
"There is no unread article.": "Es existiert kein ungelesener Artikel.",
"You are the only user.": "Sie sind der einzige Benutzer.",
"Last Login": "Letzte Anmeldung",
"Yes": "Ja",
"No": "Nein",
"This feed already exists (%s).": "Diese Abonnement existiert bereits (%s).",
"Unable to fetch feed (statusCode=%d).": "Abonnement konnte nicht abgerufen werden (code=%d).",
"Unable to open this link: %v": "Dieser Link konnte nicht geöffnet werden: %v",
"Unable to analyze this page: %v": "Diese Seite konnte nicht analysiert werden: %v",
"Unable to find any subscription.": "Es wurden keine Abonnements gefunden.",
"The URL and the category are mandatory.": "Die URL und die Kategorie sind obligatorisch.",
"All fields are mandatory.": "Alle Felder sind obligatorisch.",
"Passwords are not the same.": "Passwörter stimmen nicht überein.",
"You must use at least 6 characters.": "Wenigstens 6 Zeichen müssen genutzt werden.",
"The username is mandatory.": "Der Benutzername ist obligatorisch.",
"The username, theme, language and timezone fields are mandatory.": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"The title is mandatory.": "Der Titel ist obligatorisch.",
"About": "Über",
"version": "Version",
"Version:": "Version :",
"Build Date:": "Datum der Kompilierung:",
"Author:": "Autor:",
"Authors": "Autoren",
"License:": "Lizenz:",
"Attachments": "Anhänge",
"Download": "Herunterladen",
"Invalid username or password.": "Benutzername oder Passwort ungültig.",
"Never": "Niemals",
"Unable to execute request: %v": "Diese Anfrage konnte nicht ausgeführt werden: %v",
"Last Parsing Error": "Letzter Analyse Error",
"There is a problem with this feed": "Es gibt ein Problem mit diesem Abonnement",
"Unable to parse OPML file: %v.": "OPML Datei konnte nicht gelesen werden: %v.",
"Unable to parse RSS feed: %v.": "RSS Feed konnte nicht gelesen werden: %v.",
"Unable to parse Atom feed: %v.": "Atom Feed konnte nicht gelesen werden: %v.",
"Unable to parse JSON feed: %v.": "JSON Feed konnte nicht gelesen werden: %v.",
"Unable to parse RDF feed: %v.": "RDF Feed konnte nicht gelesen werden: %v.",
"Unable to normalize encoding: %v.": "Zeichenkodierung konnte nicht normalisiert werden: %v.",
"Unable to create this category.": "Diese Kategorie konnte nicht angelegt werden.",
"yes": "ja",
"no": "nein",
"Are you sure?": "Sind Sie sicher?",
"Work in progress...": "In Arbeit...",
"This user already exists.": "Dieser Benutzer existiert bereits.",
"This category already exists.": "Diese Kategorie existiert bereits.",
"Unable to update this category.": "Diese Kategorie konnte nicht aktualisiert werden.",
"Integrations": "Dienste",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Ziehen Sie diesen Link in Ihre Lesezeichen.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Dieser spezielle Link ermöglicht es, eine Webseite direkt über ein Lesezeichen im Browser zu abonnieren.",
"Add to Miniflux": "Mit Miniflux abonnieren",
"Refresh all feeds in background": "Alle Abonnements im Hintergrund aktualisieren",
"Sign in with Google": "Anmeldung mit Google",
"Unlink my Google account": "Google Konto abmelden",
"Link my Google account": "Google Konto verknüpfen",
"Category not found for this user.": "Diese Kategorie existiert nicht für diesen Benutzer.",
"Invalid theme.": "Dieses Thema ist fehlerhaft.",
"Entry Sorting": "Sortierung der Artikel",
"Older entries first": "Älteste Artikel zuerst",
"Recent entries first": "Neueste Artikel zuerst",
"Saving...": "Speichern...",
"Done!": "Erledigt!",
"Save this article": "Diesen Artikel speichern",
"Mark bookmark as unread": "Lesezeichen als ungelesen markieren",
"Pinboard Tags": "Pinboard Tags",
"Pinboard API Token": "Pinboard API Token",
"Save articles to Pinboard": "Artikel in Pinboard speichern",
"Save articles to Instapaper": "Artikel in Instapaper speichern",
"Instapaper Username": "Instapaper Benutzername",
"Instapaper Password": "Instapaper Passwort",
"Activate Fever API": "Fever API aktivieren",
"Fever Username": "Fever Benutzername",
"Fever Password": "Fever Passwort",
"Fetch original content": "Inhalt herunterladen",
"Scraper Rules": "Extraktionsregeln",
"Rewrite Rules": "Umschreiberegeln",
"Preferences saved!": "Einstellungen gespeichert!",
"Your external account is now linked !": "Ihr externes Konto wurde verlinkt!",
"Save articles to Wallabag": "Artikel in Wallabag speichern",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag Benutzername",
"Wallabag Password": "Wallabag Passwort",
"Keyboard Shortcut: %s": "Tastenkürzel: %s",
"Favorites": "Lesezeichen",
"Star": "Lesezeichen hinzufügen",
"Unstar": "Lesezeichen entfernen",
"Starred": "Lesezeichen",
"There is no bookmark at the moment.": "Es existiert derzeit kein Lesezeichen.",
"Last checked:": "Zuletzt geprüft:",
"ETag header:": "ETag-Kopfzeile:",
"LastModified header:": "Zuletzt geändert:",
"None": "Nicht verfügbar",
"Keyboard Shortcuts": "Tastenkürzel",
"Sections Navigation": "Navigation zwischen den Menüpunkten",
"Go to unread": "Zu den ungelesenen Artikeln gehen",
"Go to bookmarks": "Zu den Lesezeichen gehen",
"Go to history": "Zum Verlauf gehen",
"Go to feeds": "Zu den Abonnements gehen",
"Go to categories": "Zu den Kategorien gehen",
"Go to settings": "Zu den Einstellungen gehen",
"Show keyboard shortcuts": "Liste der Tastenkürzel anzeigen",
"Items Navigation": "Navigation zwischen den Artikeln",
"Go to previous item": "Zum vorherigen Artikel gehen",
"Go to next item": "Zum nächsten Artikel gehen",
"Pages Navigation": "Navigation zwischen den Seiten",
"Go to previous page": "Zur vorherigen Seite gehen",
"Go to next page": "Zur nächsten Seite gehen",
"Open selected item": "Gewählten Artikel öffnen",
"Open original link": "Original-Artikel öffnen",
"Toggle read/unread": "Gewählten Artikel als gelesen/ungelesen markieren",
"Mark current page as read": "Aktuelle Seite als gelesen markieren",
"Download original content": "Vollständigen Inhalt herunterladen",
"Toggle bookmark": "Lesezeichen hinzufügen/entfernen",
"Close modal dialog": "Liste der Tastenkürzel schließen",
"Save article": "Artikel speichern",
"There is already someone associated with this provider!": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
"There is already someone else with the same Fever username!": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
"Mark all as read": "Alle als gelesen markieren",
"This feed is empty": "Dieses Abonnement ist leer",
"Flush history": "Verlauf leeren",
"Site URL": "Webseite-URL",
"Feed URL": "Feed-URL",
"Logged as %s": "Angemeldet als %s",
"Unread Items": "Ungelesen",
"Change entry status": "Status des Artikels ändern",
"Read": "Gelesen",
"Fever API endpoint:": "Fever API Endpunkt:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpunkt",
"Your account password": "Ihr Konto Passwort"
}
+12 -1
View File
@@ -197,5 +197,16 @@
"There is already someone associated with this provider!": "Il y a déjà quelqu'un d'associé avec ce provider !",
"There is already someone else with the same Fever username!": "Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Fever !",
"Mark all as read": "Tout marquer comme lu",
"This feed is empty": "Cet abonnement est vide"
"This feed is empty": "Cet abonnement est vide",
"Flush history": "Supprimer l'historique",
"Site URL": "URL du site web",
"Feed URL": "URL du flux",
"Logged as %s": "Connecté en tant que %s",
"Unread Items": "Éléments non lus",
"Change entry status": "Changer le statut de l'élément",
"Read": "Lu",
"Fever API endpoint:": "Point de terminaison de l'API Fever :",
"Miniflux API": "API de Miniflux",
"API Endpoint": "Point de terminaison de l'API",
"Your account password": "Le mot de passe de votre compte"
}
+55 -7
View File
@@ -10,28 +10,76 @@ import (
"time"
)
var requestedLevel = InfoLevel
// LogLevel type.
type LogLevel uint32
const (
// FatalLevel should be used in fatal situations, the app will exit.
FatalLevel LogLevel = iota
// ErrorLevel should be used when someone should really look at the error.
ErrorLevel
// InfoLevel should be used during normal operations.
InfoLevel
// DebugLevel should be used only during development.
DebugLevel
)
func (level LogLevel) String() string {
switch level {
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case ErrorLevel:
return "ERROR"
case FatalLevel:
return "FATAL"
default:
return "UNKNOWN"
}
}
// EnableDebug increases logging, more verbose (debug)
func EnableDebug() {
requestedLevel = DebugLevel
formatMessage(InfoLevel, "Debug mode enabled")
}
// Debug sends a debug log message.
func Debug(format string, v ...interface{}) {
formatMessage("DEBUG", format, v...)
if requestedLevel >= DebugLevel {
formatMessage(DebugLevel, format, v...)
}
}
// Info sends an info log message.
func Info(format string, v ...interface{}) {
formatMessage("INFO", format, v...)
if requestedLevel >= InfoLevel {
formatMessage(InfoLevel, format, v...)
}
}
// Error sends an error log message.
func Error(format string, v ...interface{}) {
formatMessage("ERROR", format, v...)
if requestedLevel >= ErrorLevel {
formatMessage(ErrorLevel, format, v...)
}
}
// Fatal sends a fatal log message and stop the execution of the program.
func Fatal(format string, v ...interface{}) {
formatMessage("FATAL", format, v...)
os.Exit(1)
if requestedLevel >= FatalLevel {
formatMessage(FatalLevel, format, v...)
os.Exit(1)
}
}
func formatMessage(level, format string, v ...interface{}) {
prefix := fmt.Sprintf("[%s] [%s] ", time.Now().Format("2006-01-02T15:04:05"), level)
func formatMessage(level LogLevel, format string, v ...interface{}) {
prefix := fmt.Sprintf("[%s] [%s] ", time.Now().Format("2006-01-02T15:04:05"), level.String())
fmt.Fprintf(os.Stderr, prefix+format+"\n", v...)
}
+4 -3
View File
@@ -17,11 +17,12 @@ type SessionData struct {
OAuth2State string `json:"oauth2_state"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
}
func (s SessionData) String() string {
return fmt.Sprintf(`CSRF="%s", "OAuth2State="%s", FlashMessage="%s", "FlashErrorMessage="%s"`,
s.CSRF, s.OAuth2State, s.FlashMessage, s.FlashErrorMessage)
return fmt.Sprintf(`CSRF="%s", "OAuth2State="%s", FlashMessage="%s", FlashErrorMessage="%s", Lang="%s"`,
s.CSRF, s.OAuth2State, s.FlashMessage, s.FlashErrorMessage, s.Language)
}
// Value converts the session data to JSON.
@@ -52,5 +53,5 @@ type Session struct {
}
func (s *Session) String() string {
return fmt.Sprintf(`ID="%s", Data="%v"`, s.ID, s.Data)
return fmt.Sprintf(`ID="%s", Data={%v}`, s.ID, s.Data)
}
+2 -3
View File
@@ -10,15 +10,14 @@ import (
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
"golang.org/x/net/html/charset"
"github.com/miniflux/miniflux/reader/encoding"
)
// Parse returns a normalized feed struct from a Atom feed.
func Parse(data io.Reader) (*model.Feed, error) {
atomFeed := new(atomFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = charset.NewReaderLabel
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(atomFeed)
if err != nil {
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package encoding handles workarounds to deal with encoding edge cases found into the wild.
*/
package encoding
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package encoding
import (
"bytes"
"io"
"unicode/utf8"
"golang.org/x/net/html/charset"
)
// CharsetReader is used when the XML encoding is specified for the input document.
//
// The document is converted in UTF-8 only if a different encoding is specified
// and the document is not already UTF-8.
//
// Several edge cases could exists:
//
// - Feeds with charset specified only in Content-Type header and not in XML document
// - Feeds with charset specified in both places
// - Feeds with charset specified only in XML document and not in HTTP header
func CharsetReader(label string, input io.Reader) (io.Reader, error) {
var buf1, buf2 bytes.Buffer
w := io.MultiWriter(&buf1, &buf2)
io.Copy(w, input)
r := bytes.NewReader(buf2.Bytes())
if !utf8.Valid(buf1.Bytes()) {
// Transform document to UTF-8 from the specified XML encoding.
return charset.NewReaderLabel(label, r)
}
// The document is already UTF-8, do not do anything (avoid double-encoding)
return r, nil
}
+5 -4
View File
@@ -70,7 +70,7 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
return nil, err
}
feedProcessor := processor.NewFeedProcessor(subscription)
feedProcessor := processor.NewFeedProcessor(userID, h.store, subscription)
feedProcessor.WithCrawler(crawler)
feedProcessor.Process()
@@ -162,7 +162,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
return err
}
feedProcessor := processor.NewFeedProcessor(subscription)
feedProcessor := processor.NewFeedProcessor(userID, h.store, subscription)
feedProcessor.WithScraperRules(originalFeed.ScraperRules)
feedProcessor.WithRewriteRules(originalFeed.RewriteRules)
feedProcessor.WithCrawler(originalFeed.Crawler)
@@ -171,7 +171,8 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
originalFeed.EtagHeader = response.ETag
originalFeed.LastModifiedHeader = response.LastModified
if err := h.store.UpdateEntries(originalFeed.UserID, originalFeed.ID, subscription.Entries); err != nil {
// Note: We don't update existing entries when the crawler is enabled (we crawl only inexisting entries).
if err := h.store.UpdateEntries(originalFeed.UserID, originalFeed.ID, subscription.Entries, !originalFeed.Crawler); err != nil {
return err
}
@@ -179,7 +180,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
logger.Debug("[Handler:RefreshFeed] Looking for feed icon")
icon, err := icon.FindIcon(originalFeed.SiteURL)
if err != nil {
logger.Error("[Handler:RefreshFeed] %v", err)
logger.Debug("[Handler:RefreshFeed] %v", err)
} else {
h.store.CreateFeedIcon(originalFeed, icon)
}
+6 -7
View File
@@ -14,12 +14,11 @@ import (
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/atom"
"github.com/miniflux/miniflux/reader/encoding"
"github.com/miniflux/miniflux/reader/json"
"github.com/miniflux/miniflux/reader/rdf"
"github.com/miniflux/miniflux/reader/rss"
"github.com/miniflux/miniflux/timer"
"golang.org/x/net/html/charset"
)
// List of feed formats.
@@ -32,14 +31,14 @@ const (
)
// DetectFeedFormat detect feed format from input data.
func DetectFeedFormat(data io.Reader) string {
func DetectFeedFormat(r io.Reader) string {
defer timer.ExecutionTime(time.Now(), "[Feed:DetectFeedFormat]")
var buffer bytes.Buffer
tee := io.TeeReader(data, &buffer)
tee := io.TeeReader(r, &buffer)
decoder := xml.NewDecoder(tee)
decoder.CharsetReader = charset.NewReaderLabel
decoder.CharsetReader = encoding.CharsetReader
for {
token, _ := decoder.Token()
@@ -66,11 +65,11 @@ func DetectFeedFormat(data io.Reader) string {
return FormatUnknown
}
func parseFeed(data io.Reader) (*model.Feed, error) {
func parseFeed(r io.Reader) (*model.Feed, error) {
defer timer.ExecutionTime(time.Now(), "[Feed:ParseFeed]")
var buffer bytes.Buffer
io.Copy(&buffer, data)
io.Copy(&buffer, r)
reader := bytes.NewReader(buffer.Bytes())
format := DetectFeedFormat(reader)
+2 -2
View File
@@ -9,14 +9,14 @@ import (
"io"
"github.com/miniflux/miniflux/errors"
"golang.org/x/net/html/charset"
"github.com/miniflux/miniflux/reader/encoding"
)
// Parse reads an OPML file and returns a SubcriptionList.
func Parse(data io.Reader) (SubcriptionList, error) {
feeds := new(opml)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = charset.NewReaderLabel
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feeds)
if err != nil {
+13 -6
View File
@@ -10,10 +10,13 @@ import (
"github.com/miniflux/miniflux/reader/rewrite"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/reader/scraper"
"github.com/miniflux/miniflux/storage"
)
// FeedProcessor handles the processing of feed contents.
type FeedProcessor struct {
userID int64
store *storage.Storage
feed *model.Feed
scraperRules string
rewriteRules string
@@ -39,11 +42,15 @@ func (f *FeedProcessor) WithRewriteRules(rules string) {
func (f *FeedProcessor) Process() {
for _, entry := range f.feed.Entries {
if f.crawler {
content, err := scraper.Fetch(entry.URL, f.scraperRules)
if err != nil {
logger.Error("[FeedProcessor] %v", err)
if f.store.EntryURLExists(f.userID, entry.URL) {
logger.Debug(`[FeedProcessor] Do not crawl existing entry URL: "%s"`, entry.URL)
} else {
entry.Content = content
content, err := scraper.Fetch(entry.URL, f.scraperRules)
if err != nil {
logger.Error("[FeedProcessor] %v", err)
} else {
entry.Content = content
}
}
}
@@ -53,6 +60,6 @@ func (f *FeedProcessor) Process() {
}
// NewFeedProcessor returns a new FeedProcessor.
func NewFeedProcessor(feed *model.Feed) *FeedProcessor {
return &FeedProcessor{feed: feed, crawler: false}
func NewFeedProcessor(userID int64, store *storage.Storage, feed *model.Feed) *FeedProcessor {
return &FeedProcessor{userID: userID, store: store, feed: feed, crawler: false}
}
+2 -2
View File
@@ -10,14 +10,14 @@ import (
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
"golang.org/x/net/html/charset"
"github.com/miniflux/miniflux/reader/encoding"
)
// Parse returns a normalized feed struct from a RDF feed.
func Parse(data io.Reader) (*model.Feed, error) {
feed := new(rdfFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = charset.NewReaderLabel
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feed)
if err != nil {
+2 -3
View File
@@ -10,15 +10,14 @@ import (
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
"golang.org/x/net/html/charset"
"github.com/miniflux/miniflux/reader/encoding"
)
// Parse returns a normalized feed struct from a RSS feed.
func Parse(data io.Reader) (*model.Feed, error) {
feed := new(rssFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = charset.NewReaderLabel
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feed)
if err != nil {
+6 -1
View File
@@ -35,8 +35,13 @@ func FindSubscriptions(websiteURL string) (Subscriptions, error) {
return nil, errors.NewLocalizedError(errConnectionFailure, err)
}
body, err := response.NormalizeBodyEncoding()
if err != nil {
return nil, err
}
var buffer bytes.Buffer
io.Copy(&buffer, response.Body)
io.Copy(&buffer, body)
reader := bytes.NewReader(buffer.Bytes())
if format := feed.DetectFeedFormat(reader); format != feed.FormatUnknown {
+1 -1
View File
@@ -20,7 +20,7 @@ type Worker struct {
// Run wait for a job and refresh the given feed.
func (w *Worker) Run(c chan model.Job) {
logger.Debug("[Worker] #%d started", w.id)
logger.Info("[Worker] #%d started", w.id)
for {
job := <-c
+1 -1
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-02 21:59:10.075345511 -0800 PST m=+0.002720840
// 2018-01-31 21:53:31.179267411 -0800 PST m=+0.003439489
package sql
+18 -7
View File
@@ -76,19 +76,20 @@ func (s *Storage) UpdateEntryContent(entry *model.Entry) error {
return err
}
// updateEntry update an entry when a feed is refreshed.
// updateEntry updates an entry when a feed is refreshed.
// Note: we do not update the published date because some feeds do not contains any date,
// it default to time.Now() which could change the order of items on the history page.
func (s *Storage) updateEntry(entry *model.Entry) error {
query := `
UPDATE entries SET
title=$1, url=$2, published_at=$3, content=$4, author=$5
WHERE user_id=$6 AND feed_id=$7 AND hash=$8
title=$1, url=$2, content=$3, author=$4
WHERE user_id=$5 AND feed_id=$6 AND hash=$7
RETURNING id
`
err := s.db.QueryRow(
query,
entry.Title,
entry.URL,
entry.Date,
entry.Content,
entry.Author,
entry.UserID,
@@ -116,15 +117,17 @@ func (s *Storage) entryExists(entry *model.Entry) bool {
return result >= 1
}
// UpdateEntries update a list of entries while refreshing a feed.
func (s *Storage) UpdateEntries(userID, feedID int64, entries model.Entries) (err error) {
// UpdateEntries updates a list of entries while refreshing a feed.
func (s *Storage) UpdateEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (err error) {
var entryHashes []string
for _, entry := range entries {
entry.UserID = userID
entry.FeedID = feedID
if s.entryExists(entry) {
err = s.updateEntry(entry)
if updateExistingEntries {
err = s.updateEntry(entry)
}
} else {
err = s.createEntry(entry)
}
@@ -226,3 +229,11 @@ func (s *Storage) MarkAllAsRead(userID int64) error {
return nil
}
// EntryURLExists returns true if an entry with this URL already exists.
func (s *Storage) EntryURLExists(userID int64, entryURL string) bool {
var result int
query := `SELECT count(*) as c FROM entries WHERE user_id=$1 AND url=$2`
s.db.QueryRow(query, userID, entryURL).Scan(&result)
return result >= 1
}
+49 -4
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-02 21:59:10.101985953 -0800 PST m=+0.029361282
// 2018-02-04 14:28:15.225458631 -0800 PST m=+0.036040293
package template
@@ -22,6 +22,49 @@ var templateCommonMap = map[string]string{
{{ end }}
</div>
</div>
{{ end }}`,
"item_meta": `{{ define "item_meta" }}
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}" title="{{ .entry.Feed.Title }}">{{ domain .entry.Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
<li>
<a href="#"
title="{{ t "Change entry status" }}"
data-toggle-status="true"
data-label-read="✔ {{ t "Read" }}"
data-label-unread="✘ {{ t "Unread" }}"
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}{{ t "Unread" }}{{ else }}{{ t "Read" }}{{ end }}</a>
</li>
</ul>
</div>
{{ end }}`,
"layout": `{{ define "base" }}
<!DOCTYPE html>
@@ -86,7 +129,7 @@ var templateCommonMap = map[string]string{
<a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
</li>
<li>
<a href="{{ route "logout" }}" title="Logged as {{ .user.Username }}">{{ t "Logout" }}</a>
<a href="{{ route "logout" }}" title="{{ t "Logged as %s" .user.Username }}">{{ t "Logout" }}</a>
</li>
</ul>
</nav>
@@ -146,7 +189,8 @@ var templateCommonMap = map[string]string{
</template>
</body>
</html>
{{ end }}`,
{{ end }}
`,
"pagination": `{{ define "pagination" }}
<div class="pagination">
<div class="pagination-prev">
@@ -171,6 +215,7 @@ var templateCommonMap = map[string]string{
var templateCommonMapChecksums = map[string]string{
"entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
"layout": "83786d9e657a17cb531007b5639dc021b7cb2bff1a19162769b3a961a22e5087",
"item_meta": "4796b74adca0567f3dbf8bdf6ac8cda59f455ea34cb6d4a92c83660fa72a883d",
"layout": "c7565e2cf904612e236bc1d7167c6c124ffe5d27348608eb5c2336606f266896",
"pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924",
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package template
import "fmt"
func dict(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("Dict expects an even number of arguments")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("Dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package template
import (
"testing"
)
func TestDict(t *testing.T) {
d, err := dict("k1", "v1", "k2", "v2")
if err != nil {
t.Fatalf(`The dict should be valid: %v`, err)
}
if value, found := d["k1"]; found {
if value != "v1" {
t.Fatalf(`Incorrect value for k1: %q`, value)
}
}
if value, found := d["k2"]; found {
if value != "v2" {
t.Fatalf(`Incorrect value for k2: %q`, value)
}
}
}
func TestDictWithIncorrectNumberOfPairs(t *testing.T) {
_, err := dict("k1", "v1", "k2")
if err == nil {
t.Fatalf(`The dict should not be valid because the number of keys/values pairs are incorrect`)
}
}
func TestDictWithInvalidKey(t *testing.T) {
_, err := dict(1, "v1")
if err == nil {
t.Fatalf(`The dict should not be valid because the key is not a string`)
}
}
+80
View File
@@ -0,0 +1,80 @@
// 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 template
import (
"math"
"time"
"github.com/miniflux/miniflux/locale"
)
// 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`
)
// ElapsedTime returns in a human readable format the elapsed time
// since the given datetime.
func elapsedTime(language *locale.Language, timezone string, t time.Time) string {
if t.IsZero() {
return language.Get(NotYet)
}
var now time.Time
loc, err := time.LoadLocation(timezone)
if err != nil {
now = time.Now()
} else {
now = time.Now().In(loc)
// The provided date is already converted to the user timezone by Postgres,
// but the timezone information is not set in the time struct.
// We cannot use time.In() because the date will be converted a second time.
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
}
if now.Before(t) {
return language.Get(NotYet)
}
diff := now.Sub(t)
// Duration in seconds
s := diff.Seconds()
// Duration in days
d := int(s / 86400)
switch {
case s < 60:
return language.Get(JustNow)
case s < 120:
return language.Get(LastMinute)
case s < 3600:
return language.Get(Minutes, int(diff.Minutes()))
case s < 7200:
return language.Get(LastHour)
case s < 86400:
return language.Get(Hours, int(diff.Hours()))
case d == 1:
return language.Get(Yesterday)
case d < 7:
return language.Get(Days, d)
case d < 31:
return language.Get(Weeks, int(math.Ceil(float64(d)/7)))
case d < 365:
return language.Get(Months, int(math.Ceil(float64(d)/30)))
default:
return language.Get(Years, int(math.Ceil(float64(d)/365)))
}
}
@@ -2,7 +2,7 @@
// Use of this source code is governed by the MIT License
// that can be found in the LICENSE file.
package duration
package template
import (
"fmt"
@@ -31,8 +31,8 @@ func TestElapsedTime(t *testing.T) {
{time.Now().Add(-time.Hour * 24 * 365 * 3), fmt.Sprintf(Years, 3)},
}
for i, tt := range dt {
if out := ElapsedTime(&locale.Language{}, tt.in); out != tt.out {
t.Errorf("%d. content mismatch for %v:exp=%q got=%q", i, tt.in, tt.out, out)
if out := elapsedTime(&locale.Language{}, "Local", tt.in); out != tt.out {
t.Errorf(`%d. content mismatch for "%v": expected=%q got=%q`, i, tt.in, tt.out, out)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package template
import (
"bytes"
"html/template"
"io"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/gorilla/mux"
)
// Engine handles the templating system.
type Engine struct {
templates map[string]*template.Template
translator *locale.Translator
funcMap *funcMap
}
func (e *Engine) parseAll() {
commonTemplates := ""
for _, content := range templateCommonMap {
commonTemplates += content
}
for name, content := range templateViewsMap {
logger.Debug("[Template] Parsing: %s", name)
e.templates[name] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(commonTemplates + content))
}
}
// SetLanguage change the language for template processing.
func (e *Engine) SetLanguage(language string) {
e.funcMap.Language = e.translator.GetLanguage(language)
}
// Execute process a template.
func (e *Engine) Execute(w io.Writer, name string, data interface{}) {
tpl, ok := e.templates[name]
if !ok {
logger.Fatal("[Template] The template %s does not exists", name)
}
var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil {
logger.Fatal("[Template] Unable to render template: %v", err)
}
b.WriteTo(w)
}
// NewEngine returns a new template engine.
func NewEngine(cfg *config.Config, router *mux.Router, translator *locale.Translator) *Engine {
tpl := &Engine{
templates: make(map[string]*template.Template),
translator: translator,
funcMap: newFuncMap(cfg, router, translator.GetLanguage("en_US")),
}
tpl.parseAll()
return tpl
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package template
import (
"html/template"
"net/mail"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/filter"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/url"
)
type funcMap struct {
cfg *config.Config
router *mux.Router
Language *locale.Language
}
func (f *funcMap) Map() template.FuncMap {
return template.FuncMap{
"baseURL": func() string {
return f.cfg.BaseURL()
},
"rootURL": func() string {
return f.cfg.RootURL()
},
"hasOAuth2Provider": func(provider string) bool {
return f.cfg.OAuth2Provider() == provider
},
"hasKey": func(dict map[string]string, key string) bool {
if value, found := dict[key]; found {
return value != ""
}
return false
},
"route": func(name string, args ...interface{}) string {
return route.Path(f.router, name, args...)
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
"proxyFilter": func(data string) string {
return filter.ImageProxyFilter(f.router, data)
},
"proxyURL": func(link string) string {
if url.IsHTTPS(link) {
return link
}
return filter.Proxify(f.router, link)
},
"domain": func(websiteURL string) string {
return url.Domain(websiteURL)
},
"isEmail": func(str string) bool {
_, err := mail.ParseAddress(str)
if err != nil {
return false
}
return true
},
"hasPrefix": func(str, prefix string) bool {
return strings.HasPrefix(str, prefix)
},
"contains": func(str, substr string) bool {
return strings.Contains(str, substr)
},
"isodate": func(ts time.Time) string {
return ts.Format("2006-01-02 15:04:05")
},
"elapsed": func(timezone string, t time.Time) string {
return elapsedTime(f.Language, timezone, t)
},
"t": func(key interface{}, args ...interface{}) string {
switch key.(type) {
case string:
return f.Language.Get(key.(string), args...)
case errors.LocalizedError:
err := key.(errors.LocalizedError)
return err.Localize(f.Language)
case error:
return key.(error).Error()
default:
return ""
}
},
"plural": func(key string, n int, args ...interface{}) string {
return f.Language.Plural(key, n, args...)
},
"dict": dict,
}
}
func newFuncMap(cfg *config.Config, router *mux.Router, language *locale.Language) *funcMap {
return &funcMap{cfg, router, language}
}
+1 -32
View File
@@ -27,38 +27,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+43
View File
@@ -0,0 +1,43 @@
{{ define "item_meta" }}
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}" title="{{ .entry.Feed.Title }}">{{ domain .entry.Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
<li>
<a href="#"
title="{{ t "Change entry status" }}"
data-toggle-status="true"
data-label-read="✔ {{ t "Read" }}"
data-label-unread="✘ {{ t "Unread" }}"
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}✘ {{ t "Unread" }}{{ else }}✔ {{ t "Read" }}{{ end }}</a>
</li>
</ul>
</div>
{{ end }}
+2 -2
View File
@@ -61,7 +61,7 @@
<a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
</li>
<li>
<a href="{{ route "logout" }}" title="Logged as {{ .user.Username }}">{{ t "Logout" }}</a>
<a href="{{ route "logout" }}" title="{{ t "Logged as %s" .user.Username }}">{{ t "Logout" }}</a>
</li>
</ul>
</nav>
@@ -121,4 +121,4 @@
</template>
</body>
</html>
{{ end }}
{{ end }}
+3
View File
@@ -16,6 +16,9 @@
<li>
<a href="{{ route "users" }}">{{ t "Users" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="panel">
<ul>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed .feed.CheckedAt }}</time></li>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed $.user.Timezone .feed.CheckedAt }}</time></li>
<li><strong>{{ t "ETag header:" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
<li><strong>{{ t "LastModified header:" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
</ul>
+3
View File
@@ -19,6 +19,9 @@
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
+1 -1
View File
@@ -59,7 +59,7 @@
</span>
</div>
<div class="entry-date">
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
</div>
</header>
{{ if gt (len .entry.Content) 120 }}
+1 -32
View File
@@ -38,38 +38,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+1 -1
View File
@@ -42,7 +42,7 @@
<a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
</li>
<li>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed $.user.Timezone .CheckedAt }}</time>
</li>
</ul>
<ul>
+1 -32
View File
@@ -27,38 +27,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+19 -2
View File
@@ -39,6 +39,8 @@
<label for="form-fever-password">{{ t "Fever Password" }}</label>
<input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
<p>{{ t "Fever API endpoint:" }} <strong>{{ rootURL }}{{ route "feverEndpoint" }}</strong></p>
</div>
<h3>Pinboard</h3>
@@ -98,12 +100,27 @@
</div>
</form>
<h3>{{ t "Miniflux API" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "API Endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "Username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "Password" }} = <strong>{{ t "Your account password" }}</strong>
</li>
</ul>
</div>
<h3>{{ t "Bookmarklet" }}</h3>
<div class="panel">
<h3>{{ t "Bookmarklet" }}</h3>
<p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p>
<div class="bookmarklet">
<a href="javascript:location.href='{{ baseURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
<a href="javascript:location.href='{{ rootURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
</div>
<p>{{ t "Drag and drop this link to your bookmarks." }}</p>
+4 -1
View File
@@ -18,6 +18,9 @@
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -30,7 +33,7 @@
</tr>
{{ range .sessions }}
<tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</td>
<td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
<td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
<td class="column-20">
+1 -32
View File
@@ -20,38 +20,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+1 -32
View File
@@ -30,38 +30,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+4 -1
View File
@@ -16,6 +16,9 @@
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -36,7 +39,7 @@
<td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
<td>
{{ if .LastLoginAt }}
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed $.user.Timezone .LastLoginAt }}</time>
{{ else }}
{{ t "Never" }}
{{ end }}
-149
View File
@@ -1,149 +0,0 @@
// Copyright 2017 Frédéric Guilloe. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package template
import (
"bytes"
"html/template"
"io"
"net/mail"
"strings"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/duration"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/filter"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/url"
"github.com/gorilla/mux"
)
// Engine handles the templating system.
type Engine struct {
templates map[string]*template.Template
router *mux.Router
translator *locale.Translator
currentLocale *locale.Language
cfg *config.Config
}
func (e *Engine) parseAll() {
funcMap := template.FuncMap{
"baseURL": func() string {
return e.cfg.Get("BASE_URL", config.DefaultBaseURL)
},
"hasOAuth2Provider": func(provider string) bool {
return e.cfg.Get("OAUTH2_PROVIDER", "") == provider
},
"hasKey": func(dict map[string]string, key string) bool {
if value, found := dict[key]; found {
return value != ""
}
return false
},
"route": func(name string, args ...interface{}) string {
return route.Path(e.router, name, args...)
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
"proxyFilter": func(data string) string {
return filter.ImageProxyFilter(e.router, data)
},
"proxyURL": func(link string) string {
if url.IsHTTPS(link) {
return link
}
return filter.Proxify(e.router, link)
},
"domain": func(websiteURL string) string {
return url.Domain(websiteURL)
},
"isEmail": func(str string) bool {
_, err := mail.ParseAddress(str)
if err != nil {
return false
}
return true
},
"hasPrefix": func(str, prefix string) bool {
return strings.HasPrefix(str, prefix)
},
"contains": func(str, substr string) bool {
return strings.Contains(str, substr)
},
"isodate": func(ts time.Time) string {
return ts.Format("2006-01-02 15:04:05")
},
"elapsed": func(ts time.Time) string {
return duration.ElapsedTime(e.currentLocale, ts)
},
"t": func(key interface{}, args ...interface{}) string {
switch key.(type) {
case string:
return e.currentLocale.Get(key.(string), args...)
case errors.LocalizedError:
err := key.(errors.LocalizedError)
return err.Localize(e.currentLocale)
case error:
return key.(error).Error()
default:
return ""
}
},
"plural": func(key string, n int, args ...interface{}) string {
return e.currentLocale.Plural(key, n, args...)
},
}
commonTemplates := ""
for _, content := range templateCommonMap {
commonTemplates += content
}
for name, content := range templateViewsMap {
logger.Debug("[Template] Parsing: %s", name)
e.templates[name] = template.Must(template.New("main").Funcs(funcMap).Parse(commonTemplates + content))
}
}
// SetLanguage change the language for template processing.
func (e *Engine) SetLanguage(language string) {
e.currentLocale = e.translator.GetLanguage(language)
}
// Execute process a template.
func (e *Engine) Execute(w io.Writer, name string, data interface{}) {
tpl, ok := e.templates[name]
if !ok {
logger.Fatal("[Template] The template %s does not exists", name)
}
var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil {
logger.Fatal("[Template] Unable to render template: %v", err)
}
b.WriteTo(w)
}
// NewEngine returns a new template Engine.
func NewEngine(cfg *config.Config, router *mux.Router, translator *locale.Translator) *Engine {
tpl := &Engine{
templates: make(map[string]*template.Template),
router: router,
translator: translator,
cfg: cfg,
}
tpl.parseAll()
return tpl
}
+55 -181
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-05 18:05:45.591353454 -0800 PST m=+0.022613760
// 2018-02-04 14:28:15.215257434 -0800 PST m=+0.025839096
package template
@@ -179,38 +179,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -305,6 +274,9 @@ var templateViewsMap = map[string]string{
<li>
<a href="{{ route "users" }}">{{ t "Users" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -432,7 +404,7 @@ var templateViewsMap = map[string]string{
<div class="panel">
<ul>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed .feed.CheckedAt }}</time></li>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed $.user.Timezone .feed.CheckedAt }}</time></li>
<li><strong>{{ t "ETag header:" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
<li><strong>{{ t "LastModified header:" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
</ul>
@@ -461,6 +433,9 @@ var templateViewsMap = map[string]string{
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -549,7 +524,7 @@ var templateViewsMap = map[string]string{
</span>
</div>
<div class="entry-date">
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
</div>
</header>
{{ if gt (len .entry.Content) 120 }}
@@ -638,38 +613,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -722,7 +666,7 @@ var templateViewsMap = map[string]string{
<a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
</li>
<li>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed $.user.Timezone .CheckedAt }}</time>
</li>
</ul>
<ul>
@@ -785,38 +729,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -901,6 +814,8 @@ var templateViewsMap = map[string]string{
<label for="form-fever-password">{{ t "Fever Password" }}</label>
<input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
<p>{{ t "Fever API endpoint:" }} <strong>{{ rootURL }}{{ route "feverEndpoint" }}</strong></p>
</div>
<h3>Pinboard</h3>
@@ -960,12 +875,27 @@ var templateViewsMap = map[string]string{
</div>
</form>
<h3>{{ t "Miniflux API" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "API Endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "Username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "Password" }} = <strong>{{ t "Your account password" }}</strong>
</li>
</ul>
</div>
<h3>{{ t "Bookmarklet" }}</h3>
<div class="panel">
<h3>{{ t "Bookmarklet" }}</h3>
<p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p>
<div class="bookmarklet">
<a href="javascript:location.href='{{ baseURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
<a href="javascript:location.href='{{ rootURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
</div>
<p>{{ t "Drag and drop this link to your bookmarks." }}</p>
@@ -1022,6 +952,9 @@ var templateViewsMap = map[string]string{
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -1034,7 +967,7 @@ var templateViewsMap = map[string]string{
</tr>
{{ range .sessions }}
<tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</td>
<td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
<td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
<td class="column-20">
@@ -1161,38 +1094,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -1233,38 +1135,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
</ul>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -1290,6 +1161,9 @@ var templateViewsMap = map[string]string{
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -1310,7 +1184,7 @@ var templateViewsMap = map[string]string{
<td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
<td>
{{ if .LastLoginAt }}
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed $.user.Timezone .LastLoginAt }}</time>
{{ else }}
{{ t "Never" }}
{{ end }}
@@ -1339,23 +1213,23 @@ var templateViewsMapChecksums = map[string]string{
"about": "ad2fb778fc73c39b733b3f81b13e5c7d689b041fadd24ee2d4577f545aa788ad",
"add_subscription": "053c920b0d7e109ea19dce6a448e304ce720db8633588ea04db16677f7209a7b",
"categories": "ca1280cd157bb527d4fc907da67b05a8347378f6dce965b9389d4bcdf3600a11",
"category_entries": "ce59529666520b8363c9588ce2c437de5a3f6d91941e5c46be25ca08f6900364",
"category_entries": "686132d71c52a665329670756ac09959d915f7bc3227970149c623059988b035",
"choose_subscription": "a325f9c976ca2b2dc148e25c8fef0cf6ccab0e04e86e604e7812bb18dc4cdde1",
"create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275",
"create_user": "45e226df757126d5fe7c464e295e9a34f07952cfdb71e31e49839850d35af139",
"create_user": "233764778c915754141a20429ec8db9bf80ef2d7704867a2d7232c1e9df233ae",
"edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5",
"edit_feed": "30ff6ee5526b8beb9b457526b77ede55938f53eb279b1acc719ce896359e2ed8",
"edit_user": "82d9749d76ddbd2352816d813c4b1f6d92f2222de678b4afe5821090246735c7",
"entry": "6b4405e0c8e4a7d31874659f8835f4e43e01dc3c20686091517ac750196dd70f",
"feed_entries": "6b4a7d52b2590a7c54b58ed3180bd3bb5d869ceaecc80e3575339a9c351309c6",
"feeds": "65b0a47c4438810b9d51c60f3f3b2519690e56ff74029e6296c68626b83a470b",
"history": "abc7ea29f7d54f28f73fe14979bbd03dbc41fa6a7c86f95f56d6e94f7b09b9ba",
"edit_feed": "d2c1c8486d7faf4ee58151ccf3e3c690e53bd6872050d291c5db8452a83c3d53",
"edit_user": "5edd693460330750ba5ee03319d4e3cb5aabbd9a0e48b3b760799bca72c5ec4e",
"entry": "27ea028515e79beb546f0b2792a3918c455fd877eea4c41d1a061f8e7b54a430",
"feed_entries": "420da786e827a77fecc8794207d158af3a30e489ca2b2019f48d5228919af4a7",
"feeds": "0c884b7a9dfc4541b988641516fd95df062a5bf05018d28276a3c0a10323cffd",
"history": "967bc95236269ab3a77455910aca1939f43f93171fe1af77eb3b1b4eac579e55",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
"integrations": "3c14d7de904911aad7f3ebec6d1a20b50843287f58125c526e167f429f3d455d",
"integrations": "958b73d632a3e2a79368bb1582efb8aabc438cef4fa6e8dc1aa4932494916aca",
"login": "7d83c3067c02f1f6aafdd8816c7f97a4eb5a5a4bdaaaa4cc1e2fbb9c17ea65e8",
"sessions": "9a3609c52b071a280b85fd886f15d4b91c76bb0ab93a99ad132c695a216ac5cf",
"sessions": "3fa79031dd883847eba92fbafe5f535fa3a4e1614bb610f20588b6f8fc8b3624",
"settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",
"starred": "33dd40d1a24739e9d05f9cc4b66497cfdb8c86a7abb209a66ca65c2fbafc7d87",
"unread": "690bac794c4d21086f1a03588753088d7c26385de9a0c2723add6c7905b63f5e",
"users": "44677e28bb5347799ed0020c90ec785aadec4b1454446d92411cfdaf6e32110b",
"starred": "d006f5bcfca7abc7c68b6bb38fe25838acb81b65f960fbf06b97b259ba03b936",
"unread": "ad71a7bdd46c1d650efecbeeb37b7606a046027c15284c0712912b77397f90d4",
"users": "c6d91b0b29984b4cb3073bec6a2933cfb72981ec60f54b6c7aa05194f0e860bd",
}
+6 -2
View File
@@ -59,7 +59,7 @@ func (c *Controller) CheckLogin(ctx *handler.Context, request *handler.Request,
logger.Info("[Controller:CheckLogin] username=%s just logged in", authForm.Username)
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS))
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS, c.cfg.BasePath()))
response.Redirect(ctx.Route("unread"))
}
@@ -67,10 +67,14 @@ func (c *Controller) CheckLogin(ctx *handler.Context, request *handler.Request,
func (c *Controller) Logout(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if err := c.store.UpdateSessionField(ctx.SessionID(), "language", user.Language); err != nil {
logger.Error("[Controller:Logout] %v", err)
}
if err := c.store.RemoveUserSessionByToken(user.ID, ctx.UserSessionToken()); err != nil {
logger.Error("[Controller:Logout] %v", err)
}
response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, c.cfg.IsHTTPS))
response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, c.cfg.IsHTTPS, c.cfg.BasePath()))
response.Redirect(ctx.Route("login"))
}
+6 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/oauth2"
"github.com/tomasen/realip"
)
@@ -102,7 +103,7 @@ func (c *Controller) OAuth2Callback(ctx *handler.Context, request *handler.Reque
}
if user == nil {
if c.cfg.GetInt("OAUTH2_USER_CREATION", 0) == 0 {
if !c.cfg.IsOAuth2UserCreationAllowed() {
response.HTML().Forbidden()
return
}
@@ -131,7 +132,7 @@ func (c *Controller) OAuth2Callback(ctx *handler.Context, request *handler.Reque
logger.Info("[Controller:OAuth2Callback] username=%s just logged in", user.Username)
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS))
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS, c.cfg.BasePath()))
response.Redirect(ctx.Route("unread"))
}
@@ -163,8 +164,8 @@ func (c *Controller) OAuth2Unlink(ctx *handler.Context, request *handler.Request
func getOAuth2Manager(cfg *config.Config) *oauth2.Manager {
return oauth2.NewManager(
cfg.Get("OAUTH2_CLIENT_ID", ""),
cfg.Get("OAUTH2_CLIENT_SECRET", ""),
cfg.Get("OAUTH2_REDIRECT_URL", ""),
cfg.OAuth2ClientID(),
cfg.OAuth2ClientSecret(),
cfg.OAuth2RedirectURL(),
)
}
+13 -10
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-02 21:59:10.089270078 -0800 PST m=+0.016645407
// 2018-01-29 20:38:56.472493026 -0800 PST m=+0.027898185
package static
@@ -8,14 +8,15 @@ var Javascript = map[string]string{
static openNewTab(url){let win=window.open(url,"_blank");win.focus();}
static scrollPageTo(element){let windowScrollPosition=window.pageYOffset;let windowHeight=document.documentElement.clientHeight;let viewportPosition=windowScrollPosition+windowHeight;let itemBottomPosition=element.offsetTop+element.offsetHeight;if(viewportPosition-itemBottomPosition<0||viewportPosition-element.offsetTop>windowHeight){window.scrollTo(0,element.offsetTop-10);}}
static 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;}}
return result;}
static findParent(element,selector){for(;element&&element!==document;element=element.parentNode){if(element.classList.contains(selector)){return element;}}
return null;}}
class TouchHandler{constructor(){this.reset();}
reset(){this.touch={start:{x:-1,y:-1},move:{x:-1,y:-1},element:null};}
calculateDistance(){if(this.touch.start.x>=-1&&this.touch.move.x>=-1){let horizontalDistance=Math.abs(this.touch.move.x-this.touch.start.x);let verticalDistance=Math.abs(this.touch.move.y-this.touch.start.y);if(horizontalDistance>30&&verticalDistance<70){return this.touch.move.x-this.touch.start.x;}}
return 0;}
findElement(element){if(element.classList.contains("touch-item")){return element;}
for(;element&&element!==document;element=element.parentNode){if(element.classList.contains("touch-item")){return element;}}
return null;}
return DomHelper.findParent(element,"touch-item");}
onTouchStart(event){if(event.touches===undefined||event.touches.length!==1){return;}
this.reset();this.touch.start.x=event.touches[0].clientX;this.touch.start.y=event.touches[0].clientY;this.touch.element=this.findElement(event.touches[0].target);}
onTouchMove(event){if(event.touches===undefined||event.touches.length!==1||this.element===null){return;}
@@ -43,10 +44,11 @@ return "";}
execute(){fetch(new Request(this.url,this.options)).then((response)=>{if(this.callback){this.callback(response);}});}}
class UnreadCounterHandler{static decrement(n){this.updateValue((current)=>{return current-n;});}
static increment(n){this.updateValue((current)=>{return current+n;});}
static updateValue(callback){let counterElements=document.querySelectorAll("span.unread-counter");counterElements.forEach((element)=>{let oldValue=parseInt(element.textContent,10);element.innerHTML=callback(oldValue);});}}
class EntryHandler{static updateEntriesStatus(entryIDs,status,callback){let url=document.body.dataset.entriesStatusUrl;let request=new RequestBuilder(url);request.withBody({entry_ids:entryIDs,status:status});request.withCallback(callback);request.execute();}
static toggleEntryStatus(element){let entryID=parseInt(element.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(element.classList.contains("item-status-"+currentStatus)){element.classList.remove("item-status-"+currentStatus);element.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);if(newStatus==="read"){UnreadCounterHandler.decrement(1);}else{UnreadCounterHandler.increment(1);}
static updateValue(callback){let counterElements=document.querySelectorAll("span.unread-counter");counterElements.forEach((element)=>{let oldValue=parseInt(element.textContent,10);element.innerHTML=callback(oldValue);});if(window.location.href.endsWith('/unread')){let oldValue=parseInt(document.title.split('(')[1],10);let newValue=callback(oldValue);document.title=document.title.replace(/(.*?)\(\d+\)(.*?)/,function(match,prefix,suffix,offset,string){return prefix+'('+newValue+')'+suffix;});}}}
class EntryHandler{static updateEntriesStatus(entryIDs,status,callback){let url=document.body.dataset.entriesStatusUrl;let request=new RequestBuilder(url);request.withBody({entry_ids:entryIDs,status:status});request.withCallback(callback);request.execute();if(status==="read"){UnreadCounterHandler.decrement(1);}else{UnreadCounterHandler.increment(1);}}
static toggleEntryStatus(element){let entryID=parseInt(element.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(element.classList.contains("item-status-"+currentStatus)){element.classList.remove("item-status-"+currentStatus);element.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);let link=element.querySelector("a[data-toggle-status]");if(link){this.toggleLinkStatus(link);}
break;}}}
static toggleLinkStatus(link){if(link.dataset.value==="read"){link.innerHTML=link.dataset.labelRead;link.dataset.value="unread";}else{link.innerHTML=link.dataset.labelUnread;link.dataset.value="read";}}
static toggleBookmark(element){element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.bookmarkUrl);request.withCallback(()=>{if(element.dataset.value==="star"){element.innerHTML=element.dataset.labelStar;element.dataset.value="unstar";}else{element.innerHTML=element.dataset.labelUnstar;element.dataset.value="star";}});request.execute();}
static markEntryAsRead(element){if(element.classList.contains("item-status-unread")){element.classList.remove("item-status-unread");element.classList.add("item-status-read");let entryID=parseInt(element.dataset.id,10);this.updateEntriesStatus([entryID],"read");}}
static saveEntry(element){if(element.dataset.completed){return;}
@@ -71,7 +73,8 @@ let currentItem=document.querySelector(".current-item");if(currentItem!==null){t
toggleBookmarkLink(parent){let bookmarkLink=parent.querySelector("a[data-toggle-bookmark]");if(bookmarkLink){EntryHandler.toggleBookmark(bookmarkLink);}}
openOriginalLink(){let entryLink=document.querySelector(".entry h1 a");if(entryLink!==null){DomHelper.openNewTab(entryLink.getAttribute("href"));return;}
let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));let currentItem=document.querySelector(".current-item");this.goToNextListItem();EntryHandler.markEntryAsRead(currentItem);}}
openSelectedItem(){let currentItemLink=document.querySelector(".current-item .item-title a");if(currentItemLink!==null){window.location.href=currentItemLink.getAttribute("href");}}
openSelectedItem(){let currentItemLink=document.querySelector(".current-item .item-title a");if(currentItemLink!==null){let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){let currentItem=document.querySelector(".current-item");EntryHandler.markEntryAsRead(currentItem);}
window.location.href=currentItemLink.getAttribute("href");}}
goToPage(page,fallbackSelf){let element=document.querySelector("a[data-page="+page+"]");if(element){document.location.href=element.href;}else if(fallbackSelf){window.location.reload();}}
goToPrevious(){if(this.isListView()){this.goToPreviousListItem();}else{this.goToPage("previous");}}
goToNext(){if(this.isListView()){this.goToNextListItem();}else{this.goToPage("next");}}
@@ -84,9 +87,9 @@ if(currentItem===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");DomHelper.scrollPageTo(items[i+1]);}
break;}}}
isListView(){return document.querySelector(".items")!==null;}}
document.addEventListener("DOMContentLoaded",function(){FormHandler.handleSubmitButtons();let touchHandler=new TouchHandler();touchHandler.listen();let navHandler=new NavHandler();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>navHandler.goToPage("unread"));keyboardHandler.on("g b",()=>navHandler.goToPage("starred"));keyboardHandler.on("g h",()=>navHandler.goToPage("history"));keyboardHandler.on("g f",()=>navHandler.goToPage("feeds"));keyboardHandler.on("g c",()=>navHandler.goToPage("categories"));keyboardHandler.on("g s",()=>navHandler.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>navHandler.goToPrevious());keyboardHandler.on("ArrowRight",()=>navHandler.goToNext());keyboardHandler.on("j",()=>navHandler.goToPrevious());keyboardHandler.on("p",()=>navHandler.goToPrevious());keyboardHandler.on("k",()=>navHandler.goToNext());keyboardHandler.on("n",()=>navHandler.goToNext());keyboardHandler.on("h",()=>navHandler.goToPage("previous"));keyboardHandler.on("l",()=>navHandler.goToPage("next"));keyboardHandler.on("o",()=>navHandler.openSelectedItem());keyboardHandler.on("v",()=>navHandler.openOriginalLink());keyboardHandler.on("m",()=>navHandler.toggleEntryStatus());keyboardHandler.on("A",()=>navHandler.markPageAsRead());keyboardHandler.on("s",()=>navHandler.saveEntry());keyboardHandler.on("d",()=>navHandler.fetchOriginalContent());keyboardHandler.on("f",()=>navHandler.toggleBookmark());keyboardHandler.on("?",()=>navHandler.showKeyboardShortcuts());keyboardHandler.on("Escape",()=>ModalHandler.close());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-save-entry]",(event)=>{event.preventDefault();EntryHandler.saveEntry(event.target);});mouseHandler.onClick("a[data-toggle-bookmark]",(event)=>{event.preventDefault();EntryHandler.toggleBookmark(event.target);});mouseHandler.onClick("a[data-fetch-content-entry]",(event)=>{event.preventDefault();EntryHandler.fetchOriginalContent(event.target);});mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>navHandler.markPageAsRead());mouseHandler.onClick("a[data-confirm]",(event)=>{(new ConfirmHandler()).handle(event);});if(document.documentElement.clientWidth<600){let menuHandler=new MenuHandler();mouseHandler.onClick(".logo",()=>menuHandler.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>menuHandler.clickMenuListItem(event));}});})();`,
document.addEventListener("DOMContentLoaded",function(){FormHandler.handleSubmitButtons();let touchHandler=new TouchHandler();touchHandler.listen();let navHandler=new NavHandler();let keyboardHandler=new KeyboardHandler();keyboardHandler.on("g u",()=>navHandler.goToPage("unread"));keyboardHandler.on("g b",()=>navHandler.goToPage("starred"));keyboardHandler.on("g h",()=>navHandler.goToPage("history"));keyboardHandler.on("g f",()=>navHandler.goToPage("feeds"));keyboardHandler.on("g c",()=>navHandler.goToPage("categories"));keyboardHandler.on("g s",()=>navHandler.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>navHandler.goToPrevious());keyboardHandler.on("ArrowRight",()=>navHandler.goToNext());keyboardHandler.on("j",()=>navHandler.goToPrevious());keyboardHandler.on("p",()=>navHandler.goToPrevious());keyboardHandler.on("k",()=>navHandler.goToNext());keyboardHandler.on("n",()=>navHandler.goToNext());keyboardHandler.on("h",()=>navHandler.goToPage("previous"));keyboardHandler.on("l",()=>navHandler.goToPage("next"));keyboardHandler.on("o",()=>navHandler.openSelectedItem());keyboardHandler.on("v",()=>navHandler.openOriginalLink());keyboardHandler.on("m",()=>navHandler.toggleEntryStatus());keyboardHandler.on("A",()=>navHandler.markPageAsRead());keyboardHandler.on("s",()=>navHandler.saveEntry());keyboardHandler.on("d",()=>navHandler.fetchOriginalContent());keyboardHandler.on("f",()=>navHandler.toggleBookmark());keyboardHandler.on("?",()=>navHandler.showKeyboardShortcuts());keyboardHandler.on("Escape",()=>ModalHandler.close());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-save-entry]",(event)=>{event.preventDefault();EntryHandler.saveEntry(event.target);});mouseHandler.onClick("a[data-toggle-bookmark]",(event)=>{event.preventDefault();EntryHandler.toggleBookmark(event.target);});mouseHandler.onClick("a[data-toggle-status]",(event)=>{event.preventDefault();let currentItem=DomHelper.findParent(event.target,"item");if(currentItem){EntryHandler.toggleEntryStatus(currentItem);}});mouseHandler.onClick("a[data-fetch-content-entry]",(event)=>{event.preventDefault();EntryHandler.fetchOriginalContent(event.target);});mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>navHandler.markPageAsRead());mouseHandler.onClick("a[data-confirm]",(event)=>{(new ConfirmHandler()).handle(event);});if(document.documentElement.clientWidth<600){let menuHandler=new MenuHandler();mouseHandler.onClick(".logo",()=>menuHandler.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>menuHandler.clickMenuListItem(event));}});})();`,
}
var JavascriptChecksums = map[string]string{
"app": "36dfcfb33ddc3f75f701fc4353873e2ce6da813dbfdd3b37100a4475a32b0545",
"app": "cfeb45231fd784b9eaceefb12ecf7f2543e4c871fb3af3cebf25757446e3eae7",
}
+68 -11
View File
@@ -35,6 +35,16 @@ class DomHelper {
return result;
}
static findParent(element, selector) {
for (; element && element !== document; element = element.parentNode) {
if (element.classList.contains(selector)) {
return element;
}
}
return null;
}
}
class TouchHandler {
@@ -68,13 +78,7 @@ class TouchHandler {
return element;
}
for (; element && element !== document; element = element.parentNode) {
if (element.classList.contains("touch-item")) {
return element;
}
}
return null;
return DomHelper.findParent(element, "touch-item");
}
onTouchStart(event) {
@@ -293,6 +297,27 @@ class UnreadCounterHandler {
let oldValue = parseInt(element.textContent, 10);
element.innerHTML = callback(oldValue);
});
// The titlebar must be updated only on the "Unread" page.
if (window.location.href.endsWith('/unread')) {
// The following 3 lines ensure that the unread count in the titlebar
// is updated correctly when users presses "v".
let oldValue = parseInt(document.title.split('(')[1], 10);
let newValue = callback(oldValue);
// Notes:
// - This will only be executed in the /unread page. Therefore, it
// will not affect titles on other pages.
// - When there are no unread items, user cannot press "v".
// Therefore, we need not handle the case where title is
// "Unread Items - Miniflux". This applies to other cases as well.
// i.e.: if there are no unread items, user cannot decrement or
// increment anything.
document.title = document.title.replace(
/(.*?)\(\d+\)(.*?)/,
function (match, prefix, suffix, offset, string) {
return prefix + '(' + newValue + ')' + suffix;
}
);
}
}
}
@@ -303,6 +328,13 @@ class EntryHandler {
request.withBody({entry_ids: entryIDs, status: status});
request.withCallback(callback);
request.execute();
// The following 5 lines ensure that the unread count in the menu is
// updated correctly when users presses "v".
if (status === "read") {
UnreadCounterHandler.decrement(1);
} else {
UnreadCounterHandler.increment(1);
}
}
static toggleEntryStatus(element) {
@@ -318,10 +350,9 @@ class EntryHandler {
this.updateEntriesStatus([entryID], newStatus);
if (newStatus === "read") {
UnreadCounterHandler.decrement(1);
} else {
UnreadCounterHandler.increment(1);
let link = element.querySelector("a[data-toggle-status]");
if (link) {
this.toggleLinkStatus(link);
}
break;
@@ -329,6 +360,16 @@ class EntryHandler {
}
}
static toggleLinkStatus(link) {
if (link.dataset.value === "read") {
link.innerHTML = link.dataset.labelRead;
link.dataset.value = "unread";
} else {
link.innerHTML = link.dataset.labelUnread;
link.dataset.value = "read";
}
}
static toggleBookmark(element) {
element.innerHTML = element.dataset.labelLoading;
@@ -594,6 +635,13 @@ class NavHandler {
openSelectedItem() {
let currentItemLink = document.querySelector(".current-item .item-title a");
if (currentItemLink !== null) {
// The following 4 lines ensure that the unread count in the menu is
// updated correctly when users presses "o".
let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]");
if (currentItemOriginalLink !== null) {
let currentItem = document.querySelector(".current-item");
EntryHandler.markEntryAsRead(currentItem);
}
window.location.href = currentItemLink.getAttribute("href");
}
}
@@ -728,6 +776,15 @@ document.addEventListener("DOMContentLoaded", function() {
EntryHandler.toggleBookmark(event.target);
});
mouseHandler.onClick("a[data-toggle-status]", (event) => {
event.preventDefault();
let currentItem = DomHelper.findParent(event.target, "item");
if (currentItem) {
EntryHandler.toggleEntryStatus(currentItem);
}
});
mouseHandler.onClick("a[data-fetch-content-entry]", (event) => {
event.preventDefault();
EntryHandler.fetchOriginalContent(event.target);
+1 -1
View File
@@ -69,7 +69,7 @@ func (c *Controller) SubmitSubscription(ctx *handler.Context, request *handler.R
return
}
logger.Info("[UI:SubmitSubscription] %s", subscriptions)
logger.Debug("[UI:SubmitSubscription] %s", subscriptions)
n := len(subscriptions)
switch {
+15
View File
@@ -40,6 +40,21 @@ func main() {
return
}
fmt.Println(feeds)
// Backup to opml file.
opml, err := client.Export()
if err != nil {
fmt.Println(err)
return
}
err = ioutil.WriteFile("opml.xml", opml, 0644)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("backup done!")
}
```
+17
View File
@@ -7,6 +7,7 @@ package miniflux
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/url"
"strconv"
)
@@ -213,6 +214,22 @@ func (c *Client) Feeds() (Feeds, error) {
return feeds, nil
}
// Export creates OPML file.
func (c *Client) Export() ([]byte, error) {
body, err := c.request.Get("/v1/export")
if err != nil {
return nil, err
}
defer body.Close()
opml, err := ioutil.ReadAll(body)
if err != nil {
return nil, err
}
return opml, nil
}
// Feed gets a feed.
func (c *Client) Feed(feedID int64) (*Feed, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d", feedID))
+10 -8
View File
@@ -18,14 +18,16 @@ const (
// 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"`
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"`
EntryDirection string `json:"entry_sorting_direction"`
LastLoginAt *time.Time `json:"last_login_at"`
Extra map[string]string `json:"extra"`
}
func (u User) String() string {
+241 -89
View File
@@ -24,7 +24,9 @@ import (
"fmt"
"io"
mathrand "math/rand"
"net"
"net/http"
"path"
"strconv"
"strings"
"sync"
@@ -80,8 +82,9 @@ func defaultHostPolicy(context.Context, string) error {
}
// Manager is a stateful certificate manager built on top of acme.Client.
// It obtains and refreshes certificates automatically,
// as well as providing them to a TLS server via tls.Config.
// It obtains and refreshes certificates automatically using "tls-sni-01",
// "tls-sni-02" and "http-01" challenge types, as well as providing them
// to a TLS server via tls.Config.
//
// You must specify a cache implementation, such as DirCache,
// to reuse obtained certificates across program restarts.
@@ -150,15 +153,26 @@ type Manager struct {
stateMu sync.Mutex
state map[string]*certState // keyed by domain name
// tokenCert is keyed by token domain name, which matches server name
// of ClientHello. Keys always have ".acme.invalid" suffix.
tokenCertMu sync.RWMutex
tokenCert map[string]*tls.Certificate
// renewal tracks the set of domains currently running renewal timers.
// It is keyed by domain name.
renewalMu sync.Mutex
renewal map[string]*domainRenewal
// tokensMu guards the rest of the fields: tryHTTP01, certTokens and httpTokens.
tokensMu sync.RWMutex
// tryHTTP01 indicates whether the Manager should try "http-01" challenge type
// during the authorization flow.
tryHTTP01 bool
// httpTokens contains response body values for http-01 challenges
// and is keyed by the URL path at which a challenge response is expected
// to be provisioned.
// The entries are stored for the duration of the authorization flow.
httpTokens map[string][]byte
// certTokens contains temporary certificates for tls-sni challenges
// and is keyed by token domain name, which matches server name of ClientHello.
// Keys always have ".acme.invalid" suffix.
// The entries are stored for the duration of the authorization flow.
certTokens map[string]*tls.Certificate
}
// GetCertificate implements the tls.Config.GetCertificate hook.
@@ -185,14 +199,16 @@ func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
return nil, errors.New("acme/autocert: server name contains invalid character")
}
// In the worst-case scenario, the timeout needs to account for caching, host policy,
// domain ownership verification and certificate issuance.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// check whether this is a token cert requested for TLS-SNI challenge
if strings.HasSuffix(name, ".acme.invalid") {
m.tokenCertMu.RLock()
defer m.tokenCertMu.RUnlock()
if cert := m.tokenCert[name]; cert != nil {
m.tokensMu.RLock()
defer m.tokensMu.RUnlock()
if cert := m.certTokens[name]; cert != nil {
return cert, nil
}
if cert, err := m.cacheGet(ctx, name); err == nil {
@@ -224,6 +240,68 @@ func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
return cert, nil
}
// HTTPHandler configures the Manager to provision ACME "http-01" challenge responses.
// It returns an http.Handler that responds to the challenges and must be
// running on port 80. If it receives a request that is not an ACME challenge,
// it delegates the request to the optional fallback handler.
//
// If fallback is nil, the returned handler redirects all GET and HEAD requests
// to the default TLS port 443 with 302 Found status code, preserving the original
// request path and query. It responds with 400 Bad Request to all other HTTP methods.
// The fallback is not protected by the optional HostPolicy.
//
// Because the fallback handler is run with unencrypted port 80 requests,
// the fallback should not serve TLS-only requests.
//
// If HTTPHandler is never called, the Manager will only use TLS SNI
// challenges for domain verification.
func (m *Manager) HTTPHandler(fallback http.Handler) http.Handler {
m.tokensMu.Lock()
defer m.tokensMu.Unlock()
m.tryHTTP01 = true
if fallback == nil {
fallback = http.HandlerFunc(handleHTTPRedirect)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
fallback.ServeHTTP(w, r)
return
}
// A reasonable context timeout for cache and host policy only,
// because we don't wait for a new certificate issuance here.
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if err := m.hostPolicy()(ctx, r.Host); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
data, err := m.httpToken(ctx, r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Write(data)
})
}
func handleHTTPRedirect(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "HEAD" {
http.Error(w, "Use HTTPS", http.StatusBadRequest)
return
}
target := "https://" + stripPort(r.Host) + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusFound)
}
func stripPort(hostport string) string {
host, _, err := net.SplitHostPort(hostport)
if err != nil {
return hostport
}
return net.JoinHostPort(host, "443")
}
// cert returns an existing certificate either from m.state or cache.
// If a certificate is found in cache but not in m.state, the latter will be filled
// with the cached value.
@@ -442,13 +520,14 @@ func (m *Manager) certState(domain string) (*certState, error) {
// authorizedCert starts the domain ownership verification process and requests a new cert upon success.
// The key argument is the certificate private key.
func (m *Manager) authorizedCert(ctx context.Context, key crypto.Signer, domain string) (der [][]byte, leaf *x509.Certificate, err error) {
if err := m.verify(ctx, domain); err != nil {
return nil, nil, err
}
client, err := m.acmeClient(ctx)
if err != nil {
return nil, nil, err
}
if err := m.verify(ctx, client, domain); err != nil {
return nil, nil, err
}
csr, err := certRequest(key, domain)
if err != nil {
return nil, nil, err
@@ -464,98 +543,171 @@ func (m *Manager) authorizedCert(ctx context.Context, key crypto.Signer, domain
return der, leaf, nil
}
// verify starts a new identifier (domain) authorization flow.
// It prepares a challenge response and then blocks until the authorization
// is marked as "completed" by the CA (either succeeded or failed).
//
// verify returns nil iff the verification was successful.
func (m *Manager) verify(ctx context.Context, domain string) error {
client, err := m.acmeClient(ctx)
if err != nil {
return err
// verify runs the identifier (domain) authorization flow
// using each applicable ACME challenge type.
func (m *Manager) verify(ctx context.Context, client *acme.Client, domain string) error {
// The list of challenge types we'll try to fulfill
// in this specific order.
challengeTypes := []string{"tls-sni-02", "tls-sni-01"}
m.tokensMu.RLock()
if m.tryHTTP01 {
challengeTypes = append(challengeTypes, "http-01")
}
m.tokensMu.RUnlock()
// start domain authorization and get the challenge
authz, err := client.Authorize(ctx, domain)
if err != nil {
return err
}
// maybe don't need to at all
if authz.Status == acme.StatusValid {
return nil
}
// pick a challenge: prefer tls-sni-02 over tls-sni-01
// TODO: consider authz.Combinations
var chal *acme.Challenge
for _, c := range authz.Challenges {
if c.Type == "tls-sni-02" {
chal = c
break
var nextTyp int // challengeType index of the next challenge type to try
for {
// Start domain authorization and get the challenge.
authz, err := client.Authorize(ctx, domain)
if err != nil {
return err
}
if c.Type == "tls-sni-01" {
chal = c
// No point in accepting challenges if the authorization status
// is in a final state.
switch authz.Status {
case acme.StatusValid:
return nil // already authorized
case acme.StatusInvalid:
return fmt.Errorf("acme/autocert: invalid authorization %q", authz.URI)
}
// Pick the next preferred challenge.
var chal *acme.Challenge
for chal == nil && nextTyp < len(challengeTypes) {
chal = pickChallenge(challengeTypes[nextTyp], authz.Challenges)
nextTyp++
}
if chal == nil {
return fmt.Errorf("acme/autocert: unable to authorize %q; tried %q", domain, challengeTypes)
}
cleanup, err := m.fulfill(ctx, client, chal)
if err != nil {
continue
}
defer cleanup()
if _, err := client.Accept(ctx, chal); err != nil {
continue
}
// A challenge is fulfilled and accepted: wait for the CA to validate.
if _, err := client.WaitAuthorization(ctx, authz.URI); err == nil {
return nil
}
}
if chal == nil {
return errors.New("acme/autocert: no supported challenge type found")
}
// create a token cert for the challenge response
var (
cert tls.Certificate
name string
)
switch chal.Type {
case "tls-sni-01":
cert, name, err = client.TLSSNI01ChallengeCert(chal.Token)
case "tls-sni-02":
cert, name, err = client.TLSSNI02ChallengeCert(chal.Token)
default:
err = fmt.Errorf("acme/autocert: unknown challenge type %q", chal.Type)
}
if err != nil {
return err
}
m.putTokenCert(ctx, name, &cert)
defer func() {
// verification has ended at this point
// don't need token cert anymore
go m.deleteTokenCert(name)
}()
// ready to fulfill the challenge
if _, err := client.Accept(ctx, chal); err != nil {
return err
}
// wait for the CA to validate
_, err = client.WaitAuthorization(ctx, authz.URI)
return err
}
// putTokenCert stores the cert under the named key in both m.tokenCert map
// and m.Cache.
func (m *Manager) putTokenCert(ctx context.Context, name string, cert *tls.Certificate) {
m.tokenCertMu.Lock()
defer m.tokenCertMu.Unlock()
if m.tokenCert == nil {
m.tokenCert = make(map[string]*tls.Certificate)
// fulfill provisions a response to the challenge chal.
// The cleanup is non-nil only if provisioning succeeded.
func (m *Manager) fulfill(ctx context.Context, client *acme.Client, chal *acme.Challenge) (cleanup func(), err error) {
switch chal.Type {
case "tls-sni-01":
cert, name, err := client.TLSSNI01ChallengeCert(chal.Token)
if err != nil {
return nil, err
}
m.putCertToken(ctx, name, &cert)
return func() { go m.deleteCertToken(name) }, nil
case "tls-sni-02":
cert, name, err := client.TLSSNI02ChallengeCert(chal.Token)
if err != nil {
return nil, err
}
m.putCertToken(ctx, name, &cert)
return func() { go m.deleteCertToken(name) }, nil
case "http-01":
resp, err := client.HTTP01ChallengeResponse(chal.Token)
if err != nil {
return nil, err
}
p := client.HTTP01ChallengePath(chal.Token)
m.putHTTPToken(ctx, p, resp)
return func() { go m.deleteHTTPToken(p) }, nil
}
m.tokenCert[name] = cert
return nil, fmt.Errorf("acme/autocert: unknown challenge type %q", chal.Type)
}
func pickChallenge(typ string, chal []*acme.Challenge) *acme.Challenge {
for _, c := range chal {
if c.Type == typ {
return c
}
}
return nil
}
// putCertToken stores the cert under the named key in both m.certTokens map
// and m.Cache.
func (m *Manager) putCertToken(ctx context.Context, name string, cert *tls.Certificate) {
m.tokensMu.Lock()
defer m.tokensMu.Unlock()
if m.certTokens == nil {
m.certTokens = make(map[string]*tls.Certificate)
}
m.certTokens[name] = cert
m.cachePut(ctx, name, cert)
}
// deleteTokenCert removes the token certificate for the specified domain name
// from both m.tokenCert map and m.Cache.
func (m *Manager) deleteTokenCert(name string) {
m.tokenCertMu.Lock()
defer m.tokenCertMu.Unlock()
delete(m.tokenCert, name)
// deleteCertToken removes the token certificate for the specified domain name
// from both m.certTokens map and m.Cache.
func (m *Manager) deleteCertToken(name string) {
m.tokensMu.Lock()
defer m.tokensMu.Unlock()
delete(m.certTokens, name)
if m.Cache != nil {
m.Cache.Delete(context.Background(), name)
}
}
// httpToken retrieves an existing http-01 token value from an in-memory map
// or the optional cache.
func (m *Manager) httpToken(ctx context.Context, tokenPath string) ([]byte, error) {
m.tokensMu.RLock()
defer m.tokensMu.RUnlock()
if v, ok := m.httpTokens[tokenPath]; ok {
return v, nil
}
if m.Cache == nil {
return nil, fmt.Errorf("acme/autocert: no token at %q", tokenPath)
}
return m.Cache.Get(ctx, httpTokenCacheKey(tokenPath))
}
// putHTTPToken stores an http-01 token value using tokenPath as key
// in both in-memory map and the optional Cache.
//
// It ignores any error returned from Cache.Put.
func (m *Manager) putHTTPToken(ctx context.Context, tokenPath, val string) {
m.tokensMu.Lock()
defer m.tokensMu.Unlock()
if m.httpTokens == nil {
m.httpTokens = make(map[string][]byte)
}
b := []byte(val)
m.httpTokens[tokenPath] = b
if m.Cache != nil {
m.Cache.Put(ctx, httpTokenCacheKey(tokenPath), b)
}
}
// deleteHTTPToken removes an http-01 token value from both in-memory map
// and the optional Cache, ignoring any error returned from the latter.
//
// If m.Cache is non-nil, it blocks until Cache.Delete returns without a timeout.
func (m *Manager) deleteHTTPToken(tokenPath string) {
m.tokensMu.Lock()
defer m.tokensMu.Unlock()
delete(m.httpTokens, tokenPath)
if m.Cache != nil {
m.Cache.Delete(context.Background(), httpTokenCacheKey(tokenPath))
}
}
// httpTokenCacheKey returns a key at which an http-01 token value may be stored
// in the Manager's optional Cache.
func httpTokenCacheKey(tokenPath string) string {
return "http-01-" + path.Base(tokenPath)
}
// renew starts a cert renewal timer loop, one per domain.
//
// The loop is scheduled in two cases:
+151
View File
@@ -23,6 +23,7 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -48,6 +49,16 @@ var authzTmpl = template.Must(template.New("authz").Parse(`{
"uri": "{{.}}/challenge/2",
"type": "tls-sni-02",
"token": "token-02"
},
{
"uri": "{{.}}/challenge/dns-01",
"type": "dns-01",
"token": "token-dns-01"
},
{
"uri": "{{.}}/challenge/http-01",
"type": "http-01",
"token": "token-http-01"
}
]
}`))
@@ -419,6 +430,146 @@ func testGetCertificate(t *testing.T, man *Manager, domain string, hello *tls.Cl
}
func TestVerifyHTTP01(t *testing.T) {
var (
http01 http.Handler
authzCount int // num. of created authorizations
didAcceptHTTP01 bool
)
verifyHTTPToken := func() {
r := httptest.NewRequest("GET", "/.well-known/acme-challenge/token-http-01", nil)
w := httptest.NewRecorder()
http01.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("http token: w.Code = %d; want %d", w.Code, http.StatusOK)
}
if v := string(w.Body.Bytes()); !strings.HasPrefix(v, "token-http-01.") {
t.Errorf("http token value = %q; want 'token-http-01.' prefix", v)
}
}
// ACME CA server stub, only the needed bits.
// TODO: Merge this with startACMEServerStub, making it a configurable CA for testing.
var ca *httptest.Server
ca = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "nonce")
if r.Method == "HEAD" {
// a nonce request
return
}
switch r.URL.Path {
// Discovery.
case "/":
if err := discoTmpl.Execute(w, ca.URL); err != nil {
t.Errorf("discoTmpl: %v", err)
}
// Client key registration.
case "/new-reg":
w.Write([]byte("{}"))
// New domain authorization.
case "/new-authz":
authzCount++
w.Header().Set("Location", fmt.Sprintf("%s/authz/%d", ca.URL, authzCount))
w.WriteHeader(http.StatusCreated)
if err := authzTmpl.Execute(w, ca.URL); err != nil {
t.Errorf("authzTmpl: %v", err)
}
// Accept tls-sni-02.
case "/challenge/2":
w.Write([]byte("{}"))
// Reject tls-sni-01.
case "/challenge/1":
http.Error(w, "won't accept tls-sni-01", http.StatusBadRequest)
// Should not accept dns-01.
case "/challenge/dns-01":
t.Errorf("dns-01 challenge was accepted")
http.Error(w, "won't accept dns-01", http.StatusBadRequest)
// Accept http-01.
case "/challenge/http-01":
didAcceptHTTP01 = true
verifyHTTPToken()
w.Write([]byte("{}"))
// Authorization statuses.
// Make tls-sni-xxx invalid.
case "/authz/1", "/authz/2":
w.Write([]byte(`{"status": "invalid"}`))
case "/authz/3", "/authz/4":
w.Write([]byte(`{"status": "valid"}`))
default:
http.NotFound(w, r)
t.Errorf("unrecognized r.URL.Path: %s", r.URL.Path)
}
}))
defer ca.Close()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
m := &Manager{
Client: &acme.Client{
Key: key,
DirectoryURL: ca.URL,
},
}
http01 = m.HTTPHandler(nil)
if err := m.verify(context.Background(), m.Client, "example.org"); err != nil {
t.Errorf("m.verify: %v", err)
}
// Only tls-sni-01, tls-sni-02 and http-01 must be accepted
// The dns-01 challenge is unsupported.
if authzCount != 3 {
t.Errorf("authzCount = %d; want 3", authzCount)
}
if !didAcceptHTTP01 {
t.Error("did not accept http-01 challenge")
}
}
func TestHTTPHandlerDefaultFallback(t *testing.T) {
tt := []struct {
method, url string
wantCode int
wantLocation string
}{
{"GET", "http://example.org", 302, "https://example.org/"},
{"GET", "http://example.org/foo", 302, "https://example.org/foo"},
{"GET", "http://example.org/foo/bar/", 302, "https://example.org/foo/bar/"},
{"GET", "http://example.org/?a=b", 302, "https://example.org/?a=b"},
{"GET", "http://example.org/foo?a=b", 302, "https://example.org/foo?a=b"},
{"GET", "http://example.org:80/foo?a=b", 302, "https://example.org:443/foo?a=b"},
{"GET", "http://example.org:80/foo%20bar", 302, "https://example.org:443/foo%20bar"},
{"GET", "http://[2602:d1:xxxx::c60a]:1234", 302, "https://[2602:d1:xxxx::c60a]:443/"},
{"GET", "http://[2602:d1:xxxx::c60a]", 302, "https://[2602:d1:xxxx::c60a]/"},
{"GET", "http://[2602:d1:xxxx::c60a]/foo?a=b", 302, "https://[2602:d1:xxxx::c60a]/foo?a=b"},
{"HEAD", "http://example.org", 302, "https://example.org/"},
{"HEAD", "http://example.org/foo", 302, "https://example.org/foo"},
{"HEAD", "http://example.org/foo/bar/", 302, "https://example.org/foo/bar/"},
{"HEAD", "http://example.org/?a=b", 302, "https://example.org/?a=b"},
{"HEAD", "http://example.org/foo?a=b", 302, "https://example.org/foo?a=b"},
{"POST", "http://example.org", 400, ""},
{"PUT", "http://example.org", 400, ""},
{"GET", "http://example.org/.well-known/acme-challenge/x", 404, ""},
}
var m Manager
h := m.HTTPHandler(nil)
for i, test := range tt {
r := httptest.NewRequest(test.method, test.url, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != test.wantCode {
t.Errorf("%d: w.Code = %d; want %d", i, w.Code, test.wantCode)
t.Errorf("%d: body: %s", i, w.Body.Bytes())
}
if v := w.Header().Get("Location"); v != test.wantLocation {
t.Errorf("%d: Location = %q; want %q", i, v, test.wantLocation)
}
}
}
func TestAccountKeyCache(t *testing.T) {
m := Manager{Cache: newMemCache()}
ctx := context.Background()
+2 -1
View File
@@ -22,11 +22,12 @@ func ExampleNewListener() {
}
func ExampleManager() {
m := autocert.Manager{
m := &autocert.Manager{
Cache: autocert.DirCache("secret-dir"),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.org"),
}
go http.ListenAndServe(":http", m.HTTPHandler(nil))
s := &http.Server{
Addr: ":https",
TLSConfig: &tls.Config{GetCertificate: m.GetCertificate},
+228
View File
@@ -0,0 +1,228 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package argon2 implements the key derivation function Argon2.
// Argon2 was selected as the winner of the Password Hashing Competition and can
// be used to derive cryptographic keys from passwords.
// Argon2 is specfifed at https://github.com/P-H-C/phc-winner-argon2/blob/master/argon2-specs.pdf
package argon2
import (
"encoding/binary"
"sync"
"golang.org/x/crypto/blake2b"
)
// The Argon2 version implemented by this package.
const Version = 0x13
const (
argon2d = iota
argon2i
argon2id
)
// Key derives a key from the password, salt, and cost parameters using Argon2i
// returning a byte slice of length keyLen that can be used as cryptographic key.
// The CPU cost and parallism degree must be greater than zero.
//
// For example, you can get a derived key for e.g. AES-256 (which needs a 32-byte key) by doing:
// `key := argon2.Key([]byte("some password"), salt, 4, 32*1024, 4, 32)`
//
// The recommended parameters for interactive logins as of 2017 are time=4, memory=32*1024.
// The number of threads can be adjusted to the numbers of available CPUs.
// The time parameter specifies the number of passes over the memory and the memory
// parameter specifies the size of the memory in KiB. For example memory=32*1024 sets the
// memory cost to ~32 MB.
// The cost parameters should be increased as memory latency and CPU parallelism increases.
// Remember to get a good random salt.
func Key(password, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte {
return deriveKey(argon2i, password, salt, nil, nil, time, memory, threads, keyLen)
}
func deriveKey(mode int, password, salt, secret, data []byte, time, memory uint32, threads uint8, keyLen uint32) []byte {
if time < 1 {
panic("argon2: number of rounds too small")
}
if threads < 1 {
panic("argon2: parallelism degree too low")
}
h0 := initHash(password, salt, secret, data, time, memory, uint32(threads), keyLen, mode)
memory = memory / (syncPoints * uint32(threads)) * (syncPoints * uint32(threads))
if memory < 2*syncPoints*uint32(threads) {
memory = 2 * syncPoints * uint32(threads)
}
B := initBlocks(&h0, memory, uint32(threads))
processBlocks(B, time, memory, uint32(threads), mode)
return extractKey(B, memory, uint32(threads), keyLen)
}
const (
blockLength = 128
syncPoints = 4
)
type block [blockLength]uint64
func initHash(password, salt, key, data []byte, time, memory, threads, keyLen uint32, mode int) [blake2b.Size + 8]byte {
var (
h0 [blake2b.Size + 8]byte
params [24]byte
tmp [4]byte
)
b2, _ := blake2b.New512(nil)
binary.LittleEndian.PutUint32(params[0:4], threads)
binary.LittleEndian.PutUint32(params[4:8], keyLen)
binary.LittleEndian.PutUint32(params[8:12], memory)
binary.LittleEndian.PutUint32(params[12:16], time)
binary.LittleEndian.PutUint32(params[16:20], uint32(Version))
binary.LittleEndian.PutUint32(params[20:24], uint32(mode))
b2.Write(params[:])
binary.LittleEndian.PutUint32(tmp[:], uint32(len(password)))
b2.Write(tmp[:])
b2.Write(password)
binary.LittleEndian.PutUint32(tmp[:], uint32(len(salt)))
b2.Write(tmp[:])
b2.Write(salt)
binary.LittleEndian.PutUint32(tmp[:], uint32(len(key)))
b2.Write(tmp[:])
b2.Write(key)
binary.LittleEndian.PutUint32(tmp[:], uint32(len(data)))
b2.Write(tmp[:])
b2.Write(data)
b2.Sum(h0[:0])
return h0
}
func initBlocks(h0 *[blake2b.Size + 8]byte, memory, threads uint32) []block {
var block0 [1024]byte
B := make([]block, memory)
for lane := uint32(0); lane < threads; lane++ {
j := lane * (memory / threads)
binary.LittleEndian.PutUint32(h0[blake2b.Size+4:], lane)
binary.LittleEndian.PutUint32(h0[blake2b.Size:], 0)
blake2bHash(block0[:], h0[:])
for i := range B[j+0] {
B[j+0][i] = binary.LittleEndian.Uint64(block0[i*8:])
}
binary.LittleEndian.PutUint32(h0[blake2b.Size:], 1)
blake2bHash(block0[:], h0[:])
for i := range B[j+1] {
B[j+1][i] = binary.LittleEndian.Uint64(block0[i*8:])
}
}
return B
}
func processBlocks(B []block, time, memory, threads uint32, mode int) {
lanes := memory / threads
segments := lanes / syncPoints
processSegment := func(n, slice, lane uint32, wg *sync.WaitGroup) {
var addresses, in, zero block
if mode == argon2i || (mode == argon2id && n == 0 && slice < syncPoints/2) {
in[0] = uint64(n)
in[1] = uint64(lane)
in[2] = uint64(slice)
in[3] = uint64(memory)
in[4] = uint64(time)
in[5] = uint64(mode)
}
index := uint32(0)
if n == 0 && slice == 0 {
index = 2 // we have already generated the first two blocks
if mode == argon2i || mode == argon2id {
in[6]++
processBlock(&addresses, &in, &zero)
processBlock(&addresses, &addresses, &zero)
}
}
offset := lane*lanes + slice*segments + index
var random uint64
for index < segments {
prev := offset - 1
if index == 0 && slice == 0 {
prev += lanes // last block in lane
}
if mode == argon2i || (mode == argon2id && n == 0 && slice < syncPoints/2) {
if index%blockLength == 0 {
in[6]++
processBlock(&addresses, &in, &zero)
processBlock(&addresses, &addresses, &zero)
}
random = addresses[index%blockLength]
} else {
random = B[prev][0]
}
newOffset := indexAlpha(random, lanes, segments, threads, n, slice, lane, index)
processBlockXOR(&B[offset], &B[prev], &B[newOffset])
index, offset = index+1, offset+1
}
wg.Done()
}
for n := uint32(0); n < time; n++ {
for slice := uint32(0); slice < syncPoints; slice++ {
var wg sync.WaitGroup
for lane := uint32(0); lane < threads; lane++ {
wg.Add(1)
go processSegment(n, slice, lane, &wg)
}
wg.Wait()
}
}
}
func extractKey(B []block, memory, threads, keyLen uint32) []byte {
lanes := memory / threads
for lane := uint32(0); lane < threads-1; lane++ {
for i, v := range B[(lane*lanes)+lanes-1] {
B[memory-1][i] ^= v
}
}
var block [1024]byte
for i, v := range B[memory-1] {
binary.LittleEndian.PutUint64(block[i*8:], v)
}
key := make([]byte, keyLen)
blake2bHash(key, block[:])
return key
}
func indexAlpha(rand uint64, lanes, segments, threads, n, slice, lane, index uint32) uint32 {
refLane := uint32(rand>>32) % threads
if n == 0 && slice == 0 {
refLane = lane
}
m, s := 3*segments, ((slice+1)%syncPoints)*segments
if lane == refLane {
m += index
}
if n == 0 {
m, s = slice*segments, 0
if slice == 0 || lane == refLane {
m += index
}
}
if index == 0 || lane == refLane {
m--
}
return phi(rand, uint64(m), uint64(s), refLane, lanes)
}
func phi(rand, m, s uint64, lane, lanes uint32) uint32 {
p := rand & 0xFFFFFFFF
p = (p * p) >> 32
p = (p * m) >> 32
return lane*lanes + uint32((s+m-(p+1))%uint64(lanes))
}
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package argon2
import (
"bytes"
"encoding/hex"
"testing"
)
var (
genKatPassword = []byte{
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
}
genKatSalt = []byte{0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02}
genKatSecret = []byte{0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03}
genKatAAD = []byte{0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}
)
func TestArgon2(t *testing.T) {
defer func(sse4 bool) { useSSE4 = sse4 }(useSSE4)
if useSSE4 {
t.Log("SSE4.1 version")
testArgon2i(t)
testArgon2d(t)
testArgon2id(t)
useSSE4 = false
}
t.Log("generic version")
testArgon2i(t)
testArgon2d(t)
testArgon2id(t)
}
func testArgon2d(t *testing.T) {
want := []byte{
0x51, 0x2b, 0x39, 0x1b, 0x6f, 0x11, 0x62, 0x97,
0x53, 0x71, 0xd3, 0x09, 0x19, 0x73, 0x42, 0x94,
0xf8, 0x68, 0xe3, 0xbe, 0x39, 0x84, 0xf3, 0xc1,
0xa1, 0x3a, 0x4d, 0xb9, 0xfa, 0xbe, 0x4a, 0xcb,
}
hash := deriveKey(argon2d, genKatPassword, genKatSalt, genKatSecret, genKatAAD, 3, 32, 4, 32)
if !bytes.Equal(hash, want) {
t.Errorf("derived key does not match - got: %s , want: %s", hex.EncodeToString(hash), hex.EncodeToString(want))
}
}
func testArgon2i(t *testing.T) {
want := []byte{
0xc8, 0x14, 0xd9, 0xd1, 0xdc, 0x7f, 0x37, 0xaa,
0x13, 0xf0, 0xd7, 0x7f, 0x24, 0x94, 0xbd, 0xa1,
0xc8, 0xde, 0x6b, 0x01, 0x6d, 0xd3, 0x88, 0xd2,
0x99, 0x52, 0xa4, 0xc4, 0x67, 0x2b, 0x6c, 0xe8,
}
hash := deriveKey(argon2i, genKatPassword, genKatSalt, genKatSecret, genKatAAD, 3, 32, 4, 32)
if !bytes.Equal(hash, want) {
t.Errorf("derived key does not match - got: %s , want: %s", hex.EncodeToString(hash), hex.EncodeToString(want))
}
}
func testArgon2id(t *testing.T) {
want := []byte{
0x0d, 0x64, 0x0d, 0xf5, 0x8d, 0x78, 0x76, 0x6c,
0x08, 0xc0, 0x37, 0xa3, 0x4a, 0x8b, 0x53, 0xc9,
0xd0, 0x1e, 0xf0, 0x45, 0x2d, 0x75, 0xb6, 0x5e,
0xb5, 0x25, 0x20, 0xe9, 0x6b, 0x01, 0xe6, 0x59,
}
hash := deriveKey(argon2id, genKatPassword, genKatSalt, genKatSecret, genKatAAD, 3, 32, 4, 32)
if !bytes.Equal(hash, want) {
t.Errorf("derived key does not match - got: %s , want: %s", hex.EncodeToString(hash), hex.EncodeToString(want))
}
}
func TestVectors(t *testing.T) {
password, salt := []byte("password"), []byte("somesalt")
for i, v := range testVectors {
want, err := hex.DecodeString(v.hash)
if err != nil {
t.Fatalf("Test %d: failed to decode hash: %v", i, err)
}
hash := deriveKey(v.mode, password, salt, nil, nil, v.time, v.memory, v.threads, uint32(len(want)))
if !bytes.Equal(hash, want) {
t.Errorf("Test %d - got: %s want: %s", i, hex.EncodeToString(hash), hex.EncodeToString(want))
}
}
}
func benchmarkArgon2(mode int, time, memory uint32, threads uint8, keyLen uint32, b *testing.B) {
password := []byte("password")
salt := []byte("choosing random salts is hard")
b.ReportAllocs()
for i := 0; i < b.N; i++ {
deriveKey(mode, password, salt, nil, nil, time, memory, threads, keyLen)
}
}
func BenchmarkArgon2i(b *testing.B) {
b.Run(" Time: 3 Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2i, 3, 32*1024, 1, 32, b) })
b.Run(" Time: 4 Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2i, 4, 32*1024, 1, 32, b) })
b.Run(" Time: 5 Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2i, 5, 32*1024, 1, 32, b) })
b.Run(" Time: 3 Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2i, 3, 64*1024, 4, 32, b) })
b.Run(" Time: 4 Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2i, 4, 64*1024, 4, 32, b) })
b.Run(" Time: 5 Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2i, 5, 64*1024, 4, 32, b) })
}
func BenchmarkArgon2d(b *testing.B) {
b.Run(" Time: 3, Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2d, 3, 32*1024, 1, 32, b) })
b.Run(" Time: 4, Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2d, 4, 32*1024, 1, 32, b) })
b.Run(" Time: 5, Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2d, 5, 32*1024, 1, 32, b) })
b.Run(" Time: 3, Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2d, 3, 64*1024, 4, 32, b) })
b.Run(" Time: 4, Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2d, 4, 64*1024, 4, 32, b) })
b.Run(" Time: 5, Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2d, 5, 64*1024, 4, 32, b) })
}
func BenchmarkArgon2id(b *testing.B) {
b.Run(" Time: 3, Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2id, 3, 32*1024, 1, 32, b) })
b.Run(" Time: 4, Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2id, 4, 32*1024, 1, 32, b) })
b.Run(" Time: 5, Memory: 32 MB, Threads: 1", func(b *testing.B) { benchmarkArgon2(argon2id, 5, 32*1024, 1, 32, b) })
b.Run(" Time: 3, Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2id, 3, 64*1024, 4, 32, b) })
b.Run(" Time: 4, Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2id, 4, 64*1024, 4, 32, b) })
b.Run(" Time: 5, Memory: 64 MB, Threads: 4", func(b *testing.B) { benchmarkArgon2(argon2id, 5, 64*1024, 4, 32, b) })
}
// Generated with the CLI of https://github.com/P-H-C/phc-winner-argon2/blob/master/argon2-specs.pdf
var testVectors = []struct {
mode int
time, memory uint32
threads uint8
hash string
}{
{
mode: argon2i, time: 1, memory: 64, threads: 1,
hash: "b9c401d1844a67d50eae3967dc28870b22e508092e861a37",
},
{
mode: argon2d, time: 1, memory: 64, threads: 1,
hash: "8727405fd07c32c78d64f547f24150d3f2e703a89f981a19",
},
{
mode: argon2id, time: 1, memory: 64, threads: 1,
hash: "655ad15eac652dc59f7170a7332bf49b8469be1fdb9c28bb",
},
{
mode: argon2i, time: 2, memory: 64, threads: 1,
hash: "8cf3d8f76a6617afe35fac48eb0b7433a9a670ca4a07ed64",
},
{
mode: argon2d, time: 2, memory: 64, threads: 1,
hash: "3be9ec79a69b75d3752acb59a1fbb8b295a46529c48fbb75",
},
{
mode: argon2id, time: 2, memory: 64, threads: 1,
hash: "068d62b26455936aa6ebe60060b0a65870dbfa3ddf8d41f7",
},
{
mode: argon2i, time: 2, memory: 64, threads: 2,
hash: "2089f3e78a799720f80af806553128f29b132cafe40d059f",
},
{
mode: argon2d, time: 2, memory: 64, threads: 2,
hash: "68e2462c98b8bc6bb60ec68db418ae2c9ed24fc6748a40e9",
},
{
mode: argon2id, time: 2, memory: 64, threads: 2,
hash: "350ac37222f436ccb5c0972f1ebd3bf6b958bf2071841362",
},
{
mode: argon2i, time: 3, memory: 256, threads: 2,
hash: "f5bbf5d4c3836af13193053155b73ec7476a6a2eb93fd5e6",
},
{
mode: argon2d, time: 3, memory: 256, threads: 2,
hash: "f4f0669218eaf3641f39cc97efb915721102f4b128211ef2",
},
{
mode: argon2id, time: 3, memory: 256, threads: 2,
hash: "4668d30ac4187e6878eedeacf0fd83c5a0a30db2cc16ef0b",
},
{
mode: argon2i, time: 4, memory: 4096, threads: 4,
hash: "a11f7b7f3f93f02ad4bddb59ab62d121e278369288a0d0e7",
},
{
mode: argon2d, time: 4, memory: 4096, threads: 4,
hash: "935598181aa8dc2b720914aa6435ac8d3e3a4210c5b0fb2d",
},
{
mode: argon2id, time: 4, memory: 4096, threads: 4,
hash: "145db9733a9f4ee43edf33c509be96b934d505a4efb33c5a",
},
{
mode: argon2i, time: 4, memory: 1024, threads: 8,
hash: "0cdd3956aa35e6b475a7b0c63488822f774f15b43f6e6e17",
},
{
mode: argon2d, time: 4, memory: 1024, threads: 8,
hash: "83604fc2ad0589b9d055578f4d3cc55bc616df3578a896e9",
},
{
mode: argon2id, time: 4, memory: 1024, threads: 8,
hash: "8dafa8e004f8ea96bf7c0f93eecf67a6047476143d15577f",
},
{
mode: argon2i, time: 2, memory: 64, threads: 3,
hash: "5cab452fe6b8479c8661def8cd703b611a3905a6d5477fe6",
},
{
mode: argon2d, time: 2, memory: 64, threads: 3,
hash: "22474a423bda2ccd36ec9afd5119e5c8949798cadf659f51",
},
{
mode: argon2id, time: 2, memory: 64, threads: 3,
hash: "4a15b31aec7c2590b87d1f520be7d96f56658172deaa3079",
},
{
mode: argon2i, time: 3, memory: 1024, threads: 6,
hash: "d236b29c2b2a09babee842b0dec6aa1e83ccbdea8023dced",
},
{
mode: argon2d, time: 3, memory: 1024, threads: 6,
hash: "a3351b0319a53229152023d9206902f4ef59661cdca89481",
},
{
mode: argon2id, time: 3, memory: 1024, threads: 6,
hash: "1640b932f4b60e272f5d2207b9a9c626ffa1bd88d2349016",
},
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package argon2
import (
"encoding/binary"
"hash"
"golang.org/x/crypto/blake2b"
)
// blake2bHash computes an arbitrary long hash value of in
// and writes the hash to out.
func blake2bHash(out []byte, in []byte) {
var b2 hash.Hash
if n := len(out); n < blake2b.Size {
b2, _ = blake2b.New(n, nil)
} else {
b2, _ = blake2b.New512(nil)
}
var buffer [blake2b.Size]byte
binary.LittleEndian.PutUint32(buffer[:4], uint32(len(out)))
b2.Write(buffer[:4])
b2.Write(in)
if len(out) <= blake2b.Size {
b2.Sum(out[:0])
return
}
outLen := len(out)
b2.Sum(buffer[:0])
b2.Reset()
copy(out, buffer[:32])
out = out[32:]
for len(out) > blake2b.Size {
b2.Write(buffer[:])
b2.Sum(buffer[:0])
copy(out, buffer[:32])
out = out[32:]
b2.Reset()
}
if outLen%blake2b.Size > 0 { // outLen > 64
r := ((outLen + 31) / 32) - 2 // ⌈τ /32⌉-2
b2, _ = blake2b.New(outLen-32*r, nil)
}
b2.Write(buffer[:])
b2.Sum(out[:0])
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build amd64,!gccgo,!appengine
package argon2
func init() {
useSSE4 = supportsSSE4()
}
//go:noescape
func supportsSSE4() bool
//go:noescape
func mixBlocksSSE2(out, a, b, c *block)
//go:noescape
func xorBlocksSSE2(out, a, b, c *block)
//go:noescape
func blamkaSSE4(b *block)
func processBlockSSE(out, in1, in2 *block, xor bool) {
var t block
mixBlocksSSE2(&t, in1, in2, &t)
if useSSE4 {
blamkaSSE4(&t)
} else {
for i := 0; i < blockLength; i += 16 {
blamkaGeneric(
&t[i+0], &t[i+1], &t[i+2], &t[i+3],
&t[i+4], &t[i+5], &t[i+6], &t[i+7],
&t[i+8], &t[i+9], &t[i+10], &t[i+11],
&t[i+12], &t[i+13], &t[i+14], &t[i+15],
)
}
for i := 0; i < blockLength/8; i += 2 {
blamkaGeneric(
&t[i], &t[i+1], &t[16+i], &t[16+i+1],
&t[32+i], &t[32+i+1], &t[48+i], &t[48+i+1],
&t[64+i], &t[64+i+1], &t[80+i], &t[80+i+1],
&t[96+i], &t[96+i+1], &t[112+i], &t[112+i+1],
)
}
}
if xor {
xorBlocksSSE2(out, in1, in2, &t)
} else {
mixBlocksSSE2(out, in1, in2, &t)
}
}
func processBlock(out, in1, in2 *block) {
processBlockSSE(out, in1, in2, false)
}
func processBlockXOR(out, in1, in2 *block) {
processBlockSSE(out, in1, in2, true)
}
+252
View File
@@ -0,0 +1,252 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build amd64,!gccgo,!appengine
#include "textflag.h"
DATA ·c40<>+0x00(SB)/8, $0x0201000706050403
DATA ·c40<>+0x08(SB)/8, $0x0a09080f0e0d0c0b
GLOBL ·c40<>(SB), (NOPTR+RODATA), $16
DATA ·c48<>+0x00(SB)/8, $0x0100070605040302
DATA ·c48<>+0x08(SB)/8, $0x09080f0e0d0c0b0a
GLOBL ·c48<>(SB), (NOPTR+RODATA), $16
#define SHUFFLE(v2, v3, v4, v5, v6, v7, t1, t2) \
MOVO v4, t1; \
MOVO v5, v4; \
MOVO t1, v5; \
MOVO v6, t1; \
PUNPCKLQDQ v6, t2; \
PUNPCKHQDQ v7, v6; \
PUNPCKHQDQ t2, v6; \
PUNPCKLQDQ v7, t2; \
MOVO t1, v7; \
MOVO v2, t1; \
PUNPCKHQDQ t2, v7; \
PUNPCKLQDQ v3, t2; \
PUNPCKHQDQ t2, v2; \
PUNPCKLQDQ t1, t2; \
PUNPCKHQDQ t2, v3
#define SHUFFLE_INV(v2, v3, v4, v5, v6, v7, t1, t2) \
MOVO v4, t1; \
MOVO v5, v4; \
MOVO t1, v5; \
MOVO v2, t1; \
PUNPCKLQDQ v2, t2; \
PUNPCKHQDQ v3, v2; \
PUNPCKHQDQ t2, v2; \
PUNPCKLQDQ v3, t2; \
MOVO t1, v3; \
MOVO v6, t1; \
PUNPCKHQDQ t2, v3; \
PUNPCKLQDQ v7, t2; \
PUNPCKHQDQ t2, v6; \
PUNPCKLQDQ t1, t2; \
PUNPCKHQDQ t2, v7
#define HALF_ROUND(v0, v1, v2, v3, v4, v5, v6, v7, t0, c40, c48) \
MOVO v0, t0; \
PMULULQ v2, t0; \
PADDQ v2, v0; \
PADDQ t0, v0; \
PADDQ t0, v0; \
PXOR v0, v6; \
PSHUFD $0xB1, v6, v6; \
MOVO v4, t0; \
PMULULQ v6, t0; \
PADDQ v6, v4; \
PADDQ t0, v4; \
PADDQ t0, v4; \
PXOR v4, v2; \
PSHUFB c40, v2; \
MOVO v0, t0; \
PMULULQ v2, t0; \
PADDQ v2, v0; \
PADDQ t0, v0; \
PADDQ t0, v0; \
PXOR v0, v6; \
PSHUFB c48, v6; \
MOVO v4, t0; \
PMULULQ v6, t0; \
PADDQ v6, v4; \
PADDQ t0, v4; \
PADDQ t0, v4; \
PXOR v4, v2; \
MOVO v2, t0; \
PADDQ v2, t0; \
PSRLQ $63, v2; \
PXOR t0, v2; \
MOVO v1, t0; \
PMULULQ v3, t0; \
PADDQ v3, v1; \
PADDQ t0, v1; \
PADDQ t0, v1; \
PXOR v1, v7; \
PSHUFD $0xB1, v7, v7; \
MOVO v5, t0; \
PMULULQ v7, t0; \
PADDQ v7, v5; \
PADDQ t0, v5; \
PADDQ t0, v5; \
PXOR v5, v3; \
PSHUFB c40, v3; \
MOVO v1, t0; \
PMULULQ v3, t0; \
PADDQ v3, v1; \
PADDQ t0, v1; \
PADDQ t0, v1; \
PXOR v1, v7; \
PSHUFB c48, v7; \
MOVO v5, t0; \
PMULULQ v7, t0; \
PADDQ v7, v5; \
PADDQ t0, v5; \
PADDQ t0, v5; \
PXOR v5, v3; \
MOVO v3, t0; \
PADDQ v3, t0; \
PSRLQ $63, v3; \
PXOR t0, v3
#define LOAD_MSG_0(block, off) \
MOVOU 8*(off+0)(block), X0; \
MOVOU 8*(off+2)(block), X1; \
MOVOU 8*(off+4)(block), X2; \
MOVOU 8*(off+6)(block), X3; \
MOVOU 8*(off+8)(block), X4; \
MOVOU 8*(off+10)(block), X5; \
MOVOU 8*(off+12)(block), X6; \
MOVOU 8*(off+14)(block), X7
#define STORE_MSG_0(block, off) \
MOVOU X0, 8*(off+0)(block); \
MOVOU X1, 8*(off+2)(block); \
MOVOU X2, 8*(off+4)(block); \
MOVOU X3, 8*(off+6)(block); \
MOVOU X4, 8*(off+8)(block); \
MOVOU X5, 8*(off+10)(block); \
MOVOU X6, 8*(off+12)(block); \
MOVOU X7, 8*(off+14)(block)
#define LOAD_MSG_1(block, off) \
MOVOU 8*off+0*8(block), X0; \
MOVOU 8*off+16*8(block), X1; \
MOVOU 8*off+32*8(block), X2; \
MOVOU 8*off+48*8(block), X3; \
MOVOU 8*off+64*8(block), X4; \
MOVOU 8*off+80*8(block), X5; \
MOVOU 8*off+96*8(block), X6; \
MOVOU 8*off+112*8(block), X7
#define STORE_MSG_1(block, off) \
MOVOU X0, 8*off+0*8(block); \
MOVOU X1, 8*off+16*8(block); \
MOVOU X2, 8*off+32*8(block); \
MOVOU X3, 8*off+48*8(block); \
MOVOU X4, 8*off+64*8(block); \
MOVOU X5, 8*off+80*8(block); \
MOVOU X6, 8*off+96*8(block); \
MOVOU X7, 8*off+112*8(block)
#define BLAMKA_ROUND_0(block, off, t0, t1, c40, c48) \
LOAD_MSG_0(block, off); \
HALF_ROUND(X0, X1, X2, X3, X4, X5, X6, X7, t0, c40, c48); \
SHUFFLE(X2, X3, X4, X5, X6, X7, t0, t1); \
HALF_ROUND(X0, X1, X2, X3, X4, X5, X6, X7, t0, c40, c48); \
SHUFFLE_INV(X2, X3, X4, X5, X6, X7, t0, t1); \
STORE_MSG_0(block, off)
#define BLAMKA_ROUND_1(block, off, t0, t1, c40, c48) \
LOAD_MSG_1(block, off); \
HALF_ROUND(X0, X1, X2, X3, X4, X5, X6, X7, t0, c40, c48); \
SHUFFLE(X2, X3, X4, X5, X6, X7, t0, t1); \
HALF_ROUND(X0, X1, X2, X3, X4, X5, X6, X7, t0, c40, c48); \
SHUFFLE_INV(X2, X3, X4, X5, X6, X7, t0, t1); \
STORE_MSG_1(block, off)
// func blamkaSSE4(b *block)
TEXT ·blamkaSSE4(SB), 4, $0-8
MOVQ b+0(FP), AX
MOVOU ·c40<>(SB), X10
MOVOU ·c48<>(SB), X11
BLAMKA_ROUND_0(AX, 0, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 16, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 32, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 48, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 64, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 80, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 96, X8, X9, X10, X11)
BLAMKA_ROUND_0(AX, 112, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 0, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 2, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 4, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 6, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 8, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 10, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 12, X8, X9, X10, X11)
BLAMKA_ROUND_1(AX, 14, X8, X9, X10, X11)
RET
// func mixBlocksSSE2(out, a, b, c *block)
TEXT ·mixBlocksSSE2(SB), 4, $0-32
MOVQ out+0(FP), DX
MOVQ a+8(FP), AX
MOVQ b+16(FP), BX
MOVQ a+24(FP), CX
MOVQ $128, BP
loop:
MOVOU 0(AX), X0
MOVOU 0(BX), X1
MOVOU 0(CX), X2
PXOR X1, X0
PXOR X2, X0
MOVOU X0, 0(DX)
ADDQ $16, AX
ADDQ $16, BX
ADDQ $16, CX
ADDQ $16, DX
SUBQ $2, BP
JA loop
RET
// func xorBlocksSSE2(out, a, b, c *block)
TEXT ·xorBlocksSSE2(SB), 4, $0-32
MOVQ out+0(FP), DX
MOVQ a+8(FP), AX
MOVQ b+16(FP), BX
MOVQ a+24(FP), CX
MOVQ $128, BP
loop:
MOVOU 0(AX), X0
MOVOU 0(BX), X1
MOVOU 0(CX), X2
MOVOU 0(DX), X3
PXOR X1, X0
PXOR X2, X0
PXOR X3, X0
MOVOU X0, 0(DX)
ADDQ $16, AX
ADDQ $16, BX
ADDQ $16, CX
ADDQ $16, DX
SUBQ $2, BP
JA loop
RET
// func supportsSSE4() bool
TEXT ·supportsSSE4(SB), 4, $0-1
MOVL $1, AX
CPUID
SHRL $19, CX // Bit 19 indicates SSE4 support
ANDL $1, CX // CX != 0 if support SSE4
MOVB CX, ret+0(FP)
RET
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package argon2
var useSSE4 bool
func processBlockGeneric(out, in1, in2 *block, xor bool) {
var t block
for i := range t {
t[i] = in1[i] ^ in2[i]
}
for i := 0; i < blockLength; i += 16 {
blamkaGeneric(
&t[i+0], &t[i+1], &t[i+2], &t[i+3],
&t[i+4], &t[i+5], &t[i+6], &t[i+7],
&t[i+8], &t[i+9], &t[i+10], &t[i+11],
&t[i+12], &t[i+13], &t[i+14], &t[i+15],
)
}
for i := 0; i < blockLength/8; i += 2 {
blamkaGeneric(
&t[i], &t[i+1], &t[16+i], &t[16+i+1],
&t[32+i], &t[32+i+1], &t[48+i], &t[48+i+1],
&t[64+i], &t[64+i+1], &t[80+i], &t[80+i+1],
&t[96+i], &t[96+i+1], &t[112+i], &t[112+i+1],
)
}
if xor {
for i := range t {
out[i] ^= in1[i] ^ in2[i] ^ t[i]
}
} else {
for i := range t {
out[i] = in1[i] ^ in2[i] ^ t[i]
}
}
}
func blamkaGeneric(t00, t01, t02, t03, t04, t05, t06, t07, t08, t09, t10, t11, t12, t13, t14, t15 *uint64) {
v00, v01, v02, v03 := *t00, *t01, *t02, *t03
v04, v05, v06, v07 := *t04, *t05, *t06, *t07
v08, v09, v10, v11 := *t08, *t09, *t10, *t11
v12, v13, v14, v15 := *t12, *t13, *t14, *t15
v00 += v04 + 2*uint64(uint32(v00))*uint64(uint32(v04))
v12 ^= v00
v12 = v12>>32 | v12<<32
v08 += v12 + 2*uint64(uint32(v08))*uint64(uint32(v12))
v04 ^= v08
v04 = v04>>24 | v04<<40
v00 += v04 + 2*uint64(uint32(v00))*uint64(uint32(v04))
v12 ^= v00
v12 = v12>>16 | v12<<48
v08 += v12 + 2*uint64(uint32(v08))*uint64(uint32(v12))
v04 ^= v08
v04 = v04>>63 | v04<<1
v01 += v05 + 2*uint64(uint32(v01))*uint64(uint32(v05))
v13 ^= v01
v13 = v13>>32 | v13<<32
v09 += v13 + 2*uint64(uint32(v09))*uint64(uint32(v13))
v05 ^= v09
v05 = v05>>24 | v05<<40
v01 += v05 + 2*uint64(uint32(v01))*uint64(uint32(v05))
v13 ^= v01
v13 = v13>>16 | v13<<48
v09 += v13 + 2*uint64(uint32(v09))*uint64(uint32(v13))
v05 ^= v09
v05 = v05>>63 | v05<<1
v02 += v06 + 2*uint64(uint32(v02))*uint64(uint32(v06))
v14 ^= v02
v14 = v14>>32 | v14<<32
v10 += v14 + 2*uint64(uint32(v10))*uint64(uint32(v14))
v06 ^= v10
v06 = v06>>24 | v06<<40
v02 += v06 + 2*uint64(uint32(v02))*uint64(uint32(v06))
v14 ^= v02
v14 = v14>>16 | v14<<48
v10 += v14 + 2*uint64(uint32(v10))*uint64(uint32(v14))
v06 ^= v10
v06 = v06>>63 | v06<<1
v03 += v07 + 2*uint64(uint32(v03))*uint64(uint32(v07))
v15 ^= v03
v15 = v15>>32 | v15<<32
v11 += v15 + 2*uint64(uint32(v11))*uint64(uint32(v15))
v07 ^= v11
v07 = v07>>24 | v07<<40
v03 += v07 + 2*uint64(uint32(v03))*uint64(uint32(v07))
v15 ^= v03
v15 = v15>>16 | v15<<48
v11 += v15 + 2*uint64(uint32(v11))*uint64(uint32(v15))
v07 ^= v11
v07 = v07>>63 | v07<<1
v00 += v05 + 2*uint64(uint32(v00))*uint64(uint32(v05))
v15 ^= v00
v15 = v15>>32 | v15<<32
v10 += v15 + 2*uint64(uint32(v10))*uint64(uint32(v15))
v05 ^= v10
v05 = v05>>24 | v05<<40
v00 += v05 + 2*uint64(uint32(v00))*uint64(uint32(v05))
v15 ^= v00
v15 = v15>>16 | v15<<48
v10 += v15 + 2*uint64(uint32(v10))*uint64(uint32(v15))
v05 ^= v10
v05 = v05>>63 | v05<<1
v01 += v06 + 2*uint64(uint32(v01))*uint64(uint32(v06))
v12 ^= v01
v12 = v12>>32 | v12<<32
v11 += v12 + 2*uint64(uint32(v11))*uint64(uint32(v12))
v06 ^= v11
v06 = v06>>24 | v06<<40
v01 += v06 + 2*uint64(uint32(v01))*uint64(uint32(v06))
v12 ^= v01
v12 = v12>>16 | v12<<48
v11 += v12 + 2*uint64(uint32(v11))*uint64(uint32(v12))
v06 ^= v11
v06 = v06>>63 | v06<<1
v02 += v07 + 2*uint64(uint32(v02))*uint64(uint32(v07))
v13 ^= v02
v13 = v13>>32 | v13<<32
v08 += v13 + 2*uint64(uint32(v08))*uint64(uint32(v13))
v07 ^= v08
v07 = v07>>24 | v07<<40
v02 += v07 + 2*uint64(uint32(v02))*uint64(uint32(v07))
v13 ^= v02
v13 = v13>>16 | v13<<48
v08 += v13 + 2*uint64(uint32(v08))*uint64(uint32(v13))
v07 ^= v08
v07 = v07>>63 | v07<<1
v03 += v04 + 2*uint64(uint32(v03))*uint64(uint32(v04))
v14 ^= v03
v14 = v14>>32 | v14<<32
v09 += v14 + 2*uint64(uint32(v09))*uint64(uint32(v14))
v04 ^= v09
v04 = v04>>24 | v04<<40
v03 += v04 + 2*uint64(uint32(v03))*uint64(uint32(v04))
v14 ^= v03
v14 = v14>>16 | v14<<48
v09 += v14 + 2*uint64(uint32(v09))*uint64(uint32(v14))
v04 ^= v09
v04 = v04>>63 | v04<<1
*t00, *t01, *t02, *t03 = v00, v01, v02, v03
*t04, *t05, *t06, *t07 = v04, v05, v06, v07
*t08, *t09, *t10, *t11 = v08, v09, v10, v11
*t12, *t13, *t14, *t15 = v12, v13, v14, v15
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !amd64 appengine gccgo
package argon2
func processBlock(out, in1, in2 *block) {
processBlockGeneric(out, in1, in2, false)
}
func processBlockXOR(out, in1, in2 *block) {
processBlockGeneric(out, in1, in2, true)
}
+15 -1
View File
@@ -39,7 +39,10 @@ var (
useSSE4 bool
)
var errKeySize = errors.New("blake2b: invalid key size")
var (
errKeySize = errors.New("blake2b: invalid key size")
errHashSize = errors.New("blake2b: invalid hash size")
)
var iv = [8]uint64{
0x6a09e667f3bcc908, 0xbb67ae8584caa73b, 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1,
@@ -83,7 +86,18 @@ func New384(key []byte) (hash.Hash, error) { return newDigest(Size384, key) }
// key turns the hash into a MAC. The key must between zero and 64 bytes long.
func New256(key []byte) (hash.Hash, error) { return newDigest(Size256, key) }
// New returns a new hash.Hash computing the BLAKE2b checksum with a custom length.
// A non-nil key turns the hash into a MAC. The key must between zero and 64 bytes long.
// The hash size can be a value between 1 and 64 but it is highly recommended to use
// values equal or greater than:
// - 32 if BLAKE2b is used as a hash function (The key is zero bytes long).
// - 16 if BLAKE2b is used as a MAC function (The key is at least 16 bytes long).
func New(size int, key []byte) (hash.Hash, error) { return newDigest(size, key) }
func newDigest(hashSize int, key []byte) (*digest, error) {
if hashSize < 1 || hashSize > Size {
return nil, errHashSize
}
if len(key) > Size {
return nil, errKeySize
}
+5 -1
View File
@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package bn256 implements a particular bilinear group at the 128-bit security level.
// Package bn256 implements a particular bilinear group.
//
// Bilinear groups are the basis of many of the new cryptographic protocols
// that have been proposed over the past decade. They consist of a triplet of
@@ -14,6 +14,10 @@
// Barreto-Naehrig curve as described in
// http://cryptojedi.org/papers/dclxvi-20100714.pdf. Its output is compatible
// with the implementation described in that paper.
//
// (This package previously claimed to operate at a 128-bit security level.
// However, recent improvements in attacks mean that is no longer true. See
// https://moderncrypto.org/mail-archive/curves/2016/000740.html.)
package bn256 // import "golang.org/x/crypto/bn256"
import (
+1 -1
View File
@@ -7,7 +7,7 @@ package chacha20poly1305
import (
"encoding/binary"
"golang.org/x/crypto/chacha20poly1305/internal/chacha20"
"golang.org/x/crypto/internal/chacha20"
"golang.org/x/crypto/poly1305"
)
+19
View File
@@ -155,3 +155,22 @@ func TestWithHMACSHA1(t *testing.T) {
func TestWithHMACSHA256(t *testing.T) {
testHash(t, sha256.New, "SHA256", sha256TestVectors)
}
var sink uint8
func benchmark(b *testing.B, h func() hash.Hash) {
password := make([]byte, h().Size())
salt := make([]byte, 8)
for i := 0; i < b.N; i++ {
password = Key(password, salt, 4096, len(password), h)
}
sink += password[0]
}
func BenchmarkHMACSHA1(b *testing.B) {
benchmark(b, sha1.New)
}
func BenchmarkHMACSHA256(b *testing.B) {
benchmark(b, sha256.New)
}
+2 -1
View File
@@ -40,7 +40,8 @@ func sshPipe() (Conn, *server, error) {
}
clientConf := ClientConfig{
User: "user",
User: "user",
HostKeyCallback: InsecureIgnoreHostKey(),
}
serverConf := ServerConfig{
NoClientAuth: true,
+1 -1
View File
@@ -340,7 +340,7 @@ func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permis
// the signature of the certificate.
func (c *CertChecker) CheckCert(principal string, cert *Certificate) error {
if c.IsRevoked != nil && c.IsRevoked(cert) {
return fmt.Errorf("ssh: certicate serial %d revoked", cert.Serial)
return fmt.Errorf("ssh: certificate serial %d revoked", cert.Serial)
}
for opt := range cert.CriticalOptions {
+113
View File
@@ -6,10 +6,15 @@ package ssh
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net"
"reflect"
"testing"
"time"
"golang.org/x/crypto/ssh/testdata"
)
// Cert generated by ssh-keygen 6.0p1 Debian-4.
@@ -220,3 +225,111 @@ func TestHostKeyCert(t *testing.T) {
}
}
}
func TestCertTypes(t *testing.T) {
var testVars = []struct {
name string
keys func() Signer
}{
{
name: CertAlgoECDSA256v01,
keys: func() Signer {
s, _ := ParsePrivateKey(testdata.PEMBytes["ecdsap256"])
return s
},
},
{
name: CertAlgoECDSA384v01,
keys: func() Signer {
s, _ := ParsePrivateKey(testdata.PEMBytes["ecdsap384"])
return s
},
},
{
name: CertAlgoECDSA521v01,
keys: func() Signer {
s, _ := ParsePrivateKey(testdata.PEMBytes["ecdsap521"])
return s
},
},
{
name: CertAlgoED25519v01,
keys: func() Signer {
s, _ := ParsePrivateKey(testdata.PEMBytes["ed25519"])
return s
},
},
{
name: CertAlgoRSAv01,
keys: func() Signer {
s, _ := ParsePrivateKey(testdata.PEMBytes["rsa"])
return s
},
},
{
name: CertAlgoDSAv01,
keys: func() Signer {
s, _ := ParsePrivateKey(testdata.PEMBytes["dsa"])
return s
},
},
}
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("error generating host key: %v", err)
}
signer, err := NewSignerFromKey(k)
if err != nil {
t.Fatalf("error generating signer for ssh listener: %v", err)
}
conf := &ServerConfig{
PublicKeyCallback: func(c ConnMetadata, k PublicKey) (*Permissions, error) {
return new(Permissions), nil
},
}
conf.AddHostKey(signer)
for _, m := range testVars {
t.Run(m.name, func(t *testing.T) {
c1, c2, err := netPipe()
if err != nil {
t.Fatalf("netPipe: %v", err)
}
defer c1.Close()
defer c2.Close()
go NewServerConn(c1, conf)
priv := m.keys()
if err != nil {
t.Fatalf("error generating ssh pubkey: %v", err)
}
cert := &Certificate{
CertType: UserCert,
Key: priv.PublicKey(),
}
cert.SignCert(rand.Reader, priv)
certSigner, err := NewCertSigner(cert, priv)
if err != nil {
t.Fatalf("error generating cert signer: %v", err)
}
config := &ClientConfig{
User: "user",
HostKeyCallback: func(h string, r net.Addr, k PublicKey) error { return nil },
Auth: []AuthMethod{PublicKeys(certSigner)},
}
_, _, _, err = NewClientConn(c2, "", config)
if err != nil {
t.Fatalf("error connecting: %v", err)
}
})
}
}
+193 -51
View File
@@ -16,6 +16,9 @@ import (
"hash"
"io"
"io/ioutil"
"golang.org/x/crypto/internal/chacha20"
"golang.org/x/crypto/poly1305"
)
const (
@@ -53,78 +56,78 @@ func newRC4(key, iv []byte) (cipher.Stream, error) {
return rc4.NewCipher(key)
}
type streamCipherMode struct {
keySize int
ivSize int
skip int
createFunc func(key, iv []byte) (cipher.Stream, error)
type cipherMode struct {
keySize int
ivSize int
create func(key, iv []byte, macKey []byte, algs directionAlgorithms) (packetCipher, error)
}
func (c *streamCipherMode) createStream(key, iv []byte) (cipher.Stream, error) {
if len(key) < c.keySize {
panic("ssh: key length too small for cipher")
}
if len(iv) < c.ivSize {
panic("ssh: iv too small for cipher")
}
stream, err := c.createFunc(key[:c.keySize], iv[:c.ivSize])
if err != nil {
return nil, err
}
var streamDump []byte
if c.skip > 0 {
streamDump = make([]byte, 512)
}
for remainingToDump := c.skip; remainingToDump > 0; {
dumpThisTime := remainingToDump
if dumpThisTime > len(streamDump) {
dumpThisTime = len(streamDump)
func streamCipherMode(skip int, createFunc func(key, iv []byte) (cipher.Stream, error)) func(key, iv []byte, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
return func(key, iv, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
stream, err := createFunc(key, iv)
if err != nil {
return nil, err
}
stream.XORKeyStream(streamDump[:dumpThisTime], streamDump[:dumpThisTime])
remainingToDump -= dumpThisTime
}
return stream, nil
var streamDump []byte
if skip > 0 {
streamDump = make([]byte, 512)
}
for remainingToDump := skip; remainingToDump > 0; {
dumpThisTime := remainingToDump
if dumpThisTime > len(streamDump) {
dumpThisTime = len(streamDump)
}
stream.XORKeyStream(streamDump[:dumpThisTime], streamDump[:dumpThisTime])
remainingToDump -= dumpThisTime
}
mac := macModes[algs.MAC].new(macKey)
return &streamPacketCipher{
mac: mac,
etm: macModes[algs.MAC].etm,
macResult: make([]byte, mac.Size()),
cipher: stream,
}, nil
}
}
// cipherModes documents properties of supported ciphers. Ciphers not included
// are not supported and will not be negotiated, even if explicitly requested in
// ClientConfig.Crypto.Ciphers.
var cipherModes = map[string]*streamCipherMode{
var cipherModes = map[string]*cipherMode{
// Ciphers from RFC4344, which introduced many CTR-based ciphers. Algorithms
// are defined in the order specified in the RFC.
"aes128-ctr": {16, aes.BlockSize, 0, newAESCTR},
"aes192-ctr": {24, aes.BlockSize, 0, newAESCTR},
"aes256-ctr": {32, aes.BlockSize, 0, newAESCTR},
"aes128-ctr": {16, aes.BlockSize, streamCipherMode(0, newAESCTR)},
"aes192-ctr": {24, aes.BlockSize, streamCipherMode(0, newAESCTR)},
"aes256-ctr": {32, aes.BlockSize, streamCipherMode(0, newAESCTR)},
// Ciphers from RFC4345, which introduces security-improved arcfour ciphers.
// They are defined in the order specified in the RFC.
"arcfour128": {16, 0, 1536, newRC4},
"arcfour256": {32, 0, 1536, newRC4},
"arcfour128": {16, 0, streamCipherMode(1536, newRC4)},
"arcfour256": {32, 0, streamCipherMode(1536, newRC4)},
// Cipher defined in RFC 4253, which describes SSH Transport Layer Protocol.
// Note that this cipher is not safe, as stated in RFC 4253: "Arcfour (and
// RC4) has problems with weak keys, and should be used with caution."
// RFC4345 introduces improved versions of Arcfour.
"arcfour": {16, 0, 0, newRC4},
"arcfour": {16, 0, streamCipherMode(0, newRC4)},
// AES-GCM is not a stream cipher, so it is constructed with a
// special case. If we add any more non-stream ciphers, we
// should invest a cleaner way to do this.
gcmCipherID: {16, 12, 0, nil},
// AEAD ciphers
gcmCipherID: {16, 12, newGCMCipher},
chacha20Poly1305ID: {64, 0, newChaCha20Cipher},
// CBC mode is insecure and so is not included in the default config.
// (See http://www.isg.rhul.ac.uk/~kp/SandPfinal.pdf). If absolutely
// needed, it's possible to specify a custom Config to enable it.
// You should expect that an active attacker can recover plaintext if
// you do.
aes128cbcID: {16, aes.BlockSize, 0, nil},
aes128cbcID: {16, aes.BlockSize, newAESCBCCipher},
// 3des-cbc is insecure and is disabled by default.
tripledescbcID: {24, des.BlockSize, 0, nil},
// 3des-cbc is insecure and is not included in the default
// config.
tripledescbcID: {24, des.BlockSize, newTripleDESCBCCipher},
}
// prefixLen is the length of the packet prefix that contains the packet length
@@ -304,7 +307,7 @@ type gcmCipher struct {
buf []byte
}
func newGCMCipher(iv, key []byte) (packetCipher, error) {
func newGCMCipher(key, iv, unusedMacKey []byte, unusedAlgs directionAlgorithms) (packetCipher, error) {
c, err := aes.NewCipher(key)
if err != nil {
return nil, err
@@ -422,7 +425,7 @@ type cbcCipher struct {
oracleCamouflage uint32
}
func newCBCCipher(c cipher.Block, iv, key, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
func newCBCCipher(c cipher.Block, key, iv, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
cbc := &cbcCipher{
mac: macModes[algs.MAC].new(macKey),
decrypter: cipher.NewCBCDecrypter(c, iv),
@@ -436,13 +439,13 @@ func newCBCCipher(c cipher.Block, iv, key, macKey []byte, algs directionAlgorith
return cbc, nil
}
func newAESCBCCipher(iv, key, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
func newAESCBCCipher(key, iv, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
c, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
cbc, err := newCBCCipher(c, iv, key, macKey, algs)
cbc, err := newCBCCipher(c, key, iv, macKey, algs)
if err != nil {
return nil, err
}
@@ -450,13 +453,13 @@ func newAESCBCCipher(iv, key, macKey []byte, algs directionAlgorithms) (packetCi
return cbc, nil
}
func newTripleDESCBCCipher(iv, key, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
func newTripleDESCBCCipher(key, iv, macKey []byte, algs directionAlgorithms) (packetCipher, error) {
c, err := des.NewTripleDESCipher(key)
if err != nil {
return nil, err
}
cbc, err := newCBCCipher(c, iv, key, macKey, algs)
cbc, err := newCBCCipher(c, key, iv, macKey, algs)
if err != nil {
return nil, err
}
@@ -627,3 +630,142 @@ func (c *cbcCipher) writePacket(seqNum uint32, w io.Writer, rand io.Reader, pack
return nil
}
const chacha20Poly1305ID = "chacha20-poly1305@openssh.com"
// chacha20Poly1305Cipher implements the chacha20-poly1305@openssh.com
// AEAD, which is described here:
//
// https://tools.ietf.org/html/draft-josefsson-ssh-chacha20-poly1305-openssh-00
//
// the methods here also implement padding, which RFC4253 Section 6
// also requires of stream ciphers.
type chacha20Poly1305Cipher struct {
lengthKey [32]byte
contentKey [32]byte
buf []byte
}
func newChaCha20Cipher(key, unusedIV, unusedMACKey []byte, unusedAlgs directionAlgorithms) (packetCipher, error) {
if len(key) != 64 {
panic(len(key))
}
c := &chacha20Poly1305Cipher{
buf: make([]byte, 256),
}
copy(c.contentKey[:], key[:32])
copy(c.lengthKey[:], key[32:])
return c, nil
}
// The Poly1305 key is obtained by encrypting 32 0-bytes.
var chacha20PolyKeyInput [32]byte
func (c *chacha20Poly1305Cipher) readPacket(seqNum uint32, r io.Reader) ([]byte, error) {
var counter [16]byte
binary.BigEndian.PutUint64(counter[8:], uint64(seqNum))
var polyKey [32]byte
chacha20.XORKeyStream(polyKey[:], chacha20PolyKeyInput[:], &counter, &c.contentKey)
encryptedLength := c.buf[:4]
if _, err := io.ReadFull(r, encryptedLength); err != nil {
return nil, err
}
var lenBytes [4]byte
chacha20.XORKeyStream(lenBytes[:], encryptedLength, &counter, &c.lengthKey)
length := binary.BigEndian.Uint32(lenBytes[:])
if length > maxPacket {
return nil, errors.New("ssh: invalid packet length, packet too large")
}
contentEnd := 4 + length
packetEnd := contentEnd + poly1305.TagSize
if uint32(cap(c.buf)) < packetEnd {
c.buf = make([]byte, packetEnd)
copy(c.buf[:], encryptedLength)
} else {
c.buf = c.buf[:packetEnd]
}
if _, err := io.ReadFull(r, c.buf[4:packetEnd]); err != nil {
return nil, err
}
var mac [poly1305.TagSize]byte
copy(mac[:], c.buf[contentEnd:packetEnd])
if !poly1305.Verify(&mac, c.buf[:contentEnd], &polyKey) {
return nil, errors.New("ssh: MAC failure")
}
counter[0] = 1
plain := c.buf[4:contentEnd]
chacha20.XORKeyStream(plain, plain, &counter, &c.contentKey)
padding := plain[0]
if padding < 4 {
// padding is a byte, so it automatically satisfies
// the maximum size, which is 255.
return nil, fmt.Errorf("ssh: illegal padding %d", padding)
}
if int(padding)+1 >= len(plain) {
return nil, fmt.Errorf("ssh: padding %d too large", padding)
}
plain = plain[1 : len(plain)-int(padding)]
return plain, nil
}
func (c *chacha20Poly1305Cipher) writePacket(seqNum uint32, w io.Writer, rand io.Reader, payload []byte) error {
var counter [16]byte
binary.BigEndian.PutUint64(counter[8:], uint64(seqNum))
var polyKey [32]byte
chacha20.XORKeyStream(polyKey[:], chacha20PolyKeyInput[:], &counter, &c.contentKey)
// There is no blocksize, so fall back to multiple of 8 byte
// padding, as described in RFC 4253, Sec 6.
const packetSizeMultiple = 8
padding := packetSizeMultiple - (1+len(payload))%packetSizeMultiple
if padding < 4 {
padding += packetSizeMultiple
}
// size (4 bytes), padding (1), payload, padding, tag.
totalLength := 4 + 1 + len(payload) + padding + poly1305.TagSize
if cap(c.buf) < totalLength {
c.buf = make([]byte, totalLength)
} else {
c.buf = c.buf[:totalLength]
}
binary.BigEndian.PutUint32(c.buf, uint32(1+len(payload)+padding))
chacha20.XORKeyStream(c.buf, c.buf[:4], &counter, &c.lengthKey)
c.buf[4] = byte(padding)
copy(c.buf[5:], payload)
packetEnd := 5 + len(payload) + padding
if _, err := io.ReadFull(rand, c.buf[5+len(payload):packetEnd]); err != nil {
return err
}
counter[0] = 1
chacha20.XORKeyStream(c.buf[4:], c.buf[4:packetEnd], &counter, &c.contentKey)
var mac [poly1305.TagSize]byte
poly1305.Sum(&mac, c.buf[:packetEnd], &polyKey)
copy(c.buf[packetEnd:], mac[:])
if _, err := w.Write(c.buf); err != nil {
return err
}
return nil
}
+44 -42
View File
@@ -7,7 +7,6 @@ package ssh
import (
"bytes"
"crypto"
"crypto/aes"
"crypto/rand"
"testing"
)
@@ -15,60 +14,63 @@ import (
func TestDefaultCiphersExist(t *testing.T) {
for _, cipherAlgo := range supportedCiphers {
if _, ok := cipherModes[cipherAlgo]; !ok {
t.Errorf("default cipher %q is unknown", cipherAlgo)
t.Errorf("supported cipher %q is unknown", cipherAlgo)
}
}
for _, cipherAlgo := range preferredCiphers {
if _, ok := cipherModes[cipherAlgo]; !ok {
t.Errorf("preferred cipher %q is unknown", cipherAlgo)
}
}
}
func TestPacketCiphers(t *testing.T) {
// Still test aes128cbc cipher although it's commented out.
cipherModes[aes128cbcID] = &streamCipherMode{16, aes.BlockSize, 0, nil}
defer delete(cipherModes, aes128cbcID)
defaultMac := "hmac-sha2-256"
defaultCipher := "aes128-ctr"
for cipher := range cipherModes {
for mac := range macModes {
kr := &kexResult{Hash: crypto.SHA1}
algs := directionAlgorithms{
Cipher: cipher,
MAC: mac,
Compression: "none",
}
client, err := newPacketCipher(clientKeys, algs, kr)
if err != nil {
t.Errorf("newPacketCipher(client, %q, %q): %v", cipher, mac, err)
continue
}
server, err := newPacketCipher(clientKeys, algs, kr)
if err != nil {
t.Errorf("newPacketCipher(client, %q, %q): %v", cipher, mac, err)
continue
}
t.Run("cipher="+cipher,
func(t *testing.T) { testPacketCipher(t, cipher, defaultMac) })
}
for mac := range macModes {
t.Run("mac="+mac,
func(t *testing.T) { testPacketCipher(t, defaultCipher, mac) })
}
}
want := "bla bla"
input := []byte(want)
buf := &bytes.Buffer{}
if err := client.writePacket(0, buf, rand.Reader, input); err != nil {
t.Errorf("writePacket(%q, %q): %v", cipher, mac, err)
continue
}
func testPacketCipher(t *testing.T, cipher, mac string) {
kr := &kexResult{Hash: crypto.SHA1}
algs := directionAlgorithms{
Cipher: cipher,
MAC: mac,
Compression: "none",
}
client, err := newPacketCipher(clientKeys, algs, kr)
if err != nil {
t.Fatalf("newPacketCipher(client, %q, %q): %v", cipher, mac, err)
}
server, err := newPacketCipher(clientKeys, algs, kr)
if err != nil {
t.Fatalf("newPacketCipher(client, %q, %q): %v", cipher, mac, err)
}
packet, err := server.readPacket(0, buf)
if err != nil {
t.Errorf("readPacket(%q, %q): %v", cipher, mac, err)
continue
}
want := "bla bla"
input := []byte(want)
buf := &bytes.Buffer{}
if err := client.writePacket(0, buf, rand.Reader, input); err != nil {
t.Fatalf("writePacket(%q, %q): %v", cipher, mac, err)
}
if string(packet) != want {
t.Errorf("roundtrip(%q, %q): got %q, want %q", cipher, mac, packet, want)
}
}
packet, err := server.readPacket(0, buf)
if err != nil {
t.Fatalf("readPacket(%q, %q): %v", cipher, mac, err)
}
if string(packet) != want {
t.Errorf("roundtrip(%q, %q): got %q, want %q", cipher, mac, packet, want)
}
}
func TestCBCOracleCounterMeasure(t *testing.T) {
cipherModes[aes128cbcID] = &streamCipherMode{16, aes.BlockSize, 0, nil}
defer delete(cipherModes, aes128cbcID)
kr := &kexResult{Hash: crypto.SHA1}
algs := directionAlgorithms{
Cipher: aes128cbcID,
+64 -28
View File
@@ -5,41 +5,77 @@
package ssh
import (
"net"
"strings"
"testing"
)
func testClientVersion(t *testing.T, config *ClientConfig, expected string) {
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
receivedVersion := make(chan string, 1)
config.HostKeyCallback = InsecureIgnoreHostKey()
go func() {
version, err := readVersion(serverConn)
if err != nil {
receivedVersion <- ""
} else {
receivedVersion <- string(version)
}
serverConn.Close()
}()
NewClientConn(clientConn, "", config)
actual := <-receivedVersion
if actual != expected {
t.Fatalf("got %s; want %s", actual, expected)
func TestClientVersion(t *testing.T) {
for _, tt := range []struct {
name string
version string
multiLine string
wantErr bool
}{
{
name: "default version",
version: packageVersion,
},
{
name: "custom version",
version: "SSH-2.0-CustomClientVersionString",
},
{
name: "good multi line version",
version: packageVersion,
multiLine: strings.Repeat("ignored\r\n", 20),
},
{
name: "bad multi line version",
version: packageVersion,
multiLine: "bad multi line version",
wantErr: true,
},
{
name: "long multi line version",
version: packageVersion,
multiLine: strings.Repeat("long multi line version\r\n", 50)[:256],
wantErr: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
c1, c2, err := netPipe()
if err != nil {
t.Fatalf("netPipe: %v", err)
}
defer c1.Close()
defer c2.Close()
go func() {
if tt.multiLine != "" {
c1.Write([]byte(tt.multiLine))
}
NewClientConn(c1, "", &ClientConfig{
ClientVersion: tt.version,
HostKeyCallback: InsecureIgnoreHostKey(),
})
c1.Close()
}()
conf := &ServerConfig{NoClientAuth: true}
conf.AddHostKey(testSigners["rsa"])
conn, _, _, err := NewServerConn(c2, conf)
if err == nil == tt.wantErr {
t.Fatalf("got err %v; wantErr %t", err, tt.wantErr)
}
if tt.wantErr {
// Don't verify the version on an expected error.
return
}
if got := string(conn.ClientVersion()); got != tt.version {
t.Fatalf("got %q; want %q", got, tt.version)
}
})
}
}
func TestCustomClientVersion(t *testing.T) {
version := "Test-Client-Version-0.0"
testClientVersion(t, &ClientConfig{ClientVersion: version}, version)
}
func TestDefaultClientVersion(t *testing.T) {
testClientVersion(t, &ClientConfig{}, packageVersion)
}
func TestHostKeyCheck(t *testing.T) {
for _, tt := range []struct {
name string
+13 -3
View File
@@ -24,11 +24,21 @@ const (
serviceSSH = "ssh-connection"
)
// supportedCiphers specifies the supported ciphers in preference order.
// supportedCiphers lists ciphers we support but might not recommend.
var supportedCiphers = []string{
"aes128-ctr", "aes192-ctr", "aes256-ctr",
"aes128-gcm@openssh.com",
"arcfour256", "arcfour128",
chacha20Poly1305ID,
"arcfour256", "arcfour128", "arcfour",
aes128cbcID,
tripledescbcID,
}
// preferredCiphers specifies the default preference for ciphers.
var preferredCiphers = []string{
"aes128-gcm@openssh.com",
chacha20Poly1305ID,
"aes128-ctr", "aes192-ctr", "aes256-ctr",
}
// supportedKexAlgos specifies the supported key-exchange algorithms in
@@ -211,7 +221,7 @@ func (c *Config) SetDefaults() {
c.Rand = rand.Reader
}
if c.Ciphers == nil {
c.Ciphers = supportedCiphers
c.Ciphers = preferredCiphers
}
var ciphers []string
for _, c := range c.Ciphers {
+1 -1
View File
@@ -414,7 +414,7 @@ func (db *hostKeyDB) Read(r io.Reader, filename string) error {
// New creates a host key callback from the given OpenSSH host key
// files. The returned callback is for use in
// ssh.ClientConfig.HostKeyCallback. Hashed hostnames are not supported.
// ssh.ClientConfig.HostKeyCallback.
func New(files ...string) (ssh.HostKeyCallback, error) {
db := newHostKeyDB()
for _, fn := range files {

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