mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
313 lines
10 KiB
PHP
313 lines
10 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ShareLink — model for student-access share links.
|
|
*
|
|
* Share links enable students to submit TFEs via unique URLs without
|
|
* requiring admin authentication. Each link has a unique slug, optional
|
|
* password, activity flag, optional expiration, and usage count.
|
|
*/
|
|
class ShareLink
|
|
{
|
|
private Database $db;
|
|
|
|
public function __construct(Database $db)
|
|
{
|
|
$this->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-<random>.
|
|
* 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']);
|
|
}
|
|
}
|