Pular para o conteúdo principal

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:

FicheiroPapel
src/modules/storage/google-drive.service.tsCliente Drive (auth, pastas, upload, download, health, quota)
src/modules/storage/google-drive-folder.util.tsNomes de pasta e escape da query
src/modules/storage/google-drive-media.types.tsConstantes (profile, banner, _sem_empresa, _system)
src/modules/tenant-storage/tenant-drive-quota.service.tsQuota por empresa
src/common/utils/user-branding-drive-metadata.util.tsComo ler fileId do JSON no banco
src/modules/storage/storage-health.controller.tsDiagnóstico
scripts/test-drive-company-folder.jsScript 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:

  1. O ficheiro sai do banco e vai para um Shared Drive (Drive compartilhado do Google Workspace).
  2. No banco ficam só referências: fileId, URL de visualização, MIME, tamanho em bytes.
  3. 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.
  4. Uploads novos não gravam mais base64. Registos antigos ainda podem ser lidos (fallback).
  5. 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 NestPrecisa no PHP?
Rotas REST /api/users/me/mediaNão. Use form POST.
Jobs assíncronos (202 + polling)Não. O PHP pode esperar o upload (segundos).
Redis cache de ícone/bannerOpcional. Só se o GET de imagem for muito frequente.
Swagger / DTOsNão.
googleapis NodeNão. Use google/apiclient.
Conta de serviço + Shared Drive + supportsAllDrivesSim. Obrigatório.
Guardar só fileId no bancoSim.
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):

  1. Google Workspace (não Gmail pessoal).
  2. Criar um Shared Drive.
  3. 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”.
  4. 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

  1. Google Cloud Console.
  2. Criar (ou escolher) um projeto.
  3. APIs e serviços → Biblioteca → ativar Google Drive API.

Sem a API ativada, qualquer files.list falha.

4.2 Conta de serviço

  1. IAM e administrador → Contas de serviço → Criar.
  2. Nome livre (ex.: crm-drive).
  3. Chaves → Adicionar chave → JSON.
  4. Guardar o ficheiro. Ele contém type, project_id, private_key, client_email, etc.
  5. 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)

  1. Entrar no Drive com um user Workspace que possa criar Shared Drives.
  2. Drives compartilhados → Novo.
  3. Gerir membros → adicionar o client_email com papel Gestor de conteúdo (mínimo para criar ficheiros/pastas).
  4. Esperar 1–5 minutos (propagação de ACL).
  5. 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ávelUso
GOOGLE_DRIVE_USER_MEDIA_FOLDER_IDID do Shared Drive / pasta raiz
GOOGLE_DRIVE_SERVICE_ACCOUNT_JSONJSON inteiro numa linha (produção)
GOOGLE_APPLICATION_CREDENTIALSCaminho 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 EC se o nome ainda não começar por EC; 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.list extra 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:

  1. Autenticar o user da sua app (session PHP).
  2. Recusar se Drive não estiver configurado.
  3. Validar MIME: image/jpeg, image/jpg, image/png, image/svg+xml.
  4. Validar tamanho: máximo 10 MB.
  5. Resolver nome da empresa (para a pasta).
  6. Ler no banco o fileId antigo (se já existia imagem).
  7. (Opcional) assertUploadAllowed(empresa, bytesNovos, replaceFileId).
  8. uploadPublicImage(buffer, mime, fileName, pasta, replaceFileId).
  9. UPDATE metadata: *Url, *DriveFileId, *MimeType, *SizeBytes.
  10. 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.png sempre com o mesmo nome) atualiza o ficheiro em vez de criar mil cópias.
  • Se o nome mudou, cria um novo e manda o fileId antigo 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=view ou 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:

TipoPastaMé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:

  1. Se metadata tem profileImageDriveFileId (ou bannerImageDriveFileId) e Drive está configurado → download Drive.
  2. Se Drive está em baixo mas há fileIdnão inventa imagem; neste CRM tenta fallback legado.
  3. Se não há fileId → tenta metadata.icon.base64 antigo.
  4. 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 null ou Drive desligado → não bloqueia.
  • Uso = soma recursiva de size de todos os ficheiros na pasta {empresa}/ (BFS, pageSize 1000).
  • 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:

  1. folderId?
  2. Há credenciais (JSON parseável ou ficheiro existe)?
  3. about.get (user/email da SA).
  4. files.list (API responde?).
  5. files.get da pasta raiz (capabilities.canAddChildren).
  6. Criar _system/health-probe/, upload PNG 1×1, apagar o probe.
  7. 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:

CampoSignificado
profileImageUrlwebViewLink
profileImageDriveFileIdID Google (use isto para tudo)
profileImageMimeTypeimage/png etc.
profileImageSizeBytesinteiro

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

  1. (Opcional) trashed=true no Drive.
  2. UPDATE a pôr os quatro campos a NULL (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():

  1. Auth.
  2. $drive->about->get(['fields' => 'user']).
  3. $drive->files->get($rootId, ['fields' => 'id,name,mimeType,capabilities', 'supportsAllDrives' => true]).
  4. Se capabilities.canAddChildren for false → SA não é editor/gestor.
  5. 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çãoNode (googleapis)PHP (google/apiclient)Flags
Auth SAgoogle.auth.GoogleAuth({ credentials, scopes })Google_Client::setAuthConfig + setScopesscope drive
Clientegoogle.drive({ version: 'v3', auth })new Google_Service_Drive($client)
Quem sou eudrive.about.get({ fields: 'user' })$drive->about->get(['fields'=>'user'])
Listardrive.files.list({ q, pageSize, fields, pageToken })$drive->files->listFiles([...])supportsAllDrives + includeItemsFromAllDrives
Metadrive.files.get({ fileId, fields })$drive->files->get($id, [...])supportsAllDrives
Downloadfiles.get({ fileId, alt: 'media' }, { responseType: 'arraybuffer' })$drive->files->get($id, ['alt'=>'media', ...]) depois getBody()supportsAllDrives
Criar pastafiles.create mime application/vnd.google-apps.folderDriveFile + files->createsupportsAllDrives
Uploadfiles.create + media.body streamfiles->create + 'data' => $bytes, uploadType=multipartsupportsAllDrives
Substituirfiles.update + mediafiles->update + 'data'supportsAllDrives
Públicopermissions.create {role:reader, type:anyone}Permission + permissions->createsupportsAllDrives
Lixofiles.update { trashed: true }DriveFile(['trashed'=>true]) + updatesupportsAllDrives
Apagar probefiles.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.

SintomaCausa realCorreção
Could not load the default credentialsSem JSON e sem ficheiroGOOGLE_DRIVE_SERVICE_ACCOUNT_JSON ou path absoluto
JSON inválidoEnv partida em várias linhas / aspasJSON numa linha
File not found / 404 na pastaSA não é membro do Shared Drive, ou ID errado, ou falta supportsAllDrivesMembro do Drive + flag + conferir URL
Auth OK, 0 ficheiros visíveis, pasta 404Partilhou pasta do Meu Drive em vez do Shared DriveRecriar no Shared Drive
storage quotaPasta no Drive pessoalShared Drive
canAddChildren: falseSA é só ViewerPapel Content manager
Upload OK no localhost, 404 no servidorPath relativo GOOGLE_APPLICATION_CREDENTIALS — o JSON não está no deployJSON inline na env de produção
Probe OK, app 404Um files.get sem supportsAllDrivesFlag em todos os métodos
Imagem não abre no <img src=viewUrl>webViewLink é HTML, não binárioProxy PHP ou uc?export=view&id=
POST vazio no PHPpost_max_size / upload_max_filesizeSubir no php.ini
ACL “ainda não funciona”Propagação GoogleEsperar 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.php sem 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/reader são acessíveis por quem tiver o fileId. O fileId não é secreto absoluto. Para documentos privados (contratos), não ponha anyone; sirva só via proxy autenticado.
    • Deste CRM: branding (logo/banner) é público (anyone). Documentos usam o mesmo ensureAnyoneReader no helper genérico — no PHP, separe uploadPublicFile (logos) de uploadPrivateFile (sem permission anyone).
  • .gitignore no 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.

  1. GCP: projeto, Drive API, service account, JSON.
  2. Workspace: Shared Drive, SA como Gestor de conteúdo, copiar folder ID.
  3. Env no PHP (putenv local / vhost produção).
  4. Script CLI php bin/drive_health.php com about.get + files.get da raiz. Só avance com ok=true.
  5. Script CLI que cria {EmpresaTeste}/profile/ e envia o PNG 1×1. Confira no Drive no browser.
  6. Classe GoogleDriveService (secção 8.2).
  7. Colunas no banco + migration.
  8. Um form de logo ponta a ponta (upload → UPDATE → ver_logo.php).
  9. Replace (enviar de novo e confirmar que não duplica no Drive).
  10. Banner / documentos, se existirem, reusando a mesma classe e mudando só o caminho da pasta.
  11. (Opcional) quota, cache, health na área admin.
  12. 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 o fileId no 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

  1. Auth e flags: GoogleDriveService.buildGoogleAuth, getDrive, isConfigured.
  2. Upload: uploadOrReplaceInFoldercreateFileInFolder / tryReplaceFileContent / ensureAnyoneReader.
  3. Pastas: ensureSubfolder, buildCompanyMediaFolderPath.
  4. Orquestração imagem tenant: tenants.service.ts (updateTenantIcon / banner) — quota, parse, upload, updateTenantIconDrive.
  5. Orquestração user: users.service.ts (uploadMyProfileMedia, uploadUserBrandingImageToDrive).
  6. Documentos EC: estabelecimentos-comerciais.service.ts uploadSingleDocumento.
  7. Persistência SQL: tenant.repository.ts updateTenantIconDrive / updateTenantBannerDrive.
  8. 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.