mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-01-27 06:29:53 -06:00
485 lines
14 KiB
Go
485 lines
14 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"hash"
|
|
"io"
|
|
"strings"
|
|
|
|
"codeberg.org/shroff/phylum/server/internal/db"
|
|
"codeberg.org/shroff/phylum/server/internal/jobs"
|
|
"codeberg.org/shroff/phylum/server/internal/storage"
|
|
"github.com/doug-martin/goqu/v9"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type ResourceBindConflictResolution int32
|
|
|
|
const (
|
|
ResourceBindConflictResolutionError = ResourceBindConflictResolution(0) // Error if exists
|
|
ResourceBindConflictResolutionEnsure = ResourceBindConflictResolution(1) // Error if type mismatch
|
|
ResourceBindConflictResolutionRename = ResourceBindConflictResolution(2) // Auto rename new resource
|
|
ResourceBindConflictResolutionOverwrite = ResourceBindConflictResolution(3) // Delete existing resource only if type mismatch (preserves props)
|
|
ResourceBindConflictResolutionDelete = ResourceBindConflictResolution(4) // Delete existing resource before creating
|
|
)
|
|
|
|
func CheckResourceNameInvalid(s string) bool {
|
|
return s == "" || s == "." || s == ".." || strings.ContainsFunc(s, func(r rune) bool {
|
|
return r == 0 || r == '/'
|
|
})
|
|
}
|
|
|
|
func (f *FileSystem) CreateResourceByPath(path string, requestedID uuid.UUID, dir, createParents bool, conflictResolution ResourceBindConflictResolution) (Resource, error) {
|
|
var res Resource
|
|
err := f.runInTx(func(f txFileSystem) error {
|
|
var err error
|
|
res, err = f.CreateResourceByPath(path, requestedID, dir, createParents, conflictResolution)
|
|
return err
|
|
})
|
|
return res, err
|
|
}
|
|
|
|
func (f *FileSystem) CreateFileByPath(path string, requestedID, versionID uuid.UUID, conflictResolution ResourceBindConflictResolution) (io.WriteCloser, error) {
|
|
name, parent, err := f.targetByPathWithRoot(path, Resource{})
|
|
if err != nil {
|
|
if errors.Is(err, ErrResourceNotFound) {
|
|
err = ErrParentNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if parent.deleted.Valid {
|
|
return nil, ErrParentNotFound
|
|
}
|
|
|
|
if err := f.checkPermission(parent, PermissionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
if versionID == uuid.Nil {
|
|
versionID, _ = uuid.NewV7()
|
|
}
|
|
|
|
backend := storage.DefaultBackend()
|
|
if dest, err := backend.OpenWrite(versionID.String()); err != nil {
|
|
return nil, err
|
|
} else {
|
|
return computeProps(dest, func(len int, hash hash.Hash, mimeType string) error {
|
|
sum := hex.EncodeToString(hash.Sum(nil))
|
|
var generated bool
|
|
if generated, err = thumber.GeneratePreview(backend.Path(versionID.String()), mimeType); err != nil {
|
|
return err
|
|
}
|
|
err := f.runInTx(func(f txFileSystem) error {
|
|
if r, err := f.createMemberResource(parent, name, requestedID, false, conflictResolution); err != nil {
|
|
return err
|
|
} else {
|
|
// ID could be different if we're overwriting an existing resource
|
|
requestedID = r.id
|
|
}
|
|
|
|
if err := insertResourceVersion(f.db, requestedID, versionID, int64(len), mimeType, sum, generated); err != nil {
|
|
return err
|
|
}
|
|
if err := updateResourceModified(f.db, requestedID); err != nil {
|
|
return err
|
|
}
|
|
|
|
return jobs.MigrateVersionContents(f.db, versionID)
|
|
})
|
|
if err != nil {
|
|
backend.Delete(versionID.String())
|
|
return err
|
|
}
|
|
return nil
|
|
}), nil
|
|
}
|
|
}
|
|
|
|
func (f *FileSystem) CreateFileVersion(r Resource, versionID uuid.UUID) (io.WriteCloser, error) {
|
|
if err := f.checkPermission(r, PermissionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
if r.deleted.Valid {
|
|
return nil, ErrResourceDeleted
|
|
}
|
|
if versionID == uuid.Nil {
|
|
versionID, _ = uuid.NewV7()
|
|
}
|
|
|
|
backend := storage.DefaultBackend()
|
|
if dest, err := backend.OpenWrite(versionID.String()); err != nil {
|
|
return nil, err
|
|
} else {
|
|
return computeProps(dest, func(len int, hash hash.Hash, mimeType string) error {
|
|
sum := hex.EncodeToString(hash.Sum(nil))
|
|
var generated bool
|
|
if generated, err = thumber.GeneratePreview(backend.Path(versionID.String()), mimeType); err != nil {
|
|
return err
|
|
}
|
|
err := f.db.RunInTx(func(db db.TxHandler) error {
|
|
if err := insertResourceVersion(db, r.id, versionID, int64(len), mimeType, sum, generated); err != nil {
|
|
return err
|
|
}
|
|
if err := updateResourceModified(db, r.id); err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: #jobs #tx pass in transaction
|
|
return jobs.MigrateVersionContents(db, versionID)
|
|
})
|
|
if err != nil {
|
|
backend.Delete(versionID.String())
|
|
return err
|
|
}
|
|
return nil
|
|
}), nil
|
|
}
|
|
}
|
|
|
|
func (f txFileSystem) CreateResourceByPath(path string, requestedID uuid.UUID, dir, createParents bool, conflictResolution ResourceBindConflictResolution) (Resource, error) {
|
|
if requestedID == uuid.Nil {
|
|
requestedID, _ = uuid.NewV7()
|
|
}
|
|
|
|
if !createParents {
|
|
name, parent, err := f.targetByPathWithRoot(path, Resource{})
|
|
if err != nil {
|
|
if errors.Is(err, ErrResourceNotFound) {
|
|
err = ErrParentNotFound
|
|
}
|
|
return Resource{}, err
|
|
}
|
|
if parent.deleted.Valid {
|
|
return Resource{}, ErrParentNotFound
|
|
}
|
|
return f.createMemberResource(parent, name, requestedID, dir, conflictResolution)
|
|
}
|
|
|
|
root, path, err := parseUUIDPrefix(path)
|
|
if err != nil {
|
|
return Resource{}, ErrResourcePathInvalid
|
|
}
|
|
if root.Valid {
|
|
f.FileSystem = f.FileSystem.withPathRoot(root)
|
|
}
|
|
segments := strings.Split(strings.TrimRight(strings.TrimLeft(path, "/"), "/"), "/")
|
|
r, err := f.ResourceByID(f.pathRoot.Bytes)
|
|
for i, s := range segments {
|
|
if err != nil {
|
|
return Resource{}, err
|
|
}
|
|
d := true
|
|
resourceID, _ := uuid.NewV7()
|
|
conflict := ResourceBindConflictResolutionEnsure
|
|
if i == len(segments)-1 {
|
|
d = dir
|
|
resourceID = requestedID
|
|
conflict = conflictResolution
|
|
}
|
|
r, err = f.createMemberResource(r, s, resourceID, d, conflict)
|
|
}
|
|
|
|
return r, err
|
|
}
|
|
|
|
func (f txFileSystem) createMemberResource(r Resource, name string, requestedID uuid.UUID, dir bool, conflictResolution ResourceBindConflictResolution) (Resource, error) {
|
|
if r.deleted.Valid {
|
|
return Resource{}, ErrResourceDeleted
|
|
}
|
|
if !r.Dir() {
|
|
return Resource{}, ErrResourceNotCollection
|
|
}
|
|
if CheckResourceNameInvalid(name) {
|
|
return Resource{}, ErrResourceNameInvalid
|
|
}
|
|
if requestedID == uuid.Nil {
|
|
requestedID, _ = uuid.NewV7()
|
|
}
|
|
var res Resource
|
|
var created bool
|
|
var err error
|
|
if res, created, _, err = resolveConflictAndInsertResource(f.db, requestedID, r.id, name, dir, r.permissions, conflictResolution); err != nil {
|
|
return Resource{}, err
|
|
} else if created {
|
|
if err := f.checkPermission(r, PermissionWrite); err != nil {
|
|
return Resource{}, err
|
|
}
|
|
if err := recomputePermissions(f.db, requestedID); err != nil {
|
|
return Resource{}, err
|
|
}
|
|
if err := updateResourceModified(f.db, r.id); err != nil {
|
|
return Resource{}, err
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// resolveConflictAndInsertResource tries to insert a resource with the
|
|
// provided details if there isn't a name conflict. If there is one, then what
|
|
// happens depends on [conflictResolution].
|
|
//
|
|
// [ResourceBindConflictResolutionError] returns [ErrResourceNameConcflict]
|
|
//
|
|
// [ResourceBindConflictResolutionEnsure] returns the existing resource if
|
|
// [dir] matches. Returns [ErrResourceNameConflict] otherwise
|
|
//
|
|
// [ResourceBindConflictResolutionRename] finds the first available numbered
|
|
// suffix and creates that instead
|
|
//
|
|
// [ResourceBindConflictResolutionOverwrite] returns existing resource if [dir]
|
|
// matches after deleting children in case [dir] is `true`. If not, then
|
|
// deletes the existing resource and creates a new one.
|
|
//
|
|
// [ResourceBindConflictResolutionDelete] deletes the existing resource and
|
|
// creates a new one.
|
|
func resolveConflictAndInsertResource(
|
|
db db.TxHandler,
|
|
requestedID uuid.UUID,
|
|
parent uuid.UUID,
|
|
name string,
|
|
dir bool,
|
|
permissions []byte,
|
|
conflictResolution ResourceBindConflictResolution,
|
|
) (res Resource, created bool, deleted bool, err error) {
|
|
switch conflictResolution {
|
|
case ResourceBindConflictResolutionError:
|
|
var rID uuid.UUID
|
|
if rID, _, err = childResourceIDByName(db, parent, name); err == nil {
|
|
if rID == requestedID {
|
|
res, err = resourceByID(db, rID, -1)
|
|
res.visibleParent = pgtype.UUID{Bytes: parent, Valid: true}
|
|
res.inheritedPermissions = permissions
|
|
res.permissions = permissions
|
|
return res, true, false, err
|
|
} else {
|
|
return Resource{}, false, false, ErrResourceNameConflict
|
|
}
|
|
} else {
|
|
// There is no name conflict. Proceed as usual
|
|
err = nil
|
|
}
|
|
case ResourceBindConflictResolutionEnsure:
|
|
var rID uuid.UUID
|
|
var rDir bool
|
|
if rID, rDir, err = childResourceIDByName(db, parent, name); err == nil {
|
|
if rDir == dir {
|
|
// Return the existing resourc
|
|
res, err := resourceByID(db, rID, -1)
|
|
res.visibleParent = pgtype.UUID{Bytes: parent, Valid: true}
|
|
res.inheritedPermissions = permissions
|
|
res.permissions = permissions
|
|
return res, false, false, err
|
|
} else {
|
|
return Resource{}, false, false, ErrResourceNameConflict
|
|
}
|
|
} else {
|
|
// There is no name conflict. Proceed as usual
|
|
err = nil
|
|
}
|
|
case ResourceBindConflictResolutionRename:
|
|
name, err = detectNameConflict(db, parent, name, true)
|
|
case ResourceBindConflictResolutionOverwrite:
|
|
var rID uuid.UUID
|
|
var rDir bool
|
|
if rID, rDir, err = childResourceIDByName(db, parent, name); err == nil {
|
|
if rDir == dir {
|
|
if dir {
|
|
err = softDeleteChildren(db, rID, parent)
|
|
}
|
|
if err == nil {
|
|
res, err := resourceByID(db, rID, -1)
|
|
res.visibleParent = pgtype.UUID{Bytes: parent, Valid: true}
|
|
res.inheritedPermissions = permissions
|
|
res.permissions = permissions
|
|
return res, false, true, err
|
|
}
|
|
} else {
|
|
deleted = true
|
|
err = softDelete(db, rID)
|
|
}
|
|
} else {
|
|
// There is no name conflict. Proceed as usual
|
|
err = nil
|
|
}
|
|
case ResourceBindConflictResolutionDelete:
|
|
var rID uuid.UUID
|
|
if rID, _, err = childResourceIDByName(db, parent, name); err != nil {
|
|
deleted = true
|
|
err = softDelete(db, rID)
|
|
} else {
|
|
// There is no name conflict. Proceed as usual
|
|
err = nil
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return Resource{}, false, false, err
|
|
}
|
|
res, err = insertResource(
|
|
db,
|
|
requestedID,
|
|
parent,
|
|
name,
|
|
dir,
|
|
permissions,
|
|
)
|
|
return res, true, deleted, err
|
|
}
|
|
|
|
func insertResource(db db.TxHandler, id, parent uuid.UUID, name string, dir bool, permissions []byte) (Resource, error) {
|
|
query, args, _ := pg.From("resources").
|
|
Insert().
|
|
Rows(goqu.Record{
|
|
"id": goqu.V(id),
|
|
"parent": goqu.V(parent),
|
|
"name": goqu.V(name),
|
|
"dir": goqu.V(dir),
|
|
"permissions": goqu.V(permissions),
|
|
}).
|
|
Returning(
|
|
"*",
|
|
goqu.L("'[]'::JSONB"), // versions
|
|
goqu.L("'[]'::JSONB"), // links
|
|
goqu.L("NULL"), // visible parent
|
|
goqu.L("'{}'::JSONB"), // inherited permissions
|
|
).
|
|
ToSQL()
|
|
|
|
if rows, err := db.Query(query, args...); err != nil {
|
|
return Resource{}, err
|
|
} else if r, err := collectFullResource(rows); err != nil {
|
|
if strings.Contains(err.Error(), "unique_member_resource_name") {
|
|
err = ErrResourceNameConflict
|
|
}
|
|
if strings.Contains(err.Error(), "resources_pkey") {
|
|
// maybe the request already succeeded in the previous attempt but the client didn't receive the response?
|
|
err = ErrIDConflict
|
|
}
|
|
return Resource{}, err
|
|
} else {
|
|
r.parentID = pgtype.UUID{Bytes: parent, Valid: true}
|
|
r.visibleParent = r.parentID
|
|
r.inheritedPermissions = permissions
|
|
r.permissions = permissions
|
|
return r, nil
|
|
}
|
|
}
|
|
|
|
func updateResourceModified(db db.TxHandler, id uuid.UUID) error {
|
|
const q = "UPDATE resources SET modified = NOW() WHERE id = $1"
|
|
_, err := db.Exec(q, id)
|
|
return err
|
|
}
|
|
|
|
func insertResourceVersion(db db.TxHandler, id, versionID uuid.UUID, size int64, mimeType, sha256 string, thumb bool) error {
|
|
const q = `INSERT INTO resource_versions(id, resource_id, size, mime_type, sha256, storage, preview)
|
|
VALUES (@version_id, @resource_id, @size, @mime_type, @sha256, @storage, @preview)`
|
|
|
|
args := pgx.NamedArgs{
|
|
"resource_id": id,
|
|
"version_id": versionID,
|
|
"size": size,
|
|
"mime_type": mimeType,
|
|
"sha256": sha256,
|
|
"storage": storage.DefaultBackendName,
|
|
"preview": thumb,
|
|
}
|
|
_, err := db.Exec(q, args)
|
|
if err != nil && strings.Contains(err.Error(), "resource_versions_pkey") {
|
|
return ErrIDConflict
|
|
}
|
|
return err
|
|
}
|
|
|
|
func insertResourcesFast(db db.TxHandler, arg []insertResourcesFastParams) (int64, error) {
|
|
return db.CopyFrom([]string{"resources"}, []string{"id", "parent", "name", "dir"}, &iteratorForInsertResourcesFast{rows: arg})
|
|
}
|
|
|
|
// For bulk insert
|
|
type insertResourcesFastParams struct {
|
|
ID uuid.UUID
|
|
Parent uuid.UUID
|
|
Name string
|
|
Dir bool
|
|
}
|
|
|
|
// iteratorForInsertResourcesFast implements pgx.CopyFromSource.
|
|
type iteratorForInsertResourcesFast struct {
|
|
rows []insertResourcesFastParams
|
|
skippedFirstNextCall bool
|
|
}
|
|
|
|
func (r *iteratorForInsertResourcesFast) Next() bool {
|
|
if len(r.rows) == 0 {
|
|
return false
|
|
}
|
|
if !r.skippedFirstNextCall {
|
|
r.skippedFirstNextCall = true
|
|
return true
|
|
}
|
|
r.rows = r.rows[1:]
|
|
return len(r.rows) > 0
|
|
}
|
|
|
|
func (r iteratorForInsertResourcesFast) Values() ([]interface{}, error) {
|
|
return []interface{}{
|
|
r.rows[0].ID,
|
|
r.rows[0].Parent,
|
|
r.rows[0].Name,
|
|
r.rows[0].Dir,
|
|
}, nil
|
|
}
|
|
|
|
func (r iteratorForInsertResourcesFast) Err() error {
|
|
return nil
|
|
}
|
|
|
|
func insertResourceVersionsFast(db db.TxHandler, arg []insertResourceVersionsFastParams) (int64, error) {
|
|
return db.CopyFrom([]string{"resource_versions"}, []string{"id", "resource_id", "size", "mime_type", "sha256", "storage"}, &iteratorForInsertResourceVersionsFast{rows: arg})
|
|
}
|
|
|
|
// For bulk insert
|
|
type insertResourceVersionsFastParams struct {
|
|
ID uuid.UUID
|
|
ResourceID uuid.UUID
|
|
Size int32
|
|
MimeType string
|
|
SHA256 string
|
|
}
|
|
|
|
// iteratorForInsertResourceVersionsFast implements pgx.CopyFromSource.
|
|
type iteratorForInsertResourceVersionsFast struct {
|
|
rows []insertResourceVersionsFastParams
|
|
skippedFirstNextCall bool
|
|
}
|
|
|
|
func (r *iteratorForInsertResourceVersionsFast) Next() bool {
|
|
if len(r.rows) == 0 {
|
|
return false
|
|
}
|
|
if !r.skippedFirstNextCall {
|
|
r.skippedFirstNextCall = true
|
|
return true
|
|
}
|
|
r.rows = r.rows[1:]
|
|
return len(r.rows) > 0
|
|
}
|
|
|
|
func (r iteratorForInsertResourceVersionsFast) Values() ([]interface{}, error) {
|
|
return []interface{}{
|
|
r.rows[0].ID,
|
|
r.rows[0].ResourceID,
|
|
r.rows[0].Size,
|
|
r.rows[0].MimeType,
|
|
r.rows[0].SHA256,
|
|
storage.DefaultBackendName,
|
|
}, nil
|
|
}
|
|
|
|
func (r iteratorForInsertResourceVersionsFast) Err() error {
|
|
return nil
|
|
}
|