* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\FileProviderLocal\Providers\Personal; use KTXF\Files\Node\INodeBase; use KTXF\Files\Node\INodeCollectionBase; use KTXF\Files\Node\INodeCollectionMutable; use KTXF\Files\Node\INodeEntityBase; use KTXF\Files\Node\INodeEntityMutable; use KTXF\Files\Node\NodeType; use KTXF\Files\Service\IServiceBase; use KTXF\Files\Service\IServiceCollectionMutable; use KTXF\Files\Service\IServiceEntityMutable; use KTXF\Resource\Exceptions\InvalidParameterException; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\FileProviderLocal\Store\BlobStore; use KTXM\FileProviderLocal\Store\MetaStore; use KTXF\Blob\Signature; class PersonalService implements IServiceBase, IServiceCollectionMutable, IServiceEntityMutable { public const ROOT_ID = '00000000-0000-0000-0000-000000000000'; public const ROOT_LABEL = 'Personal Files'; protected const SERVICE_ID = 'personal'; protected const SERVICE_LABEL = 'Personal Files Service'; protected const SERVICE_PROVIDER = 'default'; protected array $serviceCollectionCache = []; protected array $serviceEntityCache = []; protected ?string $serviceTenantId = null; protected ?string $serviceUserId = null; protected ?bool $serviceEnabled = true; protected ?string $serviceRoot = null; protected array $serviceAbilities = [ // Collection capabilities self::CAPABILITY_COLLECTION_LIST => true, self::CAPABILITY_COLLECTION_LIST_FILTER => [ 'id' => 'a:10:64:192', 'label' => 's:100:256:771', ], self::CAPABILITY_COLLECTION_LIST_SORT => [ 'label', 'created', 'modified' ], self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_MODIFY => true, self::CAPABILITY_COLLECTION_DESTROY => true, self::CAPABILITY_COLLECTION_COPY => true, self::CAPABILITY_COLLECTION_MOVE => true, // Entity capabilities self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ 'id' => 'a:10:64:192', 'label' => 's:100:256:771', 'mimeType' => 's:100:256:771', ], self::CAPABILITY_ENTITY_LIST_SORT => [ 'label', 'size', 'created', 'modified' ], self::CAPABILITY_ENTITY_LIST_RANGE => [ 'tally' => ['absolute', 'relative'] ], self::CAPABILITY_ENTITY_DELTA => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, self::CAPABILITY_ENTITY_READ => true, self::CAPABILITY_ENTITY_READ_STREAM => true, self::CAPABILITY_ENTITY_READ_CHUNK => true, self::CAPABILITY_ENTITY_CREATE => true, self::CAPABILITY_ENTITY_MODIFY => true, self::CAPABILITY_ENTITY_DESTROY => true, self::CAPABILITY_ENTITY_COPY => true, self::CAPABILITY_ENTITY_MOVE => true, self::CAPABILITY_ENTITY_WRITE => true, self::CAPABILITY_ENTITY_WRITE_STREAM => true, self::CAPABILITY_ENTITY_WRITE_CHUNK => true, // Node capabilities (recursive/unified) self::CAPABILITY_NODE_LIST => true, self::CAPABILITY_NODE_LIST_FILTER => [ 'id' => 'a:10:64:192', 'label' => 's:100:256:771', 'nodeType' => 's:10:20:192', 'mimeType' => 's:100:256:771', ], self::CAPABILITY_NODE_LIST_SORT => [ 'label', 'nodeType', 'size', 'modified' ], self::CAPABILITY_NODE_LIST_RANGE => [ 'tally' => ['absolute', 'relative'] ], self::CAPABILITY_NODE_DELTA => true, ]; public function __construct( private readonly MetaStore $metaStore, private readonly BlobStore $blobStore, ) {} public function jsonSerialize(): mixed { return [ self::JSON_PROPERTY_TYPE => self::JSON_TYPE, self::JSON_PROPERTY_PROVIDER => self::SERVICE_PROVIDER, self::JSON_PROPERTY_ID => self::SERVICE_ID, self::JSON_PROPERTY_LABEL => self::SERVICE_LABEL, self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, ]; } public function init(string $tenantId, string $userId, string $root): self { $this->serviceTenantId = $tenantId; $this->serviceUserId = $userId; $this->serviceRoot = $root; // configure blob store with root path $this->blobStore->configureRoot($root); $this->serviceCollectionCache = []; $root = new NodeCollection(); $root->fromStore([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => self::ROOT_ID, 'pid' => null, 'createdBy' => $userId, 'createdOn' => (new \DateTimeImmutable())->format('c'), 'owner' => $userId, 'label' => self::ROOT_LABEL, ]); $this->serviceCollectionCache[self::ROOT_ID] = $root; return $this; } public function capable(string $value): bool { if (isset($this->serviceAbilities[$value])) { return (bool)$this->serviceAbilities[$value]; } return false; } public function capabilities(): array { return $this->serviceAbilities; } public function in(): string { return self::SERVICE_PROVIDER; } public function id(): string { return self::SERVICE_ID; } public function getLabel(): string { return (string)self::SERVICE_LABEL; } public function getEnabled(): bool { return (bool)$this->serviceEnabled; } // ==================== Collection Methods (IServiceBase) ==================== /** * @inheritdoc */ public function collectionList(string|int|null $location = null, ?IFilter $filter = null, ?ISort $sort = null): array { $entries = $this->metaStore->collectionList($this->serviceTenantId, $this->serviceUserId, $location, $filter, $sort); // cache collections foreach ($entries as $id => $collection) { $this->serviceCollectionCache[$id] = $collection; } return $entries ?? []; } /** * @inheritdoc */ public function collectionListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); } /** * @inheritdoc */ public function collectionListSort(): ISort { return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); } /** * @inheritdoc */ public function collectionExtant(string|int|null $identifier): bool { // null is root if ($identifier === null) { return true; } // check cache first if (isset($this->serviceCollectionCache[$identifier])) { return true; } // check store return $this->metaStore->collectionExtant($this->serviceTenantId, $this->serviceUserId, $identifier); } /** * @inheritdoc */ public function collectionFetch(string|int|null $identifier): ?INodeCollectionBase { // null is root if ($identifier === null) { $identifier = self::ROOT_ID; } // check cache first if (isset($this->serviceCollectionCache[$identifier])) { return $this->serviceCollectionCache[$identifier]; } // fetch from store $collections = $this->metaStore->collectionFetch($this->serviceTenantId, $this->serviceUserId, $identifier); if (isset($collections[$identifier])) { $this->serviceCollectionCache[$identifier] = $collections[$identifier]; return $collections[$identifier]; } return null; } // ==================== Collection Methods (IServiceCollectionMutable) ==================== /** * @inheritdoc */ public function collectionFresh(): INodeCollectionMutable { $collection = new NodeCollection(); $collection->fromStore([ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'nid' => null, 'pid' => null, 'createdBy' => $this->serviceUserId, 'createdOn' => (new \DateTimeImmutable())->format('c'), 'owner' => $this->serviceUserId, 'label' => '', ]); return $collection; } /** * @inheritdoc */ public function collectionCreate(string|int|null $location, INodeCollectionMutable $collection, array $options = []): INodeCollectionBase { // null is root if ($location === null) { $location = self::ROOT_ID; } // Create in meta store $node = $this->metaStore->collectionCreate($this->serviceTenantId, $this->serviceUserId, $location, $collection, $options); // cache collection $this->serviceCollectionCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function collectionModify(string|int $identifier, INodeCollectionMutable $collection): INodeCollectionBase { // Modify in meta store $node = $this->metaStore->collectionModify($this->serviceTenantId, $this->serviceUserId, $identifier, $collection); // update cache $this->serviceCollectionCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function collectionDestroy(string|int $identifier): bool { // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot destroy root collection"); } // Delete from meta store $result = $this->metaStore->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $identifier); // remove from cache unset($this->serviceCollectionCache[$identifier]); return $result; } /** * @inheritdoc */ public function collectionCopy(string|int $identifier, string|int|null $location): INodeCollectionBase { // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot copy root collection"); } // Verify collection exists if (!$this->collectionExtant($identifier)) { throw new InvalidParameterException("Collection not found: $identifier"); } // null location is root if ($location === null) { $location = self::ROOT_ID; } // Copy in meta store $node = $this->metaStore->collectionCopy($this->serviceTenantId, $this->serviceUserId, $identifier, $location); // cache collection $this->serviceCollectionCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function collectionMove(string|int $identifier, string|int|null $location): INodeCollectionBase { // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot move root collection"); } // Verify collection exists if (!$this->collectionExtant($identifier)) { throw new InvalidParameterException("Collection not found: $identifier"); } // null location is root if ($location === null) { $location = self::ROOT_ID; } // Move in meta store $node = $this->metaStore->collectionMove($this->serviceTenantId, $this->serviceUserId, $identifier, $location); // update cache $this->serviceCollectionCache[$node->id()] = $node; return $node; } // ==================== Entity Methods (IServiceBase) ==================== /** * @inheritdoc */ public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } $entries = $this->metaStore->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range); // cache entities foreach ($entries as $id => $entity) { $this->serviceEntityCache[$id] = $entity; } return $entries ?? []; } /** * @inheritdoc */ public function entityListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); } /** * @inheritdoc */ public function entityListSort(): ISort { return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); } /** * @inheritdoc */ public function entityListRange(RangeType $type): IRange { if ($type !== RangeType::TALLY) { throw new InvalidParameterException("Invalid: Entity range of type '{$type->value}' is not supported"); } return new RangeTally(); } /** * @inheritdoc */ public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): array { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } return $this->metaStore->entityDelta($this->serviceTenantId, $this->serviceUserId, $collection, $signature, $detail); } /** * @inheritdoc */ public function entityExtant(string|int|null $collection, string|int ...$identifiers): array { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } $result = []; foreach ($identifiers as $id) { // check cache first if (isset($this->serviceEntityCache[$id])) { $result[$id] = true; continue; } // check store $result[$id] = $this->metaStore->entityExtant($this->serviceTenantId, $this->serviceUserId, $collection, $id); } return $result; } /** * @inheritdoc */ public function entityFetch(string|int|null $collection, string|int ...$identifiers): array { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } $result = []; $toFetch = []; // check cache first foreach ($identifiers as $id) { if (isset($this->serviceEntityCache[$id])) { $result[$id] = $this->serviceEntityCache[$id]; } else { $toFetch[] = $id; } } // fetch remaining from store if (!empty($toFetch)) { $fetched = $this->metaStore->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$toFetch); foreach ($fetched as $id => $entity) { $this->serviceEntityCache[$id] = $entity; $result[$id] = $entity; } } return $result; } /** * @inheritdoc */ public function entityRead(string|int|null $collection, string|int $identifier): ?string { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } $entities = $this->entityFetch($collection, $identifier); if (!isset($entities[$identifier])) { return null; } return $this->blobStore->blobRead((string)$identifier); } /** * @inheritdoc */ public function entityReadStream(string|int|null $collection, string|int $identifier) { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } $entities = $this->entityFetch($collection, $identifier); if (!isset($entities[$identifier])) { return null; } return $this->blobStore->blobReadStream((string)$identifier); } /** * @inheritdoc */ public function entityReadChunk(string|int|null $collection, string|int $identifier, int $offset, int $length): ?string { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } $entities = $this->entityFetch($collection, $identifier); if (!isset($entities[$identifier])) { return null; } return $this->blobStore->blobReadChunk((string)$identifier, $offset, $length); } // ==================== Entity Methods (IServiceEntityMutable) ==================== /** * @inheritdoc */ public function entityFresh(): INodeEntityMutable { $entity = new NodeEntity(); $entity->fromStore([ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'nid' => null, 'pid' => null, 'createdBy' => $this->serviceUserId, 'createdOn' => (new \DateTimeImmutable())->format('c'), 'owner' => $this->serviceUserId, 'label' => '', ]); return $entity; } /** * Build metadata array for an entity to store in .meta file * * @param INodeEntityBase $node The entity node * @return array Metadata array */ protected function buildEntityMeta(INodeEntityBase $node): array { return [ 'nid' => $node->id(), 'pid' => $node->in(), 'label' => $node->getLabel(), 'mime' => $node->getMime(), 'format' => $node->getFormat(), 'createdBy' => $node->createdBy(), 'createdOn' => $node->createdOn()?->format('c'), 'modifiedBy' => $node->modifiedBy(), 'modifiedOn' => $node->modifiedOn()?->format('c'), ]; } /** * @inheritdoc */ public function entityCreate(string|int|null $collection, INodeEntityMutable $entity, array $options = []): INodeEntityBase { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Create in meta store $node = $this->metaStore->entityCreate($this->serviceTenantId, $this->serviceUserId, $collection, $entity, $options); // Write meta file for recovery $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); // cache entity $this->serviceEntityCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function entityModify(string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): INodeEntityBase { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Modify in meta store $node = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $entity); // Update meta file for recovery $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); // update cache $this->serviceEntityCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function entityDestroy(string|int|null $collection, string|int $identifier): bool { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Delete from meta store $result = $this->metaStore->entityDestroy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); // Delete file from filesystem if ($result) { $this->blobStore->blobDelete((string)$identifier); } // remove from cache unset($this->serviceEntityCache[$identifier]); return $result; } /** * @inheritdoc */ public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // null destination is root if ($destination === null) { $destination = self::ROOT_ID; } // Verify entity exists $extant = $this->entityExtant($collection, $identifier); if (!($extant[$identifier] ?? false)) { throw new InvalidParameterException("Entity not found: $identifier"); } // Copy in meta store (creates new entity with new ID) $node = $this->metaStore->entityCopy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $destination); // Copy file content (new entity has new UUID = new file) $content = $this->blobStore->blobRead((string)$identifier); if ($content !== null) { $this->blobStore->blobWrite((string)$node->id(), $content); } // Write meta file for recovery $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); // cache entity $this->serviceEntityCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // null destination is root if ($destination === null) { $destination = self::ROOT_ID; } // Verify entity exists $extant = $this->entityExtant($collection, $identifier); if (!($extant[$identifier] ?? false)) { throw new InvalidParameterException("Entity not found: $identifier"); } // Move in meta store (file path never changes since it's based on entity ID) $node = $this->metaStore->entityMove($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $destination); // Update meta file for recovery (parent changed) $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); // update cache $this->serviceEntityCache[$node->id()] = $node; return $node; } /** * @inheritdoc */ public function entityWrite(string|int|null $collection, string|int $identifier, string $data): int { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Verify entity exists $extant = $this->entityExtant($collection, $identifier); if (!($extant[$identifier] ?? false)) { throw new InvalidParameterException("Entity not found: $identifier"); } // Detect MIME type and format from content header $signature = Signature::detect(substr($data, 0, Signature::HEADER_SIZE)); $bytes = $this->blobStore->blobWrite((string)$identifier, $data); if ($bytes === null) { throw new \RuntimeException("Failed to write to entity: $identifier"); } // Update metadata (size, mime, and format) $this->metaStore->entityUpdate($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ 'size' => $bytes, 'mime' => $signature['mime'], 'format' => $signature['format'], ]); // Update meta file $entities = $this->entityFetch($collection, $identifier); if (isset($entities[$identifier])) { $this->blobStore->metaWrite((string)$identifier, $this->buildEntityMeta($entities[$identifier])); } return $bytes; } /** * @inheritdoc */ public function entityWriteStream(string|int|null $collection, string|int $identifier) { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Verify entity exists $extant = $this->entityExtant($collection, $identifier); if (!($extant[$identifier] ?? false)) { throw new InvalidParameterException("Entity not found: $identifier"); } return $this->blobStore->blobWriteStream((string)$identifier); } /** * @inheritdoc */ public function entityWriteChunk(string|int|null $collection, string|int $identifier, int $offset, string $data): int { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Verify entity exists $extant = $this->entityExtant($collection, $identifier); if (!($extant[$identifier] ?? false)) { throw new InvalidParameterException("Entity not found: $identifier"); } // Detect MIME type and format from first chunk (offset === 0) $signature = null; if ($offset === 0) { $signature = Signature::detect(substr($data, 0, Signature::HEADER_SIZE)); } $bytes = $this->blobStore->blobWriteChunk((string)$identifier, $offset, $data); if ($bytes === null) { throw new \RuntimeException("Failed to write chunk to entity: $identifier"); } // Build update attributes $updates = []; // Update size metadata $newSize = $this->blobStore->blobSize((string)$identifier); if ($newSize !== null) { $updates['size'] = $newSize; } // Update mime and format if detected (first chunk) if ($signature !== null) { $updates['mime'] = $signature['mime']; $updates['format'] = $signature['format']; } // Perform single database update if (!empty($updates)) { $this->metaStore->entityUpdate($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $updates); } // Update meta file $entities = $this->entityFetch($collection, $identifier); if (isset($entities[$identifier])) { $this->blobStore->metaWrite((string)$identifier, $this->buildEntityMeta($entities[$identifier])); } return $bytes; } // ==================== Node Methods (IServiceBase) ==================== /** * @inheritdoc */ public function nodeList(string|int|null $location = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array { // null location is root if ($location === null) { $location = self::ROOT_ID; } return $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $location, $recursive, $filter, $sort, $range); } /** * @inheritdoc */ public function nodeListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_NODE_LIST_FILTER] ?? []); } /** * @inheritdoc */ public function nodeListSort(): ISort { return new Sort($this->serviceAbilities[self::CAPABILITY_NODE_LIST_SORT] ?? []); } /** * @inheritdoc */ public function nodeListRange(RangeType $type): IRange { if ($type !== RangeType::TALLY) { throw new InvalidParameterException("Invalid: Node range of type '{$type->value}' is not supported"); } return new RangeTally(); } /** * @inheritdoc */ public function nodeDelta(string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array { return $this->metaStore->nodeDelta($this->serviceTenantId, $this->serviceUserId, $location, $signature, $recursive, $detail); } }