mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-11 16:29:00 -06:00
* Initial plan * Allow org members without subscription to create properties via API - Add requestUserEx() to allow skipping subscription check - Update postNewProperties() to not require user subscription (org owner subscription is checked instead) - Update getAsyncTask() to not require user subscription - Add integration tests for Portal and API to verify org members can create properties Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Fix TestApiPostPropertiesNoSubscription to check API error code Update test to use requestResponseAPISuite and check for StatusSubscriptionPropertyLimitError instead of HTTP 402 Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Refactor tests and fix task handler subscription check - Fix getAsyncTask to require subscription (nil subscriptions blocked, expired trials allowed) - Refactor API tests with helper functions createPropertyInputs, waitForAsyncTaskCompletion - Refactor API and Portal tests as test suites for nil and expired trial subscriptions - Add step to test non-member rejection before join in API tests - Simplify Portal tests to focus on member property creation Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Fix redundant imports in property_test.go Use consistent db_test alias instead of multiple aliases for the same package Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Add non-member rejection test and refactor async task wait helpers - Add non-member rejection step in API tests (verifies 403 Forbidden before invite) - Simplify Portal tests (just verify member can create properties after joining) - Refactor multiple async task wait loops to use waitForAsyncTaskCompletion helper - Add waitForAsyncTaskCompletionWithResult helper for tests that need results Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Ensure invited-but-not-joined members cannot create properties - Add RetrieveUserOrganizationMember() that excludes invited users - Add requestOrgMember() API helper that uses the new function - Add OrgMember() Portal helper that uses the new function - Update postNewProperties() to use requestOrgMember() - Update postNewOrgProperty() to use OrgMember() - Add test steps for invited-but-not-joined rejection in API and Portal tests Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Refactor: return access level from requestOrg/Org instead of new functions - Modify RetrieveUserOrganization to return access level as third value - Delete RetrieveUserOrganizationMember function - Modify requestOrg to return access level as third value - Delete requestOrgMember function - Modify Org to return access level as third value - Delete OrgMember function - Update all callers to ignore unused access level return values - Check access level in postNewProperties and postNewOrgProperty to block invited users Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> * Fix code review: use 403 Forbidden and check level.Valid - Use HTTP 403 Forbidden instead of 500 in Portal for invited users - Add level.Valid check before accessing level.AccessLevel Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>
189 lines
7.6 KiB
Go
189 lines
7.6 KiB
Go
//go:build enterprise
|
|
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
|
|
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/monitoring"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/justinas/alice"
|
|
)
|
|
|
|
const (
|
|
maxAPIPostBodySize = 128 * 1024
|
|
maxPostPropertiesBodySize = 1024 * 1024
|
|
maxDeletePropertiesBodySize = 128 * 1024
|
|
maxUpdatePropertiesBodySize = 1024 * 1024
|
|
)
|
|
|
|
func (s *Server) setupEnterprise(rg *common.RouteGenerator, publicChain alice.Chain, apiRateLimiter func(next http.Handler) http.Handler) {
|
|
arg := func(s string) string {
|
|
return fmt.Sprintf("{%s}", s)
|
|
}
|
|
|
|
// "portal" API
|
|
portalAPIChain := publicChain.Append(s.Metrics.HandlerIDFunc(rg.LastPath), apiRateLimiter, monitoring.Traced, common.SoftTimeoutHandler(5*time.Second), s.Auth.APIKey(headerAPIKey, dbgen.ApiKeyScopePortal))
|
|
// tasks
|
|
rg.Handle(rg.Get(common.AsyncTaskEndpoint, arg(common.ParamID)), portalAPIChain, http.HandlerFunc(s.getAsyncTask))
|
|
// orgs
|
|
rg.Handle(rg.Get(common.OrganizationsEndpoint), portalAPIChain, http.HandlerFunc(s.getUserOrgs))
|
|
rg.Handle(rg.Post(common.OrgEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.postNewOrg), maxAPIPostBodySize))
|
|
rg.Handle(rg.Put(common.OrgEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.updateOrg), maxAPIPostBodySize))
|
|
rg.Handle(rg.Delete(common.OrgEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.deleteOrg), maxAPIPostBodySize))
|
|
// properties
|
|
rg.Handle(rg.Get(common.OrgEndpoint, arg(common.ParamOrg), common.PropertiesEndpoint), portalAPIChain, http.HandlerFunc(s.getOrgProperties))
|
|
rg.Handle(rg.Post(common.OrgEndpoint, arg(common.ParamOrg), common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.postNewProperties), maxPostPropertiesBodySize))
|
|
rg.Handle(rg.Delete(common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.deleteProperties), maxDeletePropertiesBodySize))
|
|
rg.Handle(rg.Put(common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.updateProperties), maxUpdatePropertiesBodySize))
|
|
rg.Handle(rg.Get(common.OrgEndpoint, arg(common.ParamOrg), common.PropertyEndpoint, arg(common.ParamProperty)), portalAPIChain, http.HandlerFunc(s.getOrgProperty))
|
|
}
|
|
|
|
func (s *Server) RegisterTaskHandlers(ctx context.Context) {
|
|
if ok := s.AsyncTasks.Register(createPropertiesHandlerID, s.handleCreateProperties); !ok {
|
|
slog.ErrorContext(ctx, "Failed to register async task handler", "handler", createPropertiesHandlerID)
|
|
}
|
|
if ok := s.AsyncTasks.Register(deletePropertiesHandlerID, s.handleDeleteProperties); !ok {
|
|
slog.ErrorContext(ctx, "Failed to register async task handler", "handler", deletePropertiesHandlerID)
|
|
}
|
|
if ok := s.AsyncTasks.Register(updatePropertiesHandlerID, s.handleUpdateProperties); !ok {
|
|
slog.ErrorContext(ctx, "Failed to register async task handler", "handler", updatePropertiesHandlerID)
|
|
}
|
|
}
|
|
|
|
func (s *Server) requestUser(ctx context.Context, readOnly bool) (*dbgen.User, *dbgen.APIKey, error) {
|
|
return s.requestUserEx(ctx, readOnly, true /*requiresSubscription*/)
|
|
}
|
|
|
|
func (s *Server) requestUserEx(ctx context.Context, readOnly bool, requiresSubscription bool) (*dbgen.User, *dbgen.APIKey, error) {
|
|
portalOwnerSource := &apiKeyOwnerSource{Store: s.BusinessDB, scope: dbgen.ApiKeyScopePortal}
|
|
id, _, err := portalOwnerSource.OwnerID(ctx, time.Now().UTC())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if portalOwnerSource.cachedKey != nil && (portalOwnerSource.cachedKey.Readonly && !readOnly) {
|
|
slog.WarnContext(ctx, "API key read-write attribute does not match", "expected", readOnly,
|
|
"actual", portalOwnerSource.cachedKey.Readonly)
|
|
return nil, nil, errAPIKeyReadOnly
|
|
}
|
|
|
|
user, err := s.BusinessDB.Impl().RetrieveUser(ctx, id)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if requiresSubscription && !user.SubscriptionID.Valid {
|
|
return nil, nil, db.ErrNoActiveSubscription
|
|
}
|
|
|
|
return user, portalOwnerSource.cachedKey, nil
|
|
}
|
|
|
|
func (s *Server) requestOrg(user *dbgen.User, r *http.Request, onlyOwner bool, allowedOrgID *pgtype.Int4) (*dbgen.Organization, dbgen.NullAccessLevel, error) {
|
|
ctx := r.Context()
|
|
|
|
orgID, value, err := common.IntPathArg(r, common.ParamOrg, s.IDHasher)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to parse org path parameter", "value", value, common.ErrAttr(err))
|
|
return nil, dbgen.NullAccessLevel{}, db.ErrInvalidInput
|
|
}
|
|
|
|
if (allowedOrgID != nil) && allowedOrgID.Valid && (allowedOrgID.Int32 != orgID) {
|
|
slog.WarnContext(ctx, "Requested organization is not allowed for this requester", "allowedOrgID", allowedOrgID.Int32, "requestedOrgID", orgID)
|
|
return nil, dbgen.NullAccessLevel{}, db.ErrPermissions
|
|
}
|
|
|
|
org, level, err := s.BusinessDB.Impl().RetrieveUserOrganization(ctx, user, orgID)
|
|
if err != nil {
|
|
return nil, dbgen.NullAccessLevel{}, err
|
|
}
|
|
|
|
if onlyOwner {
|
|
if !org.UserID.Valid || (org.UserID.Int32 != user.ID) {
|
|
return nil, dbgen.NullAccessLevel{}, db.ErrPermissions
|
|
}
|
|
}
|
|
|
|
return org, level, nil
|
|
}
|
|
|
|
func (s *Server) requestProperty(org *dbgen.Organization, r *http.Request) (*dbgen.Property, error) {
|
|
ctx := r.Context()
|
|
|
|
propertyID, value, err := common.IntPathArg(r, common.ParamProperty, s.IDHasher)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to parse property path parameter", "value", value, common.ErrAttr(err))
|
|
return nil, db.ErrInvalidInput
|
|
}
|
|
|
|
property, err := s.BusinessDB.Impl().RetrieveOrgProperty(ctx, org, propertyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return property, nil
|
|
}
|
|
|
|
func (s *Server) sendHTTPErrorResponse(err error, w http.ResponseWriter) {
|
|
switch err {
|
|
case db.ErrRecordNotFound:
|
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
|
case db.ErrInvalidInput:
|
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
case db.ErrNoActiveSubscription:
|
|
http.Error(w, http.StatusText(http.StatusPaymentRequired), http.StatusPaymentRequired)
|
|
case db.ErrMaintenance:
|
|
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
|
case errAPIKeyScope, errInvalidAPIKey, errAPIKeyNotSet, errAPIKeyReadOnly, db.ErrPermissions:
|
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
|
case db.ErrSoftDeleted:
|
|
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
|
default:
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) sendAPISuccessResponse(ctx context.Context, data interface{}, w http.ResponseWriter) {
|
|
s.sendAPISuccessResponseEx(ctx, &APIResponse{Data: data}, w, common.NoCacheHeaders)
|
|
}
|
|
|
|
func (s *Server) sendAPISuccessResponseEx(ctx context.Context, response *APIResponse, w http.ResponseWriter, headers ...map[string][]string) {
|
|
response.Meta = ResponseMetadata{
|
|
Code: common.StatusOK,
|
|
Description: common.StatusOK.String(),
|
|
}
|
|
|
|
if tid, ok := ctx.Value(common.TraceIDContextKey).(string); ok {
|
|
response.Meta.RequestID = tid
|
|
}
|
|
|
|
common.SendJSONResponse(ctx, w, response, headers...)
|
|
}
|
|
|
|
func (s *Server) sendAPIErrorResponse(ctx context.Context, code common.StatusCode, r *http.Request, w http.ResponseWriter) {
|
|
response := &APIResponse{
|
|
Meta: ResponseMetadata{
|
|
Code: code,
|
|
Description: code.String(),
|
|
},
|
|
}
|
|
|
|
if tid, ok := ctx.Value(common.TraceIDContextKey).(string); ok {
|
|
response.Meta.RequestID = tid
|
|
}
|
|
|
|
common.SendJSONResponse(ctx, w, response, common.NoCacheHeaders)
|
|
|
|
slog.WarnContext(ctx, "Returned API error response", "code", int(code))
|
|
|
|
s.Metrics.ObserveApiError(r.URL.Path, r.Method, int(code))
|
|
}
|