db = $db; } public static function make(): self { require_once APP_ROOT . '/src/Database.php'; return new self(new Database()); } // ── Slug generation ─────────────────────────────────────────────────────── /** * Generate a unique slug in the format YYYYMMDD-. * The random portion uses 8 base32 chars (~40 bits of entropy). */ public static function generateSlug(): string { $date = date('Ymd'); $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $random = ''; for ($i = 0; $i < 8; $i++) { $random .= $chars[random_int(0, strlen($chars) - 1)]; } return $date . '-' . $random; } // ── CRUD ────────────────────────────────────────────────────────────────── /** * Generate a cryptographically secure random password. * Returns a 16-byte hex string (32 characters). */ public static function generatePassword(): string { return bin2hex(random_bytes(16)); } /** * Create a new share link. * * Password is always auto-generated — no custom passwords allowed. * * @param int $createdBy Admin user ID * @param string|null $expiresAt ISO-8601 expiration date, null = never expires * @return array|null The created link row with _plain_password attached */ public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null): ?array { $slug = self::generateSlug(); $plainPassword = self::generatePassword(); $passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT); $validObjet = ['tfe', 'thèse', 'frart']; if ($objetRestriction !== null && $objetRestriction !== '') { $parts = array_intersect(explode(',', $objetRestriction), $validObjet); $objetRestriction = !empty($parts) ? implode(',', $parts) : 'tfe'; } else { $objetRestriction = 'tfe'; } $stmt = $this->db->getConnection()->prepare( 'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)' ); $stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt]); $link = $this->findBySlug($slug); if ($link) { // Attach plain-text password temporarily so the caller can display it $link['_plain_password'] = $plainPassword; } return $link; } /** * Find a share link by its slug. * * @return array|null */ public function findBySlug(string $slug): ?array { $stmt = $this->db->getConnection()->prepare( 'SELECT * FROM share_links WHERE slug = ?' ); $stmt->execute([$slug]); $row = $stmt->fetch(); return $row ?: null; } /** * Find a share link by its ID. * * @return array|null */ public function findById(int $id): ?array { $stmt = $this->db->getConnection()->prepare( 'SELECT * FROM share_links WHERE id = ?' ); $stmt->execute([$id]); $row = $stmt->fetch(); return $row ?: null; } /** * Decrypt and return the plain-text password for a link. * Returns empty string if not set or decryption fails. */ public function getDecryptedPassword(int $id): string { $stmt = $this->db->getConnection()->prepare( 'SELECT encrypted_password FROM share_links WHERE id = ?' ); $stmt->execute([$id]); $enc = $stmt->fetchColumn(); return $enc ? Crypto::decrypt($enc) : ''; } /** * List active (non-archived) share links, ordered by creation date descending. * Decorates each row with its decrypted password. */ public function listActive(): array { $stmt = $this->db->getConnection()->query( 'SELECT * FROM share_links WHERE is_archived = 0 ORDER BY created_at DESC' ); $rows = $stmt->fetchAll(); return array_map(fn($row) => $this->decorateWithPassword($row), $rows); } /** * Decorate a link row with its decrypted password. */ private function decorateWithPassword(array $row): array { if (!empty($row['encrypted_password'])) { $row['_decrypted_password'] = Crypto::decrypt($row['encrypted_password']); } else { $row['_decrypted_password'] = ''; } return $row; } /** * List archived share links, ordered by creation date descending. */ public function listArchived(): array { $stmt = $this->db->getConnection()->query( 'SELECT * FROM share_links WHERE is_archived = 1 ORDER BY created_at DESC' ); return $stmt->fetchAll(); } /** * List all share links (active + archived), ordered by creation date descending. * * @return array * @deprecated Use listActive() / listArchived() instead. */ public function listAll(): array { $stmt = $this->db->getConnection()->query( 'SELECT * FROM share_links ORDER BY created_at DESC' ); return $stmt->fetchAll(); } /** * Toggle the active status of a share link. * Returns the new is_active value. */ public function toggleActive(int $id): bool { $pdo = $this->db->getConnection(); $pdo->prepare( 'UPDATE share_links SET is_active = NOT is_active WHERE id = ?' )->execute([$id]); $row = $pdo->prepare('SELECT is_active FROM share_links WHERE id = ?'); $row->execute([$id]); return (bool)($row->fetchColumn() ?? false); } /** * Set or clear the password for a share link. * * @param string|null $password Plain-text password, or null to clear */ public function setPassword(int $id, ?string $password): void { $hash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null; $this->db->getConnection()->prepare( 'UPDATE share_links SET password_hash = ? WHERE id = ?' )->execute([$hash, $id]); } /** * Archive a share link: marks it inaccessible while preserving usage stats. * Sets is_archived = 1 and is_active = 0 so the form rejects the slug. */ public function archive(int $id): void { $this->db->getConnection()->prepare( 'UPDATE share_links SET is_archived = 1, is_active = 0 WHERE id = ?' )->execute([$id]); } /** * Increment the usage count for a share link. */ public function incrementUsage(int $id): void { $this->db->getConnection()->prepare( 'UPDATE share_links SET usage_count = usage_count + 1 WHERE id = ?' )->execute([$id]); } /** * Update a share link (name, expiration). */ public function update(int $id, ?string $name = null, ?string $expiresAt = null): void { $pdo = $this->db->getConnection(); $fields = []; $params = []; if ($name !== null) { $fields[] = 'name = ?'; $params[] = $name; } if ($expiresAt !== null) { $expiresAtVal = $expiresAt !== '' ? date('Y-m-d H:i:s', strtotime($expiresAt)) : null; $fields[] = 'expires_at = ?'; $params[] = $expiresAtVal; } if (!empty($fields)) { $params[] = $id; $pdo->prepare('UPDATE share_links SET ' . implode(', ', $fields) . ' WHERE id = ?')->execute($params); } } // ── Validation ──────────────────────────────────────────────────────────── /** * Validate whether a share link is usable. * * Returns an array: * ['valid' => true] if the link is active and not expired * ['valid' => false, 'reason' => 'disabled'] if deactivated * ['valid' => false, 'reason' => 'expired'] if past expiration * ['valid' => false, 'reason' => 'not_found'] if slug doesn't exist * ['valid' => false, 'reason' => 'needs_password', 'link' => array] if password required */ public function validateLink(?string $slug): array { if ($slug === null || $slug === '') { return ['valid' => false, 'reason' => 'not_found']; } $link = $this->findBySlug($slug); if ($link === null) { return ['valid' => false, 'reason' => 'not_found']; } if (!empty($link['is_archived'])) { return ['valid' => false, 'reason' => 'archived', 'link' => $link]; } if (!$link['is_active']) { return ['valid' => false, 'reason' => 'disabled', 'link' => $link]; } if ($link['expires_at'] !== null && strtotime($link['expires_at']) < time()) { return ['valid' => false, 'reason' => 'expired', 'link' => $link]; } if ($link['password_hash'] !== null) { return ['valid' => false, 'reason' => 'needs_password', 'link' => $link]; } return ['valid' => true, 'link' => $link]; } /** * Verify the password against a share link. * * @return bool */ public function verifyPassword(array $link, string $password): bool { if ($link['password_hash'] === null) { return true; // No password set } return password_verify($password, $link['password_hash']); } }