Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions pkg/models/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
62 changes: 62 additions & 0 deletions pkg/routes/api/v2/admin_projects.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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
}
23 changes: 23 additions & 0 deletions pkg/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions pkg/webtests/huma_admin_test.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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()
Comment thread
kolaente marked this conversation as resolved.
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)
})
}
Loading