diff --git a/.golangci.yml b/.golangci.yml index 552e13cb7..19ee2f531 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -149,6 +149,9 @@ linters: - revive path: pkg/routes/api/shared/* text: 'var-naming: avoid meaningless package names' + - linters: + - contextcheck + path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' diff --git a/pkg/modules/background/background.go b/pkg/modules/background/background.go index 161485bfe..7e48d2d16 100644 --- a/pkg/modules/background/background.go +++ b/pkg/modules/background/background.go @@ -24,12 +24,12 @@ import ( // Image represents an image which can be used as a project background type Image struct { - ID string `json:"id"` - URL string `json:"url"` - Thumb string `json:"thumb,omitempty"` - BlurHash string `json:"blur_hash"` + ID string `json:"id" doc:"The provider-specific id of the image; pass this back to set it as a background."` + URL string `json:"url" doc:"The full-size URL of the image."` + Thumb string `json:"thumb,omitempty" doc:"A thumbnail URL of the image, if the provider supplies one."` + BlurHash string `json:"blur_hash" doc:"A BlurHash placeholder for the image."` // This can be used to supply extra information from an image provider to clients - Info interface{} `json:"info,omitempty"` + Info interface{} `json:"info,omitempty" doc:"Provider-specific extra information about the image (e.g. the Unsplash author for attribution)."` } const MaxBackgroundImageHeight = 3840 diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go new file mode 100644 index 000000000..c56d1acce --- /dev/null +++ b/pkg/routes/api/v2/backgrounds.go @@ -0,0 +1,190 @@ +// 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/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/background" + "code.vikunja.io/api/pkg/modules/background/unsplash" + + "github.com/danielgtaylor/huma/v2" +) + +type backgroundSearchBody struct { + Body Paginated[*background.Image] +} + +// RegisterBackgroundRoutes wires the project-background actions onto the Huma +// API. BackgroundsEnabled / BackgroundsUnsplashEnabled are static config, so the +// registrar early-returns instead of gating per request. +func RegisterBackgroundRoutes(api huma.API) { + if !config.BackgroundsEnabled.GetBool() { + return + } + + tags := []string{"project"} + + Register(api, huma.Operation{ + OperationID: "projects-background-delete", + Summary: "Remove a project background", + Description: "Removes a project's background, whichever provider set it. Succeeds even when the project has no background. Requires write access to the project. Returns the updated project.", + Method: http.MethodDelete, + Path: "/projects/{project}/background", + // Return the updated project with 200, not the wrapper's DELETE default 204. + DefaultStatus: http.StatusOK, + Tags: tags, + }, backgroundRemove) + + if config.BackgroundsUnsplashEnabled.GetBool() { + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-search", + Summary: "Search Unsplash backgrounds", + Description: "Searches Unsplash for background images. With an empty query it returns the featured wallpaper collection. Results are paginated by Unsplash; total counts are not available.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/search", + Tags: tags, + }, backgroundUnsplashSearch) + + Register(api, huma.Operation{ + OperationID: "projects-background-unsplash-set", + Summary: "Set an Unsplash image as project background", + Description: "Sets a previously searched Unsplash image as the project's background, identified by the image id from the search results. Requires write access to the project.", + Method: http.MethodPut, + Path: "/projects/{project}/backgrounds/unsplash", + Tags: tags, + }, backgroundUnsplashSet) + } +} + +func init() { AddRouteRegistrar(RegisterBackgroundRoutes) } + +func backgroundUnsplashSearch(ctx context.Context, in *struct { + Q string `query:"q" doc:"Search query; empty returns the featured wallpaper collection."` + Page int64 `query:"page" default:"1" minimum:"1" doc:"1-based page number."` +}) (*backgroundSearchBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + page := in.Page + if page < 1 { + page = 1 + } + + s := db.NewSession() + defer s.Close() + + p := &unsplash.Provider{} + result, err := p.Search(s, in.Q, page) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + // Unsplash paginates server-side and p.Search discards the total, so the + // envelope's total is just this page's length (v1 returned a bare array). + return &backgroundSearchBody{Body: NewPaginated(result, int64(len(result)), int(page), len(result))}, nil +} + +func backgroundUnsplashSet(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + Body background.Image +}) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + project, err = models.GetProjectSimpleByID(s, in.ProjectID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + p := &unsplash.Provider{} + if err := p.Set(s, &in.Body, project, a); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := project.ReadOne(s, a); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} + +func backgroundRemove(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` +}) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + if err := project.DeleteBackgroundFileIfExists(s); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := models.ClearProjectBackground(s, project.ID); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} diff --git a/pkg/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_backgrounds_misc_test.go new file mode 100644 index 000000000..8efdc3c2b --- /dev/null +++ b/pkg/webtests/huma_backgrounds_misc_test.go @@ -0,0 +1,112 @@ +// 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 webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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. +func TestHumaProjectBackgroundDelete(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner clears the background, title preserved", func(t *testing.T) { + // testuser6 owns project 35 (title "Test35 with background", background_file_id 1). + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + s := db.NewSession() + defer s.Close() + project := models.Project{ID: 35} + has, err := s.Get(&project) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, "Test35 with background", project.Title) + assert.Equal(t, int64(0), project.BackgroundFileID) + }) + t.Run("Read-only user is forbidden", func(t *testing.T) { + // testuser15 has read-only (permission 0) access to project 35. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaBackgroundDisabledByConfig verifies the registrar early-returns when +// project backgrounds are disabled: the DELETE route is then absent (404). +func TestHumaBackgroundDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(false) + defer config.BackgroundsEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String()) +} + +// TestHumaUnsplashBackground covers the Unsplash routes' auth and permission +// gates. They are only registered when the unsplash provider is enabled (off by +// default), so the router is rebuilt with the flag on. The set route's +// permission check runs before any Unsplash network call, so the negative cases +// are exercised without hitting the real API; the happy path needs the network +// and is therefore not covered here (matching v1). +func TestHumaUnsplashBackground(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(true) + config.BackgroundsUnsplashEnabled.Set(true) + defer config.BackgroundsUnsplashEnabled.Set(false) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + t.Run("Search requires auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/search?q=mountain", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Set requires auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Set forbidden for read-only user", func(t *testing.T) { + // testuser15 has read-only access to project 35; CanUpdate fails before + // p.Set reaches Unsplash. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +}