# Plan — Fase 7: Archivos subidos en disco privado (sin storage:link)

## Contexto y justificación

Hoy los archivos subidos se guardan en el disco `public`
(`storage/app/public`, servido por URL abierta vía `storage:link`). Los tres
tipos que se suben son:

1. **Fotos de referencia de docentes** (`users.reference_photo_path`).
2. **Fotos de evidencia del fichaje** (`attendance_evidence.file_path`, 3 por
   registro).
3. **PDF del plan de estudios de un curso** (`courses.study_plan_path`).

**Los tres se muestran únicamente en pantallas del panel admin autenticado**
(docentes, detalle de registro de asistencia, catálogo de cursos). Ninguno se
muestra en el kiosko público (`/`) ni en la guía pública.

Guardarlos en `public` tiene dos problemas: (a) quedan con **URL abierta y
adivinable** — cualquiera con la ruta ve rostros de docentes y evidencia de
auditoría, datos sensibles; (b) obliga a `storage:link`, que en cPanel plan
básico (sin SSH) es un dolor (truco `_link.php`).

**Decisión**: mover los tres a disco **`local`** (que en este proyecto ya
apunta a `storage/app/private`, **no accesible por web** — ver
`config/filesystems.php`) y servirlos por **rutas autenticadas del admin** que
transmiten el archivo (streaming), no por URL pública. Elimina el riesgo de URL
abierta y la dependencia de `storage:link`.

No se agrega un disco nuevo: se usa el `local` existente (root
`storage/app/private`, con `'serve' => true`).

## Alcance

**Incluye:**
- Cambiar los `->store(..., 'public')` y los `Storage::disk('public')->delete()`
  a `'local'`.
- Un controlador que sirve cada archivo desde `local`, protegido por la misma
  auth del panel admin.
- Reemplazar en las vistas admin `Storage::disk('public')->url(...)` por la
  nueva ruta.
- Actualizar tests (`Storage::fake('local')` + aserciones en `local`) y agregar
  un test de acceso (invitado no puede, admin sí).
- Migrar los archivos ya existentes de `storage/app/public` a
  `storage/app/private` (dev; en cPanel el deploy es con BD/archivos frescos).
- Quitar de la documentación el paso `storage:link` / `_link.php`.

**No incluye (a propósito):**
- No se toca el disco `s3` ni se agregan discos nuevos.
- No se cambian las imágenes estáticas de la guía (`asset('images/guide/...')`)
  — son assets del build, no archivos subidos.
- La vista previa `temporaryUrl()` de una foto recién elegida (aún sin guardar)
  se deja igual: funciona independiente del disco.

## Cambios detallados (archivo por archivo)

### 1. Subida: `public` → `local`

- `app/Livewire/Kiosk/Kiosk.php` (~línea 290):
  `$photo->store('attendance-evidence', 'public')` → `'local'`.
- `app/Livewire/Admin/Users.php` (~línea 196):
  `$this->referencePhoto->store('reference-photos', 'public')` → `'local'`.
  Y los `Storage::disk('public')->delete($previous)` (~200, ~210) → `'local'`.
- `app/Livewire/Admin/CatalogSimple.php` (~línea 179):
  `$this->studyPlan->store('course-study-plans', 'public')` → `'local'`.
  Y los `Storage::disk('public')->delete(...)` (~183, ~190, ~269) → `'local'`.

### 2. Nuevo controlador para servir archivos privados

Crear `app/Http/Controllers/Admin/PrivateFileController.php` con tres métodos.
Cada uno valida que el recurso tenga archivo (si no, 404) y devuelve el archivo
en línea (inline, con su content-type correcto) desde el disco `local`:

```php
namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AttendanceEvidence;
use App\Models\Course;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;

class PrivateFileController extends Controller
{
    public function referencePhoto(User $user): StreamedResponse
    {
        abort_if($user->reference_photo_path === null, 404);
        abort_unless(Storage::disk('local')->exists($user->reference_photo_path), 404);

        return Storage::disk('local')->response($user->reference_photo_path);
    }

    public function evidence(AttendanceEvidence $evidence): StreamedResponse
    {
        abort_unless(Storage::disk('local')->exists($evidence->file_path), 404);

        return Storage::disk('local')->response($evidence->file_path);
    }

    public function studyPlan(Course $course): StreamedResponse
    {
        abort_if($course->study_plan_path === null, 404);
        abort_unless(Storage::disk('local')->exists($course->study_plan_path), 404);

        return Storage::disk('local')->response($course->study_plan_path);
    }
}
```

> `Storage::disk('local')->response()` transmite el archivo inline con el
> content-type inferido de la extensión (imagen se ve en `<img>`, PDF se abre en
> pestaña). No usar `download()` (forzaría descarga).

### 3. Rutas (dentro del grupo admin ya protegido)

En `routes/web.php`, dentro del **mismo grupo que ya tiene `auth` + `role:ADMIN`
+ `password.change`** (el que envuelve `/admin/*`), agregar:

```php
Route::get('/admin/files/reference-photo/{user}', [PrivateFileController::class, 'referencePhoto'])
    ->name('admin.files.reference-photo');
Route::get('/admin/files/evidence/{evidence}', [PrivateFileController::class, 'evidence'])
    ->name('admin.files.evidence');
Route::get('/admin/files/study-plan/{course}', [PrivateFileController::class, 'studyPlan'])
    ->name('admin.files.study-plan');
```

Route-model binding con `{user}`, `{evidence}`, `{course}`. El admin puede ver
cualquier docente/registro/curso (es el acceso previsto para revisión), así que
la auth de admin ya es suficiente — no hace falta chequeo de pertenencia.

### 4. Vistas: `->url(...)` → `route(...)`

- `resources/views/livewire/admin/users.blade.php`
  - Miniatura y enlace de la lista (~105-113): reemplazar
    `Storage::disk('public')->url($record->reference_photo_path)` por
    `route('admin.files.reference-photo', $record)`.
  - Foto actual en el formulario (~307): `$existingPhoto` es una ruta; usar
    `route('admin.files.reference-photo', $editingId)` (o el modelo). La vista
    previa `temporaryUrl()` (~305) NO se toca.
- `resources/views/livewire/admin/attendance-record-detail.blade.php` (~115-116):
  `Storage::disk('public')->url($evidence->file_path)` →
  `route('admin.files.evidence', $evidence)`.
- `resources/views/livewire/admin/catalog-simple.blade.php` (~166 y ~332):
  `Storage::disk('public')->url(...)` → `route('admin.files.study-plan', $record)`
  (en el form, con el id del curso en edición).

> Nota: `<img src="{{ route(...) }}">` funciona porque el navegador manda la
> cookie de sesión del admin logueado a la ruta protegida.

### 5. Tests

Cambiar en TODOS los tests `Storage::fake('public')` → `Storage::fake('local')`
y `Storage::disk('public')->assert*` → `Storage::disk('local')->assert*`:
`Fase1CatalogsTest`, `Fase2aAuthUsersTest`, `Fase2bKioskManualTest`,
`Fase3AssignmentsTest`, `Fase6FingerprintsTest`, `RegistrationWindowTest`,
`GroupsResolutionsKioskTest` (revisar cada `Storage::` que aparezca).

Agregar un test nuevo (p. ej. `Fase7PrivateFilesTest`):
- Invitado (sin login) a `admin.files.reference-photo` → redirige a login (o 403).
- Admin logueado → 200 y `Content-Type` de imagen para una foto sembrada en
  `local`.
- Ruta con recurso sin archivo → 404.

### 6. Migrar archivos existentes (solo dev)

En desarrollo hay archivos en `storage/app/public/{reference-photos,
attendance-evidence,course-study-plans}`. Moverlos a
`storage/app/private/...` (misma subruta; las rutas guardadas en BD son
relativas y no cambian). Comando único dentro del contenedor:

```
docker compose exec -T app sh -c 'cd storage/app && for d in reference-photos attendance-evidence course-study-plans; do [ -d "public/$d" ] && mkdir -p "private/$d" && cp -r "public/$d/." "private/$d/" && rm -rf "public/$d"; done'
```

En cPanel el despliegue va con BD y archivos frescos, así que allá no aplica.

### 7. Limpieza de documentación (quitar storage:link)

- `DEPLOY-CPANEL.md`: eliminar el paso 8 (el truco `_link.php`) y cualquier
  mención a `storage:link`. Ya no hace falta.
- `CLAUDE.md`: en "Qué falta", quitar el punto de "Automatizar
  `php artisan storage:link`" (deja de aplicar).
- `config/filesystems.php`: la sección `links` puede quedarse (inofensiva) o
  quitarse; opcional, no es necesario para que funcione.
- Actualizar `docs/MODELO-DATOS.md` / `docs/CAMBIOS.md` si describen el disco de
  estos archivos.

## Criterios de aceptación

- [ ] Ningún `->store(..., 'public')` ni `Storage::disk('public')` queda en
  `app/` (todo en `local`).
- [ ] Las fotos de docentes, la evidencia y el PDF del plan se ven en el panel
  admin igual que antes (ahora vía ruta autenticada).
- [ ] Un usuario NO autenticado que pida una de esas rutas no obtiene el archivo
  (redirige a login / 403).
- [ ] `migrate:fresh --seed`, suite completa en verde, `pint --test` limpio.
- [ ] El sistema funciona **sin** `storage:link` (borrar el symlink
  `public/storage` en dev y confirmar que todo sigue viéndose).

## Notas

- El disco `local` ya existe y apunta a `storage/app/private` (no web). No se
  agrega disco nuevo ni se toca `.env` (el `FILESYSTEM_DISK` por defecto sigue
  siendo `local`).
- La subida de evidencia ocurre en el kiosko público (sin auth): eso no cambia:
  guardar en `local` es del lado servidor. Solo la **lectura** exige admin.
- Regla de git del repo: commits sin ninguna coautoría (solo Ricardo).
