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
3 changes: 3 additions & 0 deletions databox/api/config/packages/cache.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ framework:
analytics.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
signed_url.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'

default_pdo_provider: 'doctrine.dbal.default_connection'
3 changes: 2 additions & 1 deletion databox/api/fixtures/Newspaper.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,8 @@ App\Entity\Core\File:
workspace: '@w_newspaper'
storage: s3_main
pathPublic: '95%? true : false'
analysis: []
__calls:
- setNoAnalysisNeeded: []
metadata: '<largeJSONFileMetadata()>'

f_story_img_m{1..20} (extends file):
Expand Down
3 changes: 2 additions & 1 deletion databox/api/fixtures/Permissions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ App\Entity\Core\File:
file_p (template):
workspace: '@w_newspaper'
storage: s3_main
analysis: []
__calls:
- setNoAnalysisNeeded: []

f_img_o{1..6} (extends file_p):
type: image/jpeg
Expand Down
83 changes: 83 additions & 0 deletions databox/api/migrations/Version20260909160000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260909160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Move the detailed file analysis into its own table (file_analysis); the file only keeps the analysis date and whether it was accepted';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE file_analysis (id UUID NOT NULL, status VARCHAR(20) NOT NULL, hash VARCHAR(32) DEFAULT NULL, message TEXT DEFAULT NULL, results JSON NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN file_analysis.id IS \'(DC2Type:uuid)\'');
$this->addSql('COMMENT ON COLUMN file_analysis.created_at IS \'(DC2Type:datetime_immutable)\'');

$this->addSql('ALTER TABLE file ADD analysis_id UUID DEFAULT NULL');
$this->addSql('ALTER TABLE file ADD analyzed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE file ADD accepted BOOLEAN DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN file.analysis_id IS \'(DC2Type:uuid)\'');
$this->addSql('COMMENT ON COLUMN file.analyzed_at IS \'(DC2Type:datetime_immutable)\'');

// Data: a file analyzed with details gets a file_analysis row (reusing the file id as row id),
// every analyzed file (including "no analysis needed", stored as [] or {}) gets its date and outcome.
$this->addSql(<<<'SQL'
INSERT INTO file_analysis (id, status, hash, message, results, created_at)
SELECT id,
COALESCE(analysis->>'status', 'success'),
analysis->>'hash',
analysis->>'message',
COALESCE(analysis->'results', '[]'::json),
updated_at
FROM file
WHERE analysis IS NOT NULL
AND json_typeof(analysis) = 'object'
AND analysis::text <> '{}'
SQL);
$this->addSql(<<<'SQL'
UPDATE file SET
analysis_id = CASE WHEN json_typeof(analysis) = 'object' AND analysis::text <> '{}' THEN id END,
analyzed_at = updated_at,
accepted = (
json_typeof(analysis) <> 'object'
OR analysis::text = '{}'
OR analysis->>'status' IN ('success', 'skipped', 'bypassed')
)
WHERE analysis IS NOT NULL
SQL);

$this->addSql('ALTER TABLE file DROP analysis');
$this->addSql('ALTER TABLE file ADD CONSTRAINT FK_8C9F36107941003F FOREIGN KEY (analysis_id) REFERENCES file_analysis (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8C9F36107941003F ON file (analysis_id)');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE file ADD analysis JSON DEFAULT NULL');
$this->addSql(<<<'SQL'
UPDATE file SET analysis = json_strip_nulls(json_build_object(
'status', a.status,
'results', CASE WHEN a.results::text <> '[]' THEN a.results END,
'hash', a.hash,
'message', a.message
))
FROM file_analysis a
WHERE a.id = file.analysis_id
SQL);
$this->addSql('UPDATE file SET analysis = \'[]\' WHERE analysis IS NULL AND analyzed_at IS NOT NULL');

$this->addSql('ALTER TABLE file DROP CONSTRAINT FK_8C9F36107941003F');
$this->addSql('DROP INDEX UNIQ_8C9F36107941003F');
$this->addSql('ALTER TABLE file DROP analysis_id');
$this->addSql('ALTER TABLE file DROP analyzed_at');
$this->addSql('ALTER TABLE file DROP accepted');
$this->addSql('DROP TABLE file_analysis');
}
}
11 changes: 11 additions & 0 deletions databox/api/src/Api/Model/Output/FileOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ class FileOutput extends AbstractUuidOutput
])]
public ?bool $accepted = null;

#[Groups([
File::GROUP_LIST,
File::GROUP_READ,
Asset::GROUP_LIST,
Asset::GROUP_READ,
AssetRendition::GROUP_LIST,
AssetRendition::GROUP_READ,
AssetAttachment::GROUP_LIST,
])]
public ?\DateTimeImmutable $analyzedAt = null;

#[Groups([File::GROUP_METADATA])]
public ?array $metadata = null;

Expand Down
30 changes: 19 additions & 11 deletions databox/api/src/Api/OutputTransformer/AssetOutputTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,28 @@ public function transform(object $data, string $outputClass, array &$context = [

$output->setSource($data->getSource());

$assetPolicyFilter = $this->assetPolicyManager->getPolicyApplicationFilter($data);

if ($this->hasGroup([
// Full representation (list/read/share…) vs. the story-only context,
// where an embedded story asset only exposes its name.
$fullOutput = $this->hasGroup([
Asset::GROUP_LIST,
Asset::GROUP_STORY,
Share::GROUP_READ,
Share::GROUP_PUBLIC_READ,
ResolveEntitiesOutput::GROUP_READ,
], $context)) {
$attributesIndex = $data->attributesIndex ?? $this->attributesResolver->resolveAssetAttributes($data, true);
$attributes = array_values(array_filter($attributesIndex->getFlattenAttributes(), fn (Attribute $attribute): bool => !in_array($attribute->getDefinition()->getId(), $assetPolicyFilter->getFilteredAttributes(), true)));
], $context);

if ($fullOutput || $this->hasGroup(Asset::GROUP_STORY, $context)) {
$attributesIndex = $data->attributesIndex ?? $this->attributesResolver->resolveAssetAttributes($data, true);
$highlights = $data->getElasticHighlights();
if (!empty($highlights)) {
$this->attributesResolver->assignHighlight($attributes, $highlights);

if ($fullOutput) {
$assetPolicyFilter = $this->assetPolicyManager->getPolicyApplicationFilter($data);
$attributes = array_values(array_filter($attributesIndex->getFlattenAttributes(), fn (Attribute $attribute): bool => !in_array($attribute->getDefinition()->getId(), $assetPolicyFilter->getFilteredAttributes(), true)));

if (!empty($highlights)) {
$this->attributesResolver->assignHighlight($attributes, $highlights);
}
$output->setAttributes($attributes);
}
$output->setAttributes($attributes);

$nameAttribute = $this->assetNameResolver->resolveName($data, $attributesIndex);
if ($nameAttribute instanceof Attribute) {
Expand All @@ -119,13 +124,16 @@ public function transform(object $data, string $outputClass, array &$context = [
}

$output->setGroupValue($data->groupValue);
}

if ($fullOutput) {
$output->setPrivacy($data->getPrivacy());
$output->setTags($data->getTags()->getValues());
$output->setWorkspace($data->getWorkspace());

$renditions = $this->em
->getRepository(AssetRendition::class)
->findAssetRenditions($data->getId(), [
->getCachedAssetRenditions($data->getId(), [
AssetRenditionRepository::OPT_USED_AS => true,
AssetRenditionRepository::OPT_EXCLUDE_DEFINITIONS => $assetPolicyFilter->getFilteredRenditions(),
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Alchemy\AclBundle\Security\PermissionManager;
use Alchemy\AuthBundle\Security\JwtUser;
use Alchemy\AuthBundle\Security\Traits\SecurityAwareTrait;
use Alchemy\CoreBundle\Cache\TemporaryCacheFactory;
use Alchemy\NotifierBundle\Manager\SubscriptionManager;
use App\Api\Model\Output\CollectionOutput;
use App\Api\Traits\UserLocaleTrait;
Expand All @@ -20,6 +21,7 @@
use App\Security\Voter\CollectionVoter;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

Expand All @@ -32,14 +34,18 @@ class CollectionOutputTransformer implements OutputTransformerInterface

final public const string COLLECTION_CACHE_NS = 'coll_visibility';

private readonly CacheInterface $visibilityRequestCache;

public function __construct(
private readonly CollectionSearch $collectionSearch,
private readonly TagAwareCacheInterface $collectionCache,
private readonly PermissionManager $permissionManager,
private readonly SubscriptionManager $subscriptionManager,
#[Autowire(env: 'API_COLLECTION_OWNER_PROPERTY_REQUIRED_ROLE')]
private readonly string $ownerPropertyRequiredRole,
TemporaryCacheFactory $cacheFactory,
) {
$this->visibilityRequestCache = $cacheFactory->createCache();
}

public function supports(string $outputClass, object $data): bool
Expand Down Expand Up @@ -129,7 +135,9 @@ public function transform($data, string $outputClass, array &$context = []): obj
}
}

[$output->shared, $output->public] = $this->collectionCache->get($data->getId(), function (ItemInterface $item) use ($data): array {
// The same collection is embedded many times in a page of assets:
// hit the shared cache once per request.
[$output->shared, $output->public] = $this->visibilityRequestCache->get($data->getId(), fn (): array => $this->collectionCache->get($data->getId(), function (ItemInterface $item) use ($data): array {
$item->tag(self::COLLECTION_CACHE_NS);
$shared = false;
$public = false;
Expand Down Expand Up @@ -158,7 +166,7 @@ public function transform($data, string $outputClass, array &$context = []): obj
}

return [$shared, $public];
});
}));

if ($this->hasGroup([Collection::GROUP_LIST, Collection::GROUP_READ], $context)) {
$virtualColl = new Collection();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public function transform(object $data, string $outputClass, array &$context = [
$output->setSize((int) $data->getSize());
$output->checksum = $data->getChecksum();
$output->docUniqueId = $data->getDocUniqueId();
$output->analysis = $data->getAnalysis();
$output->analyzedAt = $data->getAnalyzedAt();

if ($data->getWorkspace()->isFileAnalysisRequired()) {
if ($data->isAnalyzed()) {
Expand All @@ -65,13 +65,14 @@ public function transform(object $data, string $outputClass, array &$context = [
$output->accepted = true;
}

if (!$data->isAccepted()) {
$output->analysis = $data->getAnalysis();
// The detailed analysis lives in its own table: only load it when the
// file is the root resource or when the client needs to explain a rejection.
if (!$data->isAccepted() || $this->hasGroup([File::GROUP_LIST, File::GROUP_READ, File::GROUP_METADATA], $context)) {
$output->analysis = $data->getAnalysis()?->toArray();
}

if ($this->hasGroup(File::GROUP_METADATA, $context)) {
$output->metadata = $data->getMetadata();
$output->analysis = $data->getAnalysis();
}

// Only resolved when the file is the root resource (GET /files/{id}),
Expand Down
10 changes: 8 additions & 2 deletions databox/api/src/Api/Provider/AssetCollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
use App\Api\Model\Output\ApiMetaWrapperOutput;
use App\Elasticsearch\AssetSearch;
use App\Elasticsearch\NoWorkspaceAllowedException;
use App\Service\Asset\AssetListPreloader;
use Symfony\Bundle\SecurityBundle\Security;

class AssetCollectionProvider extends AbstractCollectionProvider
{
public function __construct(private readonly AssetSearch $assetSearch, private readonly Security $security)
{
public function __construct(
private readonly AssetSearch $assetSearch,
private readonly Security $security,
private readonly AssetListPreloader $assetListPreloader,
) {
}

protected function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): array|object
Expand All @@ -29,6 +33,8 @@ protected function provideCollection(Operation $operation, array $uriVariables =
return [];
}

$this->assetListPreloader->preload($result->getCurrentPageResults());

$response = new ApiMetaWrapperOutput(new PagerFantaApiPlatformPaginator($result));
$response->setMeta('facets', $facets);
$response->setMeta('debug:es', [
Expand Down
80 changes: 80 additions & 0 deletions databox/api/src/Api/Serializer/FastAnonymousContextBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace App\Api\Serializer;

use ApiPlatform\JsonLd\AnonymousContextBuilderInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

/**
* API Platform builds the full "@context" of an output DTO (iterating every
* property metadata) for each normalized object, then drops it when the object
* is a collection member or an embedded relation ("has_context").
* On a page of 50 assets this happens ~1200 times per request.
* This decorator short-circuits that case and only emits "@type" / "@id".
*/
#[AsDecorator('api_platform.jsonld.context_builder')]
final class FastAnonymousContextBuilder implements AnonymousContextBuilderInterface
{
use ClassInfoTrait;

public function __construct(
#[AutowireDecorated]
private readonly AnonymousContextBuilderInterface $decorated,
private readonly IriConverterInterface $iriConverter,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
) {
}

public function getBaseContext(int $referenceType = UrlGeneratorInterface::ABS_PATH): array
{
return $this->decorated->getBaseContext($referenceType);
}

public function getEntrypointContext(int $referenceType = UrlGeneratorInterface::ABS_PATH): array
{
return $this->decorated->getEntrypointContext($referenceType);
}

public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array
{
return $this->decorated->getResourceContext($resourceClass, $referenceType);
}

public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
return $this->decorated->getResourceContextUri($resourceClass, $referenceType);
}

public function getAnonymousResourceContext(object $object, array $context = [], int $referenceType = UrlGeneratorInterface::ABS_PATH): array
{
if (!($context['has_context'] ?? false)) {
return $this->decorated->getAnonymousResourceContext($object, $context, $referenceType);
}

// Same output as the decorated builder minus the discarded "@context" part.
$shortName = isset($context['operation'])
? $context['operation']->getShortName()
: (new \ReflectionClass($this->getObjectClass($object)))->getShortName();

$jsonLdContext = ['@type' => $shortName];

if (isset($context['iri'])) {
$jsonLdContext['@id'] = $context['iri'];
} elseif (true === ($context['gen_id'] ?? true)) {
$jsonLdContext['@id'] = $this->iriConverter->getIriFromResource($object);
}

if (isset($context['api_resource'])) {
$jsonLdContext['@type'] = $this->resourceMetadataFactory->create($this->getObjectClass($context['api_resource']))[0]->getShortName();
}

return $jsonLdContext;
}
}
Loading