loadConfig(); } /** * GCS 설정 로드 (.env 기반) */ private function loadConfig(): void { $this->bucketName = config('services.google.storage_bucket'); $credentialsPath = config('services.google.credentials_path'); if ($credentialsPath && file_exists($credentialsPath)) { $this->serviceAccount = json_decode(file_get_contents($credentialsPath), true); } // fallback: 레거시 파일 경로 if (! $this->serviceAccount) { $legacyPath = '/var/www/sales/apikey/google_service_account.json'; if (file_exists($legacyPath)) { $this->serviceAccount = json_decode(file_get_contents($legacyPath), true); } } if ($this->bucketName && $this->serviceAccount) { $this->configSource = 'env'; } } /** * 현재 설정 소스 반환 */ public function getConfigSource(): string { return $this->configSource; } /** * GCS가 사용 가능한지 확인 */ public function isAvailable(): bool { return $this->bucketName !== null && $this->serviceAccount !== null; } /** * GCS에 파일 업로드 * * @param string $filePath 로컬 파일 경로 * @param string $objectName GCS에 저장할 객체 이름 * @return string|null GCS URI (gs://bucket/object) 또는 실패 시 null */ public function upload(string $filePath, string $objectName): ?string { if (! $this->isAvailable()) { Log::warning('GCS 업로드 실패: 설정되지 않음'); return null; } if (! file_exists($filePath)) { Log::error('GCS 업로드 실패: 파일 없음 - '.$filePath); return null; } // OAuth 2.0 토큰 생성 $accessToken = $this->getAccessToken(); if (! $accessToken) { return null; } // GCS에 파일 업로드 $fileContent = file_get_contents($filePath); $mimeType = mime_content_type($filePath) ?: 'application/octet-stream'; $uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/'. urlencode($this->bucketName).'/o?uploadType=media&name='. urlencode($objectName); $ch = curl_init($uploadUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer '.$accessToken, 'Content-Type: '.$mimeType, 'Content-Length: '.strlen($fileContent), ]); curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5분 타임아웃 $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($httpCode === 200) { $gcsUri = 'gs://'.$this->bucketName.'/'.$objectName; Log::info('GCS 업로드 성공: '.$gcsUri); return $gcsUri; } Log::error('GCS 업로드 실패 (HTTP '.$httpCode.'): '.($error ?: $response)); return null; } /** * GCS에서 서명된 다운로드 URL 생성 * * @param string $objectName GCS 객체 이름 또는 gs:// URI * @param int $expiresInMinutes URL 유효 시간 (분) * @return string|null 서명된 URL 또는 실패 시 null */ public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string { if (! $this->isAvailable()) { return null; } $objectName = $this->stripGsPrefix($objectName); $expiration = time() + ($expiresInMinutes * 60); $stringToSign = "GET\n\n\n{$expiration}\n/{$this->bucketName}/{$objectName}"; $privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']); if (! $privateKey) { Log::error('GCS URL 서명 실패: 개인 키 읽기 오류'); return null; } openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (PHP_VERSION_ID < 80000) { openssl_free_key($privateKey); } $encodedSignature = urlencode(base64_encode($signature)); $clientEmail = urlencode($this->serviceAccount['client_email']); return "https://storage.googleapis.com/{$this->bucketName}/{$objectName}". "?GoogleAccessId={$clientEmail}". "&Expires={$expiration}". "&Signature={$encodedSignature}"; } /** * GCS에서 파일 삭제 * * @param string $objectName GCS 객체 이름 또는 gs:// URI * @return bool 성공 여부 */ public function delete(string $objectName): bool { if (! $this->isAvailable()) { return false; } $objectName = $this->stripGsPrefix($objectName); $accessToken = $this->getAccessToken(); if (! $accessToken) { return false; } $deleteUrl = 'https://storage.googleapis.com/storage/v1/b/'. urlencode($this->bucketName).'/o/'. urlencode($objectName); $ch = curl_init($deleteUrl); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer '.$accessToken, ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $httpCode === 204 || $httpCode === 200; } /** * OAuth 2.0 액세스 토큰 획득 */ private function getAccessToken(): ?string { // JWT 생성 $now = time(); $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $jwtClaim = $this->base64UrlEncode(json_encode([ 'iss' => $this->serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']); if (! $privateKey) { Log::error('GCS 토큰 실패: 개인 키 읽기 오류'); return null; } openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (PHP_VERSION_ID < 80000) { openssl_free_key($privateKey); } $jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature); // OAuth 토큰 요청 $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { Log::error('GCS 토큰 실패: HTTP '.$httpCode); return null; } $data = json_decode($response, true); return $data['access_token'] ?? null; } /** * Base64 URL 인코딩 */ private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** * gs://bucket-name/objectName → objectName 변환 * * upload()가 gs:// URI를 반환하므로, getSignedUrl/delete에서 자동 처리 */ private function stripGsPrefix(string $objectName): string { $prefix = 'gs://'.$this->bucketName.'/'; if (str_starts_with($objectName, $prefix)) { return substr($objectName, strlen($prefix)); } // gs://다른-버킷/ 형태도 처리 if (str_starts_with($objectName, 'gs://')) { $objectName = preg_replace('#^gs://[^/]+/#', '', $objectName); } return $objectName; } /** * 버킷 이름 반환 */ public function getBucketName(): ?string { return $this->bucketName; } }