Files
2026-09-17 07:59:21 +02:00

443 lines
13 KiB
Go

package service
import (
"encoding/json"
"errors"
"regexp"
"time"
"github.com/kazier/backend/repository"
)
var resourceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
// Ops de l'outbox (docs/api-v1.md §6.1).
const (
OpCreateResource = "create_resource"
OpUpdateMetadata = "update_metadata"
OpDeleteResource = "delete_resource"
OpMoveResource = "move_resource"
OpShare = "share"
OpRevokeShare = "revoke_share"
OpUpdateShare = "update_share"
OpCreateLink = "create_link"
OpRevokeLink = "revoke_link"
)
// SyncOperation is one outbox entry (shadow of mobile PendingOperationRow).
type SyncOperation struct {
OperationID string `json:"operation_id"`
RefType *string `json:"ref_type"`
RefID *int64 `json:"ref_id"`
ResourceID *string `json:"resource_id"`
ResourceType *string `json:"resource_type"`
Operation string `json:"operation"`
Payload json.RawMessage `json:"payload"`
}
// FailedOperation reports the first non-idempotent failure.
type FailedOperation struct {
OperationID string `json:"operation_id"`
Code string `json:"code"`
Message string `json:"message"`
}
// SyncResult is the outbox response: applied = index of the next op to send.
type SyncResult struct {
Applied int `json:"applied"`
Failed *FailedOperation `json:"failed,omitempty"`
}
type createResourcePayload struct {
Name string `json:"name"`
ParentResourceID string `json:"parentResourceId"` // dossier parent (racine si vide), cf. docs/api-v1.md §6.1
MimeType string `json:"mimeType"`
Extension string `json:"extension"`
}
type renamePayload struct {
Name string `json:"name"`
}
type movePayload struct {
ToFolderResourceID string `json:"toFolderResourceId"`
}
type sharePayload struct {
GranteeUserID string `json:"granteeUserId"`
Access string `json:"access"`
Inherit *bool `json:"inherit"`
ExpiresAt *int64 `json:"expiresAt"`
}
type revokeSharePayload struct {
GranteeUserID string `json:"granteeUserId"`
}
type linkPayload struct {
Token string `json:"token"`
Access string `json:"access"`
ExpiresAt *int64 `json:"expiresAt"`
}
type revokeLinkPayload struct {
Token string `json:"token"`
}
func classifySyncError(err error) (string, string) {
switch {
case errors.Is(err, repository.ErrNameConflict):
return "NAME_CONFLICT", "a resource with this name already exists here"
case errors.Is(err, repository.ErrNotFound):
return "NOT_FOUND", "target resource or folder not found"
case errors.Is(err, repository.ErrGranteeNotFound):
return "GRANTEE_NOT_FOUND", "the specified user does not exist"
default:
return "INVALID_REQUEST", err.Error()
}
}
// ApplyBatch applique les ops de l'outbox : les mutations de ressources sont
// scopées par l'UTILISATEUR (userID), l'idempotence reste par DEVICE
// (deviceID) — cf. docs/api-v1.md §6.1.
func (s *Resources) ApplyBatch(userID, deviceID string, ops []SyncOperation) (SyncResult, error) {
for i := range ops {
op := &ops[i]
if err := validateSyncOp(op); err != nil {
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: "INVALID_REQUEST", Message: err.Error()}}, nil
}
already, err := s.Repository.Operations.Applied(deviceID, op.OperationID)
if err != nil {
return SyncResult{}, err
}
if already {
continue
}
if err := s.applySyncOp(userID, op); err != nil {
code, message := classifySyncError(err)
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil
}
if err := s.recordApplied(deviceID, op); err != nil {
return SyncResult{}, err
}
}
return SyncResult{Applied: len(ops)}, nil
}
func validateSyncOp(op *SyncOperation) error {
if !resourceIDPattern.MatchString(op.OperationID) {
return errors.New("operation_id must be 32 lowercase hex chars")
}
if op.Operation == "" {
return errors.New("missing operation type")
}
if !resourceIDPattern.MatchString(derefString(op.ResourceID)) {
return errors.New("resource_id must be 32 lowercase hex chars")
}
switch op.Operation {
case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource,
OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink:
default:
return errors.New("unknown operation " + op.Operation)
}
if derefString(op.ResourceType) != "folder" && derefString(op.ResourceType) != "file" {
return errors.New("resource_type must be 'folder' or 'file'")
}
return nil
}
func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
resourceID := derefString(op.ResourceID)
switch op.Operation {
case OpCreateResource:
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if exists {
return nil
}
var p createResourcePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.Name == "" {
return errors.New("payload.name required")
}
// Le parent doit exister et appartenir à l'utilisateur (NOT_FOUND sinon,
// cohérent avec move_resource). Ordre du SAF walk : parent avant enfant.
if p.ParentResourceID != "" {
if _, err := s.Repo.GetFolder(ownerID, p.ParentResourceID); err != nil {
return err
}
}
if derefString(op.ResourceType) == "folder" {
return s.Repo.InsertFolder(ownerID, resourceID, p.Name, p.ParentResourceID)
}
mime := p.MimeType
return s.Repo.InsertFile(ownerID, resourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension))
case OpUpdateMetadata:
// Owner can always rename. A shared resource can be renamed by an
// editor+ (docs §6.2 effective_access ranking). Below editor, or on
// a resource the caller cannot reach at all, we keep the documented
// terminal-state semantics: absent → no-op.
access, err := s.Repository.Shares.EffectiveAccess(ownerID, resourceID)
if err != nil {
return err
}
if access.EffectiveRank < repository.AccessEditor {
if !access.Accessible {
return nil
}
return repository.ErrNotFound
}
var p renamePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.Name == "" {
return errors.New("payload.name required")
}
return s.Repo.UpdateNameByID(resourceID, p.Name)
case OpMoveResource:
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return nil
}
var p movePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.ToFolderResourceID == resourceID {
return errors.New("cannot move a resource into itself")
}
return s.Repo.MoveResource(ownerID, resourceID, p.ToFolderResourceID)
case OpDeleteResource:
return s.Repo.SyncDelete(ownerID, resourceID)
case OpShare, OpUpdateShare:
return s.applyShareOp(ownerID, resourceID, op)
case OpRevokeShare:
return s.applyRevokeShareOp(ownerID, resourceID, op)
case OpCreateLink:
return s.applyCreateLinkOp(ownerID, resourceID, op)
case OpRevokeLink:
return s.applyRevokeLinkOp(ownerID, resourceID, op)
default:
return errors.New("unknown operation " + op.Operation)
}
}
// applyShareOp handles OpShare and OpUpdateShare: upsert a user-to-user grant.
func (s *Resources) applyShareOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p sharePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.GranteeUserID == "" {
return errors.New("payload.granteeUserId required")
}
if !resourceIDPattern.MatchString(p.GranteeUserID) {
return errors.New("payload.granteeUserId must be 32 lowercase hex chars")
}
rank := repository.AccessFromString(p.Access)
if rank < repository.AccessViewer || rank > repository.AccessEditor {
return errors.New("payload.access must be viewer, commenter, or editor")
}
// Validate grantee exists and is active.
if _, err := s.Repository.Users.GetByID(p.GranteeUserID); err != nil {
if errors.Is(err, repository.ErrNotFound) {
return repository.ErrGranteeNotFound
}
return err
}
inherit := true
if p.Inherit != nil {
inherit = *p.Inherit
}
var expiresAt *time.Time
if p.ExpiresAt != nil {
t := time.UnixMilli(*p.ExpiresAt)
expiresAt = &t
}
if err := s.Repository.Shares.Upsert(resourceID, p.GranteeUserID, p.Access, inherit, expiresAt, ownerID); err != nil {
return err
}
// Bump resource updated_at so the grantee's delta snapshot picks it up.
return s.Repo.TouchResource(resourceID)
}
// applyRevokeShareOp handles OpRevokeShare: soft-revoke a user-to-user grant.
func (s *Resources) applyRevokeShareOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p revokeSharePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.GranteeUserID == "" {
return errors.New("payload.granteeUserId required")
}
if !resourceIDPattern.MatchString(p.GranteeUserID) {
return errors.New("payload.granteeUserId must be 32 lowercase hex chars")
}
// Revoke is idempotent: no-op if already revoked/missing.
_ = s.Repository.Shares.Revoke(resourceID, p.GranteeUserID)
// Bump resource updated_at so the grantee's delta snapshot reflects the change.
return s.Repo.TouchResource(resourceID)
}
// applyCreateLinkOp handles OpCreateLink: create a public share link.
func (s *Resources) applyCreateLinkOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p linkPayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if !resourceIDPattern.MatchString(p.Token) {
return errors.New("payload.token must be 32 lowercase hex chars")
}
rank := repository.AccessFromString(p.Access)
if rank < repository.AccessViewer || rank > repository.AccessEditor {
return errors.New("payload.access must be viewer, commenter, or editor")
}
var expiresAt *time.Time
if p.ExpiresAt != nil {
t := time.UnixMilli(*p.ExpiresAt)
expiresAt = &t
}
if err := s.Repository.Shares.CreateLink(p.Token, resourceID, p.Access, expiresAt, ownerID); err != nil {
return err
}
return s.Repo.TouchResource(resourceID)
}
// applyRevokeLinkOp handles OpRevokeLink: soft-revoke a public share link.
func (s *Resources) applyRevokeLinkOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p revokeLinkPayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if !resourceIDPattern.MatchString(p.Token) {
return errors.New("payload.token must be 32 lowercase hex chars")
}
// Revoke is idempotent: no-op if already revoked/missing.
_ = s.Repository.Shares.RevokeLink(p.Token)
return s.Repo.TouchResource(resourceID)
}
func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error {
return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, derefString(op.RefType), nullableInt64(derefInt64(op.RefID)), derefString(op.ResourceID), op.Payload)
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func nullableString(value string) *string {
if value == "" {
return nil
}
return &value
}
func nullableInt64(value int64) *int64 {
if value == 0 {
return nil
}
return &value
}
// ResourcePermission is the snapshot shape consumed by canAccess
// (docs/api-v1.md §6.2).
type ResourcePermission struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resourceType"`
EffectiveAccess string `json:"effectiveAccess"`
Name string `json:"name"`
ParentID *string `json:"parentId"`
Inherit bool `json:"inherit"`
OwnerID string `json:"ownerId"`
SharedByID *string `json:"sharedById"`
ExpiresAt *int64 `json:"expiresAt"`
CachedAt int64 `json:"cachedAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// Snapshot returns the delta of effective permissions for the user since
// afterMs (epoch ms; 0 = all). Uses the recursive CTE computed by
// repository.Shares.ListEffectivePermissions.
func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) {
perms, err := s.Repository.Shares.ListEffectivePermissions(ownerID, afterMs)
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
out := make([]ResourcePermission, 0, len(perms))
for _, p := range perms {
rp := ResourcePermission{
ResourceID: p.ResourceID,
ResourceType: p.ResourceType,
EffectiveAccess: repository.AccessToString(p.EffectiveRank),
Name: p.Name,
ParentID: p.ParentID,
Inherit: p.Inherit,
OwnerID: p.OwnerID,
SharedByID: p.SharedByID,
CachedAt: now,
UpdatedAt: p.UpdatedAt.UnixMilli(),
}
if p.ExpiresAt != nil {
ms := p.ExpiresAt.UnixMilli()
rp.ExpiresAt = &ms
}
out = append(out, rp)
}
return out, nil
}