Implement structured logging using log/slog package

This commit is contained in:
Frédéric Guillot 2023-09-24 16:32:09 -07:00
parent 54cb8fa028
commit c0e954f19d
77 changed files with 1868 additions and 892 deletions

View file

@ -5,11 +5,11 @@ package api // import "miniflux.app/v2/internal/api"
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
) )
@ -40,25 +40,37 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
token := r.Header.Get("X-Auth-Token") token := r.Header.Get("X-Auth-Token")
if token == "" { if token == "" {
logger.Debug("[API][TokenAuth] [ClientIP=%s] No API Key provided, go to the next middleware", clientIP) slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
user, err := m.store.UserByAPIKey(token) user, err := m.store.UserByAPIKey(token)
if err != nil { if err != nil {
logger.Error("[API][TokenAuth] %v", err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
if user == nil { if user == nil {
logger.Error("[API][TokenAuth] [ClientIP=%s] No user found with the given API key", clientIP) slog.Warn("[API] No user found with the provided API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r) json.Unauthorized(w, r)
return return
} }
logger.Info("[API][TokenAuth] [ClientIP=%s] User authenticated: %s", clientIP, user.Username) slog.Info("[API] User authenticated successfully with the API Token Authentication",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", user.Username),
)
m.store.SetLastLogin(user.ID) m.store.SetLastLogin(user.ID)
m.store.SetAPIKeyUsedTimestamp(user.ID, token) m.store.SetAPIKeyUsedTimestamp(user.ID, token)
@ -84,37 +96,60 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
username, password, authOK := r.BasicAuth() username, password, authOK := r.BasicAuth()
if !authOK { if !authOK {
logger.Debug("[API][BasicAuth] [ClientIP=%s] No authentication headers sent", clientIP) slog.Warn("[API] No Basic HTTP Authentication header sent with the request",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r) json.Unauthorized(w, r)
return return
} }
if username == "" || password == "" { if username == "" || password == "" {
logger.Error("[API][BasicAuth] [ClientIP=%s] Empty username or password", clientIP) slog.Warn("[API] Empty username or password provided during Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r) json.Unauthorized(w, r)
return return
} }
if err := m.store.CheckPassword(username, password); err != nil { if err := m.store.CheckPassword(username, password); err != nil {
logger.Error("[API][BasicAuth] [ClientIP=%s] Invalid username or password: %s", clientIP, username) slog.Warn("[API] Invalid username or password provided during Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
json.Unauthorized(w, r) json.Unauthorized(w, r)
return return
} }
user, err := m.store.UserByUsername(username) user, err := m.store.UserByUsername(username)
if err != nil { if err != nil {
logger.Error("[API][BasicAuth] %v", err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
if user == nil { if user == nil {
logger.Error("[API][BasicAuth] [ClientIP=%s] User not found: %s", clientIP, username) slog.Warn("[API] User not found while using Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
json.Unauthorized(w, r) json.Unauthorized(w, r)
return return
} }
logger.Info("[API][BasicAuth] [ClientIP=%s] User authenticated: %s", clientIP, username) slog.Info("[API] User authenticated successfully with the Basic HTTP Authentication",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
m.store.SetLastLogin(user.ID) m.store.SetLastLogin(user.ID)
ctx := r.Context() ctx := r.Context()

View file

@ -16,8 +16,7 @@ func askCredentials() (string, string) {
fd := int(os.Stdin.Fd()) fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) { if !term.IsTerminal(fd) {
fmt.Fprintf(os.Stderr, "This is not a terminal, exiting.\n") printErrorAndExit(fmt.Errorf("this is not a terminal, exiting"))
os.Exit(1)
} }
fmt.Print("Enter Username: ") fmt.Print("Enter Username: ")

View file

@ -4,10 +4,10 @@
package cli // import "miniflux.app/v2/internal/cli" package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"log/slog"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/metric" "miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
@ -16,13 +16,18 @@ import (
func runCleanupTasks(store *storage.Storage) { func runCleanupTasks(store *storage.Storage) {
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays()) nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays()) nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
logger.Info("[Sessions] Removed %d application sessions and %d user sessions", nbSessions, nbUserSessions) slog.Info("Sessions cleanup completed",
slog.Int64("application_sessions_removed", nbSessions),
slog.Int64("user_sessions_removed", nbUserSessions),
)
startTime := time.Now() startTime := time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil { if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
logger.Error("[ArchiveReadEntries] %v", err) slog.Error("Unable to archive read entries", slog.Any("error", err))
} else { } else {
logger.Info("[ArchiveReadEntries] %d entries changed", rowsAffected) slog.Info("Archiving read entries completed",
slog.Int64("read_entries_archived", rowsAffected),
)
if config.Opts.HasMetricsCollector() { if config.Opts.HasMetricsCollector() {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusRead).Observe(time.Since(startTime).Seconds()) metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusRead).Observe(time.Since(startTime).Seconds())
@ -31,9 +36,11 @@ func runCleanupTasks(store *storage.Storage) {
startTime = time.Now() startTime = time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil { if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
logger.Error("[ArchiveUnreadEntries] %v", err) slog.Error("Unable to archive unread entries", slog.Any("error", err))
} else { } else {
logger.Info("[ArchiveUnreadEntries] %d entries changed", rowsAffected) slog.Info("Archiving unread entries completed",
slog.Int64("unread_entries_archived", rowsAffected),
)
if config.Opts.HasMetricsCollector() { if config.Opts.HasMetricsCollector() {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds()) metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())

View file

@ -6,11 +6,13 @@ package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"flag" "flag"
"fmt" "fmt"
"io"
"log/slog"
"os"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database" "miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static" "miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version" "miniflux.app/v2/internal/version"
@ -74,13 +76,13 @@ func Parse() {
if flagConfigFile != "" { if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile) config.Opts, err = cfg.ParseFile(flagConfigFile)
if err != nil { if err != nil {
logger.Fatal("%v", err) printErrorAndExit(err)
} }
} }
config.Opts, err = cfg.ParseEnvironmentVariables() config.Opts, err = cfg.ParseEnvironmentVariables()
if err != nil { if err != nil {
logger.Fatal("%v", err) printErrorAndExit(err)
} }
if flagConfigDump { if flagConfigDump {
@ -88,12 +90,27 @@ func Parse() {
return return
} }
if config.Opts.LogDateTime() { if flagDebugMode {
logger.EnableDateTime() config.Opts.SetLogLevel("debug")
} }
if flagDebugMode || config.Opts.HasDebugMode() { logFile := config.Opts.LogFile()
logger.EnableDebug() var logFileHandler io.Writer
switch logFile {
case "stdout":
logFileHandler = os.Stdout
case "stderr":
logFileHandler = os.Stderr
default:
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
}
defer logFileHandler.(*os.File).Close()
}
if err := InitializeDefaultLogger(config.Opts.LogLevel(), logFileHandler, config.Opts.LogFormat(), config.Opts.LogDateTime()); err != nil {
printErrorAndExit(err)
} }
if flagHealthCheck != "" { if flagHealthCheck != "" {
@ -112,25 +129,23 @@ func Parse() {
} }
if config.Opts.IsDefaultDatabaseURL() { if config.Opts.IsDefaultDatabaseURL() {
logger.Info("The default value for DATABASE_URL is used") slog.Info("The default value for DATABASE_URL is used")
} }
logger.Debug("Loading translations...")
if err := locale.LoadCatalogMessages(); err != nil { if err := locale.LoadCatalogMessages(); err != nil {
logger.Fatal("Unable to load translations: %v", err) printErrorAndExit(fmt.Errorf("unable to load translations: %v", err))
} }
logger.Debug("Loading static assets...")
if err := static.CalculateBinaryFileChecksums(); err != nil { if err := static.CalculateBinaryFileChecksums(); err != nil {
logger.Fatal("Unable to calculate binary files checksum: %v", err) printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
} }
if err := static.GenerateStylesheetsBundles(); err != nil { if err := static.GenerateStylesheetsBundles(); err != nil {
logger.Fatal("Unable to generate Stylesheet bundles: %v", err) printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundles: %v", err))
} }
if err := static.GenerateJavascriptBundles(); err != nil { if err := static.GenerateJavascriptBundles(); err != nil {
logger.Fatal("Unable to generate Javascript bundles: %v", err) printErrorAndExit(fmt.Errorf("unable to generate javascript bundles: %v", err))
} }
db, err := database.NewConnectionPool( db, err := database.NewConnectionPool(
@ -140,19 +155,19 @@ func Parse() {
config.Opts.DatabaseConnectionLifetime(), config.Opts.DatabaseConnectionLifetime(),
) )
if err != nil { if err != nil {
logger.Fatal("Unable to initialize database connection pool: %v", err) printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
} }
defer db.Close() defer db.Close()
store := storage.NewStorage(db) store := storage.NewStorage(db)
if err := store.Ping(); err != nil { if err := store.Ping(); err != nil {
logger.Fatal("Unable to connect to the database: %v", err) printErrorAndExit(err)
} }
if flagMigrate { if flagMigrate {
if err := database.Migrate(db); err != nil { if err := database.Migrate(db); err != nil {
logger.Fatal(`%v`, err) printErrorAndExit(err)
} }
return return
} }
@ -180,12 +195,12 @@ func Parse() {
// Run migrations and start the daemon. // Run migrations and start the daemon.
if config.Opts.RunMigrations() { if config.Opts.RunMigrations() {
if err := database.Migrate(db); err != nil { if err := database.Migrate(db); err != nil {
logger.Fatal(`%v`, err) printErrorAndExit(err)
} }
} }
if err := database.IsSchemaUpToDate(db); err != nil { if err := database.IsSchemaUpToDate(db); err != nil {
logger.Fatal(`You must run the SQL migrations, %v`, err) printErrorAndExit(err)
} }
// Create admin user and start the daemon. // Create admin user and start the daemon.
@ -205,3 +220,8 @@ func Parse() {
startDaemon(store) startDaemon(store)
} }
func printErrorAndExit(err error) {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}

View file

@ -4,11 +4,9 @@
package cli // import "miniflux.app/v2/internal/cli" package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"fmt" "log/slog"
"os"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator" "miniflux.app/v2/internal/validator"
@ -26,17 +24,17 @@ func createAdmin(store *storage.Storage) {
} }
if store.UserExists(userCreationRequest.Username) { if store.UserExists(userCreationRequest.Username) {
logger.Info(`User %q already exists, skipping creation`, userCreationRequest.Username) slog.Info("Skipping admin user creation because it already exists",
slog.String("username", userCreationRequest.Username),
)
return return
} }
if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil { if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil {
fmt.Fprintf(os.Stderr, "%s\n", validationErr) printErrorAndExit(validationErr.Error())
os.Exit(1)
} }
if _, err := store.CreateUser(userCreationRequest); err != nil { if _, err := store.CreateUser(userCreationRequest); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err) printErrorAndExit(err)
os.Exit(1)
} }
} }

View file

@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -13,7 +14,6 @@ import (
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
httpd "miniflux.app/v2/internal/http/server" httpd "miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/metric" "miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/systemd" "miniflux.app/v2/internal/systemd"
@ -21,7 +21,7 @@ import (
) )
func startDaemon(store *storage.Storage) { func startDaemon(store *storage.Storage) {
logger.Info("Starting daemon...") slog.Debug("Starting daemon...")
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt) signal.Notify(stop, os.Interrupt)
@ -44,26 +44,25 @@ func startDaemon(store *storage.Storage) {
} }
if systemd.HasNotifySocket() { if systemd.HasNotifySocket() {
logger.Info("Sending readiness notification to Systemd") slog.Debug("Sending readiness notification to Systemd")
if err := systemd.SdNotify(systemd.SdNotifyReady); err != nil { if err := systemd.SdNotify(systemd.SdNotifyReady); err != nil {
logger.Error("Unable to send readiness notification to systemd: %v", err) slog.Error("Unable to send readiness notification to systemd", slog.Any("error", err))
} }
if config.Opts.HasWatchdog() && systemd.HasSystemdWatchdog() { if config.Opts.HasWatchdog() && systemd.HasSystemdWatchdog() {
logger.Info("Activating Systemd watchdog") slog.Debug("Activating Systemd watchdog")
go func() { go func() {
interval, err := systemd.WatchdogInterval() interval, err := systemd.WatchdogInterval()
if err != nil { if err != nil {
logger.Error("Unable to parse watchdog interval from systemd: %v", err) slog.Error("Unable to get watchdog interval from systemd", slog.Any("error", err))
return return
} }
for { for {
err := store.Ping() if err := store.Ping(); err != nil {
if err != nil { slog.Error("Unable to ping database", slog.Any("error", err))
logger.Error(`Systemd Watchdog: %v`, err)
} else { } else {
systemd.SdNotify(systemd.SdNotifyWatchdog) systemd.SdNotify(systemd.SdNotifyWatchdog)
} }
@ -75,7 +74,7 @@ func startDaemon(store *storage.Storage) {
} }
<-stop <-stop
logger.Info("Shutting down the process...") slog.Debug("Shutting down the process")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@ -83,5 +82,5 @@ func startDaemon(store *storage.Storage) {
httpServer.Shutdown(ctx) httpServer.Shutdown(ctx)
} }
logger.Info("Process gracefully stopped") slog.Debug("Process gracefully stopped")
} }

View file

@ -5,7 +5,6 @@ package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"fmt" "fmt"
"os"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
) )
@ -13,7 +12,6 @@ import (
func flushSessions(store *storage.Storage) { func flushSessions(store *storage.Storage) {
fmt.Println("Flushing all sessions (disconnect users)") fmt.Println("Flushing all sessions (disconnect users)")
if err := store.FlushAllSessions(); err != nil { if err := store.FlushAllSessions(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err) printErrorAndExit(err)
os.Exit(1)
} }
} }

View file

@ -4,11 +4,12 @@
package cli // import "miniflux.app/v2/internal/cli" package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"fmt"
"log/slog"
"net/http" "net/http"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
) )
func doHealthCheck(healthCheckEndpoint string) { func doHealthCheck(healthCheckEndpoint string) {
@ -16,18 +17,18 @@ func doHealthCheck(healthCheckEndpoint string) {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck" healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
} }
logger.Debug(`Executing health check on %s`, healthCheckEndpoint) slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
client := &http.Client{Timeout: 3 * time.Second} client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint) resp, err := client.Get(healthCheckEndpoint)
if err != nil { if err != nil {
logger.Fatal(`Health check failure: %v`, err) printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
logger.Fatal(`Health check failed with status code %d`, resp.StatusCode) printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
} }
logger.Debug(`Health check is OK`) slog.Debug(`Health check is passing`)
} }

46
internal/cli/logger.go Normal file
View file

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"io"
"log/slog"
)
func InitializeDefaultLogger(logLevel string, logFile io.Writer, logFormat string, logTime bool) error {
var programLogLevel = new(slog.LevelVar)
switch logLevel {
case "debug":
programLogLevel.Set(slog.LevelDebug)
case "info":
programLogLevel.Set(slog.LevelInfo)
case "warning":
programLogLevel.Set(slog.LevelWarn)
case "error":
programLogLevel.Set(slog.LevelError)
}
logHandlerOptions := &slog.HandlerOptions{Level: programLogLevel}
if !logTime {
logHandlerOptions.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
}
var logger *slog.Logger
switch logFormat {
case "json":
logger = slog.New(slog.NewJSONHandler(logFile, logHandlerOptions))
default:
logger = slog.New(slog.NewTextHandler(logFile, logHandlerOptions))
}
slog.SetDefault(logger)
return nil
}

View file

@ -4,11 +4,11 @@
package cli // import "miniflux.app/v2/internal/cli" package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"log/slog"
"sync" "sync"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler" feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
@ -20,22 +20,39 @@ func refreshFeeds(store *storage.Storage) {
startTime := time.Now() startTime := time.Now()
jobs, err := store.NewBatch(config.Opts.BatchSize()) jobs, err := store.NewBatch(config.Opts.BatchSize())
if err != nil { if err != nil {
logger.Error("[Cronjob] %v", err) slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} }
nbJobs := len(jobs) nbJobs := len(jobs)
logger.Info("[Cronjob]] Created %d jobs from a batch size of %d", nbJobs, config.Opts.BatchSize())
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", nbJobs),
slog.Int("batch_size", config.Opts.BatchSize()),
)
var jobQueue = make(chan model.Job, nbJobs) var jobQueue = make(chan model.Job, nbJobs)
logger.Info("[Cronjob] Starting a pool of %d workers", config.Opts.WorkerPoolSize()) slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
)
for i := 0; i < config.Opts.WorkerPoolSize(); i++ { for i := 0; i < config.Opts.WorkerPoolSize(); i++ {
wg.Add(1) wg.Add(1)
go func(workerID int) { go func(workerID int) {
defer wg.Done() defer wg.Done()
for job := range jobQueue { for job := range jobQueue {
logger.Info("[Cronjob] Refreshing feed #%d for user #%d in worker #%d", job.FeedID, job.UserID, workerID) slog.Info("Refreshing feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),
slog.Int("worker_id", workerID),
)
if err := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); err != nil { if err := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); err != nil {
logger.Error("[Cronjob] Refreshing the feed #%d returned this error: %v", job.FeedID, err) slog.Error("Unable to refresh feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),
slog.Any("error", err),
)
} }
} }
}(i) }(i)
@ -47,5 +64,9 @@ func refreshFeeds(store *storage.Storage) {
close(jobQueue) close(jobQueue)
wg.Wait() wg.Wait()
logger.Info("[Cronjob] Refreshed %d feed(s) in %s", nbJobs, time.Since(startTime))
slog.Info("Refreshed a batch of feeds",
slog.Int("nb_feeds", nbJobs),
slog.String("duration", time.Since(startTime).String()),
)
} }

View file

@ -5,7 +5,6 @@ package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"fmt" "fmt"
"os"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
@ -16,27 +15,23 @@ func resetPassword(store *storage.Storage) {
username, password := askCredentials() username, password := askCredentials()
user, err := store.UserByUsername(username) user, err := store.UserByUsername(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err) printErrorAndExit(err)
os.Exit(1)
} }
if user == nil { if user == nil {
fmt.Fprintf(os.Stderr, "User not found!\n") printErrorAndExit(fmt.Errorf("user not found"))
os.Exit(1)
} }
userModificationRequest := &model.UserModificationRequest{ userModificationRequest := &model.UserModificationRequest{
Password: &password, Password: &password,
} }
if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil { if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil {
fmt.Fprintf(os.Stderr, "%s\n", validationErr) printErrorAndExit(validationErr.Error())
os.Exit(1)
} }
user.Password = password user.Password = password
if err := store.UpdateUser(user); err != nil { if err := store.UpdateUser(user); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err) printErrorAndExit(err)
os.Exit(1)
} }
fmt.Println("Password changed!") fmt.Println("Password changed!")

View file

@ -4,16 +4,16 @@
package cli // import "miniflux.app/v2/internal/cli" package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"log/slog"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/worker" "miniflux.app/v2/internal/worker"
) )
func runScheduler(store *storage.Storage, pool *worker.Pool) { func runScheduler(store *storage.Storage, pool *worker.Pool) {
logger.Info(`Starting background scheduler...`) slog.Debug(`Starting background scheduler...`)
go feedScheduler( go feedScheduler(
store, store,
@ -31,10 +31,12 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize int) { func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize int) {
for range time.Tick(time.Duration(frequency) * time.Minute) { for range time.Tick(time.Duration(frequency) * time.Minute) {
jobs, err := store.NewBatch(batchSize) jobs, err := store.NewBatch(batchSize)
logger.Info("[Scheduler:Feed] Pushing %d jobs to the queue", len(jobs))
if err != nil { if err != nil {
logger.Error("[Scheduler:Feed] %v", err) slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else { } else {
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", len(jobs)),
)
pool.Push(jobs) pool.Push(jobs)
} }
} }

View file

@ -8,6 +8,181 @@ import (
"testing" "testing"
) )
func TestLogFileDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFile() != defaultLogFile {
t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
}
}
func TestLogFileWithCustomFilename(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "foobar.log")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFile() != "foobar.log" {
t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
}
}
func TestLogFileWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFile() != defaultLogFile {
t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
}
}
func TestLogLevelDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != defaultLogLevel {
t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
}
}
func TestLogLevelWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_LEVEL", "warning")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "warning" {
t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
}
}
func TestLogLevelWithInvalidValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_LEVEL", "invalid")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != defaultLogLevel {
t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
}
}
func TestLogDateTimeDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogDateTime() != defaultLogDateTime {
t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
}
}
func TestLogDateTimeWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATETIME", "false")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogDateTime() != false {
t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
}
}
func TestLogDateTimeWithInvalidValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATETIME", "invalid")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogDateTime() != defaultLogDateTime {
t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
}
}
func TestLogFormatDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFormat() != defaultLogFormat {
t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
}
}
func TestLogFormatWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FORMAT", "json")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFormat() != "json" {
t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
}
}
func TestLogFormatWithInvalidValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FORMAT", "invalid")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFormat() != defaultLogFormat {
t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
}
}
func TestDebugModeOn(t *testing.T) { func TestDebugModeOn(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("DEBUG", "1") os.Setenv("DEBUG", "1")
@ -18,8 +193,8 @@ func TestDebugModeOn(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
if !opts.HasDebugMode() { if opts.LogLevel() != "debug" {
t.Fatalf(`Unexpected debug mode value, got "%v"`, opts.HasDebugMode()) t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
} }
} }
@ -32,8 +207,8 @@ func TestDebugModeOff(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
if opts.HasDebugMode() { if opts.LogLevel() != "info" {
t.Fatalf(`Unexpected debug mode value, got "%v"`, opts.HasDebugMode()) t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
} }
} }
@ -1544,8 +1719,8 @@ Invalid text
t.Errorf(`Parsing failure: %v`, err) t.Errorf(`Parsing failure: %v`, err)
} }
if opts.HasDebugMode() != true { if opts.LogLevel() != "debug" {
t.Errorf(`Unexpected debug mode value, got "%v"`, opts.HasDebugMode()) t.Errorf(`Unexpected debug mode value, got %q`, opts.LogLevel())
} }
expected := ">#1234" expected := ">#1234"

View file

@ -15,7 +15,10 @@ import (
const ( const (
defaultHTTPS = false defaultHTTPS = false
defaultLogFile = "stderr"
defaultLogDateTime = false defaultLogDateTime = false
defaultLogFormat = "text"
defaultLogLevel = "info"
defaultHSTS = true defaultHSTS = true
defaultHTTPService = true defaultHTTPService = true
defaultSchedulerService = true defaultSchedulerService = true
@ -91,11 +94,13 @@ type Option struct {
// Options contains configuration options. // Options contains configuration options.
type Options struct { type Options struct {
HTTPS bool HTTPS bool
logFile string
logDateTime bool logDateTime bool
logFormat string
logLevel string
hsts bool hsts bool
httpService bool httpService bool
schedulerService bool schedulerService bool
debug bool
serverTimingHeader bool serverTimingHeader bool
baseURL string baseURL string
rootURL string rootURL string
@ -165,11 +170,13 @@ func NewOptions() *Options {
return &Options{ return &Options{
HTTPS: defaultHTTPS, HTTPS: defaultHTTPS,
logFile: defaultLogFile,
logDateTime: defaultLogDateTime, logDateTime: defaultLogDateTime,
logFormat: defaultLogFormat,
logLevel: defaultLogLevel,
hsts: defaultHSTS, hsts: defaultHSTS,
httpService: defaultHTTPService, httpService: defaultHTTPService,
schedulerService: defaultSchedulerService, schedulerService: defaultSchedulerService,
debug: defaultDebug,
serverTimingHeader: defaultTiming, serverTimingHeader: defaultTiming,
baseURL: defaultBaseURL, baseURL: defaultBaseURL,
rootURL: defaultRootURL, rootURL: defaultRootURL,
@ -231,11 +238,30 @@ func NewOptions() *Options {
} }
} }
func (o *Options) LogFile() string {
return o.logFile
}
// LogDateTime returns true if the date/time should be displayed in log messages. // LogDateTime returns true if the date/time should be displayed in log messages.
func (o *Options) LogDateTime() bool { func (o *Options) LogDateTime() bool {
return o.logDateTime return o.logDateTime
} }
// LogFormat returns the log format.
func (o *Options) LogFormat() string {
return o.logFormat
}
// LogLevel returns the log level.
func (o *Options) LogLevel() string {
return o.logLevel
}
// SetLogLevel sets the log level.
func (o *Options) SetLogLevel(level string) {
o.logLevel = level
}
// HasMaintenanceMode returns true if maintenance mode is enabled. // HasMaintenanceMode returns true if maintenance mode is enabled.
func (o *Options) HasMaintenanceMode() bool { func (o *Options) HasMaintenanceMode() bool {
return o.maintenanceMode return o.maintenanceMode
@ -246,11 +272,6 @@ func (o *Options) MaintenanceMessage() string {
return o.maintenanceMessage return o.maintenanceMessage
} }
// HasDebugMode returns true if debug mode is enabled.
func (o *Options) HasDebugMode() bool {
return o.debug
}
// HasServerTimingHeader returns true if server-timing headers enabled. // HasServerTimingHeader returns true if server-timing headers enabled.
func (o *Options) HasServerTimingHeader() bool { func (o *Options) HasServerTimingHeader() bool {
return o.serverTimingHeader return o.serverTimingHeader
@ -593,7 +614,6 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"DATABASE_MAX_CONNS": o.databaseMaxConns, "DATABASE_MAX_CONNS": o.databaseMaxConns,
"DATABASE_MIN_CONNS": o.databaseMinConns, "DATABASE_MIN_CONNS": o.databaseMinConns,
"DATABASE_URL": redactSecretValue(o.databaseURL, redactSecret), "DATABASE_URL": redactSecretValue(o.databaseURL, redactSecret),
"DEBUG": o.debug,
"DISABLE_HSTS": !o.hsts, "DISABLE_HSTS": !o.hsts,
"DISABLE_HTTP_SERVICE": !o.httpService, "DISABLE_HTTP_SERVICE": !o.httpService,
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService, "DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
@ -609,7 +629,10 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"INVIDIOUS_INSTANCE": o.invidiousInstance, "INVIDIOUS_INSTANCE": o.invidiousInstance,
"KEY_FILE": o.certKeyFile, "KEY_FILE": o.certKeyFile,
"LISTEN_ADDR": o.listenAddr, "LISTEN_ADDR": o.listenAddr,
"LOG_FILE": o.logFile,
"LOG_DATE_TIME": o.logDateTime, "LOG_DATE_TIME": o.logDateTime,
"LOG_FORMAT": o.logFormat,
"LOG_LEVEL": o.logLevel,
"MAINTENANCE_MESSAGE": o.maintenanceMessage, "MAINTENANCE_MESSAGE": o.maintenanceMessage,
"MAINTENANCE_MODE": o.maintenanceMode, "MAINTENANCE_MODE": o.maintenanceMode,
"METRICS_ALLOWED_NETWORKS": strings.Join(o.metricsAllowedNetworks, ","), "METRICS_ALLOWED_NETWORKS": strings.Join(o.metricsAllowedNetworks, ","),

View file

@ -10,7 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
url_parser "net/url" "net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -72,10 +72,25 @@ func (p *Parser) parseLines(lines []string) (err error) {
value := strings.TrimSpace(fields[1]) value := strings.TrimSpace(fields[1])
switch key { switch key {
case "LOG_FILE":
p.opts.logFile = parseString(value, defaultLogFile)
case "LOG_DATE_TIME": case "LOG_DATE_TIME":
p.opts.logDateTime = parseBool(value, defaultLogDateTime) p.opts.logDateTime = parseBool(value, defaultLogDateTime)
case "LOG_LEVEL":
parsedValue := parseString(value, defaultLogLevel)
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
p.opts.logLevel = parsedValue
}
case "LOG_FORMAT":
parsedValue := parseString(value, defaultLogFormat)
if parsedValue == "json" || parsedValue == "text" {
p.opts.logFormat = parsedValue
}
case "DEBUG": case "DEBUG":
p.opts.debug = parseBool(value, defaultDebug) parsedValue := parseBool(value, defaultDebug)
if parsedValue {
p.opts.logLevel = "debug"
}
case "SERVER_TIMING_HEADER": case "SERVER_TIMING_HEADER":
p.opts.serverTimingHeader = parseBool(value, defaultTiming) p.opts.serverTimingHeader = parseBool(value, defaultTiming)
case "BASE_URL": case "BASE_URL":
@ -247,19 +262,19 @@ func parseBaseURL(value string) (string, string, string, error) {
value = value[:len(value)-1] value = value[:len(value)-1]
} }
url, err := url_parser.Parse(value) parsedURL, err := url.Parse(value)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err) return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
} }
scheme := strings.ToLower(url.Scheme) scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" { if scheme != "https" && scheme != "http" {
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https") return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
} }
basePath := url.Path basePath := parsedURL.Path
url.Path = "" parsedURL.Path = ""
return value, url.String(), basePath, nil return value, parsedURL.String(), basePath, nil
} }
func parseBool(value string, fallback bool) bool { func parseBool(value string, fallback bool) bool {

View file

@ -6,6 +6,7 @@ package database // import "miniflux.app/v2/internal/database"
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"time" "time"
// Postgresql driver import // Postgresql driver import
@ -31,12 +32,13 @@ func Migrate(db *sql.DB) error {
var currentVersion int var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion) db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
fmt.Println("-> Current schema version:", currentVersion) slog.Debug("Running database migrations",
fmt.Println("-> Latest schema version:", schemaVersion) slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
)
for version := currentVersion; version < schemaVersion; version++ { for version := currentVersion; version < schemaVersion; version++ {
newVersion := version + 1 newVersion := version + 1
fmt.Println("* Migrating to version:", newVersion)
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {

View file

@ -4,6 +4,7 @@
package fever // import "miniflux.app/v2/internal/fever" package fever // import "miniflux.app/v2/internal/fever"
import ( import (
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -12,7 +13,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy" "miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
@ -80,7 +80,9 @@ is_spark equal to 1.
*/ */
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) { func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching groups for user #%d", userID) slog.Debug("[Fever] Fetching groups",
slog.Int64("user_id", userID),
)
categories, err := h.store.Categories(userID) categories, err := h.store.Categories(userID)
if err != nil { if err != nil {
@ -130,7 +132,9 @@ For the “Sparks” super group the items should be limited to feeds with an is
*/ */
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) { func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching feeds for userID=%d", userID) slog.Debug("[Fever] Fetching feeds",
slog.Int64("user_id", userID),
)
feeds, err := h.store.Feeds(userID) feeds, err := h.store.Feeds(userID)
if err != nil { if err != nil {
@ -183,7 +187,9 @@ A PHP/HTML example:
*/ */
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) { func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching favicons for user #%d", userID) slog.Debug("[Fever] Fetching favicons",
slog.Int64("user_id", userID),
)
icons, err := h.store.Icons(userID) icons, err := h.store.Icons(userID)
if err != nil { if err != nil {
@ -247,16 +253,24 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
case request.HasQueryParam(r, "since_id"): case request.HasQueryParam(r, "since_id"):
sinceID := request.QueryInt64Param(r, "since_id", 0) sinceID := request.QueryInt64Param(r, "since_id", 0)
if sinceID > 0 { if sinceID > 0 {
logger.Debug("[Fever] Fetching items since #%d for user #%d", sinceID, userID) slog.Debug("[Fever] Fetching items since a given date",
slog.Int64("user_id", userID),
slog.Int64("since_id", sinceID),
)
builder.AfterEntryID(sinceID) builder.AfterEntryID(sinceID)
} }
case request.HasQueryParam(r, "max_id"): case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0) maxID := request.QueryInt64Param(r, "max_id", 0)
if maxID == 0 { if maxID == 0 {
logger.Debug("[Fever] Fetching most recent items for user #%d", userID) slog.Debug("[Fever] Fetching most recent items",
slog.Int64("user_id", userID),
)
builder.WithSorting("id", "DESC") builder.WithSorting("id", "DESC")
} else if maxID > 0 { } else if maxID > 0 {
logger.Debug("[Fever] Fetching items before #%d for user #%d", maxID, userID) slog.Debug("[Fever] Fetching items before a given item ID",
slog.Int64("user_id", userID),
slog.Int64("max_id", maxID),
)
builder.BeforeEntryID(maxID) builder.BeforeEntryID(maxID)
builder.WithSorting("id", "DESC") builder.WithSorting("id", "DESC")
} }
@ -274,7 +288,9 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
builder.WithEntryIDs(itemIDs) builder.WithEntryIDs(itemIDs)
} }
default: default:
logger.Debug("[Fever] Fetching oldest items for user #%d", userID) slog.Debug("[Fever] Fetching oldest items",
slog.Int64("user_id", userID),
)
} }
entries, err := builder.GetEntries() entries, err := builder.GetEntries()
@ -330,7 +346,9 @@ A request with the unread_item_ids argument will return one additional member:
*/ */
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching unread items for user #%d", userID) slog.Debug("[Fever] Fetching unread items",
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread) builder.WithStatus(model.EntryStatusUnread)
@ -361,7 +379,9 @@ with the remote Fever installation.
*/ */
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching saved items for user #%d", userID) slog.Debug("[Fever] Fetching saved items",
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred(true) builder.WithStarred(true)
@ -389,7 +409,9 @@ id=? where ? is replaced with the id of the item to modify
*/ */
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Receiving mark=item call for user #%d", userID) slog.Debug("[Fever] Receiving mark=item call",
slog.Int64("user_id", userID),
)
entryID := request.FormInt64Value(r, "id") entryID := request.FormInt64Value(r, "id")
if entryID <= 0 { if entryID <= 0 {
@ -407,20 +429,32 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
} }
if entry == nil { if entry == nil {
logger.Debug("[Fever] Marking entry #%d but not found, ignored", entryID) slog.Debug("[Fever] Entry not found",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
json.OK(w, r, newBaseResponse()) json.OK(w, r, newBaseResponse())
return return
} }
switch r.FormValue("as") { switch r.FormValue("as") {
case "read": case "read":
logger.Debug("[Fever] Mark entry #%d as read for user #%d", entryID, userID) slog.Debug("[Fever] Mark entry as read",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead) h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread": case "unread":
logger.Debug("[Fever] Mark entry #%d as unread for user #%d", entryID, userID) slog.Debug("[Fever] Mark entry as unread",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread) h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved": case "saved":
logger.Debug("[Fever] Mark entry #%d as saved for user #%d", entryID, userID) slog.Debug("[Fever] Mark entry as saved",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleBookmark(userID, entryID); err != nil { if err := h.store.ToggleBookmark(userID, entryID); err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
@ -436,7 +470,10 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
integration.SendEntry(entry, settings) integration.SendEntry(entry, settings)
}() }()
case "unsaved": case "unsaved":
logger.Debug("[Fever] Mark entry #%d as unsaved for user #%d", entryID, userID) slog.Debug("[Fever] Mark entry as unsaved",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleBookmark(userID, entryID); err != nil { if err := h.store.ToggleBookmark(userID, entryID); err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
@ -457,7 +494,11 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
feedID := request.FormInt64Value(r, "id") feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0) before := time.Unix(request.FormInt64Value(r, "before"), 0)
logger.Debug("[Fever] Mark feed #%d as read for user #%d before %v", feedID, userID, before) slog.Debug("[Fever] Mark feed as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Time("before_ts", before),
)
if feedID <= 0 { if feedID <= 0 {
return return
@ -465,7 +506,12 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
go func() { go func() {
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil { if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
logger.Error("[Fever] MarkFeedAsRead failed: %v", err) slog.Error("[Fever] Unable to mark feed as read",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Time("before_ts", before),
slog.Any("error", err),
)
} }
}() }()
@ -483,7 +529,11 @@ func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
groupID := request.FormInt64Value(r, "id") groupID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0) before := time.Unix(request.FormInt64Value(r, "before"), 0)
logger.Debug("[Fever] Mark group #%d as read for user #%d before %v", groupID, userID, before) slog.Debug("[Fever] Mark group as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
)
if groupID < 0 { if groupID < 0 {
return return
@ -499,7 +549,12 @@ func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
} }
if err != nil { if err != nil {
logger.Error("[Fever] MarkCategoryAsRead failed: %v", err) slog.Error("[Fever] Unable to mark group as read",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
slog.Any("error", err),
)
} }
}() }()

View file

@ -5,11 +5,11 @@ package fever // import "miniflux.app/v2/internal/fever"
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
) )
@ -26,25 +26,45 @@ func (m *middleware) serve(next http.Handler) http.Handler {
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
apiKey := r.FormValue("api_key") apiKey := r.FormValue("api_key")
if apiKey == "" { if apiKey == "" {
logger.Info("[Fever] [ClientIP=%s] No API key provided", clientIP) slog.Warn("[Fever] No API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, newAuthFailureResponse()) json.OK(w, r, newAuthFailureResponse())
return return
} }
user, err := m.store.UserByFeverToken(apiKey) user, err := m.store.UserByFeverToken(apiKey)
if err != nil { if err != nil {
logger.Error("[Fever] %v", err) slog.Error("[Fever] Unable to fetch user by API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
json.OK(w, r, newAuthFailureResponse()) json.OK(w, r, newAuthFailureResponse())
return return
} }
if user == nil { if user == nil {
logger.Info("[Fever] [ClientIP=%s] No user found with this API key", clientIP) slog.Warn("[Fever] No user found with the API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, newAuthFailureResponse()) json.OK(w, r, newAuthFailureResponse())
return return
} }
logger.Info("[Fever] [ClientIP=%s] User #%d is authenticated with user agent %q", clientIP, user.ID, r.UserAgent()) slog.Info("[Fever] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(user.ID) m.store.SetLastLogin(user.ID)
ctx := r.Context() ctx := r.Context()

View file

@ -6,19 +6,18 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/http/httputil"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy" "miniflux.app/v2/internal/proxy"
mff "miniflux.app/v2/internal/reader/handler" mff "miniflux.app/v2/internal/reader/handler"
@ -26,6 +25,8 @@ import (
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/validator" "miniflux.app/v2/internal/validator"
"github.com/gorilla/mux"
) )
type handler struct { type handler struct {
@ -201,24 +202,25 @@ func (r RequestModifiers) String() string {
// Serve handles Google Reader API calls. // Serve handles Google Reader API calls.
func Serve(router *mux.Router, store *storage.Storage) { func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router} handler := &handler{store, router}
router.HandleFunc("/accounts/ClientLogin", handler.clientLoginHandler).Methods(http.MethodPost).Name("ClientLogin")
middleware := newMiddleware(store) middleware := newMiddleware(store)
router.HandleFunc("/accounts/ClientLogin", middleware.clientLogin).Methods(http.MethodPost).Name("ClientLogin")
sr := router.PathPrefix("/reader/api/0").Subrouter() sr := router.PathPrefix("/reader/api/0").Subrouter()
sr.Use(middleware.handleCORS) sr.Use(middleware.handleCORS)
sr.Use(middleware.apiKeyAuth) sr.Use(middleware.apiKeyAuth)
sr.Methods(http.MethodOptions) sr.Methods(http.MethodOptions)
sr.HandleFunc("/token", middleware.token).Methods(http.MethodGet).Name("Token") sr.HandleFunc("/token", handler.tokenHandler).Methods(http.MethodGet).Name("Token")
sr.HandleFunc("/edit-tag", handler.editTag).Methods(http.MethodPost).Name("EditTag") sr.HandleFunc("/edit-tag", handler.editTagHandler).Methods(http.MethodPost).Name("EditTag")
sr.HandleFunc("/rename-tag", handler.renameTag).Methods(http.MethodPost).Name("Rename Tag") sr.HandleFunc("/rename-tag", handler.renameTagHandler).Methods(http.MethodPost).Name("Rename Tag")
sr.HandleFunc("/disable-tag", handler.disableTag).Methods(http.MethodPost).Name("Disable Tag") sr.HandleFunc("/disable-tag", handler.disableTagHandler).Methods(http.MethodPost).Name("Disable Tag")
sr.HandleFunc("/tag/list", handler.tagList).Methods(http.MethodGet).Name("TagList") sr.HandleFunc("/tag/list", handler.tagListHandler).Methods(http.MethodGet).Name("TagList")
sr.HandleFunc("/user-info", handler.userInfo).Methods(http.MethodGet).Name("UserInfo") sr.HandleFunc("/user-info", handler.userInfoHandler).Methods(http.MethodGet).Name("UserInfo")
sr.HandleFunc("/subscription/list", handler.subscriptionList).Methods(http.MethodGet).Name("SubscriptonList") sr.HandleFunc("/subscription/list", handler.subscriptionListHandler).Methods(http.MethodGet).Name("SubscriptonList")
sr.HandleFunc("/subscription/edit", handler.editSubscription).Methods(http.MethodPost).Name("SubscriptionEdit") sr.HandleFunc("/subscription/edit", handler.editSubscriptionHandler).Methods(http.MethodPost).Name("SubscriptionEdit")
sr.HandleFunc("/subscription/quickadd", handler.quickAdd).Methods(http.MethodPost).Name("QuickAdd") sr.HandleFunc("/subscription/quickadd", handler.quickAddHandler).Methods(http.MethodPost).Name("QuickAdd")
sr.HandleFunc("/stream/items/ids", handler.streamItemIDs).Methods(http.MethodGet).Name("StreamItemIDs") sr.HandleFunc("/stream/items/ids", handler.streamItemIDsHandler).Methods(http.MethodGet).Name("StreamItemIDs")
sr.HandleFunc("/stream/items/contents", handler.streamItemContents).Methods(http.MethodPost).Name("StreamItemsContents") sr.HandleFunc("/stream/items/contents", handler.streamItemContentsHandler).Methods(http.MethodPost).Name("StreamItemsContents")
sr.PathPrefix("/").HandlerFunc(handler.serve).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint") sr.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
} }
func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) { func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
@ -276,8 +278,7 @@ func getStream(streamID string, userID int64) (Stream, error) {
case Like: case Like:
return Stream{LikeStream, ""}, nil return Stream{LikeStream, ""}, nil
default: default:
err := fmt.Errorf("uknown stream with id: %s", id) return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
return Stream{NoStream, ""}, err
} }
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) { } else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
@ -286,8 +287,8 @@ func getStream(streamID string, userID int64) (Stream, error) {
} else if streamID == "" { } else if streamID == "" {
return Stream{NoStream, ""}, nil return Stream{NoStream, ""}, nil
} }
err := fmt.Errorf("uknown stream type: %s", streamID)
return Stream{NoStream, ""}, err return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
} }
func getStreams(streamIDs []string, userID int64) ([]Stream, error) { func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
@ -308,43 +309,43 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type { switch s.Type {
case ReadStream: case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok { if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously") return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
} }
tags[ReadStream] = true tags[ReadStream] = true
case KeptUnreadStream: case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok { if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously") return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
} }
tags[ReadStream] = false tags[ReadStream] = false
case StarredStream: case StarredStream:
tags[StarredStream] = true tags[StarredStream] = true
case BroadcastStream, LikeStream: case BroadcastStream, LikeStream:
logger.Info("Broadcast & Like tags are not implemented!") slog.Debug("Broadcast & Like tags are not implemented!")
default: default:
return nil, fmt.Errorf("unsupported tag type: %s", s.Type) return nil, fmt.Errorf("googlereader: unsupported tag type: %s", s.Type)
} }
} }
for _, s := range removeTags { for _, s := range removeTags {
switch s.Type { switch s.Type {
case ReadStream: case ReadStream:
if _, ok := tags[ReadStream]; ok { if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously") return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
} }
tags[ReadStream] = false tags[ReadStream] = false
case KeptUnreadStream: case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok { if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously") return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
} }
tags[ReadStream] = true tags[ReadStream] = true
case StarredStream: case StarredStream:
if _, ok := tags[StarredStream]; ok { if _, ok := tags[StarredStream]; ok {
return nil, fmt.Errorf(Starred + " should not be supplied for add and remove simultaneously") return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", Starred)
} }
tags[StarredStream] = false tags[StarredStream] = false
case BroadcastStream, LikeStream: case BroadcastStream, LikeStream:
logger.Info("Broadcast & Like tags are not implemented!") slog.Debug("Broadcast & Like tags are not implemented!")
default: default:
return nil, fmt.Errorf("unsupported tag type: %s", s.Type) return nil, fmt.Errorf("googlereader: unsupported tag type: %s", s.Type)
} }
} }
@ -354,7 +355,7 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
func getItemIDs(r *http.Request) ([]int64, error) { func getItemIDs(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs] items := r.Form[ParamItemIDs]
if len(items) == 0 { if len(items) == 0 {
return nil, fmt.Errorf("no items requested") return nil, fmt.Errorf("googlereader: no items requested")
} }
itemIDs := make([]int64, len(items)) itemIDs := make([]int64, len(items))
@ -365,7 +366,7 @@ func getItemIDs(r *http.Request) ([]int64, error) {
if err != nil { if err != nil {
itemID, err = strconv.ParseInt(item, 16, 64) itemID, err = strconv.ParseInt(item, 16, 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse item: %v", item) return nil, fmt.Errorf("googlereader: could not parse item: %v", item)
} }
} }
itemIDs[i] = itemID itemIDs[i] = itemID
@ -385,66 +386,191 @@ func checkOutputFormat(w http.ResponseWriter, r *http.Request) error {
output = request.QueryStringParam(r, "output", "") output = request.QueryStringParam(r, "output", "")
} }
if output != "json" { if output != "json" {
err := fmt.Errorf("output only as json supported") err := fmt.Errorf("googlereader: only json output is supported")
return err return err
} }
return nil return nil
} }
func (h *handler) editTag(w http.ResponseWriter, r *http.Request) { func (h *handler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /accounts/ClientLogin",
slog.String("handler", "clientLoginHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
json.Unauthorized(w, r)
return
}
username := r.Form.Get("Email")
password := r.Form.Get("Passwd")
output := r.Form.Get("output")
if username == "" || password == "" {
slog.Warn("[GoogleReader] Empty username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r)
return
}
if err := h.store.GoogleReaderUserCheckPassword(username, password); err != nil {
slog.Warn("[GoogleReader] Invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.Any("error", err),
)
json.Unauthorized(w, r)
return
}
slog.Info("[GoogleReader] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
integration, err := h.store.GoogleReaderUserGetIntegration(username)
if err != nil {
json.ServerError(w, r, err)
return
}
h.store.SetLastLogin(integration.UserID)
token := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
slog.Debug("[GoogleReader] Created token",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
result := login{SID: token, LSID: token, Auth: token}
if output == "json" {
json.OK(w, r, result)
return
}
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/plain; charset=UTF-8")
builder.WithBody(result.String())
builder.Write()
}
func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /token",
slog.String("handler", "tokenHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if !request.IsAuthenticated(r) {
slog.Warn("[GoogleReader] User is not authenticated",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r)
return
}
token := request.GoolgeReaderToken(r)
if token == "" {
slog.Warn("[GoogleReader] User does not have token",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", request.UserID(r)),
)
json.Unauthorized(w, r)
return
}
slog.Debug("[GoogleReader] Token handler",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", request.UserID(r)),
slog.String("token", token),
)
w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(token))
}
func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/edit-tag][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /edit-tag",
slog.String("handler", "editTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
err := r.ParseForm() if err := r.ParseForm(); err != nil {
if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID) addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID) removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
if len(addTags) == 0 && len(removeTags) == 0 { if len(addTags) == 0 && len(removeTags) == 0 {
err = fmt.Errorf("add or/and remove tags should be supplied") err = fmt.Errorf("googlreader: add or/and remove tags should be supplied")
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] ", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
tags, err := checkAndSimplifyTags(addTags, removeTags) tags, err := checkAndSimplifyTags(addTags, removeTags)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
itemIDs, err := getItemIDs(r) itemIDs, err := getItemIDs(r)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
logger.Debug("[GoogleReader][/edit-tag] [ClientIP=%s] itemIDs: %v", clientIP, itemIDs) slog.Debug("[GoogleReader] Edited tags",
logger.Debug("[GoogleReader][/edit-tag] [ClientIP=%s] tags: %v", clientIP, tags) slog.String("handler", "editTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.Any("item_ids", itemIDs),
slog.Any("tags", tags),
)
builder := h.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryIDs(itemIDs) builder.WithEntryIDs(itemIDs)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
entries, err := builder.GetEntries() entries, err := builder.GetEntries()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -477,7 +603,6 @@ func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
if len(readEntryIDs) > 0 { if len(readEntryIDs) > 0 {
err = h.store.SetEntriesStatus(userID, readEntryIDs, model.EntryStatusRead) err = h.store.SetEntriesStatus(userID, readEntryIDs, model.EntryStatusRead)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -486,7 +611,6 @@ func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
if len(unreadEntryIDs) > 0 { if len(unreadEntryIDs) > 0 {
err = h.store.SetEntriesStatus(userID, unreadEntryIDs, model.EntryStatusUnread) err = h.store.SetEntriesStatus(userID, unreadEntryIDs, model.EntryStatusUnread)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -495,7 +619,6 @@ func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
if len(unstarredEntryIDs) > 0 { if len(unstarredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false) err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -504,7 +627,6 @@ func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
if len(starredEntryIDs) > 0 { if len(starredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true) err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -513,7 +635,6 @@ func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
if len(entries) > 0 { if len(entries) > 0 {
settings, err := h.store.Integration(userID) settings, err := h.store.Integration(userID)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -529,22 +650,26 @@ func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
OK(w, r) OK(w, r)
} }
func (h *handler) quickAdd(w http.ResponseWriter, r *http.Request) { func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/subscription/quickadd][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /subscription/quickadd",
slog.String("handler", "quickAddHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/subscription/quickadd] [ClientIP=%s] %v", clientIP, err)
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
url := r.Form.Get(ParamQuickAdd) url := r.Form.Get(ParamQuickAdd)
if !validator.IsValidURL(url) { if !validator.IsValidURL(url) {
json.BadRequest(w, r, fmt.Errorf("invalid URL: %s", url)) json.BadRequest(w, r, fmt.Errorf("googlereader: invalid URL: %s", url))
return return
} }
@ -568,6 +693,15 @@ func (h *handler) quickAdd(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
slog.Debug("[GoogleReader] Added a new feed",
slog.String("handler", "quickAddHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.String("feed_url", newFeed.FeedURL),
)
json.OK(w, r, quickAddResponse{ json.OK(w, r, quickAddResponse{
NumResults: 1, NumResults: 1,
Query: newFeed.FeedURL, Query: newFeed.FeedURL,
@ -675,34 +809,37 @@ func move(stream Stream, destination Stream, store *storage.Storage, userID int6
return store.UpdateFeed(feed) return store.UpdateFeed(feed)
} }
func (h *handler) editSubscription(w http.ResponseWriter, r *http.Request) { func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/subscription/edit][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /subscription/edit",
slog.String("handler", "editSubscriptionHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
err := r.ParseForm() if err := r.ParseForm(); err != nil {
if err != nil {
logger.Error("[GoogleReader][/subscription/edit] [ClientIP=%s] %v", clientIP, err)
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
streamIds, err := getStreams(r.Form[ParamStreamID], userID) streamIds, err := getStreams(r.Form[ParamStreamID], userID)
if err != nil || len(streamIds) == 0 { if err != nil || len(streamIds) == 0 {
json.BadRequest(w, r, errors.New("no valid stream IDs provided")) json.BadRequest(w, r, errors.New("googlereader: no valid stream IDs provided"))
return return
} }
newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID) newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID)
if err != nil { if err != nil {
json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamTagsAdd)) json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamTagsAdd))
return return
} }
title := r.Form.Get(ParamTitle) title := r.Form.Get(ParamTitle)
action := r.Form.Get(ParamSubscribeAction) action := r.Form.Get(ParamSubscribeAction)
switch action { switch action {
case "subscribe": case "subscribe":
_, err := subscribe(streamIds[0], newLabel, title, h.store, userID) _, err := subscribe(streamIds[0], newLabel, title, h.store, userID)
@ -735,41 +872,42 @@ func (h *handler) editSubscription(w http.ResponseWriter, r *http.Request) {
} }
} }
default: default:
json.ServerError(w, r, fmt.Errorf("unrecognized action %s", action)) json.ServerError(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
return return
} }
OK(w, r) OK(w, r)
} }
func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) { func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/stream/items/contents][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /stream/items/contents",
slog.String("handler", "streamItemContentsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(w, r); err != nil { if err := checkOutputFormat(w, r); err != nil {
logger.Error("[GoogleReader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
var user *model.User var user *model.User
if user, err = h.store.UserByID(userID); err != nil { if user, err = h.store.UserByID(userID); err != nil {
logger.Error("[GoogleReader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
requestModifiers, err := getStreamFilterModifiers(r) requestModifiers, err := getStreamFilterModifiers(r)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -780,11 +918,17 @@ func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) {
itemIDs, err := getItemIDs(r) itemIDs, err := getItemIDs(r)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
logger.Debug("[GoogleReader][/stream/items/contents] [ClientIP=%s] itemIDs: %v", clientIP, itemIDs)
slog.Debug("[GoogleReader] Fetching item contents",
slog.String("handler", "streamItemContentsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.Any("item_ids", itemIDs),
)
builder := h.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
@ -796,12 +940,12 @@ func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
if len(entries) == 0 { if len(entries) == 0 {
err = fmt.Errorf("no items returned from the database") json.ServerError(w, r, fmt.Errorf("googlereader: no items returned from the database"))
logger.Error("[GoogleReader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err)
return return
} }
result := streamContentItems{ result := streamContentItems{
Direction: "ltr", Direction: "ltr",
ID: fmt.Sprintf("feed/%d", entries[0].FeedID), ID: fmt.Sprintf("feed/%d", entries[0].FeedID),
@ -893,29 +1037,33 @@ func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, result) json.OK(w, r, result)
} }
func (h *handler) disableTag(w http.ResponseWriter, r *http.Request) { func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/disable-tag][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /disable-tags",
slog.String("handler", "disableTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/disable-tag] [ClientIP=%s] %v", clientIP, err)
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
streams, err := getStreams(r.Form[ParamStreamID], userID) streams, err := getStreams(r.Form[ParamStreamID], userID)
if err != nil { if err != nil {
json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamStreamID)) json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
return return
} }
titles := make([]string, len(streams)) titles := make([]string, len(streams))
for i, stream := range streams { for i, stream := range streams {
if stream.Type != LabelStream { if stream.Type != LabelStream {
json.BadRequest(w, r, errors.New("only labels are supported")) json.BadRequest(w, r, errors.New("googlereader: only labels are supported"))
return return
} }
titles[i] = stream.ID titles[i] = stream.ID
@ -930,38 +1078,41 @@ func (h *handler) disableTag(w http.ResponseWriter, r *http.Request) {
OK(w, r) OK(w, r)
} }
func (h *handler) renameTag(w http.ResponseWriter, r *http.Request) { func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/rename-tag][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /rename-tag",
slog.String("handler", "renameTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/rename-tag] [ClientIP=%s] %v", clientIP, err)
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
source, err := getStream(r.Form.Get(ParamStreamID), userID) source, err := getStream(r.Form.Get(ParamStreamID), userID)
if err != nil { if err != nil {
json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamStreamID)) json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
return return
} }
destination, err := getStream(r.Form.Get(ParamDestination), userID) destination, err := getStream(r.Form.Get(ParamDestination), userID)
if err != nil { if err != nil {
json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamDestination)) json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamDestination))
return return
} }
if source.Type != LabelStream || destination.Type != LabelStream { if source.Type != LabelStream || destination.Type != LabelStream {
json.BadRequest(w, r, errors.New("only labels supported")) json.BadRequest(w, r, errors.New("googlereader: only labels supported"))
return return
} }
if destination.ID == "" { if destination.ID == "" {
json.BadRequest(w, r, errors.New("empty destination name")) json.BadRequest(w, r, errors.New("googlereader: empty destination name"))
return return
} }
@ -991,14 +1142,17 @@ func (h *handler) renameTag(w http.ResponseWriter, r *http.Request) {
OK(w, r) OK(w, r)
} }
func (h *handler) tagList(w http.ResponseWriter, r *http.Request) { func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][tags/list][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /tags/list",
slog.String("handler", "tagListHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil { if err := checkOutputFormat(w, r); err != nil {
logger.Error("[GoogleReader][OutputFormat] %v", err)
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
@ -1023,14 +1177,17 @@ func (h *handler) tagList(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, result) json.OK(w, r, result)
} }
func (h *handler) subscriptionList(w http.ResponseWriter, r *http.Request) { func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/subscription/list][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /subscription/list",
slog.String("handler", "subscriptionListHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil { if err := checkOutputFormat(w, r); err != nil {
logger.Error("[GoogleReader][/subscription/list] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1055,27 +1212,34 @@ func (h *handler) subscriptionList(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, result) json.OK(w, r, result)
} }
func (h *handler) serve(w http.ResponseWriter, r *http.Request) { func (h *handler) serveHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
dump, _ := httputil.DumpRequest(r, true)
logger.Info("[GoogleReader][UNKNOWN] [ClientIP=%s] URL: %s", clientIP, dump) slog.Debug("[GoogleReader] API endpoint not implemented yet",
logger.Error("Call to Google Reader API not implemented yet!!") slog.Any("url", r.RequestURI),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, []string{}) json.OK(w, r, []string{})
} }
func (h *handler) userInfo(w http.ResponseWriter, r *http.Request) { func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][UserInfo] [ClientIP=%s] Sending", clientIP)
slog.Debug("[GoogleReader] Handle /user-info",
slog.String("handler", "userInfoHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil { if err := checkOutputFormat(w, r); err != nil {
logger.Error("[GoogleReader][/user-info] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
user, err := h.store.UserByID(request.UserID(r)) user, err := h.store.UserByID(request.UserID(r))
if err != nil { if err != nil {
logger.Error("[GoogleReader][/user-info] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1083,16 +1247,19 @@ func (h *handler) userInfo(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, userInfo) json.OK(w, r, userInfo)
} }
func (h *handler) streamItemIDs(w http.ResponseWriter, r *http.Request) { func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[GoogleReader][/stream/items/ids][ClientIP=%s] Incoming Request for userID #%d", clientIP, userID) slog.Debug("[GoogleReader] Handle /stream/items/ids",
slog.String("handler", "streamItemIDsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(w, r); err != nil { if err := checkOutputFormat(w, r); err != nil {
err := fmt.Errorf("output only as json supported") json.ServerError(w, r, fmt.Errorf("googlereader: output only as json supported"))
logger.Error("[GoogleReader][/stream/items/ids] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err)
return return
} }
@ -1101,44 +1268,62 @@ func (h *handler) streamItemIDs(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
logger.Debug("Request Modifiers: %v", rm)
slog.Debug("[GoogleReader] Request modifiers",
slog.String("handler", "streamItemIDsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("modifiers", rm),
)
if len(rm.Streams) != 1 { if len(rm.Streams) != 1 {
err := fmt.Errorf("only one stream type expected") json.ServerError(w, r, fmt.Errorf("googlereader: only one stream type expected"))
logger.Error("[GoogleReader][/stream/items/ids] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err)
return return
} }
switch rm.Streams[0].Type { switch rm.Streams[0].Type {
case ReadingListStream: case ReadingListStream:
h.handleReadingListStream(w, r, rm) h.handleReadingListStreamHandler(w, r, rm)
case StarredStream: case StarredStream:
h.handleStarredStream(w, r, rm) h.handleStarredStreamHandler(w, r, rm)
case ReadStream: case ReadStream:
h.handleReadStream(w, r, rm) h.handleReadStreamHandler(w, r, rm)
case FeedStream: case FeedStream:
h.handleFeedStream(w, r, rm) h.handleFeedStreamHandler(w, r, rm)
default: default:
dump, _ := httputil.DumpRequest(r, true) slog.Warn("[GoogleReader] Unknown Stream",
logger.Info("[GoogleReader][/stream/items/ids] [ClientIP=%s] Unknown Stream: %s", clientIP, dump) slog.String("handler", "streamItemIDsHandler"),
err := fmt.Errorf("unknown stream type") slog.String("client_ip", clientIP),
logger.Error("[GoogleReader][/stream/items/ids] [ClientIP=%s] %v", clientIP, err) slog.String("user_agent", r.UserAgent()),
json.ServerError(w, r, err) slog.Any("stream_type", rm.Streams[0].Type),
return )
json.ServerError(w, r, fmt.Errorf("googlereader: unknown stream type %s", rm.Streams[0].Type))
} }
} }
func (h *handler) handleReadingListStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) { func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle ReadingListStream",
slog.String("handler", "handleReadingListStreamHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
builder := h.store.NewEntryQueryBuilder(rm.UserID) builder := h.store.NewEntryQueryBuilder(rm.UserID)
for _, s := range rm.ExcludeTargets { for _, s := range rm.ExcludeTargets {
switch s.Type { switch s.Type {
case ReadStream: case ReadStream:
builder.WithStatus(model.EntryStatusUnread) builder.WithStatus(model.EntryStatusUnread)
default: default:
logger.Info("[GoogleReader][ReadingListStreamIDs][ClientIP=%s] xt filter type: %#v", clientIP, s) slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
slog.String("handler", "handleReadingListStreamHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("filter_type", s.Type),
)
} }
} }
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(rm.Count) builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset) builder.WithOffset(rm.Offset)
@ -1152,7 +1337,6 @@ func (h *handler) handleReadingListStream(w http.ResponseWriter, r *http.Request
rawEntryIDs, err := builder.GetEntryIDs() rawEntryIDs, err := builder.GetEntryIDs()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#reading-list] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1164,7 +1348,6 @@ func (h *handler) handleReadingListStream(w http.ResponseWriter, r *http.Request
totalEntries, err := builder.CountEntries() totalEntries, err := builder.CountEntries()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#reading-list] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1176,9 +1359,7 @@ func (h *handler) handleReadingListStream(w http.ResponseWriter, r *http.Request
json.OK(w, r, streamIDResponse{itemRefs, continuation}) json.OK(w, r, streamIDResponse{itemRefs, continuation})
} }
func (h *handler) handleStarredStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) { func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
clientIP := request.ClientIP(r)
builder := h.store.NewEntryQueryBuilder(rm.UserID) builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true) builder.WithStarred(true)
@ -1194,7 +1375,6 @@ func (h *handler) handleStarredStream(w http.ResponseWriter, r *http.Request, rm
rawEntryIDs, err := builder.GetEntryIDs() rawEntryIDs, err := builder.GetEntryIDs()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#starred] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1206,7 +1386,6 @@ func (h *handler) handleStarredStream(w http.ResponseWriter, r *http.Request, rm
totalEntries, err := builder.CountEntries() totalEntries, err := builder.CountEntries()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#starred] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1218,9 +1397,7 @@ func (h *handler) handleStarredStream(w http.ResponseWriter, r *http.Request, rm
json.OK(w, r, streamIDResponse{itemRefs, continuation}) json.OK(w, r, streamIDResponse{itemRefs, continuation})
} }
func (h *handler) handleReadStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) { func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
clientIP := request.ClientIP(r)
builder := h.store.NewEntryQueryBuilder(rm.UserID) builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStatus(model.EntryStatusRead) builder.WithStatus(model.EntryStatusRead)
@ -1236,7 +1413,6 @@ func (h *handler) handleReadStream(w http.ResponseWriter, r *http.Request, rm Re
rawEntryIDs, err := builder.GetEntryIDs() rawEntryIDs, err := builder.GetEntryIDs()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#read] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1248,7 +1424,6 @@ func (h *handler) handleReadStream(w http.ResponseWriter, r *http.Request, rm Re
totalEntries, err := builder.CountEntries() totalEntries, err := builder.CountEntries()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#read] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1260,11 +1435,9 @@ func (h *handler) handleReadStream(w http.ResponseWriter, r *http.Request, rm Re
json.OK(w, r, streamIDResponse{itemRefs, continuation}) json.OK(w, r, streamIDResponse{itemRefs, continuation})
} }
func (h *handler) handleFeedStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) { func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
clientIP := request.ClientIP(r)
feedID, err := strconv.ParseInt(rm.Streams[0].ID, 10, 64) feedID, err := strconv.ParseInt(rm.Streams[0].ID, 10, 64)
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#feed] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1284,7 +1457,6 @@ func (h *handler) handleFeedStream(w http.ResponseWriter, r *http.Request, rm Re
rawEntryIDs, err := builder.GetEntryIDs() rawEntryIDs, err := builder.GetEntryIDs()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#feed] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
@ -1296,7 +1468,6 @@ func (h *handler) handleFeedStream(w http.ResponseWriter, r *http.Request, rm Re
totalEntries, err := builder.CountEntries() totalEntries, err := builder.CountEntries()
if err != nil { if err != nil {
logger.Error("[GoogleReader][/stream/items/ids#feed] [ClientIP=%s] %v", clientIP, err)
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }

View file

@ -8,13 +8,11 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
) )
@ -27,75 +25,6 @@ func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s} return &middleware{s}
} }
func (m *middleware) clientLogin(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
var username, password, output string
var integration *model.Integration
err := r.ParseForm()
if err != nil {
logger.Error("[GoogleReader][Login] [ClientIP=%s] Could not parse form", clientIP)
json.Unauthorized(w, r)
return
}
username = r.Form.Get("Email")
password = r.Form.Get("Passwd")
output = r.Form.Get("output")
if username == "" || password == "" {
logger.Error("[GoogleReader][Login] [ClientIP=%s] Empty username or password", clientIP)
json.Unauthorized(w, r)
return
}
if err = m.store.GoogleReaderUserCheckPassword(username, password); err != nil {
logger.Error("[GoogleReader][Login] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
json.Unauthorized(w, r)
return
}
logger.Info("[GoogleReader][Login] [ClientIP=%s] User authenticated: %s", clientIP, username)
if integration, err = m.store.GoogleReaderUserGetIntegration(username); err != nil {
logger.Error("[GoogleReader][Login] [ClientIP=%s] Could not load integration: %s", clientIP, username)
json.Unauthorized(w, r)
return
}
m.store.SetLastLogin(integration.UserID)
token := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
logger.Info("[GoogleReader][Login] [ClientIP=%s] Created token: %s", clientIP, token)
result := login{SID: token, LSID: token, Auth: token}
if output == "json" {
json.OK(w, r, result)
return
}
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/plain; charset=UTF-8")
builder.WithBody(result.String())
builder.Write()
}
func (m *middleware) token(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
if !request.IsAuthenticated(r) {
logger.Error("[GoogleReader][Token] [ClientIP=%s] User is not authenticated", clientIP)
json.Unauthorized(w, r)
return
}
token := request.GoolgeReaderToken(r)
if token == "" {
logger.Error("[GoogleReader][Token] [ClientIP=%s] User does not have token: %s", clientIP, request.UserID(r))
json.Unauthorized(w, r)
return
}
logger.Info("[GoogleReader][Token] [ClientIP=%s] token: %s", clientIP, token)
w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(token))
}
func (m *middleware) handleCORS(next http.Handler) http.Handler { func (m *middleware) handleCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
@ -115,15 +44,24 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
var token string var token string
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
err := r.ParseForm() if err := r.ParseForm(); err != nil {
if err != nil { slog.Warn("[GoogleReader] Could not parse request form data",
logger.Error("[GoogleReader][Login] [ClientIP=%s] Could not parse form", clientIP) slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
token = r.Form.Get("T") token = r.Form.Get("T")
if token == "" { if token == "" {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Post-Form T field is empty", clientIP) slog.Warn("[GoogleReader] Post-Form T field is empty",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
@ -131,29 +69,49 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
authorization := r.Header.Get("Authorization") authorization := r.Header.Get("Authorization")
if authorization == "" { if authorization == "" {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] No token provided", clientIP) slog.Warn("[GoogleReader] No token provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
fields := strings.Fields(authorization) fields := strings.Fields(authorization)
if len(fields) != 2 { if len(fields) != 2 {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization) slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
if fields[0] != "GoogleLogin" { if fields[0] != "GoogleLogin" {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Authorization header does not begin with GoogleLogin - '%s'", clientIP, authorization) slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
auths := strings.Split(fields[1], "=") auths := strings.Split(fields[1], "=")
if len(auths) != 2 { if len(auths) != 2 {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization) slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
if auths[0] != "auth" { if auths[0] != "auth" {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization) slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
@ -162,7 +120,12 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
parts := strings.Split(token, "/") parts := strings.Split(token, "/")
if len(parts) != 2 { if len(parts) != 2 {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Auth token does not have the expected structure username/hash - '%s'", clientIP, token) slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
@ -170,23 +133,54 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
var user *model.User var user *model.User
var err error var err error
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil { if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] token: %s", clientIP, token) slog.Warn("[GoogleReader] No user found with the given Google Reader username",
logger.Error("[GoogleReader][Auth] [ClientIP=%s] No user found with the given google reader username: %s", clientIP, parts[0]) slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword) expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if expectedToken != token { if expectedToken != token {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] Token does not match: %s", clientIP, token) slog.Warn("[GoogleReader] Token does not match",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
if user, err = m.store.UserByID(integration.UserID); err != nil { if user, err = m.store.UserByID(integration.UserID); err != nil {
logger.Error("[GoogleReader][Auth] [ClientIP=%s] No user found with the userID: %d", clientIP, integration.UserID) slog.Error("[GoogleReader] Unable to fetch user from database",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r) Unauthorized(w, r)
return return
} }
if user == nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
return
}
slog.Info("[GoogleReader] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(integration.UserID) m.store.SetLastLogin(integration.UserID)
ctx := r.Context() ctx := r.Context()

View file

@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"miniflux.app/v2/internal/http/response" "miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/logger"
) )
type login struct { type login struct {
@ -122,8 +121,6 @@ type contentItemOrigin struct {
// Unauthorized sends a not authorized error to the client. // Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) { func Unauthorized(w http.ResponseWriter, r *http.Request) {
logger.Error("[HTTP:Unauthorized] %s", r.URL)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized) builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", "text/plain") builder.WithHeader("Content-Type", "text/plain")
@ -134,8 +131,6 @@ func Unauthorized(w http.ResponseWriter, r *http.Request) {
// OK sends a ok response to the client. // OK sends a ok response to the client.
func OK(w http.ResponseWriter, r *http.Request) { func OK(w http.ResponseWriter, r *http.Request) {
logger.Info("[HTTP:OK] %s", r.URL)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusOK) builder.WithStatus(http.StatusOK)
builder.WithHeader("Content-Type", "text/plain") builder.WithHeader("Content-Type", "text/plain")

View file

@ -7,19 +7,16 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/errors" "miniflux.app/v2/internal/errors"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/timer"
) )
const ( const (
@ -74,28 +71,6 @@ func NewClientWithConfig(url string, opts *config.Options) *Client {
} }
} }
func (c *Client) String() string {
etagHeader := c.requestEtagHeader
if c.requestEtagHeader == "" {
etagHeader = "None"
}
lastModifiedHeader := c.requestLastModifiedHeader
if c.requestLastModifiedHeader == "" {
lastModifiedHeader = "None"
}
return fmt.Sprintf(
`InputURL=%q ETag=%s LastMod=%s Auth=%v UserAgent=%q Verify=%v`,
c.inputURL,
etagHeader,
lastModifiedHeader,
c.requestAuthorizationHeader != "" || (c.requestUsername != "" && c.requestPassword != ""),
c.requestUserAgent,
!c.AllowSelfSignedCertificates,
)
}
// WithCredentials defines the username/password for HTTP Basic authentication. // WithCredentials defines the username/password for HTTP Basic authentication.
func (c *Client) WithCredentials(username, password string) *Client { func (c *Client) WithCredentials(username, password string) *Client {
if username != "" && password != "" { if username != "" && password != "" {
@ -105,12 +80,6 @@ func (c *Client) WithCredentials(username, password string) *Client {
return c return c
} }
// WithAuthorization defines the authorization HTTP header value.
func (c *Client) WithAuthorization(authorization string) *Client {
c.requestAuthorizationHeader = authorization
return c
}
// WithCustomHeaders defines custom HTTP headers. // WithCustomHeaders defines custom HTTP headers.
func (c *Client) WithCustomHeaders(customHeaders map[string]string) *Client { func (c *Client) WithCustomHeaders(customHeaders map[string]string) *Client {
c.customHeaders = customHeaders c.customHeaders = customHeaders
@ -162,55 +131,21 @@ func (c *Client) Get() (*Response, error) {
return c.executeRequest(request) return c.executeRequest(request)
} }
// PostForm performs a POST HTTP request with form encoded values.
func (c *Client) PostForm(values url.Values) (*Response, error) {
request, err := c.buildRequest(http.MethodPost, strings.NewReader(values.Encode()))
if err != nil {
return nil, err
}
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
return c.executeRequest(request)
}
// PostJSON performs a POST HTTP request with a JSON payload.
func (c *Client) PostJSON(data interface{}) (*Response, error) {
b, err := json.Marshal(data)
if err != nil {
return nil, err
}
request, err := c.buildRequest(http.MethodPost, bytes.NewReader(b))
if err != nil {
return nil, err
}
request.Header.Add("Content-Type", "application/json")
return c.executeRequest(request)
}
// PatchJSON performs a Patch HTTP request with a JSON payload.
func (c *Client) PatchJSON(data interface{}) (*Response, error) {
b, err := json.Marshal(data)
if err != nil {
return nil, err
}
request, err := c.buildRequest(http.MethodPatch, bytes.NewReader(b))
if err != nil {
return nil, err
}
request.Header.Add("Content-Type", "application/json")
return c.executeRequest(request)
}
func (c *Client) executeRequest(request *http.Request) (*Response, error) { func (c *Client) executeRequest(request *http.Request) (*Response, error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient] inputURL=%s", c.inputURL)) startTime := time.Now()
logger.Debug("[HttpClient:Before] Method=%s %s", slog.Debug("Executing outgoing HTTP request",
request.Method, slog.Group("request",
c.String(), slog.String("method", request.Method),
slog.String("url", request.URL.String()),
slog.String("user_agent", request.UserAgent()),
slog.Bool("is_authenticated", c.requestAuthorizationHeader != "" || (c.requestUsername != "" && c.requestPassword != "")),
slog.Bool("has_cookie", c.requestCookie != ""),
slog.Bool("with_redirects", !c.doNotFollowRedirects),
slog.Bool("with_proxy", c.useProxy),
slog.String("proxy_url", c.ClientProxyURL),
slog.Bool("with_caching_headers", c.requestEtagHeader != "" || c.requestLastModifiedHeader != ""),
),
) )
client := c.buildClient() client := c.buildClient()
@ -257,15 +192,32 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
ContentLength: resp.ContentLength, ContentLength: resp.ContentLength,
} }
logger.Debug("[HttpClient:After] Method=%s %s; Response => %s", slog.Debug("Completed outgoing HTTP request",
request.Method, slog.Duration("duration", time.Since(startTime)),
c.String(), slog.Group("request",
response, slog.String("method", request.Method),
slog.String("url", request.URL.String()),
slog.String("user_agent", request.UserAgent()),
slog.Bool("is_authenticated", c.requestAuthorizationHeader != "" || (c.requestUsername != "" && c.requestPassword != "")),
slog.Bool("has_cookie", c.requestCookie != ""),
slog.Bool("with_redirects", !c.doNotFollowRedirects),
slog.Bool("with_proxy", c.useProxy),
slog.String("proxy_url", c.ClientProxyURL),
slog.Bool("with_caching_headers", c.requestEtagHeader != "" || c.requestLastModifiedHeader != ""),
),
slog.Group("response",
slog.Int("status_code", response.StatusCode),
slog.String("effective_url", response.EffectiveURL),
slog.String("content_type", response.ContentType),
slog.Int64("content_length", response.ContentLength),
slog.String("last_modified", response.LastModified),
slog.String("etag", response.ETag),
slog.String("expires", response.Expires),
),
) )
// Ignore caching headers for feeds that do not want any cache. // Ignore caching headers for feeds that do not want any cache.
if resp.Header.Get("Expires") == "0" { if resp.Header.Get("Expires") == "0" {
logger.Debug("[HttpClient] Ignore caching headers for %q", response.EffectiveURL)
response.ETag = "" response.ETag = ""
response.LastModified = "" response.LastModified = ""
} }
@ -323,9 +275,11 @@ func (c *Client) buildClient() http.Client {
if c.useProxy && c.ClientProxyURL != "" { if c.useProxy && c.ClientProxyURL != "" {
proxyURL, err := url.Parse(c.ClientProxyURL) proxyURL, err := url.Parse(c.ClientProxyURL)
if err != nil { if err != nil {
logger.Error("[HttpClient] Proxy URL error: %v", err) slog.Error("Unable to parse proxy URL",
slog.String("proxy_url", c.ClientProxyURL),
slog.Any("error", err),
)
} else { } else {
logger.Debug("[HttpClient] Use proxy: %s", proxyURL)
transport.Proxy = http.ProxyURL(proxyURL) transport.Proxy = http.ProxyURL(proxyURL)
} }
} }

View file

@ -8,11 +8,10 @@ import (
"compress/gzip" "compress/gzip"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/logger"
) )
const compressionThreshold = 1024 const compressionThreshold = 1024
@ -91,7 +90,7 @@ func (b *Builder) Write() {
b.writeHeaders() b.writeHeaders()
_, err := io.Copy(b.w, v) _, err := io.Copy(b.w, v)
if err != nil { if err != nil {
logger.Error("%v", err) slog.Error("Unable to write response body", slog.Any("error", err))
} }
} }
} }

View file

@ -4,10 +4,11 @@
package html // import "miniflux.app/v2/internal/http/response/html" package html // import "miniflux.app/v2/internal/http/response/html"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response" "miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/logger"
) )
// OK creates a new HTML response with a 200 status code. // OK creates a new HTML response with a 200 status code.
@ -21,7 +22,18 @@ func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
// ServerError sends an internal error to the client. // ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) { func ServerError(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("[HTTP:Internal Server Error] %s => %v", r.URL, err) slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError) builder.WithStatus(http.StatusInternalServerError)
@ -34,7 +46,18 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
// BadRequest sends a bad request error to the client. // BadRequest sends a bad request error to the client.
func BadRequest(w http.ResponseWriter, r *http.Request, err error) { func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("[HTTP:Bad Request] %s => %v", r.URL, err) slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest) builder.WithStatus(http.StatusBadRequest)
@ -47,7 +70,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
// Forbidden sends a forbidden error to the client. // Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) { func Forbidden(w http.ResponseWriter, r *http.Request) {
logger.Error("[HTTP:Forbidden] %s", r.URL) slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusForbidden) builder.WithStatus(http.StatusForbidden)
@ -59,7 +92,17 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
// NotFound sends a page not found error to the client. // NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) { func NotFound(w http.ResponseWriter, r *http.Request) {
logger.Error("[HTTP:Not Found] %s", r.URL) slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusNotFound) builder.WithStatus(http.StatusNotFound)
@ -76,7 +119,17 @@ func Redirect(w http.ResponseWriter, r *http.Request, uri string) {
// RequestedRangeNotSatisfiable sends a range not satisfiable error to the client. // RequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) { func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
logger.Error("[HTTP:Range Not Satisfiable] %s", r.URL) slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusRequestedRangeNotSatisfiable),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable) builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)

View file

@ -6,10 +6,11 @@ package json // import "miniflux.app/v2/internal/http/response/json"
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response" "miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/logger"
) )
const contentTypeHeader = `application/json` const contentTypeHeader = `application/json`
@ -48,7 +49,18 @@ func Accepted(w http.ResponseWriter, r *http.Request) {
// ServerError sends an internal error to the client. // ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) { func ServerError(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("[HTTP:Internal Server Error] %s => %v", r.URL, err) slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError) builder.WithStatus(http.StatusInternalServerError)
@ -59,7 +71,18 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
// BadRequest sends a bad request error to the client. // BadRequest sends a bad request error to the client.
func BadRequest(w http.ResponseWriter, r *http.Request, err error) { func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("[HTTP:Bad Request] %s => %v", r.URL, err) slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest) builder.WithStatus(http.StatusBadRequest)
@ -70,7 +93,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
// Unauthorized sends a not authorized error to the client. // Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) { func Unauthorized(w http.ResponseWriter, r *http.Request) {
logger.Error("[HTTP:Unauthorized] %s", r.URL) slog.Warn(http.StatusText(http.StatusUnauthorized),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusUnauthorized),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized) builder.WithStatus(http.StatusUnauthorized)
@ -81,7 +114,17 @@ func Unauthorized(w http.ResponseWriter, r *http.Request) {
// Forbidden sends a forbidden error to the client. // Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) { func Forbidden(w http.ResponseWriter, r *http.Request) {
logger.Error("[HTTP:Forbidden] %s", r.URL) slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusForbidden) builder.WithStatus(http.StatusForbidden)
@ -92,7 +135,17 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
// NotFound sends a page not found error to the client. // NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) { func NotFound(w http.ResponseWriter, r *http.Request) {
logger.Error("[HTTP:Not Found] %s", r.URL) slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
builder := response.New(w, r) builder := response.New(w, r)
builder.WithStatus(http.StatusNotFound) builder.WithStatus(http.StatusNotFound)
@ -112,7 +165,7 @@ func toJSONError(err error) []byte {
func toJSON(v interface{}) []byte { func toJSON(v interface{}) []byte {
b, err := json.Marshal(v) b, err := json.Marshal(v)
if err != nil { if err != nil {
logger.Error("[HTTP:JSON] %v", err) slog.Error("Unable to marshal JSON response", slog.Any("error", err))
return []byte("") return []byte("")
} }

View file

@ -7,14 +7,13 @@ import (
"strconv" "strconv"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"miniflux.app/v2/internal/logger"
) )
// Path returns the defined route based on given arguments. // Path returns the defined route based on given arguments.
func Path(router *mux.Router, name string, args ...interface{}) string { func Path(router *mux.Router, name string, args ...any) string {
route := router.Get(name) route := router.Get(name)
if route == nil { if route == nil {
logger.Fatal("[Route] Route not found: %s", name) panic("route not found: " + name)
} }
var pairs []string var pairs []string
@ -29,7 +28,7 @@ func Path(router *mux.Router, name string, args ...interface{}) string {
result, err := route.URLPath(pairs...) result, err := route.URLPath(pairs...)
if err != nil { if err != nil {
logger.Fatal("[Route] %v", err) panic(err)
} }
return result.String() return result.String()

View file

@ -5,6 +5,8 @@ package httpd // import "miniflux.app/v2/internal/http/server"
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"os" "os"
@ -17,7 +19,6 @@ import (
"miniflux.app/v2/internal/fever" "miniflux.app/v2/internal/fever"
"miniflux.app/v2/internal/googlereader" "miniflux.app/v2/internal/googlereader"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui" "miniflux.app/v2/internal/ui"
"miniflux.app/v2/internal/version" "miniflux.app/v2/internal/version"
@ -66,12 +67,12 @@ func startSystemdSocketServer(server *http.Server) {
f := os.NewFile(3, "systemd socket") f := os.NewFile(3, "systemd socket")
listener, err := net.FileListener(f) listener, err := net.FileListener(f)
if err != nil { if err != nil {
logger.Fatal(`Unable to create listener from systemd socket: %v`, err) printErrorAndExit(`Unable to create listener from systemd socket: %v`, err)
} }
logger.Info(`Listening on systemd socket`) slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed { if err := server.Serve(listener); err != http.ErrServerClosed {
logger.Fatal(`Server failed to start: %v`, err) printErrorAndExit(`Server failed to start: %v`, err)
} }
}() }()
} }
@ -82,17 +83,17 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
go func(sock string) { go func(sock string) {
listener, err := net.Listen("unix", sock) listener, err := net.Listen("unix", sock)
if err != nil { if err != nil {
logger.Fatal(`Server failed to start: %v`, err) printErrorAndExit(`Server failed to start: %v`, err)
} }
defer listener.Close() defer listener.Close()
if err := os.Chmod(sock, 0666); err != nil { if err := os.Chmod(sock, 0666); err != nil {
logger.Fatal(`Unable to change socket permission: %v`, err) printErrorAndExit(`Unable to change socket permission: %v`, err)
} }
logger.Info(`Listening on Unix socket %q`, sock) slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
if err := server.Serve(listener); err != http.ErrServerClosed { if err := server.Serve(listener); err != http.ErrServerClosed {
logger.Fatal(`Server failed to start: %v`, err) printErrorAndExit(`Server failed to start: %v`, err)
} }
}(socketFile) }(socketFile)
} }
@ -137,9 +138,12 @@ func startAutoCertTLSServer(server *http.Server, certDomain string, store *stora
go s.ListenAndServe() go s.ListenAndServe()
go func() { go func() {
logger.Info(`Listening on %q by using auto-configured certificate for %q`, server.Addr, certDomain) slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
slog.String("domain", certDomain),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed { if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
logger.Fatal(`Server failed to start: %v`, err) printErrorAndExit(`Server failed to start: %v`, err)
} }
}() }()
} }
@ -147,18 +151,24 @@ func startAutoCertTLSServer(server *http.Server, certDomain string, store *stora
func startTLSServer(server *http.Server, certFile, keyFile string) { func startTLSServer(server *http.Server, certFile, keyFile string) {
server.TLSConfig = tlsConfig() server.TLSConfig = tlsConfig()
go func() { go func() {
logger.Info(`Listening on %q by using certificate %q and key %q`, server.Addr, certFile, keyFile) slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed { if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
logger.Fatal(`Server failed to start: %v`, err) printErrorAndExit(`Server failed to start: %v`, err)
} }
}() }()
} }
func startHTTPServer(server *http.Server) { func startHTTPServer(server *http.Server) {
go func() { go func() {
logger.Info(`Listening on %q without TLS`, server.Addr) slog.Info("Starting HTTP server",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed { if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Fatal(`Server failed to start: %v`, err) printErrorAndExit(`Server failed to start: %v`, err)
} }
}() }()
} }
@ -206,7 +216,11 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
// Returns a 404 if the client is not authorized to access the metrics endpoint. // Returns a 404 if the client is not authorized to access the metrics endpoint.
if route.GetName() == "metrics" && !isAllowedToAccessMetricsEndpoint(r) { if route.GetName() == "metrics" && !isAllowedToAccessMetricsEndpoint(r) {
logger.Error(`[Metrics] [ClientIP=%s] Client not allowed (%s)`, request.ClientIP(r), r.RemoteAddr) slog.Warn("Authentication failed while accessing the metrics endpoint",
slog.String("client_ip", request.ClientIP(r)),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@ -220,21 +234,37 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
} }
func isAllowedToAccessMetricsEndpoint(r *http.Request) bool { func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
clientIP := request.ClientIP(r)
if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" { if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
clientIP := request.ClientIP(r)
username, password, authOK := r.BasicAuth() username, password, authOK := r.BasicAuth()
if !authOK { if !authOK {
logger.Info("[Metrics] [ClientIP=%s] No authentication header sent", clientIP) slog.Warn("Metrics endpoint accessed without authentication header",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false return false
} }
if username == "" || password == "" { if username == "" || password == "" {
logger.Info("[Metrics] [ClientIP=%s] Empty username or password", clientIP) slog.Warn("Metrics endpoint accessed with empty username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false return false
} }
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() { if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
logger.Error("[Metrics] [ClientIP=%s] Invalid username or password", clientIP) slog.Warn("Metrics endpoint accessed with invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false return false
} }
} }
@ -242,7 +272,14 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
for _, cidr := range config.Opts.MetricsAllowedNetworks() { for _, cidr := range config.Opts.MetricsAllowedNetworks() {
_, network, err := net.ParseCIDR(cidr) _, network, err := net.ParseCIDR(cidr)
if err != nil { if err != nil {
logger.Fatal(`[Metrics] Unable to parse CIDR %v`, err) slog.Error("Metrics endpoint accessed with invalid CIDR",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
slog.String("cidr", cidr),
)
return false
} }
// We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed. // We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed.
@ -254,3 +291,10 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
return false return false
} }
func printErrorAndExit(format string, a ...any) {
message := fmt.Sprintf(format, a...)
slog.Error(message)
fmt.Fprintf(os.Stderr, "%v\n", message)
os.Exit(1)
}

View file

@ -5,11 +5,12 @@ package httpd // import "miniflux.app/v2/internal/http/server"
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/logger"
) )
func middleware(next http.Handler) http.Handler { func middleware(next http.Handler) http.Handler {
@ -22,12 +23,18 @@ func middleware(next http.Handler) http.Handler {
config.Opts.HTTPS = true config.Opts.HTTPS = true
} }
protocol := "HTTP" t1 := time.Now()
if config.Opts.HTTPS { defer func() {
protocol = "HTTPS" slog.Debug("Incoming request",
} slog.String("client_ip", clientIP),
slog.Group("request",
logger.Debug("[%s] %s %s %s", protocol, clientIP, r.Method, r.RequestURI) slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("protocol", r.Proto),
slog.Duration("execution_time", time.Since(t1)),
),
)
}()
if config.Opts.HTTPS && config.Opts.HasHSTS() { if config.Opts.HTTPS && config.Opts.HasHSTS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000") w.Header().Set("Strict-Transport-Security", "max-age=31536000")

View file

@ -4,6 +4,8 @@
package integration // import "miniflux.app/v2/internal/integration" package integration // import "miniflux.app/v2/internal/integration"
import ( import (
"log/slog"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise" "miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/espial" "miniflux.app/v2/internal/integration/espial"
@ -20,160 +22,269 @@ import (
"miniflux.app/v2/internal/integration/telegrambot" "miniflux.app/v2/internal/integration/telegrambot"
"miniflux.app/v2/internal/integration/wallabag" "miniflux.app/v2/internal/integration/wallabag"
"miniflux.app/v2/internal/integration/webhook" "miniflux.app/v2/internal/integration/webhook"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
) )
// SendEntry sends the entry to third-party providers when the user click on "Save". // SendEntry sends the entry to third-party providers when the user click on "Save".
func SendEntry(entry *model.Entry, integration *model.Integration) { func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
if integration.PinboardEnabled { if userIntegrations.PinboardEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Pinboard", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Pinboard",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pinboard.NewClient(integration.PinboardToken) client := pinboard.NewClient(userIntegrations.PinboardToken)
err := client.CreateBookmark( err := client.CreateBookmark(
entry.URL, entry.URL,
entry.Title, entry.Title,
integration.PinboardTags, userIntegrations.PinboardTags,
integration.PinboardMarkAsUnread, userIntegrations.PinboardMarkAsUnread,
) )
if err != nil { if err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Pinboard",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.InstapaperEnabled { if userIntegrations.InstapaperEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Instapaper", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Instapaper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := instapaper.NewClient(integration.InstapaperUsername, integration.InstapaperPassword) client := instapaper.NewClient(userIntegrations.InstapaperUsername, userIntegrations.InstapaperPassword)
if err := client.AddURL(entry.URL, entry.Title); err != nil { if err := client.AddURL(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Instapaper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.WallabagEnabled { if userIntegrations.WallabagEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Wallabag", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := wallabag.NewClient( client := wallabag.NewClient(
integration.WallabagURL, userIntegrations.WallabagURL,
integration.WallabagClientID, userIntegrations.WallabagClientID,
integration.WallabagClientSecret, userIntegrations.WallabagClientSecret,
integration.WallabagUsername, userIntegrations.WallabagUsername,
integration.WallabagPassword, userIntegrations.WallabagPassword,
integration.WallabagOnlyURL, userIntegrations.WallabagOnlyURL,
) )
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil { if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.NotionEnabled { if userIntegrations.NotionEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Notion", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Notion",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := notion.NewClient( client := notion.NewClient(
integration.NotionToken, userIntegrations.NotionToken,
integration.NotionPageID, userIntegrations.NotionPageID,
) )
if err := client.UpdateDocument(entry.URL, entry.Title); err != nil { if err := client.UpdateDocument(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Notion",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.NunuxKeeperEnabled { if userIntegrations.NunuxKeeperEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to NunuxKeeper", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to NunuxKeeper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := nunuxkeeper.NewClient( client := nunuxkeeper.NewClient(
integration.NunuxKeeperURL, userIntegrations.NunuxKeeperURL,
integration.NunuxKeeperAPIKey, userIntegrations.NunuxKeeperAPIKey,
) )
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil { if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to NunuxKeeper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.EspialEnabled { if userIntegrations.EspialEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Espial", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Espial",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := espial.NewClient( client := espial.NewClient(
integration.EspialURL, userIntegrations.EspialURL,
integration.EspialAPIKey, userIntegrations.EspialAPIKey,
) )
if err := client.CreateLink(entry.URL, entry.Title, integration.EspialTags); err != nil { if err := client.CreateLink(entry.URL, entry.Title, userIntegrations.EspialTags); err != nil {
logger.Error("[Integration] Unable to send entry #%d to Espial for user #%d: %v", entry.ID, integration.UserID, err) slog.Error("Unable to send entry to Espial",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.PocketEnabled { if userIntegrations.PocketEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Pocket", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pocket.NewClient(config.Opts.PocketConsumerKey(integration.PocketConsumerKey), integration.PocketAccessToken) client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil { if err := client.AddURL(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.LinkdingEnabled { if userIntegrations.LinkdingEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Linkding", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Linkding",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linkding.NewClient( client := linkding.NewClient(
integration.LinkdingURL, userIntegrations.LinkdingURL,
integration.LinkdingAPIKey, userIntegrations.LinkdingAPIKey,
integration.LinkdingTags, userIntegrations.LinkdingTags,
integration.LinkdingMarkAsUnread, userIntegrations.LinkdingMarkAsUnread,
) )
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil { if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Linkding",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.ReadwiseEnabled { if userIntegrations.ReadwiseEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Readwise Reader", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Readwise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := readwise.NewClient( client := readwise.NewClient(
integration.ReadwiseAPIKey, userIntegrations.ReadwiseAPIKey,
) )
if err := client.CreateDocument(entry.URL); err != nil { if err := client.CreateDocument(entry.URL); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Readwise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.ShioriEnabled { if userIntegrations.ShioriEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Shiori", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Shiori",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := shiori.NewClient( client := shiori.NewClient(
integration.ShioriURL, userIntegrations.ShioriURL,
integration.ShioriUsername, userIntegrations.ShioriUsername,
integration.ShioriPassword, userIntegrations.ShioriPassword,
) )
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil { if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] Unable to send entry #%d to Shiori for user #%d: %v", entry.ID, integration.UserID, err) slog.Error("Unable to send entry to Shiori",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.ShaarliEnabled { if userIntegrations.ShaarliEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Shaarli", entry.ID, entry.URL, integration.UserID) slog.Debug("Sending entry to Shaarli",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := shaarli.NewClient( client := shaarli.NewClient(
integration.ShaarliURL, userIntegrations.ShaarliURL,
integration.ShaarliAPISecret, userIntegrations.ShaarliAPISecret,
) )
if err := client.CreateLink(entry.URL, entry.Title); err != nil { if err := client.CreateLink(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] Unable to send entry #%d to Shaarli for user #%d: %v", entry.ID, integration.UserID, err) slog.Error("Unable to send entry to Shaarli",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if integration.WebhookEnabled { if userIntegrations.WebhookEnabled {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Webhook URL: %s", entry.ID, entry.URL, integration.UserID, integration.WebhookURL) slog.Debug("Sending entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", userIntegrations.WebhookURL),
)
webhookClient := webhook.NewClient(integration.WebhookURL, integration.WebhookSecret) webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil { if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) slog.Error("Unable to send entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.Any("error", err),
)
} }
} }
} }
@ -181,20 +292,47 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes. // PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *model.Integration) { func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *model.Integration) {
if userIntegrations.MatrixBotEnabled { if userIntegrations.MatrixBotEnabled {
logger.Debug("[Integration] Sending %d entries for user #%d to Matrix", len(entries), userIntegrations.UserID) slog.Debug("Sending new entries to Matrix",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
err := matrixbot.PushEntries(feed, entries, userIntegrations.MatrixBotURL, userIntegrations.MatrixBotUser, userIntegrations.MatrixBotPassword, userIntegrations.MatrixBotChatID) err := matrixbot.PushEntries(
feed,
entries,
userIntegrations.MatrixBotURL,
userIntegrations.MatrixBotUser,
userIntegrations.MatrixBotPassword,
userIntegrations.MatrixBotChatID,
)
if err != nil { if err != nil {
logger.Error("[Integration] push entries to matrix bot failed: %v", err) slog.Error("Unable to send new entries to Matrix",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.Any("error", err),
)
} }
} }
if userIntegrations.WebhookEnabled { if userIntegrations.WebhookEnabled {
logger.Debug("[Integration] Sending %d entries for user #%d to Webhook URL: %s", len(entries), userIntegrations.UserID, userIntegrations.WebhookURL) slog.Debug("Sending new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("webhook_url", userIntegrations.WebhookURL),
)
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret) webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil { if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
logger.Error("[Integration] sending entries to webhook failed: %v", err) slog.Debug("Unable to send new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.Any("error", err),
)
} }
} }
@ -202,7 +340,11 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled { if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
for _, entry := range entries { for _, entry := range entries {
if userIntegrations.TelegramBotEnabled { if userIntegrations.TelegramBotEnabled {
logger.Debug("[Integration] Sending entry %q for user #%d to Telegram", entry.URL, userIntegrations.UserID) slog.Debug("Sending a new entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := telegrambot.PushEntry( if err := telegrambot.PushEntry(
feed, feed,
@ -213,12 +355,21 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
userIntegrations.TelegramBotDisableWebPagePreview, userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification, userIntegrations.TelegramBotDisableNotification,
); err != nil { ); err != nil {
logger.Error("[Integration] %v", err) slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
if userIntegrations.AppriseEnabled { if userIntegrations.AppriseEnabled {
logger.Debug("[Integration] Sending entry %q for user #%d to Apprise", entry.URL, userIntegrations.UserID) slog.Debug("Sending a new entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
appriseServiceURLs := userIntegrations.AppriseURL appriseServiceURLs := userIntegrations.AppriseURL
if feed.AppriseServiceURLs != "" { if feed.AppriseServiceURLs != "" {
@ -231,7 +382,12 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
) )
if err := client.SendNotification(entry); err != nil { if err := client.SendNotification(entry); err != nil {
logger.Error("[Integration] %v", err) slog.Error("Unable to send entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} }
} }
} }

View file

@ -1,97 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package logger // import "miniflux.app/v2/internal/logger"
import (
"fmt"
"os"
"time"
)
var requestedLevel = InfoLevel
var displayDateTime = false
// 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"
}
}
// EnableDateTime enables date time in log messages.
func EnableDateTime() {
displayDateTime = true
}
// 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{}) {
if requestedLevel >= DebugLevel {
formatMessage(DebugLevel, format, v...)
}
}
// Info sends an info log message.
func Info(format string, v ...interface{}) {
if requestedLevel >= InfoLevel {
formatMessage(InfoLevel, format, v...)
}
}
// Error sends an error log message.
func Error(format string, v ...interface{}) {
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{}) {
if requestedLevel >= FatalLevel {
formatMessage(FatalLevel, format, v...)
os.Exit(1)
}
}
func formatMessage(level LogLevel, format string, v ...interface{}) {
var prefix string
if displayDateTime {
prefix = fmt.Sprintf("[%s] [%s] ", time.Now().Format("2006-01-02T15:04:05"), level)
} else {
prefix = fmt.Sprintf("[%s] ", level)
}
fmt.Fprintf(os.Stderr, prefix+format+"\n", v...)
}

View file

@ -4,9 +4,9 @@
package metric // import "miniflux.app/v2/internal/metric" package metric // import "miniflux.app/v2/internal/metric"
import ( import (
"log/slog"
"time" "time"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@ -164,7 +164,7 @@ func NewCollector(store *storage.Storage, refreshInterval int) *Collector {
// GatherStorageMetrics polls the database to fetch metrics. // GatherStorageMetrics polls the database to fetch metrics.
func (c *Collector) GatherStorageMetrics() { func (c *Collector) GatherStorageMetrics() {
for range time.Tick(time.Duration(c.refreshInterval) * time.Second) { for range time.Tick(time.Duration(c.refreshInterval) * time.Second) {
logger.Debug("[Metric] Collecting database metrics") slog.Debug("Collecting metrics from the database")
usersGauge.Set(float64(c.store.CountUsers())) usersGauge.Set(float64(c.store.CountUsers()))
brokenFeedsGauge.Set(float64(c.store.CountAllFeedsWithErrors())) brokenFeedsGauge.Set(float64(c.store.CountAllFeedsWithErrors()))

View file

@ -6,8 +6,7 @@ package oauth2 // import "miniflux.app/v2/internal/oauth2"
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"miniflux.app/v2/internal/logger"
) )
type Manager struct { type Manager struct {
@ -32,7 +31,9 @@ func NewManager(ctx context.Context, clientID, clientSecret, redirectURL, oidcDi
if oidcDiscoveryEndpoint != "" { if oidcDiscoveryEndpoint != "" {
if genericOidcProvider, err := NewOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil { if genericOidcProvider, err := NewOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil {
logger.Error("[OAuth2] failed to initialize OIDC provider: %v", err) slog.Error("Failed to initialize OIDC provider",
slog.Any("error", err),
)
} else { } else {
m.AddProvider("oidc", genericOidcProvider) m.AddProvider("oidc", genericOidcProvider)
} }

View file

@ -6,11 +6,11 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import ( import (
"encoding/base64" "encoding/base64"
"html" "html"
"log/slog"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date" "miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer" "miniflux.app/v2/internal/reader/sanitizer"
@ -126,7 +126,11 @@ func (a *atom03Entry) entryDate() time.Time {
if dateText != "" { if dateText != "" {
result, err := date.Parse(dateText) result, err := date.Parse(dateText)
if err != nil { if err != nil {
logger.Error("atom: %v", err) slog.Warn("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now() return time.Now()
} }

View file

@ -6,12 +6,12 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import ( import (
"encoding/xml" "encoding/xml"
"html" "html"
"log/slog"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date" "miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/media" "miniflux.app/v2/internal/reader/media"
@ -144,7 +144,11 @@ func (a *atom10Entry) entryDate() time.Time {
if dateText != "" { if dateText != "" {
result, err := date.Parse(dateText) result, err := date.Parse(dateText)
if err != nil { if err != nil {
logger.Error("atom: %v (entry ID = %s)", err, a.ID) slog.Warn("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now() return time.Now()
} }

View file

@ -4,22 +4,19 @@
package handler // import "miniflux.app/v2/internal/reader/handler" package handler // import "miniflux.app/v2/internal/reader/handler"
import ( import (
"fmt" "log/slog"
"time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/errors" "miniflux.app/v2/internal/errors"
"miniflux.app/v2/internal/http/client" "miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/browser" "miniflux.app/v2/internal/reader/browser"
"miniflux.app/v2/internal/reader/icon" "miniflux.app/v2/internal/reader/icon"
"miniflux.app/v2/internal/reader/parser" "miniflux.app/v2/internal/reader/parser"
"miniflux.app/v2/internal/reader/processor" "miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/timer"
) )
var ( var (
@ -30,7 +27,10 @@ var (
// CreateFeed fetch, parse and store a new feed. // CreateFeed fetch, parse and store a new feed.
func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequest) (*model.Feed, error) { func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequest) (*model.Feed, error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[CreateFeed] FeedURL=%s", feedCreationRequest.FeedURL)) slog.Debug("Begin feed creation process",
slog.Int64("user_id", userID),
slog.String("feed_url", feedCreationRequest.FeedURL),
)
user, storeErr := store.UserByID(userID) user, storeErr := store.UserByID(userID)
if storeErr != nil { if storeErr != nil {
@ -90,7 +90,11 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
return nil, storeErr return nil, storeErr
} }
logger.Debug("[CreateFeed] Feed saved with ID: %d", subscription.ID) slog.Debug("Created feed",
slog.Int64("user_id", userID),
slog.Int64("feed_id", subscription.ID),
slog.String("feed_url", subscription.FeedURL),
)
checkFeedIcon( checkFeedIcon(
store, store,
@ -106,7 +110,12 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
// RefreshFeed refreshes a feed. // RefreshFeed refreshes a feed.
func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool) error { func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool) error {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[RefreshFeed] feedID=%d", feedID)) slog.Debug("Begin feed refresh process",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Bool("force_refresh", forceRefresh),
)
user, storeErr := store.UserByID(userID) user, storeErr := store.UserByID(userID)
if storeErr != nil { if storeErr != nil {
return storeErr return storeErr
@ -164,7 +173,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
} }
if originalFeed.IgnoreHTTPCache || response.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) { if originalFeed.IgnoreHTTPCache || response.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
logger.Debug("[RefreshFeed] Feed #%d has been modified", feedID) slog.Debug("Feed modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
)
updatedFeed, parseErr := parser.ParseFeed(response.EffectiveURL, response.BodyAsString()) updatedFeed, parseErr := parser.ParseFeed(response.EffectiveURL, response.BodyAsString())
if parseErr != nil { if parseErr != nil {
@ -187,7 +199,11 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
userIntegrations, intErr := store.Integration(userID) userIntegrations, intErr := store.Integration(userID)
if intErr != nil { if intErr != nil {
logger.Error("[RefreshFeed] Fetching integrations for user %d failed: %v; the refresh process will go on, but no integrations will run this time.", userID, intErr) slog.Error("Fetching integrations failed; the refresh process will go on, but no integrations will run this time",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Any("error", intErr),
)
} else if userIntegrations != nil && len(newEntries) > 0 { } else if userIntegrations != nil && len(newEntries) > 0 {
go integration.PushEntries(originalFeed, newEntries, userIntegrations) go integration.PushEntries(originalFeed, newEntries, userIntegrations)
} }
@ -206,7 +222,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.AllowSelfSignedCertificates, originalFeed.AllowSelfSignedCertificates,
) )
} else { } else {
logger.Debug("[RefreshFeed] Feed #%d not modified", feedID) slog.Debug("Feed not modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
)
} }
originalFeed.ResetErrorCounter() originalFeed.ResetErrorCounter()
@ -224,12 +243,26 @@ func checkFeedIcon(store *storage.Storage, feedID int64, websiteURL, feedIconURL
if !store.HasIcon(feedID) { if !store.HasIcon(feedID) {
icon, err := icon.FindIcon(websiteURL, feedIconURL, userAgent, fetchViaProxy, allowSelfSignedCertificates) icon, err := icon.FindIcon(websiteURL, feedIconURL, userAgent, fetchViaProxy, allowSelfSignedCertificates)
if err != nil { if err != nil {
logger.Debug(`[CheckFeedIcon] %v (feedID=%d websiteURL=%s)`, err, feedID, websiteURL) slog.Warn("Unable to find feed icon",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
slog.Any("error", err),
)
} else if icon == nil { } else if icon == nil {
logger.Debug(`[CheckFeedIcon] No icon found (feedID=%d websiteURL=%s)`, feedID, websiteURL) slog.Debug("No icon found",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
)
} else { } else {
if err := store.CreateFeedIcon(feedID, icon); err != nil { if err := store.CreateFeedIcon(feedID, icon); err != nil {
logger.Debug(`[CheckFeedIcon] %v (feedID=%d websiteURL=%s)`, err, feedID, websiteURL) slog.Error("Unable to store feed icon",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
slog.Any("error", err),
)
} }
} }
} }

View file

@ -13,7 +13,6 @@ import (
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client" "miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/urllib"
@ -65,7 +64,6 @@ func generateIconURL(websiteURL, feedIconURL string) (iconURL string, err error)
func fetchHTMLDocumentAndFindIconURL(websiteURL, userAgent string, fetchViaProxy, allowSelfSignedCertificates bool) (string, error) { func fetchHTMLDocumentAndFindIconURL(websiteURL, userAgent string, fetchViaProxy, allowSelfSignedCertificates bool) (string, error) {
rootURL := urllib.RootURL(websiteURL) rootURL := urllib.RootURL(websiteURL)
logger.Debug("[FindIcon] Find icon from HTML webpage: %s", rootURL)
clt := client.NewClientWithConfig(rootURL, config.Opts) clt := client.NewClientWithConfig(rootURL, config.Opts)
clt.WithUserAgent(userAgent) clt.WithUserAgent(userAgent)

View file

@ -4,11 +4,11 @@
package json // import "miniflux.app/v2/internal/reader/json" package json // import "miniflux.app/v2/internal/reader/json"
import ( import (
"log/slog"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date" "miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer" "miniflux.app/v2/internal/reader/sanitizer"
@ -110,7 +110,11 @@ func (j *jsonItem) GetDate() time.Time {
if value != "" { if value != "" {
d, err := date.Parse(value) d, err := date.Parse(value)
if err != nil { if err != nil {
logger.Error("json: %v", err) slog.Warn("Unable to parse date from JSON feed",
slog.String("date", value),
slog.String("url", j.URL),
slog.Any("error", err),
)
return time.Now() return time.Now()
} }

View file

@ -4,11 +4,9 @@
package opml // import "miniflux.app/v2/internal/reader/opml" package opml // import "miniflux.app/v2/internal/reader/opml"
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
) )
@ -53,21 +51,18 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
if subscription.CategoryName == "" { if subscription.CategoryName == "" {
category, err = h.store.FirstCategory(userID) category, err = h.store.FirstCategory(userID)
if err != nil { if err != nil {
logger.Error("[OPML:Import] %v", err) return fmt.Errorf("opml: unable to find first category: %w", err)
return errors.New("unable to find first category")
} }
} else { } else {
category, err = h.store.CategoryByTitle(userID, subscription.CategoryName) category, err = h.store.CategoryByTitle(userID, subscription.CategoryName)
if err != nil { if err != nil {
logger.Error("[OPML:Import] %v", err) return fmt.Errorf("opml: unable to search category by title: %w", err)
return errors.New("unable to search category by title")
} }
if category == nil { if category == nil {
category, err = h.store.CreateCategory(userID, &model.CategoryRequest{Title: subscription.CategoryName}) category, err = h.store.CreateCategory(userID, &model.CategoryRequest{Title: subscription.CategoryName})
if err != nil { if err != nil {
logger.Error("[OPML:Import] %v", err) return fmt.Errorf(`opml: unable to create this category: %q`, subscription.CategoryName)
return fmt.Errorf(`unable to create this category: %q`, subscription.CategoryName)
} }
} }
} }

View file

@ -7,10 +7,9 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"log/slog"
"sort" "sort"
"time" "time"
"miniflux.app/v2/internal/logger"
) )
// Serialize returns a SubcriptionList in OPML format. // Serialize returns a SubcriptionList in OPML format.
@ -23,7 +22,9 @@ func Serialize(subscriptions SubcriptionList) string {
encoder := xml.NewEncoder(writer) encoder := xml.NewEncoder(writer)
encoder.Indent("", " ") encoder.Indent("", " ")
if err := encoder.Encode(opmlDocument); err != nil { if err := encoder.Encode(opmlDocument); err != nil {
logger.Error("[OPML:Serialize] %v", err) slog.Error("Unable to serialize OPML document",
slog.Any("error", err),
)
return "" return ""
} }

View file

@ -6,6 +6,7 @@ package processor
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"math" "math"
"regexp" "regexp"
"strconv" "strconv"
@ -15,7 +16,6 @@ import (
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client" "miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/metric" "miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/browser" "miniflux.app/v2/internal/reader/browser"
@ -43,7 +43,13 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
for i := len(feed.Entries) - 1; i >= 0; i-- { for i := len(feed.Entries) - 1; i >= 0; i-- {
entry := feed.Entries[i] entry := feed.Entries[i]
logger.Debug("[Processor] Processing entry %q from feed %q", entry.URL, feed.FeedURL) slog.Debug("Processing entry",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
)
if isBlockedEntry(feed, entry) || !isAllowedEntry(feed, entry) { if isBlockedEntry(feed, entry) || !isAllowedEntry(feed, entry) {
continue continue
@ -52,7 +58,13 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
url := getUrlFromEntry(feed, entry) url := getUrlFromEntry(feed, entry)
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL) entryIsNew := !store.EntryURLExists(feed.ID, entry.URL)
if feed.Crawler && (entryIsNew || forceRefresh) { if feed.Crawler && (entryIsNew || forceRefresh) {
logger.Debug("[Processor] Crawling entry %q from feed %q", url, feed.FeedURL) slog.Debug("Scraping entry",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
)
startTime := time.Now() startTime := time.Now()
content, scraperErr := scraper.Fetch( content, scraperErr := scraper.Fetch(
@ -73,7 +85,14 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
} }
if scraperErr != nil { if scraperErr != nil {
logger.Error(`[Processor] Unable to crawl this entry: %q => %v`, entry.URL, scraperErr) slog.Warn("Unable to scrape entry",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", scraperErr),
)
} else if content != "" { } else if content != "" {
// We replace the entry content only if the scraper doesn't return any error. // We replace the entry content only if the scraper doesn't return any error.
entry.Content = content entry.Content = content
@ -96,7 +115,13 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
if feed.BlocklistRules != "" { if feed.BlocklistRules != "" {
match, _ := regexp.MatchString(feed.BlocklistRules, entry.Title) match, _ := regexp.MatchString(feed.BlocklistRules, entry.Title)
if match { if match {
logger.Debug("[Processor] Blocking entry %q from feed %q based on rule %q", entry.Title, feed.FeedURL, feed.BlocklistRules) slog.Debug("Blocking entry based on rule",
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", feed.BlocklistRules),
)
return true return true
} }
} }
@ -107,7 +132,13 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
if feed.KeeplistRules != "" { if feed.KeeplistRules != "" {
match, _ := regexp.MatchString(feed.KeeplistRules, entry.Title) match, _ := regexp.MatchString(feed.KeeplistRules, entry.Title)
if match { if match {
logger.Debug("[Processor] Allow entry %q from feed %q based on rule %q", entry.Title, feed.FeedURL, feed.KeeplistRules) slog.Debug("Allow entry based on rule",
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", feed.KeeplistRules),
)
return true return true
} }
return false return false
@ -160,9 +191,22 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
if len(parts) >= 3 { if len(parts) >= 3 {
re := regexp.MustCompile(parts[1]) re := regexp.MustCompile(parts[1])
url = re.ReplaceAllString(entry.URL, parts[2]) url = re.ReplaceAllString(entry.URL, parts[2])
logger.Debug(`[Processor] Rewriting entry URL %s to %s`, entry.URL, url) slog.Debug("Rewriting entry URL",
slog.Int64("entry_id", entry.ID),
slog.String("original_entry_url", entry.URL),
slog.String("rewritten_entry_url", url),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
)
} else { } else {
logger.Debug("[Processor] Cannot find search and replace terms for replace rule %s", feed.UrlRewriteRules) slog.Debug("Cannot find search and replace terms for replace rule",
slog.Int64("entry_id", entry.ID),
slog.String("original_entry_url", entry.URL),
slog.String("rewritten_entry_url", url),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
)
} }
} }
return url return url
@ -173,7 +217,14 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
if entryIsNew { if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL) watchTime, err := fetchYouTubeWatchTime(entry.URL)
if err != nil { if err != nil {
logger.Error("[Processor] Unable to fetch YouTube watch time: %q => %v", entry.URL, err) slog.Warn("Unable to fetch YouTube watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
} }
entry.ReadingTime = watchTime entry.ReadingTime = watchTime
} else { } else {
@ -185,7 +236,14 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
if entryIsNew { if entryIsNew {
watchTime, err := fetchOdyseeWatchTime(entry.URL) watchTime, err := fetchOdyseeWatchTime(entry.URL)
if err != nil { if err != nil {
logger.Error("[Processor] Unable to fetch Odysee watch time: %q => %v", entry.URL, err) slog.Warn("Unable to fetch Odysee watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
} }
entry.ReadingTime = watchTime entry.ReadingTime = watchTime
} else { } else {

View file

@ -6,11 +6,11 @@ package rdf // import "miniflux.app/v2/internal/reader/rdf"
import ( import (
"encoding/xml" "encoding/xml"
"html" "html"
"log/slog"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date" "miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/dublincore" "miniflux.app/v2/internal/reader/dublincore"
@ -100,7 +100,11 @@ func (r *rdfItem) entryDate() time.Time {
if r.DublinCoreDate != "" { if r.DublinCoreDate != "" {
result, err := date.Parse(r.DublinCoreDate) result, err := date.Parse(r.DublinCoreDate)
if err != nil { if err != nil {
logger.Error("rdf: %v (entry link = %s)", err, r.Link) slog.Warn("Unable to parse date from RDF feed",
slog.String("date", r.DublinCoreDate),
slog.String("link", r.Link),
slog.Any("error", err),
)
return time.Now() return time.Now()
} }

View file

@ -7,12 +7,11 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"log/slog"
"math" "math"
"regexp" "regexp"
"strings" "strings"
"miniflux.app/v2/internal/logger"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
@ -83,10 +82,12 @@ func ExtractContent(page io.Reader) (string, error) {
removeUnlikelyCandidates(document) removeUnlikelyCandidates(document)
candidates := getCandidates(document) candidates := getCandidates(document)
logger.Debug("[Readability] Candidates: %v", candidates)
topCandidate := getTopCandidate(document, candidates) topCandidate := getTopCandidate(document, candidates)
logger.Debug("[Readability] TopCandidate: %v", topCandidate)
slog.Debug("Readability parsing",
slog.Any("candidates", candidates),
slog.Any("topCandidate", topCandidate),
)
output := getArticle(topCandidate, candidates) output := getArticle(topCandidate, candidates)
return output, nil return output, nil

View file

@ -7,12 +7,12 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"html" "html"
"log/slog"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@ -359,7 +359,9 @@ func addHackerNewsLinksUsing(entryContent, app string) string {
open_with_hack := `<a href="` + url + `">Open with HACK</a>` open_with_hack := `<a href="` + url + `">Open with HACK</a>`
a.Parent().AppendHtml(" " + open_with_hack) a.Parent().AppendHtml(" " + open_with_hack)
} else { } else {
logger.Error("[openHackerNewsLinksWith] unknown app provided: %q", app) slog.Warn("Unknown app provided for openHackerNewsLinksWith rewrite rule",
slog.String("app", app),
)
return return
} }
}) })

View file

@ -4,11 +4,11 @@
package rewrite // import "miniflux.app/v2/internal/reader/rewrite" package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
import ( import (
"log/slog"
"strconv" "strconv"
"strings" "strings"
"text/scanner" "text/scanner"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/urllib"
) )
@ -28,7 +28,10 @@ func Rewriter(entryURL string, entry *model.Entry, customRewriteRules string) {
rules := parseRules(rulesList) rules := parseRules(rulesList)
rules = append(rules, rule{name: "add_pdf_download_link"}) rules = append(rules, rule{name: "add_pdf_download_link"})
logger.Debug(`[Rewrite] Applying rules %v for %q`, rules, entryURL) slog.Debug("Rewrite rules applied",
slog.Any("rules", rules),
slog.String("entry_url", entryURL),
)
for _, rule := range rules { for _, rule := range rules {
applyRule(entryURL, entry, rule) applyRule(entryURL, entry, rule)
@ -89,21 +92,30 @@ func applyRule(entryURL string, entry *model.Entry, rule rule) {
if len(rule.args) >= 2 { if len(rule.args) >= 2 {
entry.Content = replaceCustom(entry.Content, rule.args[0], rule.args[1]) entry.Content = replaceCustom(entry.Content, rule.args[0], rule.args[1])
} else { } else {
logger.Debug("[Rewrite] Cannot find search and replace terms for replace rule %s", rule) slog.Warn("Cannot find search and replace terms for replace rule",
slog.Any("rule", rule),
slog.String("entry_url", entryURL),
)
} }
case "replace_title": case "replace_title":
// Format: replace_title("search-term"|"replace-term") // Format: replace_title("search-term"|"replace-term")
if len(rule.args) >= 2 { if len(rule.args) >= 2 {
entry.Title = replaceCustom(entry.Title, rule.args[0], rule.args[1]) entry.Title = replaceCustom(entry.Title, rule.args[0], rule.args[1])
} else { } else {
logger.Debug("[Rewrite] Cannot find search and replace terms for replace rule %s", rule) slog.Warn("Cannot find search and replace terms for replace_title rule",
slog.Any("rule", rule),
slog.String("entry_url", entryURL),
)
} }
case "remove": case "remove":
// Format: remove("#selector > .element, .another") // Format: remove("#selector > .element, .another")
if len(rule.args) >= 1 { if len(rule.args) >= 1 {
entry.Content = removeCustom(entry.Content, rule.args[0]) entry.Content = removeCustom(entry.Content, rule.args[0])
} else { } else {
logger.Debug("[Rewrite] Cannot find selector for remove rule %s", rule) slog.Warn("Cannot find selector for remove rule",
slog.Any("rule", rule),
slog.String("entry_url", entryURL),
)
} }
case "add_castopod_episode": case "add_castopod_episode":
entry.Content = addCastopodEpisode(entryURL, entry.Content) entry.Content = addCastopodEpisode(entryURL, entry.Content)

View file

@ -6,13 +6,13 @@ package rss // import "miniflux.app/v2/internal/reader/rss"
import ( import (
"encoding/xml" "encoding/xml"
"html" "html"
"log/slog"
"path" "path"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date" "miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/dublincore" "miniflux.app/v2/internal/reader/dublincore"
@ -216,7 +216,11 @@ func (r *rssItem) entryDate() time.Time {
if value != "" { if value != "" {
result, err := date.Parse(value) result, err := date.Parse(value)
if err != nil { if err != nil {
logger.Error("rss: %v (entry GUID = %s)", err, r.GUID) slog.Warn("Unable to parse date from RSS feed",
slog.String("date", value),
slog.String("guid", r.GUID.Data),
slog.Any("error", err),
)
return time.Now() return time.Now()
} }

View file

@ -7,11 +7,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"strings" "strings"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client" "miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/reader/readability" "miniflux.app/v2/internal/reader/readability"
"miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/urllib"
@ -55,10 +55,15 @@ func Fetch(websiteURL, rules, userAgent string, cookie string, allowSelfSignedCe
var content string var content string
if sameSite && rules != "" { if sameSite && rules != "" {
logger.Debug(`[Scraper] Using rules %q for %q`, rules, websiteURL) slog.Debug("Extracting content with custom rules",
"url", websiteURL,
"rules", rules,
)
content, err = scrapContent(response.Body, rules) content, err = scrapContent(response.Body, rules)
} else { } else {
logger.Debug(`[Scraper] Using readability for %q`, websiteURL) slog.Debug("Extracting content with readability",
"url", websiteURL,
)
content, err = readability.ExtractContent(response.Body) content, err = readability.ExtractContent(response.Body)
} }

View file

@ -238,7 +238,7 @@ func (s *Storage) RemoveCategory(userID, categoryID int64) error {
func (s *Storage) RemoveAndReplaceCategoriesByName(userid int64, titles []string) error { func (s *Storage) RemoveAndReplaceCategoriesByName(userid int64, titles []string) error {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return errors.New("unable to begin transaction") return errors.New("store: unable to begin transaction")
} }
titleParam := pq.Array(titles) titleParam := pq.Array(titles)
@ -247,11 +247,11 @@ func (s *Storage) RemoveAndReplaceCategoriesByName(userid int64, titles []string
err = tx.QueryRow(query, userid, titleParam).Scan(&count) err = tx.QueryRow(query, userid, titleParam).Scan(&count)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return errors.New("unable to retrieve category count") return errors.New("store: unable to retrieve category count")
} }
if count < 1 { if count < 1 {
tx.Rollback() tx.Rollback()
return errors.New("at least 1 category must remain after deletion") return errors.New("store: at least 1 category must remain after deletion")
} }
query = ` query = `
@ -268,14 +268,14 @@ func (s *Storage) RemoveAndReplaceCategoriesByName(userid int64, titles []string
_, err = tx.Exec(query, userid, titleParam) _, err = tx.Exec(query, userid, titleParam)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("unable to replace categories: %v", err) return fmt.Errorf("store: unable to replace categories: %v", err)
} }
query = "DELETE FROM categories WHERE user_id = $1 AND title = ANY($2)" query = "DELETE FROM categories WHERE user_id = $1 AND title = ANY($2)"
_, err = tx.Exec(query, userid, titleParam) _, err = tx.Exec(query, userid, titleParam)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("unable to delete categories: %v", err) return fmt.Errorf("store: unable to delete categories: %v", err)
} }
tx.Commit() tx.Commit()
return nil return nil

View file

@ -7,10 +7,10 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"time" "time"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"github.com/lib/pq" "github.com/lib/pq"
@ -52,7 +52,10 @@ func (s *Storage) CountUnreadEntries(userID int64) int {
n, err := builder.CountEntries() n, err := builder.CountEntries()
if err != nil { if err != nil {
logger.Error(`store: unable to count unread entries for user #%d: %v`, userID, err) slog.Error("Unable to count unread entries",
slog.Int64("user_id", userID),
slog.Any("error", err),
)
return 0 return 0
} }
@ -316,7 +319,11 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
go func() { go func() {
if err := s.cleanupEntries(feedID, entryHashes); err != nil { if err := s.cleanupEntries(feedID, entryHashes); err != nil {
logger.Error(`store: feed #%d: %v`, feedID, err) slog.Error("Unable to cleanup entries",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Any("error", err),
)
} }
}() }()
@ -463,7 +470,10 @@ func (s *Storage) MarkAllAsRead(userID int64) error {
} }
count, _ := result.RowsAffected() count, _ := result.RowsAffected()
logger.Debug("[Storage:MarkAllAsRead] %d items marked as read", count) slog.Debug("Marked all entries as read",
slog.Int64("user_id", userID),
slog.Int64("nb_entries", count),
)
return nil return nil
} }
@ -490,7 +500,10 @@ func (s *Storage) MarkGloballyVisibleFeedsAsRead(userID int64) error {
} }
count, _ := result.RowsAffected() count, _ := result.RowsAffected()
logger.Debug("[Storage:MarkGloballyVisibleFeedsAsRead] %d items marked as read", count) slog.Debug("Marked globally visible feed entries as read",
slog.Int64("user_id", userID),
slog.Int64("nb_entries", count),
)
return nil return nil
} }
@ -512,7 +525,11 @@ func (s *Storage) MarkFeedAsRead(userID, feedID int64, before time.Time) error {
} }
count, _ := result.RowsAffected() count, _ := result.RowsAffected()
logger.Debug("[Storage:MarkFeedAsRead] %d items marked as read", count) slog.Debug("Marked feed entries as read",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Int64("nb_entries", count),
)
return nil return nil
} }
@ -540,7 +557,11 @@ func (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time)
} }
count, _ := result.RowsAffected() count, _ := result.RowsAffected()
logger.Debug("[Storage:MarkCategoryAsRead] %d items marked as read", count) slog.Debug("Marked category entries as read",
slog.Int64("user_id", userID),
slog.Int64("category_id", categoryID),
slog.Int64("nb_entries", count),
)
return nil return nil
} }

View file

@ -7,10 +7,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
"time"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/timer"
) )
// EntryPaginationBuilder is a builder for entry prev/next queries. // EntryPaginationBuilder is a builder for entry prev/next queries.
@ -101,8 +99,6 @@ func (e *EntryPaginationBuilder) Entries() (*model.Entry, *model.Entry, error) {
} }
func (e *EntryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID int64, err error) { func (e *EntryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID int64, err error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[EntryPaginationBuilder] %v, %v", e.conditions, e.args))
cte := ` cte := `
WITH entry_pagination AS ( WITH entry_pagination AS (
SELECT SELECT

View file

@ -7,11 +7,10 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"runtime" "log/slog"
"sort" "sort"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
) )
@ -432,7 +431,11 @@ func (s *Storage) RemoveFeed(userID, feedID int64) error {
return fmt.Errorf(`store: unable to read user feed entry ID: %v`, err) return fmt.Errorf(`store: unable to read user feed entry ID: %v`, err)
} }
logger.Debug(`[FEED DELETION] Deleting entry #%d of feed #%d for user #%d (%d GoRoutines)`, entryID, feedID, userID, runtime.NumGoroutine()) slog.Debug("Deleting entry",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Int64("entry_id", entryID),
)
if _, err := s.db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID); err != nil { if _, err := s.db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID); err != nil {
return fmt.Errorf(`store: unable to delete user feed entries #%d: %v`, entryID, err) return fmt.Errorf(`store: unable to delete user feed entries #%d: %v`, entryID, err)

View file

@ -6,11 +6,11 @@ package storage // import "miniflux.app/v2/internal/storage"
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"runtime" "runtime"
"strings" "strings"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"github.com/lib/pq" "github.com/lib/pq"
@ -506,14 +506,20 @@ func (s *Storage) RemoveUser(userID int64) error {
func (s *Storage) RemoveUserAsync(userID int64) { func (s *Storage) RemoveUserAsync(userID int64) {
go func() { go func() {
if err := s.deleteUserFeeds(userID); err != nil { if err := s.deleteUserFeeds(userID); err != nil {
logger.Error(`%v`, err) slog.Error("Unable to delete user feedd",
slog.Int64("user_id", userID),
slog.Any("error", err),
)
return return
} }
s.db.Exec(`DELETE FROM users WHERE id=$1`, userID) s.db.Exec(`DELETE FROM users WHERE id=$1`, userID)
s.db.Exec(`DELETE FROM integrations WHERE user_id=$1`, userID) s.db.Exec(`DELETE FROM integrations WHERE user_id=$1`, userID)
logger.Debug(`[MASS DELETE] User #%d has been deleted (%d GoRoutines)`, userID, runtime.NumGoroutine()) slog.Debug("User deleted",
slog.Int64("user_id", userID),
slog.Int("goroutines", runtime.NumGoroutine()),
)
}() }()
} }
@ -528,7 +534,11 @@ func (s *Storage) deleteUserFeeds(userID int64) error {
var feedID int64 var feedID int64
rows.Scan(&feedID) rows.Scan(&feedID)
logger.Debug(`[USER DELETION] Deleting feed #%d for user #%d (%d GoRoutines)`, feedID, userID, runtime.NumGoroutine()) slog.Debug("Deleting feed",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Int("goroutines", runtime.NumGoroutine()),
)
if err := s.RemoveFeed(userID, feedID); err != nil { if err := s.RemoveFeed(userID, feedID); err != nil {
return err return err

View file

@ -7,12 +7,12 @@ import (
"bytes" "bytes"
"embed" "embed"
"html/template" "html/template"
"log/slog"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/errors" "miniflux.app/v2/internal/errors"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -73,7 +73,10 @@ func (e *Engine) ParseTemplates() error {
templateContents.WriteString(commonTemplateContents.String()) templateContents.WriteString(commonTemplateContents.String())
templateContents.Write(fileData) templateContents.Write(fileData)
logger.Debug("[Template] Parsing: %s", templateName) slog.Debug("Parsing template",
slog.String("template_name", templateName),
)
e.templates[templateName] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(templateContents.String())) e.templates[templateName] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(templateContents.String()))
} }
@ -89,7 +92,9 @@ func (e *Engine) ParseTemplates() error {
return err return err
} }
logger.Debug("[Template] Parsing: %s", templateName) slog.Debug("Parsing template",
slog.String("template_name", templateName),
)
e.templates[templateName] = template.Must(template.New("base").Funcs(e.funcMap.Map()).Parse(string(fileData))) e.templates[templateName] = template.Must(template.New("base").Funcs(e.funcMap.Map()).Parse(string(fileData)))
} }
@ -100,7 +105,7 @@ func (e *Engine) ParseTemplates() error {
func (e *Engine) Render(name string, data map[string]interface{}) []byte { func (e *Engine) Render(name string, data map[string]interface{}) []byte {
tpl, ok := e.templates[name] tpl, ok := e.templates[name]
if !ok { if !ok {
logger.Fatal("[Template] The template %s does not exists", name) panic("This template does not exists: " + name)
} }
printer := locale.NewPrinter(data["language"].(string)) printer := locale.NewPrinter(data["language"].(string))
@ -132,7 +137,7 @@ func (e *Engine) Render(name string, data map[string]interface{}) []byte {
var b bytes.Buffer var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data) err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil { if err != nil {
logger.Fatal("[Template] Unable to render template: %v", err) panic(err)
} }
return b.Bytes() return b.Bytes()

View file

@ -1,16 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package timer // import "miniflux.app/v2/internal/timer"
import (
"time"
"miniflux.app/v2/internal/logger"
)
// ExecutionTime returns the elapsed time of a block of code.
func ExecutionTime(start time.Time, name string) {
elapsed := time.Since(start)
logger.Debug("%s took %s", name, elapsed)
}

View file

@ -9,14 +9,14 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
) )
func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) { func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) {
keyID := request.RouteInt64Param(r, "keyID") keyID := request.RouteInt64Param(r, "keyID")
err := h.store.RemoveAPIKey(request.UserID(r), keyID) err := h.store.RemoveAPIKey(request.UserID(r), keyID)
if err != nil { if err != nil {
logger.Error("[UI:RemoveAPIKey] %v", err) html.ServerError(w, r, err)
return
} }
html.Redirect(w, r, route.Path(h.router, "apiKeys")) html.Redirect(w, r, route.Path(h.router, "apiKeys"))

View file

@ -9,7 +9,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -47,9 +46,7 @@ func (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {
apiKey := model.NewAPIKey(user.ID, apiKeyForm.Description) apiKey := model.NewAPIKey(user.ID, apiKeyForm.Description)
if err = h.store.CreateAPIKey(apiKey); err != nil { if err = h.store.CreateAPIKey(apiKey); err != nil {
logger.Error("[UI:SaveAPIKey] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_create_api_key")
html.OK(w, r, view.Render("create_api_key"))
return return
} }

View file

@ -9,7 +9,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -43,9 +42,7 @@ func (h *handler) saveCategory(w http.ResponseWriter, r *http.Request) {
} }
if _, err = h.store.CreateCategory(loggedUser.ID, categoryRequest); err != nil { if _, err = h.store.CreateCategory(loggedUser.ID, categoryRequest); err != nil {
logger.Error("[UI:SaveCategory] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_create_category")
html.OK(w, r, view.Render("create_category"))
return return
} }

View file

@ -9,7 +9,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -60,9 +59,7 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
categoryRequest.Patch(category) categoryRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil { if err := h.store.UpdateCategory(category); err != nil {
logger.Error("[UI:UpdateCategory] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_update_category")
html.OK(w, r, view.Render("edit_category"))
return return
} }

View file

@ -4,12 +4,12 @@
package ui // import "miniflux.app/v2/internal/ui" package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
feedHandler "miniflux.app/v2/internal/reader/handler" feedHandler "miniflux.app/v2/internal/reader/handler"
) )
@ -17,7 +17,12 @@ func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID") feedID := request.RouteInt64Param(r, "feedID")
forceRefresh := request.QueryBoolParam(r, "forceRefresh", false) forceRefresh := request.QueryBoolParam(r, "forceRefresh", false)
if err := feedHandler.RefreshFeed(h.store, request.UserID(r), feedID, forceRefresh); err != nil { if err := feedHandler.RefreshFeed(h.store, request.UserID(r), feedID, forceRefresh); err != nil {
logger.Error("[UI:RefreshFeed] %v", err) slog.Warn("Unable to refresh feed",
slog.Int64("user_id", request.UserID(r)),
slog.Int64("feed_id", feedID),
slog.Bool("force_refresh", forceRefresh),
slog.Any("error", err),
)
} }
html.Redirect(w, r, route.Path(h.router, "feedEntries", "feedID", feedID)) html.Redirect(w, r, route.Path(h.router, "feedEntries", "feedID", feedID))

View file

@ -10,7 +10,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -74,9 +73,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
err = h.store.UpdateFeed(feedForm.Merge(feed)) err = h.store.UpdateFeed(feedForm.Merge(feed))
if err != nil { if err != nil {
logger.Error("[UI:UpdateFeed] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_update_feed")
html.OK(w, r, view.Render("edit_feed"))
return return
} }

View file

@ -4,6 +4,7 @@
package ui // import "miniflux.app/v2/internal/ui" package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -12,7 +13,6 @@ import (
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/integration/pocket" "miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
) )
@ -35,7 +35,10 @@ func (h *handler) pocketAuthorize(w http.ResponseWriter, r *http.Request) {
redirectURL := config.Opts.RootURL() + route.Path(h.router, "pocketCallback") redirectURL := config.Opts.RootURL() + route.Path(h.router, "pocketCallback")
requestToken, err := connector.RequestToken(redirectURL) requestToken, err := connector.RequestToken(redirectURL)
if err != nil { if err != nil {
logger.Error("[Pocket:Authorize] %v", err) slog.Warn("Pocket authorization request failed",
slog.Any("user_id", user.ID),
slog.Any("error", err),
)
sess.NewFlashErrorMessage(printer.Printf("error.pocket_request_token")) sess.NewFlashErrorMessage(printer.Printf("error.pocket_request_token"))
html.Redirect(w, r, route.Path(h.router, "integrations")) html.Redirect(w, r, route.Path(h.router, "integrations"))
return return
@ -64,7 +67,10 @@ func (h *handler) pocketCallback(w http.ResponseWriter, r *http.Request) {
connector := pocket.NewConnector(config.Opts.PocketConsumerKey(integration.PocketConsumerKey)) connector := pocket.NewConnector(config.Opts.PocketConsumerKey(integration.PocketConsumerKey))
accessToken, err := connector.AccessToken(request.PocketRequestToken(r)) accessToken, err := connector.AccessToken(request.PocketRequestToken(r))
if err != nil { if err != nil {
logger.Error("[Pocket:Callback] %v", err) slog.Warn("Unable to get Pocket access token",
slog.Any("user_id", user.ID),
slog.Any("error", err),
)
sess.NewFlashErrorMessage(printer.Printf("error.pocket_access_token")) sess.NewFlashErrorMessage(printer.Printf("error.pocket_access_token"))
html.Redirect(w, r, route.Path(h.router, "integrations")) html.Redirect(w, r, route.Path(h.router, "integrations"))
return return

View file

@ -4,6 +4,7 @@
package ui // import "miniflux.app/v2/internal/ui" package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -11,7 +12,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view" "miniflux.app/v2/internal/ui/view"
@ -27,13 +27,25 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
view.Set("form", authForm) view.Set("form", authForm)
if err := authForm.Validate(); err != nil { if err := authForm.Validate(); err != nil {
logger.Error("[UI:CheckLogin] %v", err) slog.Warn("Validation error during login check",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", authForm.Username),
slog.Any("error", err),
)
html.OK(w, r, view.Render("login")) html.OK(w, r, view.Render("login"))
return return
} }
if err := h.store.CheckPassword(authForm.Username, authForm.Password); err != nil { if err := h.store.CheckPassword(authForm.Username, authForm.Password); err != nil {
logger.Error("[UI:CheckLogin] [ClientIP=%s] %v", clientIP, err) slog.Warn("Incorrect username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", authForm.Username),
slog.Any("error", err),
)
html.OK(w, r, view.Render("login")) html.OK(w, r, view.Render("login"))
return return
} }
@ -44,7 +56,14 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
logger.Info("[UI:CheckLogin] username=%s just logged in", authForm.Username) slog.Info("User authenticated successfully with username/password",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.String("username", authForm.Username),
)
h.store.SetLastLogin(userID) h.store.SetLastLogin(userID)
user, err := h.store.UserByID(userID) user, err := h.store.UserByID(userID)

View file

@ -11,7 +11,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
) )
@ -27,7 +26,8 @@ func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
sess.SetTheme(user.Theme) sess.SetTheme(user.Theme)
if err := h.store.RemoveUserSessionByToken(user.ID, request.UserSessionToken(r)); err != nil { if err := h.store.RemoveUserSessionByToken(user.ID, request.UserSessionToken(r)); err != nil {
logger.Error("[UI:Logout] %v", err) html.ServerError(w, r, err)
return
} }
http.SetCookie(w, cookie.Expired( http.SetCookie(w, cookie.Expired(

View file

@ -6,6 +6,7 @@ package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -13,7 +14,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -38,11 +38,17 @@ func (m *middleware) handleUserSession(next http.Handler) http.Handler {
if m.isPublicRoute(r) { if m.isPublicRoute(r) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
} else { } else {
logger.Debug("[UI:UserSession] Session not found, redirect to login page") slog.Debug("Redirecting to login page because no user session has been found",
slog.Any("url", r.RequestURI),
)
html.Redirect(w, r, route.Path(m.router, "login")) html.Redirect(w, r, route.Path(m.router, "login"))
} }
} else { } else {
logger.Debug("[UI:UserSession] %s", session) slog.Debug("User session found",
slog.Any("url", r.RequestURI),
slog.Int64("user_id", session.UserID),
slog.Int64("user_session_id", session.ID),
)
ctx := r.Context() ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, session.UserID) ctx = context.WithValue(ctx, request.UserIDContextKey, session.UserID)
@ -62,14 +68,16 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler {
if session == nil { if session == nil {
if request.IsAuthenticated(r) { if request.IsAuthenticated(r) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[UI:AppSession] Cookie expired but user #%d is logged: creating a new session", userID) slog.Debug("Cookie expired but user is logged: creating a new app session",
slog.Int64("user_id", userID),
)
session, err = m.store.CreateAppSessionWithUserPrefs(userID) session, err = m.store.CreateAppSessionWithUserPrefs(userID)
if err != nil { if err != nil {
html.ServerError(w, r, err) html.ServerError(w, r, err)
return return
} }
} else { } else {
logger.Debug("[UI:AppSession] Session not found, creating a new one") slog.Debug("App session not found, creating a new one")
session, err = m.store.CreateAppSession() session, err = m.store.CreateAppSession()
if err != nil { if err != nil {
html.ServerError(w, r, err) html.ServerError(w, r, err)
@ -78,8 +86,6 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler {
} }
http.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS, config.Opts.BasePath())) http.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS, config.Opts.BasePath()))
} else {
logger.Debug("[UI:AppSession] %s", session)
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
@ -87,7 +93,11 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler {
headerValue := r.Header.Get("X-Csrf-Token") headerValue := r.Header.Get("X-Csrf-Token")
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue { if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
logger.Error(`[UI:AppSession] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue) slog.Warn("Invalid or missing CSRF token",
slog.Any("url", r.RequestURI),
slog.String("form_csrf", formValue),
slog.String("header_csrf", headerValue),
)
if mux.CurrentRoute(r).GetName() == "checkLogin" { if mux.CurrentRoute(r).GetName() == "checkLogin" {
html.Redirect(w, r, route.Path(m.router, "login")) html.Redirect(w, r, route.Path(m.router, "login"))
@ -121,7 +131,10 @@ func (m *middleware) getAppSessionValueFromCookie(r *http.Request) *model.Sessio
session, err := m.store.AppSession(cookieValue) session, err := m.store.AppSession(cookieValue)
if err != nil { if err != nil {
logger.Error("[UI:AppSession] %v", err) slog.Debug("Unable to fetch app session from the database; another session will be created",
slog.Any("cookie_value", cookieValue),
slog.Any("error", err),
)
return nil return nil
} }
@ -159,7 +172,10 @@ func (m *middleware) getUserSessionFromCookie(r *http.Request) *model.UserSessio
session, err := m.store.UserSessionByToken(cookieValue) session, err := m.store.UserSessionByToken(cookieValue)
if err != nil { if err != nil {
logger.Error("[UI:UserSession] %v", err) slog.Error("Unable to fetch user session from the database",
slog.Any("cookie_value", cookieValue),
slog.Any("error", err),
)
return nil return nil
} }
@ -180,7 +196,11 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
} }
clientIP := request.ClientIP(r) clientIP := request.ClientIP(r)
logger.Info("[AuthProxy] [ClientIP=%s] Received authenticated requested for %q", clientIP, username) slog.Debug("[AuthProxy] Received authenticated requested",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
user, err := m.store.UserByUsername(username) user, err := m.store.UserByUsername(username)
if err != nil { if err != nil {
@ -189,9 +209,13 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
} }
if user == nil { if user == nil {
logger.Error("[AuthProxy] [ClientIP=%s] %q doesn't exist", clientIP, username)
if !config.Opts.IsAuthProxyUserCreationAllowed() { if !config.Opts.IsAuthProxyUserCreationAllowed() {
slog.Debug("[AuthProxy] User doesn't exist and user creation is not allowed",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
html.Forbidden(w, r) html.Forbidden(w, r)
return return
} }
@ -208,7 +232,13 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
return return
} }
logger.Info("[AuthProxy] [ClientIP=%s] username=%s just logged in", clientIP, user.Username) slog.Info("[AuthProxy] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(user.ID) m.store.SetLastLogin(user.ID)

View file

@ -6,6 +6,7 @@ package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -14,7 +15,6 @@ import (
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
) )
@ -26,41 +26,48 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
provider := request.RouteStringParam(r, "provider") provider := request.RouteStringParam(r, "provider")
if provider == "" { if provider == "" {
logger.Error("[OAuth2] Invalid or missing provider") slog.Warn("Invalid or missing OAuth2 provider")
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
code := request.QueryStringParam(r, "code", "") code := request.QueryStringParam(r, "code", "")
if code == "" { if code == "" {
logger.Error("[OAuth2] No code received on callback") slog.Warn("No code received on OAuth2 callback")
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
state := request.QueryStringParam(r, "state", "") state := request.QueryStringParam(r, "state", "")
if subtle.ConstantTimeCompare([]byte(state), []byte(request.OAuth2State(r))) == 0 { if subtle.ConstantTimeCompare([]byte(state), []byte(request.OAuth2State(r))) == 0 {
logger.Error(`[OAuth2] Invalid state value: got "%s" instead of "%s"`, state, request.OAuth2State(r)) slog.Warn("Invalid OAuth2 state value received",
slog.String("expected", request.OAuth2State(r)),
slog.String("received", state),
)
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider) authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
if err != nil { if err != nil {
logger.Error("[OAuth2] %v", err) slog.Error("Unable to initialize OAuth2 provider",
slog.String("provider", provider),
slog.Any("error", err),
)
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
profile, err := authProvider.GetProfile(r.Context(), code, request.OAuth2CodeVerifier(r)) profile, err := authProvider.GetProfile(r.Context(), code, request.OAuth2CodeVerifier(r))
if err != nil { if err != nil {
logger.Error("[OAuth2] %v", err) slog.Warn("Unable to get OAuth2 profile from provider",
slog.String("provider", provider),
slog.Any("error", err),
)
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
logger.Info("[OAuth2] [ClientIP=%s] Successful auth for %s", clientIP, profile)
if request.IsAuthenticated(r) { if request.IsAuthenticated(r) {
loggedUser, err := h.store.UserByID(request.UserID(r)) loggedUser, err := h.store.UserByID(request.UserID(r))
if err != nil { if err != nil {
@ -69,7 +76,11 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
} }
if h.store.AnotherUserWithFieldExists(loggedUser.ID, profile.Key, profile.ID) { if h.store.AnotherUserWithFieldExists(loggedUser.ID, profile.Key, profile.ID) {
logger.Error("[OAuth2] User #%d cannot be associated because it is already associated with another user", loggedUser.ID) slog.Error("Oauth2 user cannot be associated because it is already associated with another user",
slog.Int64("user_id", loggedUser.ID),
slog.String("oauth2_provider", provider),
slog.String("oauth2_profile_id", profile.ID),
)
sess.NewFlashErrorMessage(printer.Printf("error.duplicate_linked_account")) sess.NewFlashErrorMessage(printer.Printf("error.duplicate_linked_account"))
html.Redirect(w, r, route.Path(h.router, "settings")) html.Redirect(w, r, route.Path(h.router, "settings"))
return return
@ -119,7 +130,13 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
return return
} }
logger.Info("[OAuth2] [ClientIP=%s] username=%s (%s) just logged in", clientIP, user.Username, profile) slog.Info("User authenticated successfully using OAuth2",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
h.store.SetLastLogin(user.ID) h.store.SetLastLogin(user.ID)
sess.SetLanguage(user.Language) sess.SetLanguage(user.Language)

View file

@ -4,12 +4,12 @@
package ui // import "miniflux.app/v2/internal/ui" package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/oauth2" "miniflux.app/v2/internal/oauth2"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
) )
@ -19,14 +19,17 @@ func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
provider := request.RouteStringParam(r, "provider") provider := request.RouteStringParam(r, "provider")
if provider == "" { if provider == "" {
logger.Error("[OAuth2] Invalid or missing provider: %s", provider) slog.Warn("Invalid or missing OAuth2 provider")
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider) authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
if err != nil { if err != nil {
logger.Error("[OAuth2] %v", err) slog.Error("Unable to initialize OAuth2 provider",
slog.String("provider", provider),
slog.Any("error", err),
)
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }

View file

@ -4,13 +4,13 @@
package ui // import "miniflux.app/v2/internal/ui" package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
) )
@ -18,14 +18,17 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
printer := locale.NewPrinter(request.UserLanguage(r)) printer := locale.NewPrinter(request.UserLanguage(r))
provider := request.RouteStringParam(r, "provider") provider := request.RouteStringParam(r, "provider")
if provider == "" { if provider == "" {
logger.Info("[OAuth2] Invalid or missing provider") slog.Warn("Invalid or missing OAuth2 provider")
html.Redirect(w, r, route.Path(h.router, "login")) html.Redirect(w, r, route.Path(h.router, "login"))
return return
} }
authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider) authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
if err != nil { if err != nil {
logger.Error("[OAuth2] %v", err) slog.Error("Unable to initialize OAuth2 provider",
slog.String("provider", provider),
slog.Any("error", err),
)
html.Redirect(w, r, route.Path(h.router, "settings")) html.Redirect(w, r, route.Path(h.router, "settings"))
return return
} }

View file

@ -4,6 +4,7 @@
package ui // import "miniflux.app/v2/internal/ui" package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"log/slog"
"net/http" "net/http"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -11,14 +12,14 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/reader/opml" "miniflux.app/v2/internal/reader/opml"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view" "miniflux.app/v2/internal/ui/view"
) )
func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) { func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r)) loggedUserID := request.UserID(r)
user, err := h.store.UserByID(loggedUserID)
if err != nil { if err != nil {
html.ServerError(w, r, err) html.ServerError(w, r, err)
return return
@ -26,17 +27,20 @@ func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
file, fileHeader, err := r.FormFile("file") file, fileHeader, err := r.FormFile("file")
if err != nil { if err != nil {
logger.Error("[UI:UploadOPML] %v", err) slog.Error("OPML file upload error",
slog.Int64("user_id", loggedUserID),
slog.Any("error", err),
)
html.Redirect(w, r, route.Path(h.router, "import")) html.Redirect(w, r, route.Path(h.router, "import"))
return return
} }
defer file.Close() defer file.Close()
logger.Debug( slog.Info("OPML file uploaded",
"[UI:UploadOPML] User #%d uploaded this file: %s (%d bytes)", slog.Int64("user_id", loggedUserID),
user.ID, slog.String("file_name", fileHeader.Filename),
fileHeader.Filename, slog.Int64("file_size", fileHeader.Size),
fileHeader.Size,
) )
sess := session.New(h.store, request.SessionID(r)) sess := session.New(h.store, request.SessionID(r))
@ -62,7 +66,8 @@ func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
} }
func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) { func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r)) loggedUserID := request.UserID(r)
user, err := h.store.UserByID(loggedUserID)
if err != nil { if err != nil {
html.ServerError(w, r, err) html.ServerError(w, r, err)
return return
@ -74,10 +79,9 @@ func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
return return
} }
logger.Debug( slog.Info("Fetching OPML file remotely",
"[UI:FetchOPML] User #%d fetching this URL: %s", slog.Int64("user_id", loggedUserID),
user.ID, slog.String("opml_file_url", url),
url,
) )
sess := session.New(h.store, request.SessionID(r)) sess := session.New(h.store, request.SessionID(r))

View file

@ -8,6 +8,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"time" "time"
@ -16,7 +17,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response" "miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/logger"
) )
func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) { func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
@ -29,19 +29,19 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
encodedDigest := request.RouteStringParam(r, "encodedDigest") encodedDigest := request.RouteStringParam(r, "encodedDigest")
encodedURL := request.RouteStringParam(r, "encodedURL") encodedURL := request.RouteStringParam(r, "encodedURL")
if encodedURL == "" { if encodedURL == "" {
html.BadRequest(w, r, errors.New("No URL provided")) html.BadRequest(w, r, errors.New("no URL provided"))
return return
} }
decodedDigest, err := base64.URLEncoding.DecodeString(encodedDigest) decodedDigest, err := base64.URLEncoding.DecodeString(encodedDigest)
if err != nil { if err != nil {
html.BadRequest(w, r, errors.New("Unable to decode this Digest")) html.BadRequest(w, r, errors.New("unable to decode this digest"))
return return
} }
decodedURL, err := base64.URLEncoding.DecodeString(encodedURL) decodedURL, err := base64.URLEncoding.DecodeString(encodedURL)
if err != nil { if err != nil {
html.BadRequest(w, r, errors.New("Unable to decode this URL")) html.BadRequest(w, r, errors.New("unable to decode this URL"))
return return
} }
@ -55,7 +55,9 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
} }
mediaURL := string(decodedURL) mediaURL := string(decodedURL)
logger.Debug(`[Proxy] Fetching %q`, mediaURL) slog.Debug("MediaProxy: Fetching remote resource",
slog.String("media_url", mediaURL),
)
req, err := http.NewRequest("GET", mediaURL, nil) req, err := http.NewRequest("GET", mediaURL, nil)
if err != nil { if err != nil {
@ -82,19 +84,28 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
resp, err := clt.Do(req) resp, err := clt.Do(req)
if err != nil { if err != nil {
logger.Error(`[Proxy] Unable to initialize HTTP client: %v`, err) slog.Error("MediaProxy: Unable to initialize HTTP client",
slog.String("media_url", mediaURL),
slog.Any("error", err),
)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
logger.Error(`[Proxy] Status Code is %d for URL %q`, resp.StatusCode, mediaURL) slog.Warn("MediaProxy: "+http.StatusText(http.StatusRequestedRangeNotSatisfiable),
slog.String("media_url", mediaURL),
slog.Int("status_code", resp.StatusCode),
)
html.RequestedRangeNotSatisfiable(w, r, resp.Header.Get("Content-Range")) html.RequestedRangeNotSatisfiable(w, r, resp.Header.Get("Content-Range"))
return return
} }
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
logger.Error(`[Proxy] Status Code is %d for URL %q`, resp.StatusCode, mediaURL) slog.Warn("MediaProxy: Unexpected response status code",
slog.String("media_url", mediaURL),
slog.Int("status_code", resp.StatusCode),
)
html.NotFound(w, r) html.NotFound(w, r)
return return
} }

View file

@ -9,14 +9,14 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
) )
func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) { func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {
sessionID := request.RouteInt64Param(r, "sessionID") sessionID := request.RouteInt64Param(r, "sessionID")
err := h.store.RemoveUserSessionByID(request.UserID(r), sessionID) err := h.store.RemoveUserSessionByID(request.UserID(r), sessionID)
if err != nil { if err != nil {
logger.Error("[UI:RemoveSession] %v", err) html.ServerError(w, r, err)
return
} }
html.Redirect(w, r, route.Path(h.router, "sessions")) html.Redirect(w, r, route.Path(h.router, "sessions"))

View file

@ -10,7 +10,6 @@ import (
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -74,9 +73,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
err = h.store.UpdateUser(settingsForm.Merge(loggedUser)) err = h.store.UpdateUser(settingsForm.Merge(loggedUser))
if err != nil { if err != nil {
logger.Error("[UI:UpdateSettings] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_update_user")
html.OK(w, r, view.Render("settings"))
return return
} }

View file

@ -10,7 +10,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler" feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/reader/subscription" "miniflux.app/v2/internal/reader/subscription"
@ -61,15 +60,12 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
subscriptionForm.AllowSelfSignedCertificates, subscriptionForm.AllowSelfSignedCertificates,
) )
if findErr != nil { if findErr != nil {
logger.Error("[UI:SubmitSubscription] %q -> %s", subscriptionForm.URL, findErr)
v.Set("form", subscriptionForm) v.Set("form", subscriptionForm)
v.Set("errorMessage", findErr) v.Set("errorMessage", findErr)
html.OK(w, r, v.Render("add_subscription")) html.OK(w, r, v.Render("add_subscription"))
return return
} }
logger.Debug("[UI:SubmitSubscription] %s", subscriptions)
n := len(subscriptions) n := len(subscriptions)
switch { switch {
case n == 0: case n == 0:

View file

@ -6,7 +6,6 @@ package ui // import "miniflux.app/v2/internal/ui"
import ( import (
"net/http" "net/http"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/template" "miniflux.app/v2/internal/template"
"miniflux.app/v2/internal/worker" "miniflux.app/v2/internal/worker"
@ -20,7 +19,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
templateEngine := template.NewEngine(router) templateEngine := template.NewEngine(router)
if err := templateEngine.ParseTemplates(); err != nil { if err := templateEngine.ParseTemplates(); err != nil {
logger.Fatal(`Unable to parse templates: %v`, err) panic(err)
} }
handler := &handler{router, store, templateEngine, pool} handler := &handler{router, store, templateEngine, pool}

View file

@ -37,7 +37,7 @@ func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
} }
if selectedUser.ID == loggedUser.ID { if selectedUser.ID == loggedUser.ID {
html.BadRequest(w, r, errors.New("You cannot remove yourself")) html.BadRequest(w, r, errors.New("you cannot remove yourself"))
return return
} }

View file

@ -9,7 +9,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
@ -64,9 +63,7 @@ func (h *handler) saveUser(w http.ResponseWriter, r *http.Request) {
} }
if _, err := h.store.CreateUser(userCreationRequest); err != nil { if _, err := h.store.CreateUser(userCreationRequest); err != nil {
logger.Error("[UI:SaveUser] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_create_user")
html.OK(w, r, view.Render("create_user"))
return return
} }

View file

@ -9,7 +9,6 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html" "miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/ui/form" "miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session" "miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view" "miniflux.app/v2/internal/ui/view"
@ -64,9 +63,7 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
userForm.Merge(selectedUser) userForm.Merge(selectedUser)
if err := h.store.UpdateUser(selectedUser); err != nil { if err := h.store.UpdateUser(selectedUser); err != nil {
logger.Error("[UI:UpdateUser] %v", err) html.ServerError(w, r, err)
view.Set("errorMessage", "error.unable_to_update_user")
html.OK(w, r, view.Render("edit_user"))
return return
} }

View file

@ -4,10 +4,10 @@
package worker // import "miniflux.app/v2/internal/worker" package worker // import "miniflux.app/v2/internal/worker"
import ( import (
"log/slog"
"time" "time"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/logger"
"miniflux.app/v2/internal/metric" "miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler" feedHandler "miniflux.app/v2/internal/reader/handler"
@ -22,11 +22,17 @@ type Worker struct {
// Run wait for a job and refresh the given feed. // Run wait for a job and refresh the given feed.
func (w *Worker) Run(c chan model.Job) { func (w *Worker) Run(c chan model.Job) {
logger.Debug("[Worker] #%d started", w.id) slog.Debug("Worker started",
slog.Int("worker_id", w.id),
)
for { for {
job := <-c job := <-c
logger.Debug("[Worker #%d] Received feed #%d for user #%d", w.id, job.FeedID, job.UserID) slog.Debug("Job received by worker",
slog.Int("worker_id", w.id),
slog.Int64("user_id", job.UserID),
slog.Int64("feed_id", job.FeedID),
)
startTime := time.Now() startTime := time.Now()
refreshErr := feedHandler.RefreshFeed(w.store, job.UserID, job.FeedID, false) refreshErr := feedHandler.RefreshFeed(w.store, job.UserID, job.FeedID, false)
@ -40,7 +46,11 @@ func (w *Worker) Run(c chan model.Job) {
} }
if refreshErr != nil { if refreshErr != nil {
logger.Error("[Worker] Refreshing the feed #%d returned this error: %v", job.FeedID, refreshErr) slog.Warn("Unable to refresh a feed",
slog.Int64("user_id", job.UserID),
slog.Int64("feed_id", job.FeedID),
slog.Any("error", refreshErr),
)
} }
} }
} }

View file

@ -1,5 +1,5 @@
.\" Manpage for miniflux. .\" Manpage for miniflux.
.TH "MINIFLUX" "1" "July 21, 2023" "\ \&" "\ \&" .TH "MINIFLUX" "1" "September 24, 2023" "\ \&" "\ \&"
.SH NAME .SH NAME
miniflux \- Minimalist and opinionated feed reader miniflux \- Minimalist and opinionated feed reader
@ -36,7 +36,7 @@ Create admin user\&.
.PP .PP
.B \-debug .B \-debug
.RS 4 .RS 4
Show debug logs\&. Set log level to debug\&.
.RE .RE
.PP .PP
.B \-flush-sessions .B \-flush-sessions
@ -113,11 +113,6 @@ Environment variables override the values defined in the config file.
.SH ENVIRONMENT .SH ENVIRONMENT
.TP .TP
.B DEBUG
Set the value to 1 to enable debug logs\&.
.br
Disabled by default\&.
.TP
.B FETCH_ODYSEE_WATCH_TIME .B FETCH_ODYSEE_WATCH_TIME
Set the value to 1 to scrape video duration from Odysee website and Set the value to 1 to scrape video duration from Odysee website and
use it as a reading time\&. use it as a reading time\&.
@ -145,6 +140,21 @@ Display the date and time in log messages\&.
.br .br
Disabled by default\&. Disabled by default\&.
.TP .TP
.B LOG_FILE
Supported values are "stderr", "stdout", or a file name\&.
.br
Default is "stderr"\&.
.TP
.B LOG_FORMAT
Supported log formats are "text" or "json"\&.
.br
Default is "text"\&.
.TP
.B LOG_LEVEL
Supported values are "debug", "info", "warning", or "error"\&.
.br
Default is "info"\&.
.TP
.B WORKER_POOL_SIZE .B WORKER_POOL_SIZE
Number of background workers\&. Number of background workers\&.
.br .br