Google Drive — guia de implementação para PHP (projeto monolítico)
Documento para um desenvolvedor PHP implementar a mesma integração Google Drive deste CRM num projeto em que backend e frontend estão no mesmo código, sem API REST.
Este repositório (NestJS) usa HTTP JSON. O PHP não precisa de API, jobs assíncronos, Redis nem JWT. Precisa das mesmas decisões de Drive: conta de serviço, Shared Drive, pastas, upload/replace, permissão pública, e gravar só o fileId no banco.
Código de referência neste backend:
| Ficheiro | Papel |
|---|---|
src/modules/storage/google-drive.service.ts | Cliente Drive (auth, pastas, upload, download, health, quota) |
src/modules/storage/google-drive-folder.util.ts | Nomes de pasta e escape da query |
src/modules/storage/google-drive-media.types.ts | Constantes (profile, banner, _sem_empresa, _system) |
src/modules/tenant-storage/tenant-drive-quota.service.ts | Quota por empresa |
src/common/utils/user-branding-drive-metadata.util.ts | Como ler fileId do JSON no banco |
src/modules/storage/storage-health.controller.ts | Diagnóstico |
scripts/test-drive-company-folder.js | Script de teste isolado |
Pacote Node usado aqui: googleapis ^171.4.0. Em PHP o equivalente é google/apiclient.
1. O que foi implementado (visão de produto)
Antes, imagens e documentos iam em base64 para o PostgreSQL. Isso inchava o banco, tornava GETs pesados e não escalava.
A migração fez:
- O ficheiro sai do banco e vai para um Shared Drive (Drive compartilhado do Google Workspace).
- No banco ficam só referências:
fileId, URL de visualização, MIME, tamanho em bytes. - Na leitura, o servidor baixa o ficheiro do Drive (proxy) e devolve os bytes — ou, em páginas públicas, usa a URL do Drive.
- Uploads novos não gravam mais base64. Registos antigos ainda podem ser lidos (fallback).
- Sem Drive configurado no servidor, upload falha (503 neste backend). Não há “gravar localmente se o Drive cair”.
Isto é o coração da implementação. O resto (rotas Nest, jobs 202, Redis) é adaptação a uma API. No PHP, o mesmo coração corre dentro do POST do formulário.
2. Arquitetura — NestJS vs PHP monolítico
2.1 Como está neste CRM (API)
Browser
→ POST/PATCH JSON ou multipart na API Nest
→ UsersService / TenantsService / EstabelecimentosService
1. valida MIME e tamanho
2. verifica quota da empresa
3. GoogleDriveService.upload...()
4. UPDATE no PostgreSQL (só metadata)
5. invalida cache Redis
→ JSON de sucesso
GET icon/banner:
→ lê fileId no banco
→ GoogleDriveService.downloadFile(fileId)
→ devolve bytes (ou base64 se ?json=true)
O frontend não fala com o Google. Só o servidor tem as credenciais.
2.2 Como deve ficar no PHP (mesmo projeto, sem API)
Browser (form HTML)
→ POST multipart para o próprio PHP (ex.: upload_logo.php)
→ session/login já existente da app
→ valida MIME e tamanho
→ (opcional) verifica quota
→ classe GoogleDriveService (PHP)
→ UPDATE na tabela (file_id, view_url, mime, size)
→ redirect / re-render da mesma página com mensagem
Exibir imagem:
→ <img src="ver_imagem.php?tipo=logo&id=123">
→ PHP lê file_id no banco
→ baixa do Drive e faz echo com Content-Type
OU usa URL pública do Drive no src
Não crie um “microsserviço de Drive”. Uma classe PHP + 2–3 scripts (upload, ver, diagnóstico) bastam.
2.3 O que NÃO precisa copiar do Nest
| Recurso Nest | Precisa no PHP? |
|---|---|
Rotas REST /api/users/me/media | Não. Use form POST. |
Jobs assíncronos (202 + polling) | Não. O PHP pode esperar o upload (segundos). |
| Redis cache de ícone/banner | Opcional. Só se o GET de imagem for muito frequente. |
| Swagger / DTOs | Não. |
googleapis Node | Não. Use google/apiclient. |
Conta de serviço + Shared Drive + supportsAllDrives | Sim. Obrigatório. |
Guardar só fileId no banco | Sim. |
Criar pastas {Empresa}/profile/ | Sim, se quiser a mesma organização. |
3. Decisão crítica: Service Account + Shared Drive
3.1 Por que não OAuth do utilizador
Não pedimos ao utilizador “entrar com Google”. A aplicação autentica-se como um robô (conta de serviço). Qualquer user autenticado na sua app pode enviar ficheiros; o Drive vê sempre o mesmo client_email.
Fluxo OAuth (login with Google + refresh token) não foi usado e complica um monolito (callback, tokens por user, pastas pessoais). Não replique isso.
3.2 Por que Shared Drive (Drive compartilhado) e não “Meu Drive”
Contas de serviço não têm quota no Drive pessoal de um Gmail/Workspace. Se apontar a pasta raiz para “Meu Drive”, o upload falha com:
storage quota / Service accounts do not have storage quota
A solução (a que este projeto usa):
- Google Workspace (não Gmail pessoal).
- Criar um Shared Drive.
- Adicionar o e-mail da service account como Gestor de conteúdo (Content manager) ou superior — membro do Shared Drive inteiro, não só “partilhar uma pasta”.
GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID= ID desse Shared Drive ou de uma pasta dentro dele.
O ID aparece no URL:
https://drive.google.com/drive/folders/0AKcaVeNV8V6AUk9PVA
^^^^^^^^^^^^^^^^^^^^
este é o folder ID
3.3 supportsAllDrives = true em TODAS as chamadas
Sem este flag, a API v3 trata o ficheiro como Drive pessoal e devolve 404 File not found mesmo com ID certo.
Em Node:
supportsAllDrives: true,
includeItemsFromAllDrives: true, // só em files.list
Em PHP (todas as operações: create, get, update, list, permissions, delete):
'supportsAllDrives' => true,
'includeItemsFromAllDrives' => true, // só em list
Isto é a causa nº 1 de “funciona no script de teste e falha na app” se alguém esquecer o flag num método.
3.4 Scope
https://www.googleapis.com/auth/drive
Scope completo. drive.file não chega: a service account precisa criar pastas, listar, substituir e apagar dentro do Shared Drive.
4. Setup no Google Cloud (fazer uma vez)
Passos iguais aos deste projeto. O PHP usa o mesmo JSON e o mesmo Shared Drive, se quiser.
4.1 Projeto GCP
- Google Cloud Console.
- Criar (ou escolher) um projeto.
- APIs e serviços → Biblioteca → ativar Google Drive API.
Sem a API ativada, qualquer files.list falha.
4.2 Conta de serviço
- IAM e administrador → Contas de serviço → Criar.
- Nome livre (ex.:
crm-drive). - Chaves → Adicionar chave → JSON.
- Guardar o ficheiro. Ele contém
type,project_id,private_key,client_email, etc. - Nunca commitar este JSON no Git.
O campo importante para partilhar o Drive:
client_email: algo@projeto.iam.gserviceaccount.com
4.3 Shared Drive no Google Drive (conta Workspace)
- Entrar no Drive com um user Workspace que possa criar Shared Drives.
- Drives compartilhados → Novo.
- Gerir membros → adicionar o
client_emailcom papel Gestor de conteúdo (mínimo para criar ficheiros/pastas). - Esperar 1–5 minutos (propagação de ACL).
- Copiar o ID da pasta raiz (URL
folders/<ID>).
Se partilhar só uma pasta do “Meu Drive” com a SA, o health check autentica mas o acesso à pasta dá 404, ou o upload dá erro de quota.
4.4 Variáveis de ambiente (iguais às deste backend)
| Variável | Uso |
|---|---|
GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID | ID do Shared Drive / pasta raiz |
GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON | JSON inteiro numa linha (produção) |
GOOGLE_APPLICATION_CREDENTIALS | Caminho absoluto para o .json (dev local) |
Prioridade neste código: se existir JSON inline válido, usa-o; senão, ficheiro. Não usa Application Default Credentials (ADC) de VM — evita surpresas.
Produção PHP (Apache/Nginx): coloque o JSON fora de public_html, permissões 640, ou a variável de ambiente no vhost. Não deixe o .json servível por HTTP.
Gerar a linha JSON a partir do ficheiro (igual ao docs/google-drive-media.md):
node -e "console.log(JSON.stringify(require('./crm-drive-xxxxx.json')))"
Ou em PHP:
echo json_encode(json_decode(file_get_contents('crm-drive-xxxxx.json'), true));
5. Estrutura de pastas no Drive
A raiz é GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID. Pastas não são pré-criadas à mão. O código cria sob demanda (ensureSubfolder).
{Shared Drive}/
{Nome da Empresa}/ ← nome fantasia ou razão social (sanitizado)
profile/ ← ícone / foto de perfil / logo
banner/ ← banners
documents/ ← reservado
EC{NomeDoEc}/ ← documentos do estabelecimento comercial
{NomeDoUtilizador}/ ← documentos do utilizador
_sem_empresa/ ← entidade sem empresa vinculada
_system/
health-probe/ ← ficheiros de teste do health check (apagados a seguir)
Regras de nome (google-drive-folder.util.ts):
- Caracteres inválidos
\ / : * ? " < > |e controlos →_. - Espaços múltiplos → um espaço.
- Máximo 120 caracteres.
- Empresa vazia →
_sem_empresa. - EC: prefixo
ECse o nome ainda não começar porEC; se não houver nome,EC_{8 primeiros do UUID}. - User sem nome →
User_{8 primeiros do UUID}.
Query de pesquisa no Drive: escapar ' e \ no nome:
function escapeDriveQueryValue(string $value): string
{
return str_replace(["\\", "'"], ["\\\\", "\\'"], $value);
}
Cache: este backend guarda em memória parentId:nome → folderId por processo. No PHP (PHP-FPM), o cache morre no fim do request. Pode:
- não cachear (2
files.listextra por pasta — aceitável); - guardar IDs de pasta conhecidos numa tabela
drive_folders(parent_id, name, folder_id).
6. Fluxos da aplicação (o que o PHP deve replicar)
6.1 Upload de imagem (logo / banner / foto)
Ordem exata usada em tenants.service.ts e users.service.ts:
- Autenticar o user da sua app (session PHP).
- Recusar se Drive não estiver configurado.
- Validar MIME:
image/jpeg,image/jpg,image/png,image/svg+xml. - Validar tamanho: máximo 10 MB.
- Resolver nome da empresa (para a pasta).
- Ler no banco o
fileIdantigo (se já existia imagem). - (Opcional)
assertUploadAllowed(empresa, bytesNovos, replaceFileId). uploadPublicImage(buffer, mime, fileName, pasta, replaceFileId).UPDATEmetadata:*Url,*DriveFileId,*MimeType,*SizeBytes.- Apagar cache local, se houver.
Nome do ficheiro neste CRM:
{prefixo}-{sufixoUnico}.{ext}
Exemplos: tenant-icon-acme-1725….png, user-icon-{uuid}.jpg.
O upload não grava o binário no banco.
6.2 Lógica interna de uploadOrReplaceInFolder
Isto é o algoritmo que o PHP deve copiar à letra (google-drive.service.ts):
1. sanitizar nome do ficheiro (só [a-zA-Z0-9._-], máx. 120 chars)
2. procurar na pasta destino um ficheiro NÃO-pasta com o mesmo nome (trashed=false)
3. se existir:
files.update (substitui o conteúdo, mantém o mesmo fileId)
se o update disser trashed ou 404 → tratar como “não existe”
4. se não existir:
files.create com parents = [folderId]
5. permissions.create: role=reader, type=anyone
6. se veio replaceFileId E é diferente do fileId final:
files.update { trashed: true } no antigo (lixo, não delete permanente)
7. devolver { fileId, viewUrl }
viewUrl = webViewLink ou https://drive.google.com/file/d/{fileId}/view
Porquê replace por nome + replaceFileId:
- Reupload do mesmo slot (
logo.pngsempre com o mesmo nome) atualiza o ficheiro em vez de criar mil cópias. - Se o nome mudou, cria um novo e manda o
fileIdantigo para o lixo.
Porquê trashed e não delete: falha de permissão no delete não deve partir o upload; tryTrashFile só faz log se falhar.
6.3 Permissão pública (anyone / reader)
Depois de criar/atualizar:
$perm = new Google_Service_Drive_Permission([
'type' => 'anyone',
'role' => 'reader',
]);
$drive->permissions->create($fileId, $perm, [
'supportsAllDrives' => true,
]);
Se a API disser already exists / duplicate / cannotModify, ignore (já está público).
Com isto, o viewUrl abre no browser sem login Google. Útil para <img> em páginas públicas.
URLs úteis para <img> (além de webViewLink, que é página HTML do Drive):
https://drive.google.com/uc?export=view&id={fileId}
https://lh3.googleusercontent.com/d/{fileId}
O CRM, nos GET autenticados de icon/banner, não depende disto: faz download via API e serve os bytes. Assim o frontend continua a usar o mesmo endpoint, e há fallback para base64 legado.
Recomendação PHP:
- Páginas internas: proxy
ver_imagem.php(controla auth). - Páginas públicas (login com logo da empresa): URL
uc?export=viewou o mesmo proxy.
6.4 Download (proxy)
1. files.get fileId, fields=id,mimeType,trashed, supportsAllDrives=true
2. se trashed → 404
3. files.get fileId, alt=media, supportsAllDrives=true → bytes
4. se buffer vazio → erro
5. Content-Type = mime do Drive ou o gravado no banco
Em PHP o download de media usa alt=media e devolve o body. Ver secção 8.
6.5 Documentos (EC e utilizador)
Mesmo upload, pastas diferentes:
| Tipo | Pasta | Método Nest |
|---|---|---|
| Imagem branding | {empresa}/profile/ ou banner/ | uploadPublicImage |
| Documento do EC | {empresa}/EC{Nome}/ | uploadEcDocument |
| Documento do user | {empresa}/{NomeUser}/ | uploadUserDocument |
No banco, além de fileId/viewUrl, este CRM grava um objeto JSON por slot (mime, size, nome original, pasta). Coluna caminho_arquivo recebe a URL (se couber) ou o fileId. Não grava inline:base64.
6.6 Leitura com fallback legado
Na leitura de icon/banner:
- Se
metadatatemprofileImageDriveFileId(oubannerImageDriveFileId) e Drive está configurado → download Drive. - Se Drive está em baixo mas há
fileId→ não inventa imagem; neste CRM tenta fallback legado. - Se não há
fileId→ tentametadata.icon.base64antigo. - Se nada → 404.
Novos writes proíbem base64 inline no payload (assertNoInlineBase64…).
6.7 Quota por empresa (opcional, mas já existe aqui)
TenantDriveQuotaService:
- Quota em
tenants.metadata.driveQuotaMb. - Se
nullou Drive desligado → não bloqueia. - Uso = soma recursiva de
sizede todos os ficheiros na pasta{empresa}/(BFS,pageSize1000). - Projeção = uso atual + bytes novos − tamanho do ficheiro que vai ser substituído (
getFileSizeBytes(replaceFileId)). - Se projeção > quota → 403 “Quota de armazenamento no Google Drive excedida”.
Pastas nativas do Google (application/vnd.google-apps.folder) e ficheiros trashed não contam. Google Docs nativos às vezes têm size vazio → 0.
No PHP, só implemente se a app tiver limite por cliente.
6.8 Health check
Rota pública neste backend: GET /api/storage/drive/health.
O PHP deve ter um script protegido (admin), não público, que faça o mesmo:
- Há
folderId? - Há credenciais (JSON parseável ou ficheiro existe)?
about.get(user/email da SA).files.list(API responde?).files.getda pasta raiz (capabilities.canAddChildren).- Criar
_system/health-probe/, upload PNG 1×1, apagar o probe. - JSON
{ ok: true/false, error, hint }.
PNG 1×1 usado no probe (base64):
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
7. O que gravar no banco (contrato de metadata)
O Drive não é a fonte da verdade de “qual é o logo desta empresa”. O banco aponta para o Drive.
7.1 Campos (iguais aos deste CRM)
Ícone / logo / foto:
| Campo | Significado |
|---|---|
profileImageUrl | webViewLink |
profileImageDriveFileId | ID Google (use isto para tudo) |
profileImageMimeType | image/png etc. |
profileImageSizeBytes | inteiro |
Banner: bannerImageUrl, bannerImageDriveFileId, bannerImageMimeType, bannerImageSizeBytes.
Formato aninhado legado (ainda lido):
{ "icon": { "driveFileId": "...", "url": "...", "mimeType": "...", "sizeBytes": 123 } }
Escrita nova substitui o objeto icon/banner (e o base64 antigo) pelos campos planos acima.
7.2 Schema sugerido no PHP (se não usar JSONB)
Pode ser colunas:
logo_drive_file_id VARCHAR(128) NULL,
logo_view_url TEXT NULL,
logo_mime_type VARCHAR(64) NULL,
logo_size_bytes INT NULL
Ou um JSON na linha (como este CRM). O importante: nunca LONGBLOB da imagem.
7.3 Remoção
- (Opcional)
trashed=trueno Drive. UPDATEa pôr os quatro campos aNULL(e remover chaves JSON).
Este CRM, em alguns removes, só limpa o banco — o ficheiro pode ficar no Drive. Para o PHP, recomenda-se trash + limpar banco.
8. Implementação PHP — código para copiar
8.1 Dependência
composer require google/apiclient:^2.15
composer.json mínimo:
{
"require": {
"google/apiclient": "^2.15"
}
}
8.2 Classe de serviço (espelho de GoogleDriveService)
Ficheiro sugerido: src/GoogleDrive/GoogleDriveService.php (ajuste ao autoload da app).
<?php
use Google_Client;
use Google_Service_Drive;
use Google_Service_Drive_DriveFile;
use Google_Service_Drive_Permission;
final class GoogleDriveService
{
private const SCOPE = 'https://www.googleapis.com/auth/drive';
private const FOLDER_MIME = 'application/vnd.google-apps.folder';
private ?Google_Service_Drive $drive = null;
/** @var array<string,string> parentId:name => folderId */
private array $folderCache = [];
public function isConfigured(): bool
{
$folder = $this->rootFolderId();
return $folder !== '' && $this->hasCredentials();
}
public function rootFolderId(): string
{
return trim((string) getenv('GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID'));
}
public function hasCredentials(): bool
{
$inline = trim((string) getenv('GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON'));
if ($inline !== '') {
json_decode($inline, true);
return json_last_error() === JSON_ERROR_NONE;
}
$path = trim((string) getenv('GOOGLE_APPLICATION_CREDENTIALS'));
return $path !== '' && is_file($path);
}
public function client(): Google_Service_Drive
{
if ($this->drive instanceof Google_Service_Drive) {
return $this->drive;
}
$google = new Google_Client();
$google->setScopes([self::SCOPE]);
$inline = trim((string) getenv('GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON'));
if ($inline !== '') {
$creds = json_decode($inline, true);
if (!is_array($creds)) {
throw new RuntimeException('GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON não é JSON válido.');
}
$google->setAuthConfig($creds);
} else {
$path = trim((string) getenv('GOOGLE_APPLICATION_CREDENTIALS'));
if ($path === '' || !is_file($path)) {
throw new RuntimeException(
'Defina GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON ou GOOGLE_APPLICATION_CREDENTIALS.'
);
}
$google->setAuthConfig($path);
}
$this->drive = new Google_Service_Drive($google);
return $this->drive;
}
/**
* Garante {empresa}/{profile|banner}/ e devolve o ID da pasta folha.
*/
public function resolveCompanyMediaFolderId(?string $companyName, string $folderKind): string
{
$parent = $this->rootFolderId();
if ($parent === '') {
throw new RuntimeException('GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID vazio.');
}
foreach ($this->companyMediaPath($companyName, $folderKind) as $segment) {
$parent = $this->ensureSubfolder($parent, $segment);
}
return $parent;
}
/**
* @return array{fileId:string,viewUrl:string}
*/
public function uploadPublicFile(
string $bytes,
string $mimeType,
string $originalName,
string $parentFolderId,
?string $replaceFileId = null
): array {
$drive = $this->client();
$safeName = $this->sanitizeFileName($originalName);
$existing = $this->findFileByNameInFolder($parentFolderId, $safeName);
if ($existing) {
$replaced = $this->tryReplaceFileContent($existing, $bytes, $mimeType, $safeName);
if ($replaced !== null) {
if ($replaceFileId && $replaceFileId !== $replaced['fileId']) {
$this->tryTrashFile($replaceFileId);
}
return $replaced;
}
}
$created = $this->createFileInFolder($parentFolderId, $bytes, $mimeType, $safeName);
if ($replaceFileId && $replaceFileId !== $created['fileId']) {
$this->tryTrashFile($replaceFileId);
}
return $created;
}
/**
* @return array{buffer:string,mimeType:string}
*/
public function downloadFile(string $fileId): array
{
$drive = $this->client();
$id = trim($fileId);
if ($id === '') {
throw new RuntimeException('ID do ficheiro no Drive inválido.');
}
$meta = $drive->files->get($id, [
'fields' => 'id, mimeType, trashed',
'supportsAllDrives' => true,
]);
if ($meta->getTrashed()) {
throw new RuntimeException('Ficheiro removido do Google Drive.');
}
$response = $drive->files->get($id, [
'alt' => 'media',
'supportsAllDrives' => true,
]);
$buffer = $response->getBody()->getContents();
if ($buffer === '') {
throw new RuntimeException('Google Drive devolveu ficheiro vazio.');
}
$mime = trim((string) $meta->getMimeType()) ?: 'application/octet-stream';
return ['buffer' => $buffer, 'mimeType' => $mime];
}
public function getFileSizeBytes(string $fileId): int
{
$id = trim($fileId);
if ($id === '') {
return 0;
}
try {
$meta = $this->client()->files->get($id, [
'fields' => 'id, size, mimeType, trashed',
'supportsAllDrives' => true,
]);
if ($meta->getTrashed()) {
return 0;
}
if ($meta->getMimeType() === self::FOLDER_MIME) {
return 0;
}
return (int) $meta->getSize();
} catch (Throwable $e) {
return 0;
}
}
// --- pastas ---
public function ensureSubfolder(string $parentId, string $name): string
{
$key = $parentId . ':' . $name;
if (isset($this->folderCache[$key])) {
return $this->folderCache[$key];
}
$existing = $this->findSubfolderByName($parentId, $name);
if ($existing) {
return $this->folderCache[$key] = $existing;
}
$meta = new Google_Service_Drive_DriveFile([
'name' => $name,
'mimeType' => self::FOLDER_MIME,
'parents' => [$parentId],
]);
$created = $this->client()->files->create($meta, [
'fields' => 'id',
'supportsAllDrives' => true,
]);
$id = $created->getId();
if (!$id) {
throw new RuntimeException('Drive não devolveu id ao criar pasta "' . $name . '".');
}
return $this->folderCache[$key] = $id;
}
public function findSubfolderByName(string $parentId, string $name): ?string
{
$safe = $this->escapeQuery($name);
$res = $this->client()->files->listFiles([
'q' => "name='{$safe}' and '{$parentId}' in parents and mimeType='" . self::FOLDER_MIME . "' and trashed=false",
'fields' => 'files(id)',
'pageSize' => 1,
'supportsAllDrives' => true,
'includeItemsFromAllDrives' => true,
]);
$files = $res->getFiles();
return $files ? $files[0]->getId() : null;
}
public function findFileByNameInFolder(string $parentId, string $name): ?string
{
$safe = $this->escapeQuery($name);
$res = $this->client()->files->listFiles([
'q' => "name='{$safe}' and '{$parentId}' in parents and mimeType!='" . self::FOLDER_MIME . "' and trashed=false",
'fields' => 'files(id)',
'pageSize' => 1,
'supportsAllDrives' => true,
'includeItemsFromAllDrives' => true,
]);
$files = $res->getFiles();
return $files ? $files[0]->getId() : null;
}
/**
* @return array{fileId:string,viewUrl:string}
*/
private function createFileInFolder(
string $folderId,
string $bytes,
string $mimeType,
string $safeName
): array {
$meta = new Google_Service_Drive_DriveFile([
'name' => $safeName,
'parents' => [$folderId],
]);
$created = $this->client()->files->create($meta, [
'data' => $bytes,
'mimeType' => $mimeType,
'uploadType' => 'multipart',
'fields' => 'id, webViewLink',
'supportsAllDrives' => true,
]);
$fileId = $created->getId();
if (!$fileId) {
throw new RuntimeException('Google Drive não devolveu o id do ficheiro.');
}
$this->ensureAnyoneReader($fileId);
$viewUrl = $created->getWebViewLink()
?: ('https://drive.google.com/file/d/' . $fileId . '/view');
return ['fileId' => $fileId, 'viewUrl' => $viewUrl];
}
/**
* @return array{fileId:string,viewUrl:string}|null
*/
private function tryReplaceFileContent(
string $fileId,
string $bytes,
string $mimeType,
string $safeName
): ?array {
try {
$meta = new Google_Service_Drive_DriveFile(['name' => $safeName]);
$updated = $this->client()->files->update($fileId, $meta, [
'data' => $bytes,
'mimeType' => $mimeType,
'uploadType' => 'multipart',
'fields' => 'id, webViewLink, trashed',
'supportsAllDrives' => true,
]);
if ($updated->getTrashed()) {
return null;
}
$resolvedId = $updated->getId() ?: $fileId;
$this->ensureAnyoneReader($resolvedId);
$viewUrl = $updated->getWebViewLink()
?: ('https://drive.google.com/file/d/' . $resolvedId . '/view');
return ['fileId' => $resolvedId, 'viewUrl' => $viewUrl];
} catch (Google_Service_Exception $e) {
if ($e->getCode() === 404) {
return null;
}
throw $e;
}
}
private function ensureAnyoneReader(string $fileId): void
{
try {
$perm = new Google_Service_Drive_Permission([
'type' => 'anyone',
'role' => 'reader',
]);
$this->client()->permissions->create($fileId, $perm, [
'supportsAllDrives' => true,
]);
} catch (Google_Service_Exception $e) {
$msg = $e->getMessage();
if (
str_contains($msg, 'already exists')
|| str_contains($msg, 'duplicate')
|| str_contains($msg, 'cannotModify')
) {
return;
}
throw $e;
}
}
private function tryTrashFile(string $fileId): void
{
try {
$meta = new Google_Service_Drive_DriveFile(['trashed' => true]);
$this->client()->files->update($fileId, $meta, [
'supportsAllDrives' => true,
]);
} catch (Throwable $e) {
error_log('Não foi possível enviar para o lixo ' . $fileId . ': ' . $e->getMessage());
}
}
public function sanitizeFileName(string $name): string
{
$base = substr(preg_replace('/[^a-zA-Z0-9._-]+/', '_', $name) ?? '', 0, 120);
return $base !== '' ? $base : ('upload_' . time() . '.bin');
}
public function normalizeCompanyFolderName(?string $companyName): string
{
$trimmed = trim((string) $companyName);
$trimmed = preg_replace('/[\\\\\/:*?"<>|\x00-\x1f]/u', '_', $trimmed) ?? '';
if ($trimmed === '') {
return '_sem_empresa';
}
$trimmed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
return substr($trimmed, 0, 120);
}
/** @return list<string> */
public function companyMediaPath(?string $companyName, string $folderKind): array
{
return [$this->normalizeCompanyFolderName($companyName), $folderKind];
}
public function escapeQuery(string $value): string
{
return str_replace(["\\", "'"], ["\\\\", "\\'"], $value);
}
}
Trate erros de quota na camada do upload:
try {
$uploaded = $drive->uploadPublicFile(...);
} catch (Google_Service_Exception $e) {
if (str_contains($e->getMessage(), 'storage quota')) {
throw new RuntimeException(
'Service accounts não têm quota em Drive pessoal. Use um Shared Drive ' .
'com a service account como membro e atualize GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID.'
);
}
throw $e;
}
8.3 Formulário HTML (sem API)
<form method="post" action="empresa_logo_salvar.php" enctype="multipart/form-data">
<input type="hidden" name="empresa_id" value="42">
<input type="file" name="logo" accept="image/jpeg,image/png,image/svg+xml" required>
<button type="submit">Enviar logo</button>
</form>
empresa_logo_salvar.php (esqueleto):
<?php
session_start();
require __DIR__ . '/vendor/autoload.php';
// require da classe GoogleDriveService e da conexão PDO
$ALLOWED = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml'];
$MAX = 10 * 1024 * 1024;
if (empty($_SESSION['user_id'])) {
http_response_code(403);
exit('Não autenticado');
}
$empresaId = (int) ($_POST['empresa_id'] ?? 0);
$file = $_FILES['logo'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
exit('Ficheiro em falta');
}
if ($file['size'] > $MAX) {
exit('Imagem excede 10 MB');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $ALLOWED, true)) {
exit('Use JPEG, PNG ou SVG.');
}
$drive = new GoogleDriveService();
if (!$drive->isConfigured()) {
http_response_code(503);
exit('Google Drive não configurado no servidor.');
}
// SELECT nome, logo_drive_file_id FROM empresas WHERE id = ?
$empresa = /* ... */;
$bytes = file_get_contents($file['tmp_name']);
$folderId = $drive->resolveCompanyMediaFolderId($empresa['nome'], 'profile');
$fileName = 'empresa-logo-' . $empresaId . '-' . time() . '.png'; // ajuste extensão
$uploaded = $drive->uploadPublicFile(
$bytes,
$mime,
$fileName,
$folderId,
$empresa['logo_drive_file_id'] ?: null
);
// UPDATE empresas SET logo_drive_file_id=?, logo_view_url=?, logo_mime_type=?, logo_size_bytes=?
header('Location: empresa_editar.php?id=' . $empresaId . '&ok=1');
php.ini: upload_max_filesize e post_max_size ≥ 10M + margem. Sem isso o PHP corta o POST antes do seu código.
8.4 Servir a imagem (proxy)
ver_logo.php:
<?php
session_start();
$empresaId = (int) ($_GET['id'] ?? 0);
// SELECT logo_drive_file_id, logo_mime_type FROM empresas WHERE id = ?
$drive = new GoogleDriveService();
$got = $drive->downloadFile($row['logo_drive_file_id']);
header('Content-Type: ' . ($row['logo_mime_type'] ?: $got['mimeType']));
header('Cache-Control: private, max-age=3600');
echo $got['buffer'];
No HTML da mesma app:
<img src="ver_logo.php?id=42" alt="Logo">
Cache opcional: gravar o buffer em disco/APCu com TTL 1h, invalidar no upload. Neste CRM o Redis faz isso (CACHE_BRANDING_TTL_SECONDS=3600) para não bater no Drive em cada GET.
8.5 Diagnóstico (admin)
admin_drive_health.php — só para admin autenticado. Espelhe testConnection():
- Auth.
$drive->about->get(['fields' => 'user']).$drive->files->get($rootId, ['fields' => 'id,name,mimeType,capabilities', 'supportsAllDrives' => true]).- Se
capabilities.canAddChildrenfor false → SA não é editor/gestor. - Probe upload + delete.
Script Node equivalente já existe: scripts/test-drive-company-folder.js. Pode correr esse script no servidor para validar credenciais antes de mexer no PHP.
9. Chamadas Drive API usadas (mapa Node → PHP)
Todas na API v3.
| Operação | Node (googleapis) | PHP (google/apiclient) | Flags |
|---|---|---|---|
| Auth SA | google.auth.GoogleAuth({ credentials, scopes }) | Google_Client::setAuthConfig + setScopes | scope drive |
| Cliente | google.drive({ version: 'v3', auth }) | new Google_Service_Drive($client) | |
| Quem sou eu | drive.about.get({ fields: 'user' }) | $drive->about->get(['fields'=>'user']) | |
| Listar | drive.files.list({ q, pageSize, fields, pageToken }) | $drive->files->listFiles([...]) | supportsAllDrives + includeItemsFromAllDrives |
| Meta | drive.files.get({ fileId, fields }) | $drive->files->get($id, [...]) | supportsAllDrives |
| Download | files.get({ fileId, alt: 'media' }, { responseType: 'arraybuffer' }) | $drive->files->get($id, ['alt'=>'media', ...]) depois getBody() | supportsAllDrives |
| Criar pasta | files.create mime application/vnd.google-apps.folder | DriveFile + files->create | supportsAllDrives |
| Upload | files.create + media.body stream | files->create + 'data' => $bytes, uploadType=multipart | supportsAllDrives |
| Substituir | files.update + media | files->update + 'data' | supportsAllDrives |
| Público | permissions.create {role:reader, type:anyone} | Permission + permissions->create | supportsAllDrives |
| Lixo | files.update { trashed: true } | DriveFile(['trashed'=>true]) + update | supportsAllDrives |
| Apagar probe | files.delete | $drive->files->delete($id, ['supportsAllDrives'=>true]) | supportsAllDrives |
Query q típicas:
name='Logo.png' and 'PARENT_ID' in parents and mimeType!='application/vnd.google-apps.folder' and trashed=false
name='profile' and 'PARENT_ID' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false
'FOLDER_ID' in parents and trashed = false
fields mínimos (não peça *): id, webViewLink, mimeType, size, trashed, capabilities, nextPageToken.
10. Validação de input (copiar limites)
De src/common/utils/branding-image-media.util.ts:
MAX = 10 * 1024 * 1024 # 10 MB
MIME = image/jpeg | image/jpg | image/png | image/svg+xml
Validar MIME com finfo no ficheiro temporário, não só com a extensão ou $_FILES['type'] (o browser mente).
Documentos (PDF, etc.): este CRM aceita o MIME do upload no EC/user. Defina a lista da app PHP (application/pdf, imagens, etc.) e recuse o resto.
Sanitizar o nome antes de mandar ao Drive (sanitizeFileName). O nome original do user pode ir só na coluna original_file_name do banco.
11. Erros que este projeto já encontrou (e as hints)
Use as mesmas mensagens no PHP — poupam horas.
| Sintoma | Causa real | Correção |
|---|---|---|
Could not load the default credentials | Sem JSON e sem ficheiro | GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON ou path absoluto |
| JSON inválido | Env partida em várias linhas / aspas | JSON numa linha |
File not found / 404 na pasta | SA não é membro do Shared Drive, ou ID errado, ou falta supportsAllDrives | Membro do Drive + flag + conferir URL |
| Auth OK, 0 ficheiros visíveis, pasta 404 | Partilhou pasta do Meu Drive em vez do Shared Drive | Recriar no Shared Drive |
storage quota | Pasta no Drive pessoal | Shared Drive |
canAddChildren: false | SA é só Viewer | Papel Content manager |
| Upload OK no localhost, 404 no servidor | Path relativo GOOGLE_APPLICATION_CREDENTIALS — o JSON não está no deploy | JSON inline na env de produção |
| Probe OK, app 404 | Um files.get sem supportsAllDrives | Flag em todos os métodos |
Imagem não abre no <img src=viewUrl> | webViewLink é HTML, não binário | Proxy PHP ou uc?export=view&id= |
| POST vazio no PHP | post_max_size / upload_max_filesize | Subir no php.ini |
| ACL “ainda não funciona” | Propagação Google | Esperar minutos, repetir health |
12. Segurança
- A service account é chave de root do Drive da app. Quem lê o JSON lê/escreve todos os ficheiros do Shared Drive.
- Não exponha
admin_drive_health.phpsem login admin (neste Nest o health é público de propósito para debug — não copie isso para um PHP na internet). - Ficheiros com
anyone/readersão acessíveis por quem tiver ofileId. OfileIdnão é secreto absoluto. Para documentos privados (contratos), não ponhaanyone; sirva só via proxy autenticado.- Deste CRM: branding (logo/banner) é público (
anyone). Documentos usam o mesmoensureAnyoneReaderno helper genérico — no PHP, separeuploadPublicFile(logos) deuploadPrivateFile(sem permission anyone).
- Deste CRM: branding (logo/banner) é público (
.gitignoreno JSON. Env no servidor, não no repositório.- CSRF no form POST da app PHP (token da session).
- Autorização: o user só faz upload na sua empresa (regras da app). O Drive não sabe o que é “empresa 42”.
13. Ordem de implementação recomendada (checklist para o dev PHP)
Faça nesta ordem. Não comece pelo form.
- GCP: projeto, Drive API, service account, JSON.
- Workspace: Shared Drive, SA como Gestor de conteúdo, copiar folder ID.
- Env no PHP (
putenvlocal / vhost produção). - Script CLI
php bin/drive_health.phpcomabout.get+files.getda raiz. Só avance comok=true. - Script CLI que cria
{EmpresaTeste}/profile/e envia o PNG 1×1. Confira no Drive no browser. - Classe
GoogleDriveService(secção 8.2). - Colunas no banco + migration.
- Um form de logo ponta a ponta (upload → UPDATE →
ver_logo.php). - Replace (enviar de novo e confirmar que não duplica no Drive).
- Banner / documentos, se existirem, reusando a mesma classe e mudando só o caminho da pasta.
- (Opcional) quota, cache, health na área admin.
- Recusar gravação de base64/blob no banco a partir deste ponto.
14. Mini mapa mental (uma frase)
O PHP recebe o ficheiro do form, autentica-se no Google como robô, garante as pastas
{empresa}/{tipo}/num Shared Drive, envia ou substitui o ficheiro, torna o branding público, grava ofileIdno MySQL/PostgreSQL, e na hora de mostrar ou baixa os bytes pelo API ou usa a URL pública — nunca guarda a imagem no banco.
15. Referência rápida de env (colar no .env PHP)
GOOGLE_DRIVE_USER_MEDIA_FOLDER_ID=COLE_O_ID_AQUI
GOOGLE_APPLICATION_CREDENTIALS=/caminho/absoluto/service-account.json
# Produção (alternativa ao ficheiro):
# GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"...","private_key_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"...@....iam.gserviceaccount.com","client_id":"...","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"..."}
Reinicie PHP-FPM / Apache depois de mudar env.
16. Onde ler o código Nest se restar dúvida
- Auth e flags:
GoogleDriveService.buildGoogleAuth,getDrive,isConfigured. - Upload:
uploadOrReplaceInFolder→createFileInFolder/tryReplaceFileContent/ensureAnyoneReader. - Pastas:
ensureSubfolder,buildCompanyMediaFolderPath. - Orquestração imagem tenant:
tenants.service.ts(updateTenantIcon/ banner) — quota, parse, upload,updateTenantIconDrive. - Orquestração user:
users.service.ts(uploadMyProfileMedia,uploadUserBrandingImageToDrive). - Documentos EC:
estabelecimentos-comerciais.service.tsuploadSingleDocumento. - Persistência SQL:
tenant.repository.tsupdateTenantIconDrive/updateTenantBannerDrive. - Health:
testConnection+probeUpload.
O PHP não precisa traduzir o Nest linha a linha. Precisa traduzir estas ideias. O código da secção 8 já é essa tradução.