diff --git a/go.mod b/go.mod
index d41b8e3f6..5cb9f70df 100644
--- a/go.mod
+++ b/go.mod
@@ -33,6 +33,7 @@ require (
github.com/d4l3k/messagediff v1.2.1
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
+ github.com/fatih/color v1.18.0
github.com/fclairamb/afero-s3 v0.4.0
github.com/gabriel-vasile/mimetype v1.4.12
github.com/ganigeorgiev/fexpr v0.5.0
@@ -121,7 +122,6 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
- github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
diff --git a/go.sum b/go.sum
index 84bb12f36..0cbcbd482 100644
--- a/go.sum
+++ b/go.sum
@@ -423,8 +423,6 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
-github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
-github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
diff --git a/pkg/cmd/doctor.go b/pkg/cmd/doctor.go
new file mode 100644
index 000000000..ac2b72230
--- /dev/null
+++ b/pkg/cmd/doctor.go
@@ -0,0 +1,68 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/doctor"
+ "code.vikunja.io/api/pkg/log"
+
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ rootCmd.AddCommand(doctorCmd)
+}
+
+var doctorCmd = &cobra.Command{
+ Use: "doctor",
+ Short: "Run diagnostic checks on your Vikunja installation",
+ Long: `The doctor command runs a series of diagnostic checks to help troubleshoot
+issues with your Vikunja installation. It checks:
+
+- System information (version, user, working directory)
+- Configuration (config file, public URL, JWT secret, CORS)
+- Database connectivity and version
+- File storage (local or S3)
+- Optional services (Redis, Typesense, Mailer, LDAP, OpenID)
+
+Exit codes:
+ 0 - All checks passed
+ 1 - One or more checks failed`,
+ PreRun: func(_ *cobra.Command, _ []string) {
+ // Minimal init - just config and logger
+ // Each check will initialize and test its own components
+ log.InitLogger()
+ config.InitConfig()
+ },
+ Run: func(_ *cobra.Command, _ []string) {
+ results := doctor.Run()
+
+ doctor.PrintResults(os.Stdout, results)
+
+ failed := doctor.CountFailed(results)
+ if failed > 0 {
+ fmt.Printf("%d check(s) failed\n", failed)
+ os.Exit(1)
+ }
+
+ fmt.Println("All checks passed")
+ },
+}
diff --git a/pkg/doctor/config.go b/pkg/doctor/config.go
new file mode 100644
index 000000000..b9114d757
--- /dev/null
+++ b/pkg/doctor/config.go
@@ -0,0 +1,120 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "code.vikunja.io/api/pkg/config"
+
+ "github.com/spf13/viper"
+)
+
+// CheckConfig returns configuration checks.
+func CheckConfig() CheckGroup {
+ results := []CheckResult{
+ checkConfigFile(),
+ checkPublicURL(),
+ checkJWTSecret(),
+ }
+
+ // Only show CORS details if CORS is enabled
+ if config.CorsEnable.GetBool() {
+ results = append(results, checkCORS())
+ }
+
+ return CheckGroup{
+ Name: "Configuration",
+ Results: results,
+ }
+}
+
+func checkConfigFile() CheckResult {
+ configFile := viper.ConfigFileUsed()
+ if configFile == "" {
+ return CheckResult{
+ Name: "Config file",
+ Passed: true,
+ Value: "none (using defaults/environment)",
+ }
+ }
+
+ return CheckResult{
+ Name: "Config file",
+ Passed: true,
+ Value: configFile,
+ }
+}
+
+func checkPublicURL() CheckResult {
+ publicURL := config.ServicePublicURL.GetString()
+ if publicURL == "" {
+ return CheckResult{
+ Name: "Public URL",
+ Passed: false,
+ Error: "not configured (required for many features)",
+ }
+ }
+
+ return CheckResult{
+ Name: "Public URL",
+ Passed: true,
+ Value: publicURL,
+ }
+}
+
+func checkJWTSecret() CheckResult {
+ // We can't check the actual value, but we can check if it's the default length
+ // which would indicate it was auto-generated
+ secret := config.ServiceJWTSecret.GetString()
+
+ // Auto-generated secrets are 64 hex characters (32 bytes)
+ if len(secret) == 64 {
+ return CheckResult{
+ Name: "JWT secret",
+ Passed: true,
+ Value: "configured (auto-generated)",
+ }
+ }
+
+ return CheckResult{
+ Name: "JWT secret",
+ Passed: true,
+ Value: "configured",
+ }
+}
+
+func checkCORS() CheckResult {
+ origins := config.CorsOrigins.GetStringSlice()
+
+ result := CheckResult{
+ Name: "CORS origins",
+ Passed: true,
+ Value: "",
+ }
+
+ if len(origins) == 0 {
+ result.Value = "none configured"
+ return result
+ }
+
+ // Show first origin in the value, rest as additional lines
+ result.Value = origins[0]
+ if len(origins) > 1 {
+ result.Lines = append(result.Lines, origins[1:]...)
+ }
+
+ return result
+}
diff --git a/pkg/doctor/database.go b/pkg/doctor/database.go
new file mode 100644
index 000000000..071b36230
--- /dev/null
+++ b/pkg/doctor/database.go
@@ -0,0 +1,124 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "fmt"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+)
+
+// CheckDatabase returns database connectivity checks.
+func CheckDatabase() CheckGroup {
+ dbType := config.DatabaseType.GetString()
+
+ // Initialize database engine
+ _, err := db.CreateDBEngine()
+ if err != nil {
+ return CheckGroup{
+ Name: fmt.Sprintf("Database (%s)", dbType),
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+
+ results := []CheckResult{
+ checkDatabaseConnection(),
+ checkDatabaseVersion(dbType),
+ }
+
+ return CheckGroup{
+ Name: fmt.Sprintf("Database (%s)", dbType),
+ Results: results,
+ }
+}
+
+func checkDatabaseConnection() CheckResult {
+ s := db.NewSession()
+ defer s.Close()
+
+ if err := s.Ping(); err != nil {
+ return CheckResult{
+ Name: "Connection",
+ Passed: false,
+ Error: err.Error(),
+ }
+ }
+
+ return CheckResult{
+ Name: "Connection",
+ Passed: true,
+ Value: "OK",
+ }
+}
+
+func checkDatabaseVersion(dbType string) CheckResult {
+ s := db.NewSession()
+ defer s.Close()
+
+ var versionQuery string
+ switch dbType {
+ case "sqlite":
+ versionQuery = "SELECT sqlite_version()"
+ case "mysql":
+ versionQuery = "SELECT version()"
+ case "postgres":
+ versionQuery = "SELECT version()"
+ default:
+ return CheckResult{
+ Name: "Server version",
+ Passed: false,
+ Error: fmt.Sprintf("unknown database type: %s", dbType),
+ }
+ }
+
+ results, err := s.QueryString(versionQuery)
+ if err != nil {
+ return CheckResult{
+ Name: "Server version",
+ Passed: false,
+ Error: err.Error(),
+ }
+ }
+
+ if len(results) == 0 || len(results[0]) == 0 {
+ return CheckResult{
+ Name: "Server version",
+ Passed: false,
+ Error: "could not retrieve version",
+ }
+ }
+
+ // Get the first value from the result map
+ var version string
+ for _, v := range results[0] {
+ version = v
+ break
+ }
+
+ return CheckResult{
+ Name: "Server version",
+ Passed: true,
+ Value: version,
+ }
+}
diff --git a/pkg/doctor/doctor.go b/pkg/doctor/doctor.go
new file mode 100644
index 000000000..3ad076a1c
--- /dev/null
+++ b/pkg/doctor/doctor.go
@@ -0,0 +1,33 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package doctor provides diagnostic checks for Vikunja installations.
+package doctor
+
+// Run executes all diagnostic checks and returns the results.
+func Run() []CheckGroup {
+ groups := []CheckGroup{
+ CheckSystem(),
+ CheckConfig(),
+ CheckDatabase(),
+ CheckFiles(),
+ }
+
+ // Add optional service checks
+ groups = append(groups, CheckOptionalServices()...)
+
+ return groups
+}
diff --git a/pkg/doctor/files.go b/pkg/doctor/files.go
new file mode 100644
index 000000000..7e1f43533
--- /dev/null
+++ b/pkg/doctor/files.go
@@ -0,0 +1,132 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "fmt"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/files"
+)
+
+// CheckFiles returns file storage checks.
+func CheckFiles() CheckGroup {
+ fileType := config.FilesType.GetString()
+
+ // Initialize file handler
+ if err := files.InitFileHandler(); err != nil {
+ return CheckGroup{
+ Name: fmt.Sprintf("Files (%s)", fileType),
+ Results: []CheckResult{
+ {
+ Name: "Initialization",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+
+ var results []CheckResult
+
+ switch fileType {
+ case "local":
+ results = checkLocalStorage()
+ case "s3":
+ results = checkS3Storage()
+ default:
+ results = []CheckResult{
+ {
+ Name: "Type",
+ Passed: false,
+ Error: fmt.Sprintf("unknown storage type: %s", fileType),
+ },
+ }
+ }
+
+ return CheckGroup{
+ Name: fmt.Sprintf("Files (%s)", fileType),
+ Results: results,
+ }
+}
+
+func checkLocalStorage() []CheckResult {
+ basePath := config.FilesBasePath.GetString()
+
+ results := []CheckResult{
+ {
+ Name: "Path",
+ Passed: true,
+ Value: basePath,
+ },
+ }
+
+ // Check writable using the existing ValidateFileStorage function
+ if err := files.ValidateFileStorage(); err != nil {
+ results = append(results, CheckResult{
+ Name: "Writable",
+ Passed: false,
+ Error: err.Error(),
+ })
+ } else {
+ results = append(results, CheckResult{
+ Name: "Writable",
+ Passed: true,
+ Value: "yes",
+ })
+ }
+
+ // Check disk space (platform-specific)
+ results = append(results, checkDiskSpace(basePath))
+
+ return results
+}
+
+func checkS3Storage() []CheckResult {
+ endpoint := config.FilesS3Endpoint.GetString()
+ bucket := config.FilesS3Bucket.GetString()
+
+ results := []CheckResult{
+ {
+ Name: "Endpoint",
+ Passed: true,
+ Value: endpoint,
+ },
+ {
+ Name: "Bucket",
+ Passed: true,
+ Value: bucket,
+ },
+ }
+
+ // Check writable using the existing ValidateFileStorage function
+ if err := files.ValidateFileStorage(); err != nil {
+ results = append(results, CheckResult{
+ Name: "Writable",
+ Passed: false,
+ Error: err.Error(),
+ })
+ } else {
+ results = append(results, CheckResult{
+ Name: "Writable",
+ Passed: true,
+ Value: "yes",
+ })
+ }
+
+ return results
+}
diff --git a/pkg/doctor/files_unix.go b/pkg/doctor/files_unix.go
new file mode 100644
index 000000000..d7a4b0062
--- /dev/null
+++ b/pkg/doctor/files_unix.go
@@ -0,0 +1,46 @@
+//go:build !windows
+
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "fmt"
+
+ "golang.org/x/sys/unix"
+)
+
+func checkDiskSpace(path string) CheckResult {
+ var stat unix.Statfs_t
+ if err := unix.Statfs(path, &stat); err != nil {
+ return CheckResult{
+ Name: "Disk space",
+ Passed: false,
+ Error: err.Error(),
+ }
+ }
+
+ // Available space in bytes
+ availableBytes := stat.Bavail * uint64(stat.Bsize)
+ availableGB := float64(availableBytes) / (1024 * 1024 * 1024)
+
+ return CheckResult{
+ Name: "Disk space",
+ Passed: true,
+ Value: fmt.Sprintf("%.1f GB available", availableGB),
+ }
+}
diff --git a/pkg/doctor/files_windows.go b/pkg/doctor/files_windows.go
new file mode 100644
index 000000000..ce954944a
--- /dev/null
+++ b/pkg/doctor/files_windows.go
@@ -0,0 +1,27 @@
+//go:build windows
+
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+func checkDiskSpace(_ string) CheckResult {
+ return CheckResult{
+ Name: "Disk space",
+ Passed: true,
+ Value: "check not available on Windows",
+ }
+}
diff --git a/pkg/doctor/output.go b/pkg/doctor/output.go
new file mode 100644
index 000000000..3a4759915
--- /dev/null
+++ b/pkg/doctor/output.go
@@ -0,0 +1,75 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/fatih/color"
+)
+
+var (
+ greenCheck = color.New(color.FgGreen).SprintFunc()
+ redCross = color.New(color.FgRed).SprintFunc()
+ bold = color.New(color.Bold).SprintFunc()
+)
+
+// PrintResults writes all check groups to the given writer with colored output.
+func PrintResults(w io.Writer, groups []CheckGroup) {
+ fmt.Fprintln(w, bold("Vikunja Doctor"))
+ fmt.Fprintln(w, "==============")
+ fmt.Fprintln(w)
+
+ for _, group := range groups {
+ fmt.Fprintln(w, bold(group.Name))
+ for _, result := range group.Results {
+ printResult(w, result)
+ }
+ fmt.Fprintln(w)
+ }
+}
+
+func printResult(w io.Writer, result CheckResult) {
+ marker := greenCheck("✓")
+ value := result.Value
+ if !result.Passed {
+ marker = redCross("✗")
+ if result.Error != "" {
+ value = result.Error
+ }
+ }
+
+ fmt.Fprintf(w, " %s %s: %s\n", marker, result.Name, value)
+
+ for _, line := range result.Lines {
+ fmt.Fprintf(w, " %s\n", line)
+ }
+}
+
+// CountFailed returns the number of failed checks across all groups.
+func CountFailed(groups []CheckGroup) int {
+ count := 0
+ for _, group := range groups {
+ for _, result := range group.Results {
+ if !result.Passed {
+ count++
+ }
+ }
+ }
+ return count
+}
diff --git a/pkg/doctor/services.go b/pkg/doctor/services.go
new file mode 100644
index 000000000..082f61baa
--- /dev/null
+++ b/pkg/doctor/services.go
@@ -0,0 +1,406 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/auth/ldap"
+ "code.vikunja.io/api/pkg/red"
+)
+
+// CheckOptionalServices returns check groups for all enabled optional services.
+func CheckOptionalServices() []CheckGroup {
+ var groups []CheckGroup
+
+ if config.RedisEnabled.GetBool() {
+ groups = append(groups, checkRedis())
+ }
+
+ if config.TypesenseEnabled.GetBool() {
+ groups = append(groups, checkTypesense())
+ }
+
+ if config.MailerEnabled.GetBool() {
+ groups = append(groups, checkMailer())
+ }
+
+ if config.AuthLdapEnabled.GetBool() {
+ groups = append(groups, checkLDAP())
+ }
+
+ if config.AuthOpenIDEnabled.GetBool() {
+ groups = append(groups, checkOpenID())
+ }
+
+ return groups
+}
+
+func checkRedis() CheckGroup {
+ // Initialize Redis
+ red.InitRedis()
+
+ r := red.GetRedis()
+ if r == nil {
+ return CheckGroup{
+ Name: "Redis",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: "Redis client not initialized",
+ },
+ },
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := r.Ping(ctx).Err(); err != nil {
+ return CheckGroup{
+ Name: "Redis",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+
+ return CheckGroup{
+ Name: "Redis",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: true,
+ Value: fmt.Sprintf("OK (%s)", config.RedisHost.GetString()),
+ },
+ },
+ }
+}
+
+func checkTypesense() CheckGroup {
+ url := config.TypesenseURL.GetString()
+ healthURL := url + "/health"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
+ if err != nil {
+ return CheckGroup{
+ Name: "Typesense",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+
+ req.Header.Set("X-TYPESENSE-API-KEY", config.TypesenseAPIKey.GetString())
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return CheckGroup{
+ Name: "Typesense",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return CheckGroup{
+ Name: "Typesense",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: fmt.Sprintf("health check returned status %d", resp.StatusCode),
+ },
+ },
+ }
+ }
+
+ return CheckGroup{
+ Name: "Typesense",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: true,
+ Value: fmt.Sprintf("OK (%s)", url),
+ },
+ },
+ }
+}
+
+func checkMailer() CheckGroup {
+ host := config.MailerHost.GetString()
+ port := config.MailerPort.GetInt()
+
+ if host == "" {
+ return CheckGroup{
+ Name: "Mailer",
+ Results: []CheckResult{
+ {
+ Name: "SMTP connection",
+ Passed: false,
+ Error: "mailer host not configured",
+ },
+ },
+ }
+ }
+
+ address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+
+ // Simple TCP dial test with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ dialer := &net.Dialer{}
+ conn, err := dialer.DialContext(ctx, "tcp", address)
+ if err != nil {
+ return CheckGroup{
+ Name: "Mailer",
+ Results: []CheckResult{
+ {
+ Name: "SMTP connection",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+ defer conn.Close()
+
+ return CheckGroup{
+ Name: "Mailer",
+ Results: []CheckResult{
+ {
+ Name: "SMTP connection",
+ Passed: true,
+ Value: fmt.Sprintf("OK (%s)", address),
+ },
+ },
+ }
+}
+
+func checkLDAP() CheckGroup {
+ host := config.AuthLdapHost.GetString()
+ port := config.AuthLdapPort.GetInt()
+ useTLS := config.AuthLdapUseTLS.GetBool()
+
+ address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+
+ protocol := "ldap"
+ if useTLS {
+ protocol = "ldaps"
+ }
+
+ // Use the actual LDAP connection function which tests bind credentials
+ l, err := ldap.ConnectAndBindToLDAPDirectory()
+ if err != nil {
+ return CheckGroup{
+ Name: "LDAP",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: false,
+ Error: err.Error(),
+ },
+ },
+ }
+ }
+ defer l.Close()
+
+ return CheckGroup{
+ Name: "LDAP",
+ Results: []CheckResult{
+ {
+ Name: "Connection",
+ Passed: true,
+ Value: fmt.Sprintf("OK (%s://%s)", protocol, address),
+ },
+ },
+ }
+}
+
+func checkOpenID() CheckGroup {
+ // Parse raw config to get all providers (including ones that fail to connect)
+ rawProviders := config.AuthOpenIDProviders.Get()
+ if rawProviders == nil {
+ return CheckGroup{
+ Name: "OpenID Connect",
+ Results: []CheckResult{
+ {
+ Name: "Providers",
+ Passed: true,
+ Value: "none configured",
+ },
+ },
+ }
+ }
+
+ // Convert to map[string]interface{}
+ var providerMap map[string]interface{}
+ switch p := rawProviders.(type) {
+ case map[string]interface{}:
+ providerMap = p
+ case map[interface{}]interface{}:
+ providerMap = make(map[string]interface{}, len(p))
+ for k, v := range p {
+ if key, ok := k.(string); ok {
+ providerMap[key] = v
+ }
+ }
+ default:
+ return CheckGroup{
+ Name: "OpenID Connect",
+ Results: []CheckResult{
+ {
+ Name: "Configuration",
+ Passed: false,
+ Error: "invalid provider configuration format",
+ },
+ },
+ }
+ }
+
+ if len(providerMap) == 0 {
+ return CheckGroup{
+ Name: "OpenID Connect",
+ Results: []CheckResult{
+ {
+ Name: "Providers",
+ Passed: true,
+ Value: "none configured",
+ },
+ },
+ }
+ }
+
+ var results []CheckResult
+ for key, p := range providerMap {
+ result := checkOpenIDProvider(key, p)
+ results = append(results, result)
+ }
+
+ return CheckGroup{
+ Name: "OpenID Connect",
+ Results: results,
+ }
+}
+
+func checkOpenIDProvider(key string, rawProvider interface{}) CheckResult {
+ // Extract provider config
+ var pi map[string]interface{}
+ switch p := rawProvider.(type) {
+ case map[string]interface{}:
+ pi = p
+ case map[interface{}]interface{}:
+ pi = make(map[string]interface{}, len(p))
+ for k, v := range p {
+ if kStr, ok := k.(string); ok {
+ pi[kStr] = v
+ }
+ }
+ default:
+ return CheckResult{
+ Name: fmt.Sprintf("Provider: %s", key),
+ Passed: false,
+ Error: "invalid configuration format",
+ }
+ }
+
+ // Get provider name
+ name := key
+ if n, ok := pi["name"].(string); ok {
+ name = n
+ }
+
+ // Get auth URL
+ authURL, ok := pi["authurl"].(string)
+ if !ok || authURL == "" {
+ return CheckResult{
+ Name: fmt.Sprintf("Provider: %s", name),
+ Passed: false,
+ Error: "authurl not configured",
+ }
+ }
+
+ // Check if the provider's discovery endpoint is reachable
+ // OpenID Connect discovery is at /.well-known/openid-configuration
+ discoveryURL := authURL
+ if authURL[len(authURL)-1] != '/' {
+ discoveryURL += "/"
+ }
+ discoveryURL += ".well-known/openid-configuration"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
+ if err != nil {
+ return CheckResult{
+ Name: fmt.Sprintf("Provider: %s", name),
+ Passed: false,
+ Error: err.Error(),
+ }
+ }
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return CheckResult{
+ Name: fmt.Sprintf("Provider: %s", name),
+ Passed: false,
+ Error: err.Error(),
+ }
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return CheckResult{
+ Name: fmt.Sprintf("Provider: %s", name),
+ Passed: false,
+ Error: fmt.Sprintf("discovery endpoint returned status %d", resp.StatusCode),
+ }
+ }
+
+ return CheckResult{
+ Name: fmt.Sprintf("Provider: %s", name),
+ Passed: true,
+ Value: "OK",
+ }
+}
diff --git a/pkg/doctor/system.go b/pkg/doctor/system.go
new file mode 100644
index 000000000..50f720ccf
--- /dev/null
+++ b/pkg/doctor/system.go
@@ -0,0 +1,82 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+
+ "code.vikunja.io/api/pkg/version"
+)
+
+// CheckSystem returns system information checks.
+func CheckSystem() CheckGroup {
+ results := []CheckResult{
+ checkVersion(),
+ checkGoVersion(),
+ checkOS(),
+ checkUser(),
+ checkWorkingDirectory(),
+ }
+
+ return CheckGroup{
+ Name: "System",
+ Results: results,
+ }
+}
+
+func checkVersion() CheckResult {
+ return CheckResult{
+ Name: "Version",
+ Passed: true,
+ Value: version.Version,
+ }
+}
+
+func checkGoVersion() CheckResult {
+ return CheckResult{
+ Name: "Go",
+ Passed: true,
+ Value: runtime.Version(),
+ }
+}
+
+func checkOS() CheckResult {
+ return CheckResult{
+ Name: "OS",
+ Passed: true,
+ Value: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
+ }
+}
+
+func checkWorkingDirectory() CheckResult {
+ wd, err := os.Getwd()
+ if err != nil {
+ return CheckResult{
+ Name: "Working directory",
+ Passed: false,
+ Error: err.Error(),
+ }
+ }
+
+ return CheckResult{
+ Name: "Working directory",
+ Passed: true,
+ Value: wd,
+ }
+}
diff --git a/pkg/doctor/system_unix.go b/pkg/doctor/system_unix.go
new file mode 100644
index 000000000..ed2e1066c
--- /dev/null
+++ b/pkg/doctor/system_unix.go
@@ -0,0 +1,40 @@
+//go:build !windows
+
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ "fmt"
+ "os"
+ osuser "os/user"
+)
+
+func checkUser() CheckResult {
+ uid := os.Getuid()
+ username := "unknown"
+
+ if u, err := osuser.Current(); err == nil {
+ username = u.Username
+ }
+
+ return CheckResult{
+ Name: "User",
+ Passed: true,
+ Value: fmt.Sprintf("%s (uid=%d)", username, uid),
+ }
+}
diff --git a/pkg/doctor/system_windows.go b/pkg/doctor/system_windows.go
new file mode 100644
index 000000000..caa52bc30
--- /dev/null
+++ b/pkg/doctor/system_windows.go
@@ -0,0 +1,37 @@
+//go:build windows
+
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+import (
+ osuser "os/user"
+)
+
+func checkUser() CheckResult {
+ username := "unknown"
+
+ if u, err := osuser.Current(); err == nil {
+ username = u.Username
+ }
+
+ return CheckResult{
+ Name: "User",
+ Passed: true,
+ Value: username,
+ }
+}
diff --git a/pkg/doctor/types.go b/pkg/doctor/types.go
new file mode 100644
index 000000000..117f1377a
--- /dev/null
+++ b/pkg/doctor/types.go
@@ -0,0 +1,32 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package doctor
+
+// CheckResult represents the result of a single diagnostic check.
+type CheckResult struct {
+ Name string
+ Passed bool
+ Value string // e.g., "vikunja (uid=1000)" or "OK"
+ Error string // only populated if Passed is false
+ Lines []string // additional lines to display (e.g., list of CORS origins)
+}
+
+// CheckGroup represents a category of checks with a header.
+type CheckGroup struct {
+ Name string // e.g., "Database (sqlite)"
+ Results []CheckResult
+}