db = $db; $this->table = Schema::tables()['keys']; } /** @return array{key: array, secret: string} */ public function create(string $label, string $permission, ?string $expires_at, bool $one_time): array { $label = trim(mb_substr(sanitize_text_field($label), 0, 100)); if ($label === '') { throw new RelayError('keys.label_required', esc_html__('Give the key a label, e.g. the site that will use it.', 'siterelay')); } if (!in_array($permission, Permissions::KEY_PERMISSIONS, true)) { throw new RelayError('keys.invalid_permission', esc_html__('Choose pull, push or both.', 'siterelay')); } $secret = random_bytes(32); $id = ''; for ($i = 0; $i < 12; $i++) { $id .= self::ALPHABET[random_int(0, 35)]; } $row = [ 'id' => $id, 'label' => $label, 'secret_enc' => Crypto::encrypt($secret), 'permissions' => $permission, 'expires_at' => $expires_at, 'max_uses' => $one_time ? 1 : null, 'uses' => 0, 'created_by' => get_current_user_id(), 'created_at' => gmdate('Y-m-d H:i:s'), ]; $this->db->quietly(fn () => $this->db->wpdb()->insert($this->table, $row)); return ['key' => $this->find($id), 'secret' => $secret]; } public function find(string $id): ?array { $rows = $this->db->results($this->db->wpdb()->prepare('SELECT * FROM ' . Db::ident($this->table) . ' WHERE id = %s', $id), ARRAY_A); return $rows ? $rows[0] : null; } /** @return array[] newest first */ public function all(): array { return $this->db->results('SELECT * FROM ' . Db::ident($this->table) . ' ORDER BY created_at DESC', ARRAY_A); } public function secret(array $key): string { return Crypto::decrypt((string) $key['secret_enc']); } public function revoke(string $id): void { if ($this->find($id) === null) { throw new RelayError('keys.not_found', esc_html__('Access key not found.', 'siterelay')); } $this->db->quietly(fn () => $this->db->wpdb()->update($this->table, ['revoked_at' => gmdate('Y-m-d H:i:s')], ['id' => $id])); } public function delete(string $id): void { $this->db->quietly(fn () => $this->db->wpdb()->delete($this->table, ['id' => $id])); } public function record_use(string $id, string $peer): void { $this->db->query($this->db->wpdb()->prepare('UPDATE ' . Db::ident($this->table) . ' SET uses = uses + 1, last_used_at = %s, last_used_by = %s WHERE id = %s', gmdate('Y-m-d H:i:s'), mb_substr($peer, 0, 255), $id)); } /** A one-time key is spent by the job it opens. */ public function bind(string $id, string $job_id): void { $this->db->quietly(fn () => $this->db->wpdb()->update($this->table, ['bound_job' => $job_id], ['id' => $id])); } /** active | revoked | expired | used */ /** * When a key expires: never | 1h | 24h | 7d | a date/time in the future → UTC "Y-m-d H:i:s", or null for never. * * @param mixed $value */ public static function expires_at($value): ?string { $value = is_string($value) ? trim($value) : ''; $presets = ['1h' => HOUR_IN_SECONDS, '24h' => DAY_IN_SECONDS, '7d' => WEEK_IN_SECONDS]; if ($value === '' || $value === 'never') { return null; } if (isset($presets[$value])) { return gmdate('Y-m-d H:i:s', time() + $presets[$value]); } $time = strtotime($value); if ($time === false || $time <= time()) { throw new RelayError('keys.invalid_expiry', esc_html__('Choose an expiry in the future.', 'siterelay')); } return gmdate('Y-m-d H:i:s', $time); } public static function status(array $key): string { if (!empty($key['revoked_at'])) { return 'revoked'; } if (!empty($key['expires_at']) && strtotime($key['expires_at'] . ' UTC') <= time()) { return 'expired'; } if ($key['max_uses'] !== null && !empty($key['bound_job'])) { return 'used'; } return 'active'; } public static function to_api(array $key): array { return [ 'id' => $key['id'], 'label' => $key['label'], 'permission' => $key['permissions'], 'expires_at' => $key['expires_at'], 'one_time' => $key['max_uses'] !== null, 'uses' => (int) $key['uses'], 'last_used_at' => $key['last_used_at'], 'last_used_by' => $key['last_used_by'], 'revoked_at' => $key['revoked_at'], 'created_at' => $key['created_at'], 'status' => self::status($key), ]; } }