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 ────────────────────────────────────────────────────────────────── /** * Create a new share link. * * @param int $createdBy Admin user ID * @param string|null $password Plain-text password (will be hashed), null = no password * @param string|null $expiresAt ISO-8601 expiration date, null = never expires * @return array The created link row */ public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null): array { $slug = self::generateSlug(); $passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null; $stmt = $this->db->getConnection()->prepare( "INSERT INTO share_links (slug, password_hash, is_active, created_by, expires_at) VALUES (?, ?, 1, ?, ?)" ); $stmt->execute([$slug, $passwordHash, $createdBy, $expiresAt]); return $this->findBySlug($slug); } /** * 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; } /** * List all share links, ordered by creation date descending. * * @return array */ 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. */ public function toggleActive(int $id): void { $this->db->getConnection()->prepare( "UPDATE share_links SET is_active = NOT is_active WHERE id = ?" )->execute([$id]); } /** * 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]); } /** * Delete a share link. */ public function delete(int $id): void { $this->db->getConnection()->prepare( "DELETE FROM share_links 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]); } // ── 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 (!$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']); } }