From 56b1ba47ec7e260a3cc3fb468e501dab5bfea3b6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 20:41:18 +0200 Subject: [PATCH] feat(api/v2): add public instance info endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/v2/info (public — no auth). Extract the /info response type and its assembly out of the v1 handler into pkg/routes/api/shared.BuildInfo() so both API versions return byte-identical info; refactor v1's handler onto it. Add the v2 path to unauthenticatedAPIPaths. --- pkg/routes/api/shared/info.go | 164 +++++++++++++++++++++ pkg/routes/api/v1/info.go | 139 +---------------- pkg/routes/api/v2/info.go | 51 +++++++ pkg/routes/routes.go | 1 + pkg/webtests/huma_backgrounds_misc_test.go | 17 +++ 5 files changed, 236 insertions(+), 136 deletions(-) create mode 100644 pkg/routes/api/shared/info.go create mode 100644 pkg/routes/api/v2/info.go diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go new file mode 100644 index 000000000..423aae2c7 --- /dev/null +++ b/pkg/routes/api/shared/info.go @@ -0,0 +1,164 @@ +// 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 shared + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/auth/openid" + csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" + microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" + "code.vikunja.io/api/pkg/modules/migration/ticktick" + "code.vikunja.io/api/pkg/modules/migration/todoist" + "code.vikunja.io/api/pkg/modules/migration/trello" + vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" + "code.vikunja.io/api/pkg/modules/migration/wekan" + "code.vikunja.io/api/pkg/version" +) + +// VikunjaInfos holds public information about this Vikunja instance. +type VikunjaInfos struct { + Version string `json:"version" doc:"The Vikunja version this instance runs."` + FrontendURL string `json:"frontend_url" doc:"The publicly configured frontend URL of this instance."` + Motd string `json:"motd" doc:"The message of the day, shown to all users."` + LinkSharingEnabled bool `json:"link_sharing_enabled" doc:"Whether sharing projects via public links is enabled."` + MaxFileSize string `json:"max_file_size" doc:"The maximum allowed upload size, as a human-readable string (e.g. 20MB)."` + MaxItemsPerPage int `json:"max_items_per_page" doc:"The maximum number of items a paginated endpoint returns per page."` + AvailableMigrators []string `json:"available_migrators" doc:"The migrators enabled on this instance."` + TaskAttachmentsEnabled bool `json:"task_attachments_enabled" doc:"Whether task attachments are enabled."` + EnabledBackgroundProviders []string `json:"enabled_background_providers" doc:"The project-background providers enabled on this instance (e.g. upload, unsplash)."` + TotpEnabled bool `json:"totp_enabled" doc:"Whether TOTP two-factor authentication is enabled."` + Legal LegalInfo `json:"legal" doc:"Links to the instance's legal documents."` + CaldavEnabled bool `json:"caldav_enabled" doc:"Whether the CalDAV interface is enabled."` + AuthInfo AuthInfo `json:"auth" doc:"The authentication methods enabled on this instance."` + EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"Whether email reminders are enabled."` + UserDeletionEnabled bool `json:"user_deletion_enabled" doc:"Whether users may delete their own account."` + TaskCommentsEnabled bool `json:"task_comments_enabled" doc:"Whether task comments are enabled."` + DemoModeEnabled bool `json:"demo_mode_enabled" doc:"Whether this instance runs in demo mode (data is periodically reset)."` + WebhooksEnabled bool `json:"webhooks_enabled" doc:"Whether webhooks are enabled."` + PublicTeamsEnabled bool `json:"public_teams_enabled" doc:"Whether public teams are enabled."` + AllowIconChanges bool `json:"allow_icon_changes" doc:"Whether users may change project icons."` + EnabledProFeatures []license.Feature `json:"enabled_pro_features" doc:"The licensed pro features enabled on this instance."` +} + +// AuthInfo describes the authentication methods enabled on this instance. +type AuthInfo struct { + Local LocalAuthInfo `json:"local"` + Ldap LdapAuthInfo `json:"ldap"` + OpenIDConnect OpenIDAuthInfo `json:"openid_connect"` +} + +// LocalAuthInfo describes the local (username/password) authentication method. +type LocalAuthInfo struct { + Enabled bool `json:"enabled"` + RegistrationEnabled bool `json:"registration_enabled"` +} + +// LdapAuthInfo describes the LDAP authentication method. +type LdapAuthInfo struct { + Enabled bool `json:"enabled"` +} + +// OpenIDAuthInfo describes the OpenID Connect authentication method. +type OpenIDAuthInfo struct { + Enabled bool `json:"enabled"` + Providers []*openid.Provider `json:"providers"` +} + +// LegalInfo holds links to the instance's legal documents. +type LegalInfo struct { + ImprintURL string `json:"imprint_url"` + PrivacyPolicyURL string `json:"privacy_policy_url"` +} + +// BuildInfo assembles the public instance information returned by GET /info on +// both API versions. +func BuildInfo() VikunjaInfos { + info := VikunjaInfos{ + Version: version.Version, + FrontendURL: config.ServicePublicURL.GetString(), + Motd: config.ServiceMotd.GetString(), + LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(), + MaxFileSize: config.FilesMaxSize.GetString(), + MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(), + TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), + TotpEnabled: config.ServiceEnableTotp.GetBool(), + CaldavEnabled: config.ServiceEnableCaldav.GetBool(), + EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(), + UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(), + TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(), + DemoModeEnabled: config.ServiceDemoMode.GetBool(), + WebhooksEnabled: config.WebhooksEnabled.GetBool(), + PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), + AllowIconChanges: config.ServiceAllowIconChanges.GetBool(), + EnabledProFeatures: license.EnabledProFeatures(), + AvailableMigrators: []string{ + (&vikunja_file.FileMigrator{}).Name(), + (&ticktick.Migrator{}).Name(), + (&wekan.Migrator{}).Name(), + (&csvmigrator.Migrator{}).Name(), + }, + Legal: LegalInfo{ + ImprintURL: config.LegalImprintURL.GetString(), + PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), + }, + AuthInfo: AuthInfo{ + Local: LocalAuthInfo{ + Enabled: config.AuthLocalEnabled.GetBool(), + RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(), + }, + Ldap: LdapAuthInfo{ + Enabled: config.AuthLdapEnabled.GetBool(), + }, + OpenIDConnect: OpenIDAuthInfo{ + Enabled: config.AuthOpenIDEnabled.GetBool(), + }, + }, + } + + providers, err := openid.GetAllProviders() + if err != nil { + log.Errorf("Error while getting openid providers for /info: %s", err) + // No return here to not break /info + } + info.AuthInfo.OpenIDConnect.Providers = providers + + if config.MigrationTodoistEnable.GetBool() { + m := &todoist.Migration{} + info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) + } + if config.MigrationTrelloEnable.GetBool() { + m := &trello.Migration{} + info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) + } + if config.MigrationMicrosoftTodoEnable.GetBool() { + m := µsofttodo.Migration{} + info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) + } + + if config.BackgroundsEnabled.GetBool() { + if config.BackgroundsUploadEnabled.GetBool() { + info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload") + } + if config.BackgroundsUnsplashEnabled.GetBool() { + info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash") + } + } + + return info +} diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 0e0a64ff2..87891ff15 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -19,151 +19,18 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/license" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/modules/auth/openid" - csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" - microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" - "code.vikunja.io/api/pkg/modules/migration/ticktick" - "code.vikunja.io/api/pkg/modules/migration/todoist" - "code.vikunja.io/api/pkg/modules/migration/trello" - vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" - "code.vikunja.io/api/pkg/modules/migration/wekan" - "code.vikunja.io/api/pkg/version" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type vikunjaInfos struct { - Version string `json:"version"` - FrontendURL string `json:"frontend_url"` - Motd string `json:"motd"` - LinkSharingEnabled bool `json:"link_sharing_enabled"` - MaxFileSize string `json:"max_file_size"` - MaxItemsPerPage int `json:"max_items_per_page"` - AvailableMigrators []string `json:"available_migrators"` - TaskAttachmentsEnabled bool `json:"task_attachments_enabled"` - EnabledBackgroundProviders []string `json:"enabled_background_providers"` - TotpEnabled bool `json:"totp_enabled"` - Legal legalInfo `json:"legal"` - CaldavEnabled bool `json:"caldav_enabled"` - AuthInfo authInfo `json:"auth"` - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - UserDeletionEnabled bool `json:"user_deletion_enabled"` - TaskCommentsEnabled bool `json:"task_comments_enabled"` - DemoModeEnabled bool `json:"demo_mode_enabled"` - WebhooksEnabled bool `json:"webhooks_enabled"` - PublicTeamsEnabled bool `json:"public_teams_enabled"` - AllowIconChanges bool `json:"allow_icon_changes"` - EnabledProFeatures []license.Feature `json:"enabled_pro_features"` -} - -type authInfo struct { - Local localAuthInfo `json:"local"` - Ldap ldapAuthInfo `json:"ldap"` - OpenIDConnect openIDAuthInfo `json:"openid_connect"` -} - -type localAuthInfo struct { - Enabled bool `json:"enabled"` - RegistrationEnabled bool `json:"registration_enabled"` -} - -type ldapAuthInfo struct { - Enabled bool `json:"enabled"` -} - -type openIDAuthInfo struct { - Enabled bool `json:"enabled"` - Providers []*openid.Provider `json:"providers"` -} - -type legalInfo struct { - ImprintURL string `json:"imprint_url"` - PrivacyPolicyURL string `json:"privacy_policy_url"` -} - // Info is the handler to get infos about this vikunja instance // @Summary Info // @Description Returns the version, frontendurl, motd and various settings of Vikunja // @tags service // @Produce json -// @Success 200 {object} v1.vikunjaInfos +// @Success 200 {object} shared.VikunjaInfos // @Router /info [get] func Info(c *echo.Context) error { - info := vikunjaInfos{ - Version: version.Version, - FrontendURL: config.ServicePublicURL.GetString(), - Motd: config.ServiceMotd.GetString(), - LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(), - MaxFileSize: config.FilesMaxSize.GetString(), - MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(), - TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), - TotpEnabled: config.ServiceEnableTotp.GetBool(), - CaldavEnabled: config.ServiceEnableCaldav.GetBool(), - EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(), - UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(), - TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(), - DemoModeEnabled: config.ServiceDemoMode.GetBool(), - WebhooksEnabled: config.WebhooksEnabled.GetBool(), - PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), - AllowIconChanges: config.ServiceAllowIconChanges.GetBool(), - EnabledProFeatures: license.EnabledProFeatures(), - AvailableMigrators: []string{ - (&vikunja_file.FileMigrator{}).Name(), - (&ticktick.Migrator{}).Name(), - (&wekan.Migrator{}).Name(), - (&csvmigrator.Migrator{}).Name(), - }, - Legal: legalInfo{ - ImprintURL: config.LegalImprintURL.GetString(), - PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), - }, - AuthInfo: authInfo{ - Local: localAuthInfo{ - Enabled: config.AuthLocalEnabled.GetBool(), - RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(), - }, - Ldap: ldapAuthInfo{ - Enabled: config.AuthLdapEnabled.GetBool(), - }, - OpenIDConnect: openIDAuthInfo{ - Enabled: config.AuthOpenIDEnabled.GetBool(), - }, - }, - } - - providers, err := openid.GetAllProviders() - if err != nil { - log.Errorf("Error while getting openid providers for /info: %s", err) - // No return here to not break /info - } - - info.AuthInfo.OpenIDConnect.Providers = providers - - // Migrators - if config.MigrationTodoistEnable.GetBool() { - m := &todoist.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - if config.MigrationTrelloEnable.GetBool() { - m := &trello.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - if config.MigrationMicrosoftTodoEnable.GetBool() { - m := µsofttodo.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - - if config.BackgroundsEnabled.GetBool() { - if config.BackgroundsUploadEnabled.GetBool() { - info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload") - } - if config.BackgroundsUnsplashEnabled.GetBool() { - info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash") - } - } - - return c.JSON(http.StatusOK, info) + return c.JSON(http.StatusOK, shared.BuildInfo()) } diff --git a/pkg/routes/api/v2/info.go b/pkg/routes/api/v2/info.go new file mode 100644 index 000000000..483abf027 --- /dev/null +++ b/pkg/routes/api/v2/info.go @@ -0,0 +1,51 @@ +// 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 apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/routes/api/shared" + + "github.com/danielgtaylor/huma/v2" +) + +type infoBody struct { + Body shared.VikunjaInfos +} + +// RegisterInfoRoutes wires the public instance-info endpoint onto the Huma API. +func RegisterInfoRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "info", + Summary: "Instance info", + Description: "Returns version, frontend URL, motd and the enabled features of this Vikunja instance. Public — no authentication required.", + Method: http.MethodGet, + Path: "/info", + Tags: []string{"service"}, + // Public: opt out of the globally-applied auth. The path is also listed + // in unauthenticatedAPIPaths so the token middleware lets it through. + Security: []map[string][]string{}, + }, info) +} + +func init() { AddRouteRegistrar(RegisterInfoRoutes) } + +func info(_ context.Context, _ *struct{}) (*infoBody, error) { + return &infoBody{Body: shared.BuildInfo()}, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 159994724..949180b64 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -343,6 +343,7 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/docs": true, "/api/v2/docs/scalar.standalone.js": true, "/api/v2/schemas/:schema": true, + "/api/v2/info": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. diff --git a/pkg/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_backgrounds_misc_test.go index 8efdc3c2b..57a87413d 100644 --- a/pkg/webtests/huma_backgrounds_misc_test.go +++ b/pkg/webtests/huma_backgrounds_misc_test.go @@ -17,6 +17,7 @@ package webtests import ( + "encoding/json" "net/http" "testing" @@ -29,6 +30,22 @@ import ( "github.com/stretchr/testify/require" ) +// TestHumaInfo covers the public instance-info endpoint. It needs no auth and +// always reports the running version. +func TestHumaInfo(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/info", "", "", "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body, "version") + assert.Contains(t, body, "auth") + assert.Contains(t, body, "available_migrators") +} + // TestHumaProjectBackgroundDelete covers removing a project background. It // mirrors the v1 background_test.go matrix: the owner clears the background // (and keeps the title), a read-only user is refused.