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
2 changes: 1 addition & 1 deletion .github/avancement.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Score global : **9/10** — 4/5 axes de remédiation implémentés.
4. **Rendre les interactions asynchrones (fetch/AJAX) au lieu de POST + reload complet** — ✅ fait pour create/edit/delete de la gestion des invités (`guest_management_controller.js`) ; reste à généraliser le même pattern (détection `Accept: application/json` + fallback progressif + contrôleur Stimulus) aux autres actions POST+redirect encore existantes (albums, dossiers, fichiers, partages...)
5. **Passer l'envoi des emails de notification en asynchrone (Messenger)** — `ShareNotificationMailer::notify()` envoie actuellement de façon synchrone pendant la requête `/share-create`, ce qui bloque la réponse le temps de l'envoi ; faire passer par un message Messenger + worker/transport dédié
6. **Nettoyer les ~168 notices PHPUnit** — `createMock()` utilisé sans `expects()` déclenche une notice PHPUnit 13 ("Consider refactoring your test code to use a test stub instead") ; concentrées dans `tests/Unit/{Entity,EventListener,Security,Service}`, notamment `ShareLinkFactoryTest.php` (~15) ; remplacer ces `createMock()` par `createStub()` là où aucune expectation n'est réellement vérifiée (le chantier worker média a déjà converti quelques cas)
7. **EXIF des fichiers RAW** — `ExifService` s'appuie sur `exif_read_data()`, qui ne lit pas les conteneurs RAW : date de prise de vue, modèle d'appareil et GPS restent vides pour ces photos (la vignette, elle, fonctionne depuis l'intégration de `ronanlenouvel/raw-preview-extractor`). Deux pistes : lire les EXIF de la preview JPEG extraite (simple, mais métadonnées appauvries), ou exposer les tags TIFF depuis le package (plus riche — réglages complets, résolution capteur réelle)
7. ✅ **FAIT — Pack photographe EXIF (RAW + JPEG)** (#268) : `ronanlenouvel/raw-preview-extractor` **1.2.0** expose `ExtractedPreview->metadata` (date, ouverture, vitesse, ISO, focale, objectif, appareil), lu depuis l'EXIF du RAW ; `ExifService` fait de même pour les JPEG. `Media` gagne 5 colonnes (aperture, shutter_speed, iso, focal_length, lens), exposées par l'API, affichées dans un panneau EXIF du lightbox galerie, avec tri « date de prise de vue ». CR3 : métadonnées `null` pour l'instant (parseur ISO-BMFF, piste d'itération)
8. **Optimiser le redimensionnement des grandes previews** — une preview RAW pleine résolution (8256×5504) prend ~2,9 s à redimensionner sous GD, contre 23 ms pour l'extraction elle-même. Acceptable car le pipeline média est asynchrone (Messenger), mais un redimensionnement en deux passes réduirait nettement le coût
9. **Chiffrement au repos** — `APP_ENCRYPTION_KEY` est déclaré dans `config/services.yaml` mais injecté nulle part : les fichiers sont stockés en clair. Prévu comme option laissée à l'utilisateur. Incohérence résiduelle : le docblock `@param` de `ThumbnailService::generate()` (ligne 43) dit encore « chiffrée sur disque » alors que le reste de la classe (ligne 24) indique correctement « stockés en clair » — à corriger
10. ✅ **RÉSOLU** — ~~Extensions PHP sous-déclarées~~ : `composer.json` requiert désormais `ext-ctype`, `ext-iconv`, **`ext-gd`** et **`ext-exif`**. Plus de risque d'échec silencieux à l'exécution
Expand Down
25 changes: 24 additions & 1 deletion assets/controllers/gallery_lightbox_controller.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Controller } from '@hotwired/stimulus';
import Slideshow from './Slideshow.js';
import { buildExifPanelHtml } from '../js/exif-panel.js';

/* Lightbox de la galerie médias : ouvre le média en plein écran au clic
* sur une vignette, navigation précédent/suivant au clavier et via boutons.
* Le diaporama (auto-avance, pause/lecture) est délégué à Slideshow. */
export default class extends Controller {
static targets = ['img', 'video', 'prev', 'next', 'play'];
static targets = ['img', 'video', 'prev', 'next', 'play', 'info'];

connect() {
this.links = Array.from(document.querySelectorAll('[data-lightbox]'));
Expand Down Expand Up @@ -65,11 +66,33 @@ export default class extends Controller {
this.videoTarget.style.display = 'none';
}

this.renderInfo(isVideo ? null : this.links[this.current]);

this.element.style.display = 'flex';
this.prevTarget.style.visibility = this.current > 0 ? 'visible' : 'hidden';
this.nextTarget.style.visibility = this.current < this.srcs.length - 1 ? 'visible' : 'hidden';
}

/* Remplit (ou masque) le panneau EXIF depuis les data-* de la vignette. */
renderInfo(link) {
if (!this.hasInfoTarget) return;

const html = link ? buildExifPanelHtml({
takenAt: link.getAttribute('data-taken-at') || '',
camera: link.getAttribute('data-camera') || '',
aperture: link.getAttribute('data-aperture') || '',
shutter: link.getAttribute('data-shutter') || '',
iso: link.getAttribute('data-iso') || '',
focal: link.getAttribute('data-focal') || '',
lens: link.getAttribute('data-lens') || '',
gpsLat: link.getAttribute('data-gps-lat') || '',
gpsLon: link.getAttribute('data-gps-lon') || '',
}) : '';

this.infoTarget.innerHTML = html;
this.infoTarget.hidden = html === '';
}

prev() {
this.show(this.current - 1);
}
Expand Down
53 changes: 53 additions & 0 deletions assets/js/exif-panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Panneau EXIF du lightbox (#268) — construction pure et testable du contenu à
* partir des attributs data-* d'une vignette photo. Aucune dépendance au DOM du
* contrôleur : le rendu se teste isolément.
*/

function escapeHtml(text) {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
return String(text).replace(/[&<>"']/g, (m) => map[m]);
}

/**
* Réduit un dataset de vignette aux réglages présents (champs vides ignorés).
*
* @param {Object} d - { takenAt, camera, aperture, shutter, iso, focal, lens }
* @returns {Array<{label: string, value: string}>}
*/
export function buildExifItems(d = {}) {
const items = [];
if (d.takenAt) items.push({ label: 'Prise le', value: d.takenAt });
if (d.camera) items.push({ label: 'Appareil', value: d.camera });
if (d.aperture) items.push({ label: 'Ouverture', value: `f/${d.aperture}` });
if (d.shutter) items.push({ label: 'Vitesse', value: `${d.shutter}s` });
if (d.iso) items.push({ label: 'ISO', value: String(d.iso) });
if (d.focal) items.push({ label: 'Focale', value: `${d.focal} mm` });
if (d.lens) items.push({ label: 'Objectif', value: d.lens });
return items;
}

/**
* Rend le panneau EXIF en HTML, ou une chaîne vide s'il n'y a rien à montrer.
*
* @param {Object} d - dataset de la vignette (data-* déjà lus)
* @returns {string}
*/
export function buildExifPanelHtml(d = {}) {
const items = buildExifItems(d);
let html = items
.map(
(i) =>
`<span class="hc-exif-item"><span class="hc-exif-label">${escapeHtml(i.label)}</span> ${escapeHtml(i.value)}</span>`,
)
.join('');

if (d.gpsLat && d.gpsLon) {
const lat = encodeURIComponent(d.gpsLat);
const lon = encodeURIComponent(d.gpsLon);
const url = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=15/${lat}/${lon}`;
html += `<a class="hc-exif-item hc-exif-gps" href="${url}" target="_blank" rel="noopener">📍 Voir sur la carte</a>`;
}

return html;
}
30 changes: 30 additions & 0 deletions assets/styles/gallery.css
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,33 @@
right: 0.5rem;
}
}

/* Panneau EXIF du lightbox (réglages de prise de vue) — #268 */
.hc-lightbox-exif {
position: absolute;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%);
max-width: min(92vw, 900px);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem 0.9rem;
padding: 0.55rem 0.9rem;
border-radius: 0.75rem;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px);
color: #fff;
font-size: 0.82rem;
line-height: 1.3;
pointer-events: auto;
}
.hc-lightbox-exif[hidden] { display: none; }
.hc-exif-item { white-space: nowrap; }
.hc-exif-label { opacity: 0.6; margin-right: 0.15rem; }
.hc-exif-gps { color: #fff; text-decoration: none; }
.hc-exif-gps:hover { text-decoration: underline; }

@media (max-width: 767px) {
.hc-lightbox-exif { font-size: 0.75rem; bottom: 0.6rem; gap: 0.3rem 0.6rem; }
}
61 changes: 61 additions & 0 deletions assets/tests/exif-panel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, test, expect } from '@jest/globals';

const { buildExifItems, buildExifPanelHtml } = await import('../js/exif-panel.js');

describe('buildExifItems', () => {
test('ne garde que les réglages présents', () => {
const items = buildExifItems({
takenAt: '15/06/2024 12:30',
camera: 'NIKON Z 6',
aperture: '2.8',
shutter: '1/250',
iso: '400',
focal: '50',
lens: 'NIKKOR Z 50mm',
});
expect(items).toEqual([
{ label: 'Prise le', value: '15/06/2024 12:30' },
{ label: 'Appareil', value: 'NIKON Z 6' },
{ label: 'Ouverture', value: 'f/2.8' },
{ label: 'Vitesse', value: '1/250s' },
{ label: 'ISO', value: '400' },
{ label: 'Focale', value: '50 mm' },
{ label: 'Objectif', value: 'NIKKOR Z 50mm' },
]);
});

test('champs vides ignorés', () => {
expect(buildExifItems({ aperture: '', iso: '400' })).toEqual([
{ label: 'ISO', value: '400' },
]);
});

test('dataset vide → aucun item', () => {
expect(buildExifItems({})).toEqual([]);
});
});

describe('buildExifPanelHtml', () => {
test('rend les réglages en HTML', () => {
const html = buildExifPanelHtml({ aperture: '2.8', iso: '400' });
expect(html).toContain('f/2.8');
expect(html).toContain('ISO');
expect(html).toContain('400');
});

test('ajoute un lien carte quand le GPS est présent', () => {
const html = buildExifPanelHtml({ gpsLat: '48.8566', gpsLon: '2.3522' });
expect(html).toContain('openstreetmap.org');
expect(html).toContain('48.8566');
});

test('chaîne vide quand rien à montrer', () => {
expect(buildExifPanelHtml({})).toBe('');
});

test('échappe le HTML des valeurs (anti-XSS)', () => {
const html = buildExifPanelHtml({ lens: '<script>alert(1)</script>' });
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
});
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"nelmio/cors-bundle": "^2.6.1",
"phpdocumentor/reflection-docblock": "^6.0.3",
"phpstan/phpdoc-parser": "^2.3.3",
"ronanlenouvel/raw-preview-extractor": "^1.1",
"ronanlenouvel/raw-preview-extractor": "^1.2",
"symfony/asset": "8.0.*",
"symfony/asset-mapper": "8.0.*",
"symfony/console": "8.0.*",
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions migrations/Version20260719194000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

/**
* Réglages de prise de vue (EXIF) sur medias : ouverture, vitesse, ISO, focale,
* objectif — pack photographe pour JPEG et RAW (#268).
*/
final class Version20260719194000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajoute aperture, shutter_speed, iso, focal_length, lens sur medias (#268)';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE medias ADD aperture VARCHAR(16) DEFAULT NULL, ADD shutter_speed VARCHAR(16) DEFAULT NULL, ADD iso INT DEFAULT NULL, ADD focal_length VARCHAR(16) DEFAULT NULL, ADD lens VARCHAR(255) DEFAULT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE medias DROP aperture, DROP shutter_speed, DROP iso, DROP focal_length, DROP lens');
}
}
5 changes: 5 additions & 0 deletions src/ApiResource/MediaOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public function __construct(
public readonly ?string $gpsLat,
public readonly ?string $gpsLon,
public readonly ?string $cameraModel,
public readonly ?string $aperture,
public readonly ?string $shutterSpeed,
public readonly ?int $iso,
public readonly ?string $focalLength,
public readonly ?string $lens,
/** URL de l'endpoint thumbnail, null si pas encore généré */
public readonly ?string $thumbnailUrl,
public readonly string $createdAt,
Expand Down
60 changes: 60 additions & 0 deletions src/Entity/Media.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ class Media
#[ORM\Column(length: 255, nullable: true)]
private ?string $cameraModel = null;

/** Ouverture (f-number EXIF), ex: "2.8" */
#[ORM\Column(length: 16, nullable: true)]
private ?string $aperture = null;

/** Vitesse d'obturation (EXIF), ex: "1/250" */
#[ORM\Column(length: 16, nullable: true)]
private ?string $shutterSpeed = null;

/** Sensibilité ISO (EXIF) */
#[ORM\Column(nullable: true)]
private ?int $iso = null;

/** Longueur focale en mm (EXIF), ex: "50" */
#[ORM\Column(length: 16, nullable: true)]
private ?string $focalLength = null;

/** Modèle d'objectif (EXIF LensModel) */
#[ORM\Column(length: 255, nullable: true)]
private ?string $lens = null;

/** Chemin relatif du thumbnail dans var/storage/ */
#[ORM\Column(length: 1024, nullable: true)]
private ?string $thumbnailPath = null;
Expand Down Expand Up @@ -121,6 +141,26 @@ public function getCameraModel(): ?string
{
return $this->cameraModel;
}
public function getAperture(): ?string
{
return $this->aperture;
}
public function getShutterSpeed(): ?string
{
return $this->shutterSpeed;
}
public function getIso(): ?int
{
return $this->iso;
}
public function getFocalLength(): ?string
{
return $this->focalLength;
}
public function getLens(): ?string
{
return $this->lens;
}
public function getThumbnailPath(): ?string
{
return $this->thumbnailPath;
Expand Down Expand Up @@ -154,6 +194,26 @@ public function setCameraModel(?string $cameraModel): void
{
$this->cameraModel = $cameraModel;
}
public function setAperture(?string $aperture): void
{
$this->aperture = $aperture;
}
public function setShutterSpeed(?string $shutterSpeed): void
{
$this->shutterSpeed = $shutterSpeed;
}
public function setIso(?int $iso): void
{
$this->iso = $iso;
}
public function setFocalLength(?string $focalLength): void
{
$this->focalLength = $focalLength;
}
public function setLens(?string $lens): void
{
$this->lens = $lens;
}
public function setThumbnailPath(?string $thumbnailPath): void
{
$this->thumbnailPath = $thumbnailPath;
Expand Down
4 changes: 3 additions & 1 deletion src/Repository/MediaRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ public function findByOwner(User $user, ?string $type = null, array $orderBy = [
$qb->andWhere('m.mediaType = :type')->setParameter('type', $type);
}

$allowed = ['originalName' => 'f.originalName', 'size' => 'f.size', 'createdAt' => 'f.createdAt'];
// 'takenAt' trie par date de prise de vue (EXIF) : les médias sans date
// de capture (takenAt NULL) se regroupent à une extrémité selon le SGBD.
$allowed = ['originalName' => 'f.originalName', 'size' => 'f.size', 'createdAt' => 'f.createdAt', 'takenAt' => 'm.takenAt'];
$applied = false;
foreach ($orderBy as $field => $dir) {
if (isset($allowed[$field])) {
Expand Down
Loading
Loading