tokenOf($projectId); $release = $token === null ? null : $this->load($token); return $release === null ? null : self::summary($release); } /** * Copies a stored project document into the project's release, under the token it already has. * * @param array{id:string,name:string,revision:int,project:array} $doc * @return array{token:string,revision:int,publishedAt:string} */ public function publish(array $doc): array { $token = $this->tokenOf($doc['id']); if ($token === null) { do { $token = bin2hex(random_bytes(16)); } while (file_exists($this->path($token))); } $release = [ 'token' => $token, 'projectId' => $doc['id'], 'name' => $doc['name'], 'revision' => $doc['revision'], 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), 'project' => $doc['project'], ]; // The copy first, the pointer to it second: a crash in between leaves an unused file, never // a project pointing at nothing. $this->write($this->path($token), $release); $this->write($this->indexPath($doc['id']), ['token' => $token]); return self::summary($release); } /** @return array{token:string,projectId:string,name:string,revision:int,publishedAt:string,project:array}|null */ public function load(string $token): ?array { if (!self::isValidToken($token)) { return null; } $release = $this->read($this->path($token)); if ($release === null || !is_array($release['project'] ?? null) || !is_string($release['projectId'] ?? null)) { return null; } return [ 'token' => $token, 'projectId' => $release['projectId'], 'name' => is_string($release['name'] ?? null) ? $release['name'] : '', 'revision' => (int) ($release['revision'] ?? 0), 'publishedAt' => is_string($release['publishedAt'] ?? null) ? $release['publishedAt'] : '', 'project' => $release['project'], ]; } /** Makes the link dead. False when the project had none. */ public function takeDown(string $projectId): bool { $token = $this->tokenOf($projectId); $index = $this->indexPath($projectId); if (is_file($index)) { unlink($index); } if ($token === null) { return false; } $path = $this->path($token); if (is_file($path)) { unlink($path); } return true; } private function tokenOf(string $projectId): ?string { $token = $this->read($this->indexPath($projectId))['token'] ?? null; return is_string($token) && self::isValidToken($token) ? $token : null; } /** @return array{token:string,revision:int,publishedAt:string} */ private static function summary(array $release): array { return [ 'token' => $release['token'], 'revision' => $release['revision'], 'publishedAt' => $release['publishedAt'], ]; } private function path(string $token): string { return $this->dir . '/' . $token . '.json'; } private function indexPath(string $projectId): string { return $this->dir . '/projects/' . $projectId . '.json'; } private function read(string $path): ?array { if (!is_file($path)) { return null; } $raw = file_get_contents($path); $data = $raw === false ? null : json_decode($raw, true); return is_array($data) ? $data : null; } private function write(string $path, array $data): void { $folder = dirname($path); if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) { throw new RuntimeException("Cannot create release directory {$folder}"); } $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === false) { throw new RuntimeException('Cannot encode release'); } AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'release file'); } }