diff --git a/pkg/models/project.go b/pkg/models/project.go
index c860ed0621..022f673a5f 100644
--- a/pkg/models/project.go
+++ b/pkg/models/project.go
@@ -38,52 +38,52 @@ import (
// Project represents a project of tasks
type Project struct {
// The unique, numeric id of this project.
- ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"`
+ ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project" readOnly:"true" doc:"The unique, numeric id of this project."`
// The title of the project. You'll see this in the overview.
- Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
+ Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of the project. You'll see this in the overview."`
// The description of the project.
- Description string `xorm:"longtext null" json:"description"`
+ Description string `xorm:"longtext null" json:"description" doc:"The description of the project."`
// The unique project short identifier. Used to build task identifiers.
- Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10"`
+ Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10" doc:"The unique project short identifier. Used to build task identifiers (e.g. PROJ-123)."`
// The hex color of this project
- HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
+ HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The hex color of this project, without the leading #."`
OwnerID int64 `xorm:"bigint INDEX not null" json:"-"`
- ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"`
+ ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id" doc:"The id of the parent project. 0 if this is a top-level project."`
ParentProject *Project `xorm:"-" json:"-"`
// The user who created this project.
- Owner *user.User `xorm:"-" json:"owner" valid:"-"`
+ Owner *user.User `xorm:"-" json:"owner" valid:"-" readOnly:"true" doc:"The user who owns this project. Set by the server; ignored on write."`
// Whether a project is archived.
- IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"`
+ IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived" doc:"Whether the project is archived. Archived projects are read-only."`
// The id of the file this project has set as background
BackgroundFileID int64 `xorm:"null" json:"-"`
// Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /projects/{projectID}/background
- BackgroundInformation interface{} `xorm:"-" json:"background_information"`
+ BackgroundInformation interface{} `xorm:"-" json:"background_information" readOnly:"true" doc:"Extra information about the background (e.g. attribution). When not null, the background is available at /projects/{projectID}/background."`
// Contains a very small version of the project background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works.
- BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash"`
+ BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash" readOnly:"true" doc:"A small BlurHash preview of the project background, shown until the real background loads. See https://blurha.sh/."`
// True if a project is a favorite. Favorite projects show up in a separate parent project. This value depends on the user making the call to the api.
- IsFavorite bool `xorm:"-" json:"is_favorite"`
+ IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the project is a favorite of the requesting user. This value is per-user and depends on who makes the call."`
// The subscription status for the user reading this project. You can only read this property, use the subscription endpoints to modify it.
// Will only returned when retreiving one project.
- Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
+ Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription status for this project. Read-only here; use the subscription endpoints to change it. Only returned when retrieving a single project."`
// The position this project has when querying all projects. See the tasks.position property on how to use this.
- Position float64 `xorm:"double null" json:"position"`
+ Position float64 `xorm:"double null" json:"position" doc:"The position of this project when listing all projects. See the tasks.position property for how positions work."`
- Views []*ProjectView `xorm:"-" json:"views"`
+ Views []*ProjectView `xorm:"-" json:"views" readOnly:"true" doc:"The views configured for this project. Managed through the project view endpoints."`
Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"`
- MaxPermission Permission `xorm:"-" json:"max_permission"`
+ MaxPermission Permission `xorm:"-" json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this project (0 = read, 1 = read/write, 2 = admin)."`
// A timestamp when this project was created. You cannot change this value.
- Created time.Time `xorm:"created not null" json:"created"`
+ Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this project was created. You cannot change this value."`
// A timestamp when this project was last updated. You cannot change this value.
- Updated time.Time `xorm:"updated not null" json:"updated"`
+ Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this project was last updated. You cannot change this value."`
web.CRUDable `xorm:"-" json:"-"`
web.Permissions `xorm:"-" json:"-"`
diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go
new file mode 100644
index 0000000000..8e7b51d3d2
--- /dev/null
+++ b/pkg/routes/api/v2/admin_projects.go
@@ -0,0 +1,62 @@
+// 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"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type adminProjectListBody struct {
+ Body Paginated[*models.Project]
+}
+
+// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler.
+func RegisterAdminProjectRoutes(api huma.API) {
+ tags := []string{"admin"}
+
+ Register(api, huma.Operation{
+ OperationID: "admin-projects-list",
+ Summary: "List all projects (admin)",
+ Description: "Returns every project on the instance, including archived ones and projects the caller does not own. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404, making the endpoint indistinguishable from one that is not registered.",
+ Method: http.MethodGet,
+ Path: "/admin/projects",
+ Tags: tags,
+ }, adminProjectsList)
+}
+
+func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.AdminProjectList{}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ items, ok := result.([]*models.Project)
+ if !ok {
+ return nil, fmt.Errorf("AdminProjectList.ReadAll returned unexpected type %T (expected []*models.Project)", result)
+ }
+ return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 5b00b13620..d95f7e7eff 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -376,6 +376,25 @@ func noStoreCacheControl() echo.MiddlewareFunc {
}
}
+const v2AdminPathPrefix = "/api/v2/admin"
+
+// gateV2AdminRoutes reuses v1's RequireFeature/RequireInstanceAdmin gate (both
+// 404-on-failure) as path-scoped middleware: splitting v2 into a gated Echo
+// sub-group would split the Huma API and drop admin ops from the OpenAPI spec.
+func gateV2AdminRoutes() echo.MiddlewareFunc {
+ feature := RequireFeature(license.FeatureAdminPanel)
+ admin := RequireInstanceAdmin()
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ gated := feature(admin(next))
+ return func(c *echo.Context) error {
+ if strings.HasPrefix(c.Request().URL.Path, v2AdminPathPrefix) {
+ return gated(c)
+ }
+ return next(c)
+ }
+ }
+}
+
// registerAPIRoutesV2 wires the /api/v2 Echo group. Token middleware is
// attached before any route so Huma's spec and Scalar docs share the
// resource handlers' stack; unauthenticatedAPIPaths keeps them public.
@@ -386,6 +405,9 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
// apply to v2 resource endpoints too.
setupRateLimit(a, config.RateLimitKind.GetString())
setupMetricsMiddleware(a)
+ // Must come after rate limiting: the gate does a per-request admin DB read,
+ // so an unauthenticated flood to /api/v2/admin/* would otherwise be unbounded.
+ a.Use(gateV2AdminRoutes())
api := apiv2.NewAPI(e, a)
@@ -396,6 +418,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
// Resource registrations.
apiv2.RegisterLabelRoutes(api)
apiv2.RegisterTaskDuplicateRoutes(api)
+ apiv2.RegisterAdminProjectRoutes(api)
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
// synthesize their PATCH counterparts.
diff --git a/pkg/webtests/huma_admin_test.go b/pkg/webtests/huma_admin_test.go
new file mode 100644
index 0000000000..36f25de154
--- /dev/null
+++ b/pkg/webtests/huma_admin_test.go
@@ -0,0 +1,103 @@
+// 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 (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// The error body shape is covered by TestHuma_ErrorShapeIsRFC9457; this test
+// only asserts gate status codes (404 on failure, matching v1).
+func TestHumaAdminProjects(t *testing.T) {
+ t.Run("non-admin user gets 404", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+ require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin")
+
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", u, "")
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("admin without the feature gets 404", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // Empty feature set = licensed instance without the admin feature.
+ license.SetForTests([]license.Feature{})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", admin, "")
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("admin with the feature sees every project", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", admin, "")
+ require.Equal(t, http.StatusOK, res.Code, res.Body.String())
+
+ var envelope struct {
+ Items []struct {
+ ID int64 `json:"id"`
+ } `json:"items"`
+ Total int64 `json:"total"`
+ }
+ require.NoError(t, json.Unmarshal(res.Body.Bytes(), &envelope))
+
+ ids := make(map[int64]bool, len(envelope.Items))
+ for _, item := range envelope.Items {
+ ids[item.ID] = true
+ }
+ // Project 6 (owned by user6, not shared with user1) proves the list ignores ownership.
+ assert.True(t, ids[6], "expected project 6 in the admin list, got items %v", ids)
+ // Project 22 is archived, proving the list includes archived projects.
+ assert.True(t, ids[22], "expected archived project 22 in the admin list, got items %v", ids)
+ })
+
+ t.Run("unauthenticated caller gets 401", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ // The token middleware rejects with 401 before the gate runs, matching v1.
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", nil, "")
+ assert.Equal(t, http.StatusUnauthorized, res.Code)
+ })
+}