> 파일 목록 (상대 경로 오름차순) */ public function list(string $extensionType, string $identifier): array { $directory = CustomAssets::directory($extensionType, $identifier); if ($directory === null || ! is_dir($directory)) { return []; } // 로드 대상 서술자를 미리 만들어, 목록의 각 파일이 실제로 페이지에 실리는지 // (`loaded`) 알려준다. 규약 스캔·선언 파일 어느 쪽이든 결과는 같은 형태다. // // 서술자는 상대 경로 필드를 갖지 않는다 — 소비자가 출처에 의존하지 않도록 URL 과 // id 만 노출하는 계약이다. 그래서 id 접두(`custom:{type}:{identifier}:`)를 떼어 // 상대 경로를 얻는다. 훅이 더한 항목은 이 접두가 없어 자연히 제외되는데, 그것이 // 옳다 — 디스크에 없는 항목을 파일 목록에 표시할 이유가 없다. $idPrefix = 'custom:'.$extensionType.':'.$identifier.':'; $loadedPaths = []; foreach (CustomAssets::forExtension($extensionType, $identifier) as $asset) { $id = (string) ($asset['id'] ?? ''); if (($asset['source'] ?? null) !== 'file' || ! str_starts_with($id, $idPrefix)) { continue; } $loadedPaths[substr($id, strlen($idPrefix))] = true; } $files = []; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if (! $file->isFile()) { continue; } $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($directory) + 1)); $extension = strtolower($file->getExtension()); $files[] = [ 'path' => $relative, 'name' => $file->getFilename(), 'extension' => $extension, 'size' => $file->getSize(), 'modified_at' => date('c', $file->getMTime()), 'editable' => in_array($extension, self::EDITABLE_EXTENSIONS, true), 'loaded' => isset($loadedPaths[$relative]), ]; } usort($files, fn (array $a, array $b) => strcmp($a['path'], $b['path'])); return $files; } /** * 텍스트 파일 본문을 읽습니다. * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param string $relative `custom/` 기준 상대 경로 * @return array{path: string, content: string, size: int} 본문 * * @throws CustomAssetOperationException 파일 부재·비편집 대상·크기 초과 시 */ public function read(string $extensionType, string $identifier, string $relative): array { $absolute = $this->resolveExisting($extensionType, $identifier, $relative); $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); if (! in_array($extension, self::EDITABLE_EXTENSIONS, true)) { throw new CustomAssetOperationException('custom_assets.errors.not_editable', ['extension' => $extension]); } $size = (int) filesize($absolute); if ($size > self::MAX_TEXT_BYTES) { throw new CustomAssetOperationException('custom_assets.errors.too_large_to_edit', [ 'limit' => (string) self::MAX_TEXT_BYTES, ]); } $content = file_get_contents($absolute); if ($content === false) { throw new CustomAssetOperationException('custom_assets.errors.read_failed', ['path' => $relative]); } return ['path' => $relative, 'content' => $content, 'size' => $size]; } /** * 텍스트 파일 본문을 저장합니다 (없으면 생성). * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param string $relative `custom/` 기준 상대 경로 * @param string $content 본문 * @return array 저장된 파일 정보 * * @throws CustomAssetOperationException 경로 무효·쓰기 실패 시 */ public function save(string $extensionType, string $identifier, string $relative, string $content): array { $absolute = $this->resolveWritable($extensionType, $identifier, $relative); $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); if (! in_array($extension, self::EDITABLE_EXTENSIONS, true)) { throw new CustomAssetOperationException('custom_assets.errors.not_editable', ['extension' => $extension]); } if (strlen($content) > self::MAX_TEXT_BYTES) { throw new CustomAssetOperationException('custom_assets.errors.too_large_to_edit', [ 'limit' => (string) self::MAX_TEXT_BYTES, ]); } $this->ensureDirectory(dirname($absolute)); if (file_put_contents($absolute, $content) === false) { throw new CustomAssetOperationException('custom_assets.errors.write_failed', ['path' => $relative]); } $this->invalidate($extensionType, $identifier, 'save', $relative); return [ 'path' => $relative, 'size' => strlen($content), 'modified_at' => date('c'), ]; } /** * 업로드 파일을 `custom/` 에 저장합니다. * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param UploadedFile $file 업로드 파일 * @param string|null $directory `custom/` 기준 하위 디렉토리 (선택) * @return array 저장된 파일 정보 * * @throws CustomAssetOperationException 경로 무효·확장자 불허·쓰기 실패 시 */ public function upload( string $extensionType, string $identifier, UploadedFile $file, ?string $directory = null ): array { $name = $this->sanitizeFileName($file->getClientOriginalName()); $relative = $directory !== null && $directory !== '' ? trim($directory, '/').'/'.$name : $name; $absolute = $this->resolveWritable($extensionType, $identifier, $relative); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (! in_array($extension, AllowedTemplateFileType::getAllowedExtensions(), true)) { throw new CustomAssetOperationException('custom_assets.errors.extension_not_allowed', [ 'extension' => $extension, ]); } if ($file->getSize() > self::MAX_UPLOAD_BYTES) { throw new CustomAssetOperationException('custom_assets.errors.upload_too_large', [ 'limit' => (string) self::MAX_UPLOAD_BYTES, ]); } $this->ensureDirectory(dirname($absolute)); $file->move(dirname($absolute), basename($absolute)); $this->invalidate($extensionType, $identifier, 'upload', $relative); return [ 'path' => $relative, 'size' => (int) @filesize($absolute), 'modified_at' => date('c'), ]; } /** * 파일을 삭제합니다. * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param string $relative `custom/` 기준 상대 경로 * @return void * * @throws CustomAssetOperationException 파일 부재·삭제 실패 시 */ public function delete(string $extensionType, string $identifier, string $relative): void { $absolute = $this->resolveExisting($extensionType, $identifier, $relative); if (! @unlink($absolute)) { throw new CustomAssetOperationException('custom_assets.errors.delete_failed', ['path' => $relative]); } $this->invalidate($extensionType, $identifier, 'delete', $relative); } /** * 쓰기 뒤 캐시·게시본을 무효화합니다. * * 확장 캐시 버전을 올리면 그 단일 지점이 정적 재게시까지 예약한다 — 편집한 파일이 * 게시본에 반영되는 경로가 여기 한 곳으로 모인다. * * 변경 감지 서명은 **지운다**. 뷰 컴포저가 다음 렌더에서 같은 변경을 다시 발견해 * 버전을 한 번 더 올리는 것을 막기 위해서다 (서명이 없으면 그 관측은 기록만 한다). * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param string $operation 수행한 작업 (로그용) * @param string $relative 대상 상대 경로 (로그용) * @return void */ private function invalidate(string $extensionType, string $identifier, string $operation, string $relative): void { CustomAssets::flushCache(); try { app(CacheInterface::class)->forget(CustomAssets::SIGNATURE_CACHE_KEY); } catch (\Exception $e) { Log::warning('사용자 추가 에셋 서명 캐시 삭제 실패', ['error' => $e->getMessage()]); } $this->incrementExtensionCacheVersion(); Log::info('사용자 추가 에셋 변경', [ 'extension_type' => $extensionType, 'identifier' => $identifier, 'operation' => $operation, 'path' => $relative, ]); // 운영자가 올린 스크립트는 사이트 전 화면에서 실행된다 — 누가 언제 무엇을 바꿨는지 // 남지 않으면 사후에 되짚을 수단이 없다. 활동 로그가 그 유일한 기록이다. HookManager::doAction('core.custom_assets.after_change', $extensionType, $identifier, $operation, $relative); } /** * 존재하는 파일의 절대 경로를 해석합니다. * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param string $relative 상대 경로 * @return string 절대 경로 * * @throws CustomAssetOperationException 경로 무효·파일 부재 시 */ private function resolveExisting(string $extensionType, string $identifier, string $relative): string { $absolute = $this->resolveWritable($extensionType, $identifier, $relative); if (! is_file($absolute)) { throw new CustomAssetOperationException('custom_assets.errors.not_found', ['path' => $relative]); } return $absolute; } /** * 쓰기 대상 절대 경로를 해석합니다 (아직 없어도 됩니다). * * 컨테인먼트는 문자열 접두 비교가 아니라 세그먼트 검사로 판정한다. 아직 없는 * 파일은 `realpath` 가 실패하므로 실경로 정규화에 기댈 수 없고, 접두 비교만으로는 * `custom-evil/` 같은 형제 디렉토리가 통과한다. * * @param string $extensionType 확장 타입 * @param string $identifier 확장 식별자 * @param string $relative 상대 경로 * @return string 절대 경로 * * @throws CustomAssetOperationException 경로가 무효한 경우 */ private function resolveWritable(string $extensionType, string $identifier, string $relative): string { $directory = CustomAssets::directory($extensionType, $identifier); if ($directory === null) { throw new CustomAssetOperationException('custom_assets.errors.invalid_extension_target', [ 'identifier' => $identifier, ]); } $normalized = str_replace('\\', '/', trim($relative)); if ($normalized === '' || str_starts_with($normalized, '/')) { throw new CustomAssetOperationException('custom_assets.errors.invalid_path', ['path' => $relative]); } foreach (explode('/', $normalized) as $segment) { if ($segment === '' || $segment === '.' || $segment === '..') { throw new CustomAssetOperationException('custom_assets.errors.invalid_path', ['path' => $relative]); } } return $directory.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $normalized); } /** * 업로드 파일명을 안전한 형태로 정규화합니다. * * @param string $name 원본 파일명 * @return string 정규화된 파일명 */ private function sanitizeFileName(string $name): string { $base = basename(str_replace('\\', '/', $name)); return preg_replace('/[^A-Za-z0-9._-]/', '_', $base) ?? ''; } /** * 디렉토리를 보장합니다. * * @param string $directory 절대 경로 * @return void * * @throws CustomAssetOperationException 생성 실패 시 */ private function ensureDirectory(string $directory): void { if (is_dir($directory)) { return; } if (! @mkdir($directory, 0755, true) && ! is_dir($directory)) { throw new CustomAssetOperationException('custom_assets.errors.directory_failed', ['path' => $directory]); } } }