Skip to content
Merged
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
12 changes: 11 additions & 1 deletion .github/avancement.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
# 📋 Avancement — HomeCloud API

> Dernière mise à jour : 2026-07-22
> Dernière mise à jour : 2026-07-23

> **Status git :** `main` — dernière PR mergée #331 (`feat/311-media-viewer`)

---

## ✅ Espace « Mes partages » pour l'invité (2026-07-23, #273, branche `feature/273-mes-partages`)

- Nouvelle page `/mes-partages` listant les `Share` actifs (non révoqués, non expirés) dont l'utilisateur connecté est le `guest` — seule porte d'entrée jusqu'ici était le lien contenu dans l'email de notification.
- `ShareRepository::findActiveByGuest(User $guest)` : nouvelle requête filtrée `guest = :user AND revokedAt IS NULL AND (expiresAt IS NULL OR expiresAt > now)`, triée par `createdAt DESC`. Aucune migration Doctrine nécessaire, l'index `idx_share_lookup(guest_id, resource_type, resource_id)` couvrait déjà ce besoin.
- `MyReceivedSharesWebController` (nouveau) : lecture seule, réutilise le pattern `resolveResourceName`/`redirectUrlFor` déjà présent dans `ShareWebController` (dupliqué à l'identique plutôt que factorisé — 2 appelants seulement, règle des 3 occurrences).
- Décisions de conception : page séparée de `/partages` (qui reste dédiée à l'owner) ; partages expirés/révoqués exclus de la liste (pas affichés grisés) ; route accessible à un compte `full` mais toujours liste vide (pas de 403 — cohérent avec l'absence de précédent pour bloquer une route selon `accountType`).
- Lien de navigation conditionnel `{% if app.user.isGuest %}` ajouté dans la sidebar (`templates/web/layout.html.twig`), pas dans la tab-bar mobile fixe.
- Correction d'un commentaire obsolète dans `ShareWebController::index()` qui affirmait à tort qu'un guest n'avait pas accès à `/partages`.
- Tests : isolation stricte entre guests (un guest ne voit que ses propres partages reçus), exclusion expiré/révoqué, cas vide, lien direct vers la ressource, visibilité du lien nav selon `accountType`.

## 🚧 Vignettes médias — explorer et vidéo (en cours, #312, branche `feat/312-vignettes-medias`)

- **Volet 1** ✅ : afficher les vignettes images dans « Mes fichiers ». Images existantes, câblage pur.
Expand Down
77 changes: 77 additions & 0 deletions src/Controller/Web/MyReceivedSharesWebController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace App\Controller\Web;

use App\Entity\Album;
use App\Entity\File;
use App\Entity\Folder;
use App\Entity\Share;
use App\Entity\User;
use App\Interface\ShareRepositoryInterface;
use App\Security\ResourceLocator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
* Page web « Mes partages » — liste, pour l'utilisateur connecté, les
* partages actifs dont il est le destinataire (guest). Cf. issue #273 :
* un invité n'a sinon aucun moyen de retrouver ses partages reçus en dehors
* du lien contenu dans l'email de notification.
*/
#[IsGranted('ROLE_USER')]
final class MyReceivedSharesWebController extends AbstractController
{
public function __construct(
private readonly ShareRepositoryInterface $shareRepository,
private readonly ResourceLocator $resourceLocator,
) {}

#[Route('/mes-partages', name: 'app_my_received_shares')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();

$incoming = array_map(
fn (Share $share) => [
'share' => $share,
'resourceName' => $this->resolveResourceName($share),
'targetUrl' => $this->redirectUrlFor($share->getResourceType(), (string) $share->getResourceId()),
],
$this->shareRepository->findActiveByGuest($user),
);

return $this->render('web/my_received_shares.html.twig', [
'incoming' => $incoming,
]);
}

private function redirectUrlFor(string $resourceType, string $resourceId): string
{
return match ($resourceType) {
Share::RESOURCE_ALBUM => '/albums/' . $resourceId,
Share::RESOURCE_FOLDER => '/explorer?folder=' . $resourceId,
default => '/explorer',
};
}

private function resolveResourceName(Share $share): string
{
try {
$resource = $this->resourceLocator->locate($share->getResourceType(), $share->getResourceId());
} catch (NotFoundHttpException) {
return 'Ressource supprimée';
}

return match (true) {
$resource instanceof File => $resource->getOriginalName(),
$resource instanceof Folder => $resource->getName(),
$resource instanceof Album => $resource->getName(),
};
}
}
10 changes: 6 additions & 4 deletions src/Controller/Web/ShareWebController.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ public function index(): Response
/** @var User $user */
$user = $this->getUser();

// findByUser retourne owner OU guest, mais HomeCloud est mono-owner
// par instance : l'utilisateur connecté ici est toujours l'owner, il
// n'y a donc jamais de partage "reçu" à distinguer (cf. GuestRestrictionChecker,
// les invités n'ont pas accès à cette page).
// findByUser retourne owner OU guest, mais cette page n'affiche que
// le volet sortant (créé par l'utilisateur) : un guest peut y accéder
// (aucune restriction GuestRestrictionChecker ici, qui ne bloque que
// l'écriture), il y verrait simplement une liste vide côté sortant.
// Le volet "reçu" par un guest est exposé séparément par
// /mes-partages (cf. MyReceivedSharesWebController, issue #273).
$outgoing = array_map(
fn ($share) => [
'share' => $share,
Expand Down
7 changes: 7 additions & 0 deletions src/Interface/ShareRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public function countByUser(User $user): int;

public function findActiveShare(User $guest, string $resourceType, Uuid $resourceId, string $permission): ?Share;

/**
* Partages actifs (non révoqués, non expirés) reçus par ce guest.
*
* @return Share[]
*/
public function findActiveByGuest(User $guest): array;

/** Supprime tous les shares pointant vers cette ressource (nettoyage à la suppression). */
public function deleteByResource(string $resourceType, Uuid $resourceId): void;
}
14 changes: 14 additions & 0 deletions src/Repository/ShareRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ public function findActiveShare(User $guest, string $resourceType, Uuid $resourc
return $qb->getQuery()->getOneOrNullResult();
}

/** Partages actifs (non révoqués, non expirés) reçus par ce guest. */
public function findActiveByGuest(User $guest): array
{
return $this->createQueryBuilder('s')
->where('s.guest = :guest')
->andWhere('s.revokedAt IS NULL')
->andWhere('s.expiresAt IS NULL OR s.expiresAt > :now')
->setParameter('guest', $guest->getId(), 'uuid')
->setParameter('now', new \DateTimeImmutable())
->orderBy('s.createdAt', 'DESC')
->getQuery()
->getResult();
}

/** Supprime tous les shares pointant vers cette ressource (nettoyage à la suppression). */
public function deleteByResource(string $resourceType, Uuid $resourceId): void
{
Expand Down
10 changes: 10 additions & 0 deletions templates/web/layout.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@
Partages
</a>

{% if app.user.isGuest %}
<a href="{{ path('app_my_received_shares') }}"
class="hc-nav-item {{ current_route == 'app_my_received_shares' ? 'active' : '' }}">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/>
</svg>
Mes partages
</a>
{% endif %}

<a href="{{ path('app_guests') }}"
class="hc-nav-item {{ current_route == 'app_guests' ? 'active' : '' }}">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
Expand Down
53 changes: 53 additions & 0 deletions templates/web/my_received_shares.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{% extends 'web/layout.html.twig' %}

{% block title %}Mes partages — HomeCloud{% endblock %}

{% block content %}

{% set shareIcon %}<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>{% endset %}

<div class="flex flex-col gap-7 max-w-[1100px] mx-auto h-full">

{# En-tête de page #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-black tracking-tight mb-1 flex items-center gap-2">
{{ shareIcon }}
Mes partages
</h1>
<p class="hc-page-sub">{{ incoming|length }} reçu{{ incoming|length != 1 ? 's' : '' }}</p>
</div>
</div>

{% if incoming is empty %}
<div data-testid="my-shares-empty">
<twig:EmptyState
title="Vous n'avez reçu aucun partage actif"
subtitle="Les ressources partagées avec vous par un autre utilisateur apparaîtront ici."
icon="<svg width='64' height='64' fill='none' stroke='currentColor' stroke-width='1.5' viewBox='0 0 24 24' class='hc-icon'><path d='M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4'/></svg>"
/>
</div>
{% else %}
<div class="hc-item-card" style="display:flex; flex-direction:column; gap:0; padding:0;" data-testid="my-shares-list">
{% for row in incoming %}
<div class="flex items-center justify-between px-4 py-3" style="border-bottom:1px solid var(--hc-border);" data-testid="share-row-incoming">
<div>
<a href="{{ row.targetUrl }}" class="hc-item-name">{{ row.resourceName }}</a>
<div class="hc-item-meta">
Partagé par {{ row.share.owner.displayName }} ·
{{ row.share.permission == 'write' ? 'lecture/écriture' : 'lecture seule' }} ·
{% if row.share.expiresAt %}
expire le {{ row.share.expiresAt|date('d/m/Y') }}
{% else %}
permanent
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}

</div>

{% endblock %}
86 changes: 86 additions & 0 deletions tests/Repository/ShareRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,90 @@ public function testDeleteByResourceLeavesOtherSharesIntact(): void
$this->assertNull($this->repository->find($targetShare->getId()));
$this->assertNotNull($this->repository->find($otherShare->getId()));
}

public function testFindActiveByGuestReturnsOnlyActiveSharesForThatGuest(): void
{
$owner = $this->createUser('owner-find-active-guest@example.com');
$guestA = $this->createUser('guest-a-find-active@example.com');
$guestB = $this->createUser('guest-b-find-active@example.com');

$shareA = new Share($owner, $guestA, Share::RESOURCE_FOLDER, Uuid::v7(), Share::PERMISSION_READ);
$shareB = new Share($owner, $guestB, Share::RESOURCE_FOLDER, Uuid::v7(), Share::PERMISSION_READ);
$this->em->persist($shareA);
$this->em->persist($shareB);
$this->em->flush();

$result = $this->repository->findActiveByGuest($guestA);

$this->assertCount(1, $result);
$this->assertTrue($result[0]->getId()->equals($shareA->getId()));
}

public function testFindActiveByGuestExcludesExpiredShares(): void
{
$owner = $this->createUser('owner-expired-guest@example.com');
$guest = $this->createUser('guest-expired@example.com');

$expired = new Share(
$owner,
$guest,
Share::RESOURCE_FOLDER,
Uuid::v7(),
Share::PERMISSION_READ,
new \DateTimeImmutable('-1 day'),
);
$this->em->persist($expired);
$this->em->flush();

$result = $this->repository->findActiveByGuest($guest);

$this->assertSame([], $result);
}

public function testFindActiveByGuestExcludesRevokedShares(): void
{
$owner = $this->createUser('owner-revoked-guest@example.com');
$guest = $this->createUser('guest-revoked@example.com');

$revoked = new Share($owner, $guest, Share::RESOURCE_FOLDER, Uuid::v7(), Share::PERMISSION_READ);
$revoked->revoke();
$this->em->persist($revoked);
$this->em->flush();

$result = $this->repository->findActiveByGuest($guest);

$this->assertSame([], $result);
}

public function testFindActiveByGuestReturnsEmptyArrayWhenNoShares(): void
{
$guest = $this->createUser('guest-no-shares@example.com');

$result = $this->repository->findActiveByGuest($guest);

$this->assertSame([], $result);
}

public function testFindActiveByGuestOrdersByCreatedAtDescending(): void
{
$owner = $this->createUser('owner-order-guest@example.com');
$guest = $this->createUser('guest-order@example.com');

$older = new Share($owner, $guest, Share::RESOURCE_FOLDER, Uuid::v7(), Share::PERMISSION_READ);
$newer = new Share($owner, $guest, Share::RESOURCE_FOLDER, Uuid::v7(), Share::PERMISSION_READ);
$this->em->persist($older);
$this->em->persist($newer);
$this->em->flush();

$reflection = new \ReflectionProperty(Share::class, 'createdAt');
$reflection->setValue($older, new \DateTimeImmutable('-2 days'));
$reflection->setValue($newer, new \DateTimeImmutable('-1 day'));
$this->em->flush();

$result = $this->repository->findActiveByGuest($guest);

$this->assertCount(2, $result);
$this->assertTrue($result[0]->getId()->equals($newer->getId()));
$this->assertTrue($result[1]->getId()->equals($older->getId()));
}
}
Loading