['name'=>'Voiceover · male and female','description'=>'English voiceover audio files. Search the installed filenames for the available words.','source'=>'https://kenney.nl/assets/voiceover-pack','url'=>$base.'voiceover-pack/3f7f168698-1677589897/kenney_voiceover-pack.zip'], 'fighter' => ['name'=>'Voiceover · fighter','description'=>'Expressive game voiceover audio files. Not a complete crying or screaming library.','source'=>'https://kenney.nl/assets/voiceover-pack-fighter','url'=>$base.'voiceover-pack-fighter/6ceb77c6f1-1677589837/kenney_voiceover-pack-fighter.zip'], 'impact' => ['name'=>'Impacts · material sounds','description'=>'Foley impact sample files, with names supplied by the creator.','source'=>'https://kenney.nl/assets/impact-sounds','url'=>$base.'impact-sounds/87b4ddecda-1677589768/kenney_impact-sounds.zip'], 'scifi' => ['name'=>'Sci-fi · designed sound effects','description'=>'Designed science-fiction effects. These are not field recordings.','source'=>'https://kenney.nl/assets/sci-fi-sounds','url'=>$base.'sci-fi-sounds/6b296f9ecf-1677589334/kenney_sci-fi-sounds.zip'], 'interface' => ['name'=>'Interface · clicks and feedback','description'=>'Designed interface feedback sample files.','source'=>'https://kenney.nl/assets/interface-sounds','url'=>$base.'interface-sounds/fa43c1dd4d-1677589452/kenney_interface-sounds.zip'], ]; } public function allowed(): bool { return current_user_can('edit_posts') && current_user_can('upload_files'); } public function routes(): void { register_rest_route('digiter-builder/v1', '/audio-packs', ['methods'=>'GET','permission_callback'=>[$this,'allowed'],'callback'=>[$this,'catalog']]); register_rest_route('digiter-builder/v1', '/audio-packs/(?P[a-z]+)', ['methods'=>'POST','permission_callback'=>[$this,'allowed'],'callback'=>[$this,'install']]); register_rest_route('digiter-builder/v1', '/audio-packs/(?P[a-z]+)/import', ['methods'=>'POST','permission_callback'=>[$this,'allowed'],'callback'=>[$this,'import']]); } private function location(): array { $u = wp_upload_dir(null, false); if (!empty($u['error']) || empty($u['basedir']) || empty($u['baseurl'])) { throw new \RuntimeException('WordPress uploads directory is unavailable. Check the upload directory setting and write permissions.'); } return ['path'=>rtrim($u['basedir'], '/\\').'/digiter-builder-audio','url'=>rtrim($u['baseurl'], '/').'/digiter-builder-audio']; } private function manifest(string $id, array $location): array { $path = $location['path'].'/'.$id.'/manifest.json'; // This is a local uploads manifest, never a remote URL. if (wp_is_stream($path) || !is_readable($path) || is_link($path) || filesize($path) > 1048576) { return []; } $data = wp_json_file_decode($path, ['associative' => true]); return is_array($data) && is_array($data['items'] ?? null) ? $data : []; } private function items(string $id, array $location): array { $data = $this->manifest($id, $location); $out = []; $seen = []; foreach (array_slice($data['items'] ?? [], 0, self::LIMIT_ITEMS) as $item) { $file = $item['file'] ?? ''; if (!is_string($file) || !preg_match('/^[a-f0-9]{24}\.(wav|ogg|mp3)$/D', $file) || isset($seen[$file])) { continue; } $path = $location['path'].'/'.$id.'/'.$file; if (!is_file($path) || is_link($path) || filesize($path) < 4 || (isset($item['size']) && filesize($path) !== (int)$item['size'])) { continue; } $seen[$file] = true; $out[] = ['id'=>$id.':'.$file,'name'=>sanitize_text_field($item['name'] ?? $file),'url'=>$location['url'].'/'.$id.'/'.$file,'pack'=>$id,'license'=>'CC0','author'=>'Kenney']; } return $out; } private function complete(string $id, array $location): bool { $data = $this->manifest($id, $location); $count = count($data['items'] ?? []); return $count > 0 && $count <= self::LIMIT_ITEMS && count($this->items($id, $location)) === $count; } private function writable(string $path): bool { while (!file_exists($path) && dirname($path) !== $path) { $path = dirname($path); } return is_dir($path) && wp_is_writable($path); } public function catalog() { try { $location = $this->location(); $packs = []; $engine = AudioPackArchive::engine(); foreach (self::packs() as $id => $p) { unset($p['url']); $items = $this->items($id, $location); $packs[] = ['id'=>$id,'license'=>'CC0','author'=>'Kenney','installed'=>$this->complete($id, $location),'count'=>count($items),'items'=>$items, 'repair'=>is_dir($location['path'].'/'.$id) && !$this->complete($id, $location), 'lastError'=>get_option('digibl_audio_error_'.$id, null)] + $p; } $writable = $this->writable($location['path']); return ['packs'=>$packs,'canInstall'=>$engine !== '' && $writable,'diagnostics'=>[ 'version'=>defined('DIGIBL_VERSION') ? DIGIBL_VERSION : '1.6.1','archiveReader'=>$engine,'phpZip'=>class_exists('ZipArchive'), 'uploadsWritable'=>$writable,'maxUploadBytes'=>min(self::LIMIT_ZIP, (int)wp_max_upload_size()), 'externalHttpRestricted'=>defined('WP_HTTP_BLOCK_EXTERNAL') && WP_HTTP_BLOCK_EXTERNAL, ]]; } catch (\Throwable $e) { return new \WP_Error('digibl_audio_catalog', $e->getMessage(), ['status'=>500,'phase'=>'catalog']); } } /** No path supplied by an archive is ever used as an output filename. */ public static function validate_member(array $stat, int $running): bool { $name = (string)($stat['name'] ?? ''); $size = (int)($stat['size'] ?? 0); $compressed = (int)($stat['comp_size'] ?? 0); if (!self::safe_name($name) || !empty($stat['link']) || !empty($stat['encryption_method'])) { return false; } if ($size <= 0 || $size > 16777216 || $running + $size > self::LIMIT_TOTAL || $compressed <= 0 || $size / $compressed > 200) { return false; } return in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), ['wav','ogg','mp3'], true); } private static function safe_name(string $name): bool { return $name !== '' && strlen($name) <= 320 && !str_contains($name, "\0") && !str_contains($name, '\\') && !preg_match('~(^/|(^|/)\.\.(/|$)|^[A-Za-z]:|[\x00-\x1f])~', $name); } private static function valid_audio(string $ext, string $data): bool { return $ext === 'wav' ? (strlen($data) >= 44 && substr($data,0,4) === 'RIFF' && substr($data,8,4) === 'WAVE') : ($ext === 'ogg' ? strlen($data) >= 27 && substr($data,0,4) === 'OggS' : strlen($data) > 10 && (substr($data,0,3) === 'ID3' || (ord($data[0]) === 255 && (ord($data[1]) & 224) === 224))); } /** Allow only this provider, this exact pack and HTTPS on the default port. */ public static function provider_url(string $url, array $pack, bool $archive = true): bool { $parts = wp_parse_url($url); if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || !in_array(strtolower($parts['host'] ?? ''), ['kenney.nl','www.kenney.nl'], true) || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment']) || (isset($parts['port']) && $parts['port'] !== 443)) { return false; } $path = rawurldecode($parts['path'] ?? ''); if (!self::safe_name(ltrim($path, '/')) || str_contains($path, '//')) { return false; } if (!$archive) { return rtrim($path,'/') === wp_parse_url($pack['source'], PHP_URL_PATH); } $slug = basename(wp_parse_url($pack['source'], PHP_URL_PATH)); return str_starts_with($path, '/media/pages/assets/'.$slug.'/') && basename($path) === basename(wp_parse_url($pack['url'], PHP_URL_PATH)); } private static function absolute_url(string $url, string $base): string { $url = html_entity_decode(trim($url), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (str_starts_with($url, '//')) { return 'https:'.$url; } if (str_starts_with($url, '/')) { return 'https://'.wp_parse_url($base, PHP_URL_HOST).$url; } if (!wp_parse_url($url, PHP_URL_SCHEME)) { return substr($base, 0, (int)strrpos($base, '/') + 1).$url; } return $url; } /** Follow at most 3 redirects, validating the target BEFORE every request. * wp_safe_remote_get additionally rejects unsafe IP addresses. TLS stays on. */ private function request(string $url, array $pack, bool $archive, string $tmp = '') { for ($hop = 0; $hop <= 3; $hop++) { if (!self::provider_url($url, $pack, $archive)) { return new \WP_Error('digibl_audio_redirect','The provider redirected outside the permitted HTTPS pack address.',['status'=>502,'phase'=>'download']); } $args = ['timeout'=>$archive ? 60 : 15,'redirection'=>0,'sslverify'=>true,'limit_response_size'=>$archive ? self::LIMIT_ZIP + 1 : 1048576, 'headers'=>['Accept'=>$archive ? 'application/zip, application/octet-stream;q=0.9' : 'text/html']]; if ($tmp !== '') { $args['stream'] = true; $args['filename'] = $tmp; } $response = wp_safe_remote_get($url, $args); if (is_wp_error($response)) { return new \WP_Error('digibl_audio_download',sprintf(Language::text('Sample download failed: %s Use Import ZIP if the server cannot reach Kenney.'),$response->get_error_message()),['status'=>502,'phase'=>'download']); } $code = wp_remote_retrieve_response_code($response); if (!in_array($code, [301,302,303,307,308], true)) { return $response; } $next = (string)wp_remote_retrieve_header($response, 'location'); if (!$next) { break; } $url = self::absolute_url($next, $url); } return new \WP_Error('digibl_audio_redirect','Too many or incomplete redirects from the audio provider.',['status'=>502,'phase'=>'download']); } private function download(array $pack, string $tmp) { $response = $this->request($pack['url'], $pack, true, $tmp); if (is_wp_error($response)) { return $response; } $code = wp_remote_retrieve_response_code($response); // A release can move its media directory. Discover a new URL only from // the official pack page, and retain the exact host, pack and ZIP name. if (in_array($code, [404,410], true)) { $page = $this->request($pack['source'], $pack, false); if (!is_wp_error($page) && wp_remote_retrieve_response_code($page) === 200) { $html = wp_remote_retrieve_body($page); preg_match_all('~href\s*=\s*([\'"])(.*?)\1~is', $html, $matches); foreach ($matches[2] ?? [] as $href) { $url = self::absolute_url($href, $pack['source']); if ($url !== $pack['url'] && self::provider_url($url, $pack)) { $response = $this->request($url, $pack, true, $tmp); break; } } } } if (is_wp_error($response)) { return $response; } $code = wp_remote_retrieve_response_code($response); if ($code !== 200) { return new \WP_Error('digibl_audio_http',sprintf(Language::text('The audio provider returned HTTP %d. Retry, or use Import ZIP if access from this server is restricted.'),$code),['status'=>502,'phase'=>'download','providerStatus'=>$code]); } clearstatcache(true, $tmp); if (!is_file($tmp) || filesize($tmp) < 22 || filesize($tmp) > self::LIMIT_ZIP) { return new \WP_Error('digibl_audio_size','The audio download is empty, incomplete or exceeds 40 MB.',['status'=>422,'phase'=>'download']); } $length = (string)wp_remote_retrieve_header($response, 'content-length'); if (ctype_digit($length) && (int)$length !== filesize($tmp)) { return new \WP_Error('digibl_audio_incomplete','The audio download was interrupted. Retry the installation.',['status'=>502,'phase'=>'download']); } if (file_get_contents($tmp, false, null, 0, 4) !== "PK\x03\x04") { return new \WP_Error('digibl_audio_not_zip','The server received a web page instead of a ZIP archive. A provider or network filter may be blocking the download. Use Import ZIP.',['status'=>422,'phase'=>'download']); } return true; } /** Remove only this operation's flat staging folder, never a previous pack. */ private function clean_temp(string $path): void { if (!$path || !is_dir($path) || is_link($path)) { return; } foreach (glob($path.'/*') ?: [] as $file) { if (is_file($file) || is_link($file)) { @wp_delete_file($file); } } @LocalFiles::get()->rmdir($path); } private function fail(string $id, string $code, string $message, string $phase, int $status = 500) { $message = sanitize_text_field($message); update_option('digibl_audio_error_'.$id, ['code'=>$code,'message'=>$message,'phase'=>$phase,'at'=>gmdate('c')], false); return new \WP_Error($code, $message, ['status'=>$status,'phase'=>$phase,'pack'=>$id]); } public function install($request) { return $this->run($request, false); } public function import($request) { return $this->run($request, true); } private function run($request, bool $upload) { $id = (string)$request['pack']; $pack = self::packs()[$id] ?? null; if (!$pack) { return new \WP_Error('digibl_audio_pack','Unknown audio pack.',['status'=>400]); } // Defense in depth: WP REST also calls allowed() before this callback. if (!$this->allowed()) { return new \WP_Error('digibl_audio_permission','Editing and media upload permissions are required.',['status'=>403]); } try { $location = $this->location(); // Already installed remains usable even on a server with no ZIP reader. if ($this->complete($id, $location)) { delete_option('digibl_audio_error_'.$id); return $this->catalog(); } } catch (\Throwable $e) { return $this->fail($id,'digibl_audio_storage',$e->getMessage(),'storage'); } if (!AudioPackArchive::engine()) { return $this->fail($id,'digibl_audio_zip','No ZIP reader is available. Enable PHP ZIP, or Phar with zlib.','requirements',503); } $lock = 'digibl_audio_install_'.$id; $stamp = time(); $token = bin2hex(random_bytes(16)); $old = get_option($lock, 0); $created = is_array($old) ? (int)($old['created'] ?? 0) : (int)$old; if ($old && $created < $stamp - 900) { delete_option($lock); } if (!add_option($lock,['created'=>$stamp,'token'=>$token],'','no')) { return new \WP_Error('digibl_audio_busy','This sample pack is being installed. Refresh the library shortly.',['status'=>409,'phase'=>'lock']); } $tmp = ''; $stage = ''; $archive = null; $phase = 'storage'; try { if (!wp_mkdir_p($location['path']) || !wp_is_writable($location['path'])) { throw new \RuntimeException('Cannot write to the audio library under WordPress uploads. Check directory permissions and disk space.'); } if (is_link($location['path']) || is_link($location['path'].'/'.$id)) { throw new \RuntimeException('An audio library path is a symbolic link. Ask the administrator to verify the storage location.'); } $stage = $location['path'].'/.install-'.$id.'-'.bin2hex(random_bytes(8)); if (!LocalFiles::get()->mkdir($stage, 0755)) { throw new \RuntimeException('Cannot prepare a temporary audio pack directory.'); } if (file_put_contents($stage.'/index.html', '') === false) { throw new \RuntimeException('Cannot protect the sample directory listing.'); } if (!function_exists('wp_tempnam')) { require_once ABSPATH.'wp-admin/includes/file.php'; } // Reserve a unique temporary file, then keep an explicit .zip suffix. // PharData rejects extensionless temporary paths on some hosts. $tmp = wp_tempnam('dbs-audio-'.$id.'.zip'); if (!$tmp) { throw new \RuntimeException('No writable temporary file is available on the server.'); } $zipTmp = $tmp.'.zip'; if (file_exists($zipTmp) || !LocalFiles::get()->move($tmp,$zipTmp)) { throw new \RuntimeException('Cannot prepare a temporary ZIP file.'); } $tmp = $zipTmp; if (function_exists('set_time_limit')) { @set_time_limit(120); } $phase = $upload ? 'upload' : 'download'; if ($upload) { $files = $request->get_file_params(); $file = $files['archive'] ?? null; if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new \RuntimeException('The ZIP upload did not complete. Check the PHP upload and POST size limits.'); } if (!is_string($file['name'] ?? null) || strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'zip' || !is_uploaded_file($file['tmp_name'] ?? '') || (int)($file['size'] ?? 0) > min(self::LIMIT_ZIP, (int)wp_max_upload_size())) { throw new \RuntimeException('Choose an original Kenney ZIP within the server upload size limit.'); } if (!TemporaryUpload::move($file, $tmp, ['zip' => 'application/zip'])) { throw new \RuntimeException('The uploaded ZIP could not be moved to temporary storage.'); } } else { $result = $this->download($pack, $tmp); if (is_wp_error($result)) { return $this->fail($id,$result->get_error_code(),$result->get_error_message(),'download',(int)($result->get_error_data()['status'] ?? 502)); } } $phase = 'archive'; $archive = new AudioPackArchive($tmp); $entries = $archive->entries(); $items = []; $total = 0; $seen = []; $license = ''; $selected = []; foreach ($entries as $stat) { $name = (string)($stat['name'] ?? ''); if (self::safe_name($name) && !empty($stat['size']) && (int)$stat['size'] <= 65536 && empty($stat['link']) && preg_match('~(^|/)(licen[cs]e|copying)[^/]*\.txt$~i', $name)) { $text = $archive->read($stat, 65536); if (stripos($text,'kenney') !== false && (stripos($text,'CC0') !== false || stripos($text,'Creative Commons Zero') !== false || str_contains($text,'publicdomain/zero/'))) { $license = $text; } } if (!self::validate_member($stat, 0)) { continue; } $total += (int)$stat['size']; if ($total > self::LIMIT_TOTAL || count($selected) >= self::LIMIT_ITEMS) { throw new \RuntimeException('The sample pack exceeds the allowed expanded size or number of recordings.'); } if (isset($seen[$name])) { throw new \RuntimeException('The sample pack contains duplicate filenames.'); } $seen[$name] = true; $selected[] = $stat; } if (!$license && $upload) { throw new \RuntimeException('The ZIP does not contain a Kenney CC0 license. Import the original, unmodified Kenney download.'); } $phase = 'extract'; $total = 0; foreach ($selected as $stat) { $name = $stat['name']; $size = (int)$stat['size']; $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $data = $archive->read($stat); if (!self::valid_audio($ext, $data)) { continue; } $filename = substr(hash('sha256', $name),0,24).'.'.$ext; if (file_put_contents($stage.'/'.$filename, $data, LOCK_EX) !== $size) { throw new \RuntimeException('Could not write an audio sample. Check available disk space.'); } $label = preg_replace('/([a-z])([A-Z])/','$1 $2',pathinfo($name, PATHINFO_FILENAME)); $items[] = ['file'=>$filename,'name'=>sanitize_text_field(str_replace(['_','/'],[' ',' · '],dirname($name).' · '.$label)),'sha256'=>hash('sha256',$data),'size'=>$size]; $total += $size; } $reader = $archive->reader(); $archive->close(); $archive = null; if (!$items) { throw new \RuntimeException('No valid WAV, OGG or MP3 recordings were found in this ZIP.'); } $phase = 'commit'; $manifest = ['version'=>2,'author'=>'Kenney','license'=>'CC0','source'=>$pack['source'],'method'=>$upload?'manual-import':'provider-download','reader'=>$reader, 'archiveSha256'=>hash_file('sha256',$tmp),'installedAt'=>gmdate('c'),'items'=>$items]; $json = wp_json_encode($manifest); if (!is_string($json) || file_put_contents($stage.'/manifest.json',$json,LOCK_EX) !== strlen($json)) { throw new \RuntimeException('Could not save the audio pack manifest.'); } $licenseText = $license ?: "Kenney — CC0 1.0\n"; $licenseText .= "\nSource: ".$pack['source']."\nhttps://creativecommons.org/publicdomain/zero/1.0/\n"; if (file_put_contents($stage.'/LICENSE.txt',$licenseText,LOCK_EX) !== strlen($licenseText)) { throw new \RuntimeException('Could not save the audio license.'); } $dest = $location['path'].'/'.$id; $backup = ''; if (is_dir($dest)) { // Keep old public sample URLs playable, even when the provider // removed that filename from its current pack. Never copy scripts. foreach (glob($dest.'/*') ?: [] as $oldfile) { if (!preg_match('/^[a-f0-9]{24}\.(wav|ogg|mp3)$/D', basename($oldfile)) || !is_file($oldfile) || is_link($oldfile) || file_exists($stage.'/'.basename($oldfile))) { continue; } $size = filesize($oldfile); $ext = strtolower(pathinfo($oldfile, PATHINFO_EXTENSION)); if ($size < 1 || $size > 16777216 || $total + $size > self::LIMIT_TOTAL) { throw new \RuntimeException('Cannot safely retain all previous samples within the storage limit. The old pack has been kept unchanged.'); } if (!self::valid_audio($ext,(string)file_get_contents($oldfile,false,null,0,64))) { continue; } if (!copy($oldfile,$stage.'/'.basename($oldfile))) { throw new \RuntimeException('Could not retain a previous audio sample. The old pack has been kept unchanged.'); } $total += $size; } $backup = $location['path'].'/.previous-'.$id.'-'.bin2hex(random_bytes(8)); if (!LocalFiles::get()->move($dest,$backup)) { throw new \RuntimeException('Could not move the incomplete sample pack aside. The previous files have not been deleted.'); } } elseif (file_exists($dest)) { throw new \RuntimeException('A file blocks the audio pack directory. Ask the administrator to inspect uploads/digiter-builder-audio.'); } if (!LocalFiles::get()->move($stage,$dest)) { if ($backup !== '' && !LocalFiles::get()->move($backup,$dest)) { throw new \RuntimeException('The new pack could not be committed. Previous files are retained in the .previous backup directory; ask the administrator to restore it.'); } throw new \RuntimeException('Could not complete the sample installation. The previous pack has been restored.'); } $stage = ''; delete_option('digibl_audio_error_'.$id); return $this->catalog(); } catch (\Throwable $e) { return $this->fail($id,'digibl_audio_'.$phase,$e->getMessage(),$phase, in_array($phase,['upload','archive','extract'],true) ? 422 : 500); } finally { if ($archive instanceof AudioPackArchive) { $archive->close(); } if ($tmp && is_file($tmp)) { @wp_delete_file($tmp); } $this->clean_temp($stage); $current = get_option($lock, []); if (is_array($current) && ($current['token'] ?? '') === $token) { delete_option($lock); } } } }