Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb7306dcb | |||
| 12ff562d31 | |||
| 3884a33b36 | |||
| b5b1930599 | |||
| 61bc012a62 | |||
| 9c42997209 | |||
| 78385a351e | |||
| 32cec5bad0 | |||
| b78172033f | |||
| b0442e0277 | |||
| bca717873e | |||
| 23d2d9109f | |||
| c8138351c9 | |||
| 24a2f472ec | |||
| 40eb1b10fe | |||
| 26af3f98d2 | |||
| 577bcddb2a | |||
| da5c8c2edb | |||
| ffabb009b8 | |||
| 208cf9892d | |||
| 713b38e34c | |||
| 3b62f904d6 | |||
| 09785df07f | |||
| bdcc4134fa | |||
| 6302c3b219 | |||
| 68508ff800 | |||
| 45f0d3bed3 | |||
| 13db40f6bb | |||
| c2fd2e747a | |||
| d7f66ffa5c | |||
| 631e0a2e20 | |||
| 4aec2453f4 |
+2
-3
@@ -1,5 +1,4 @@
|
||||
miniflux-linux-amd64
|
||||
miniflux-linux-arm64
|
||||
miniflux-linux-arm
|
||||
miniflux-linux-arm*
|
||||
miniflux-darwin-amd64
|
||||
miniflux-test
|
||||
miniflux-test
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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") != ""}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<li>
|
||||
<a href="{{ route "users" }}">{{ t "Users" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route "about" }}">{{ t "About" }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Generated
Vendored
Generated
Vendored
+19
@@ -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
@@ -40,7 +40,8 @@ func sshPipe() (Conn, *server, error) {
|
||||
}
|
||||
|
||||
clientConf := ClientConfig{
|
||||
User: "user",
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
serverConf := ServerConfig{
|
||||
NoClientAuth: true,
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user