Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ src/main/resources/application-local.yml
/scripts/init-dev-env.sh
.vercel
/scripts/init-prod-env.sh
service-account.json

# bruno
api-flows/results.html
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1: Build the application using Gradle and JDK 21 (Temurin)
FROM gradle:8.7-jdk21-alpine AS build
FROM gradle:8.7-jdk21 AS build
WORKDIR /app

# Copy configuration files to cache dependencies
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ dependencies {
implementation("com.google.api-client:google-api-client:2.8.1")
implementation("com.google.oauth-client:google-oauth-client-jetty:1.34.1")
implementation("com.google.apis:google-api-services-drive:v3-rev20230822-2.0.0")
implementation("com.google.auth:google-auth-library-oauth2-http:1.23.0")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.skyscreamer:jsonassert:1.5.3")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;

/** Global controller to handle all exceptions for the API. */
@SuppressWarnings({"PMD.ExcessiveImports"})
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

Expand Down Expand Up @@ -70,6 +72,7 @@ public ResponseEntity<ErrorDetails> handleNotFoundException(
@ResponseStatus(INTERNAL_SERVER_ERROR)
public ResponseEntity<ErrorDetails> handleInternalError(
final RuntimeException ex, final WebRequest request) {
log.error("Internal error: {}", ex.getMessage(), ex);
final var errorDetails =
new ErrorDetails(
INTERNAL_SERVER_ERROR.value(), ex.getMessage(), request.getDescription(false));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
package com.wcc.platform.repository.googledrive;

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.InputStreamContent;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.drive.Drive;
import com.google.api.services.drive.DriveScopes;
import com.google.api.services.drive.model.File;
import com.google.api.services.drive.model.FileList;
import com.google.api.services.drive.model.Permission;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.oauth2.GoogleCredentials;
import com.wcc.platform.domain.exceptions.PlatformInternalException;
import com.wcc.platform.domain.platform.filestorage.FileStored;
import com.wcc.platform.properties.FolderStorageProperties;
Expand All @@ -24,7 +20,6 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.GeneralSecurityException;
import java.util.Collections;
import java.util.List;
Expand All @@ -49,8 +44,7 @@ public class GoogleDriveFileStorageRepository implements FileStorageRepository {
private static final String APPLICATION_NAME = "WCC Backend";
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
private static final List<String> SCOPES = Collections.singletonList(DriveScopes.DRIVE);
private static final String CREDS_FILE_PATH = "/credentials.json";
private static final String TOKENS_DIR_PATH = "tokens";
private static final String SERVICE_ACCOUNT_PATH = "/service-account.json";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on what I checked that is not the best solution because we need to upload the json account for each deployment in fly.io so it is not so simple to make this.

# Production Plan for Google Drive and Email on Fly.io

## Goal

This document describes the production-ready approach for running Google Drive
storage and transactional email in Fly.io without relying on local-only
workarounds such as:

- committing `credentials.json` into the image
- manually copying files into a running Fly machine
- using browser-based OAuth flows on the server
- relying on placeholder mail configuration in `application.yml`

It also explains the code changes needed in the backend and the operational
setup for both development and production environments.

## Short Answer

Google Drive and email are not the same situation in this codebase.

### Google Drive today

The current Google Drive implementation in
`src/main/java/com/wcc/platform/repository/googledrive/GoogleDriveFileStorageRepository.java`
expects:

- a classpath resource `/credentials.json`
- a writable local `tokens/` directory
- an interactive OAuth browser flow if no token exists

That is not production-safe on Fly.io.

## Target Production Architecture

### Google Drive

Replace desktop OAuth credentials with a Google service account and load the
credentials from a filesystem path or environment-provided secret at runtime.

Target outcome:

- no browser auth on the server
- no `tokens/` directory required
- no `credentials.json` bundled into the application JAR
- credentials provided through Fly secrets and written to a runtime path
- Drive folders shared explicitly with the service account identity

## Required Code Changes

### 1. Google Drive: stop loading credentials from the classpath

Current implementation:

- `CREDS_FILE_PATH = "/credentials.json"`
- `GoogleDriveFileStorageRepository.class.getResourceAsStream(CREDS_FILE_PATH)`

This should be replaced by configuration-driven loading.

#### Proposed new properties

Add properties such as:

```yaml
wcc:
  google-drive:
    auth-mode: service-account
    credentials-path: ${GOOGLE_DRIVE_CREDENTIALS_PATH:}
    application-name: WCC Backend

Optional future extension:

wcc:
  google-drive:
    credentials-json-base64: ${GOOGLE_DRIVE_CREDENTIALS_JSON_B64:}

Code changes

Modify:

  • src/main/java/com/wcc/platform/repository/googledrive/GoogleDriveFileStorageRepository.java

Add:

  • a configuration properties class, for example:
    src/main/java/com/wcc/platform/properties/GoogleDriveAuthProperties.java

Implementation direction:

  • remove the desktop OAuth AuthorizationCodeInstalledApp flow from production
  • load service account credentials from GOOGLE_DRIVE_CREDENTIALS_PATH
  • use Google credentials suitable for server-to-server access
  • build the Drive client from those credentials directly

The production code should fail fast at startup if:

  • storage.type=google
  • but credentials path is missing
  • or the credentials file cannot be parsed

2. Google Drive: support profile-based auth modes

Recommended split:

  • local: allow local filesystem storage by default
  • dev: optionally use service-account-based Google Drive if the developer has
    the credentials file locally
  • prod or flyio: only allow service-account mode

This avoids production inheriting local OAuth assumptions.

3. Add startup validation

Add validation so the app fails fast when production configuration is invalid.

Examples:

  • if storage.type=google and GOOGLE_DRIVE_CREDENTIALS_PATH is missing
  • if spring.mail.host is set but MAIL_USERNAME or MAIL_PASSWORD is blank
  • if required Drive folder IDs are missing

This can be implemented with:

  • @ConfigurationProperties
  • @Validated
  • bean initialization checks

Google Drive Service Account Setup

1. Create the service account

In Google Cloud Console:

  1. Open your Google Cloud project
  2. Go to IAM & Admin -> Service Accounts
  3. Create a new service account
  4. Give it a clear name such as wcc-backend-drive-prod
  5. Create a JSON key
  6. Download the JSON key securely

Do not commit this file to the repository.

2. Share the Drive folders with the service account

This is the critical step that replaces the desktop OAuth user flow.

For each required Google Drive folder:

  1. Open the folder in Google Drive
  2. Share it with the service account email address
  3. Grant the correct access level

Typical minimum access:

  • Editor for upload/update flows

Share:

  • the environment root folder
  • or each specific subfolder the app writes to

3. Record the folder IDs [DONE AND UPDATED TO PROD]

Use the same folder IDs already expected by the app:

  • main folder
  • resources folder
  • events folder
  • images folder
  • mentor pictures folder
  • mentor resources folder

These should remain configuration values, not code constants.

Fly.io Setup

Google Drive secrets

Store the service account JSON in Fly secrets.

Example:

fly secrets set GOOGLE_DRIVE_CREDENTIALS_JSON_B64="$(base64 < service-account.json | tr -d '\n')"

At startup:

  1. create a runtime directory such as /app/secrets
  2. decode the secret into /app/secrets/google-drive-service-account.json
  3. set:
[env]
SPRING_PROFILES_ACTIVE = "flyio"
GOOGLE_DRIVE_CREDENTIALS_PATH = "/app/secrets/google-drive-service-account.json"

This requires either:

  • an entrypoint script
  • or a launch command wrapper

Suggested Fly startup flow

Create a startup script that:

  1. creates /app/secrets
  2. decodes GOOGLE_DRIVE_CREDENTIALS_JSON_B64 into the credentials file
  3. starts the Java app

Example outline:

#!/bin/sh
set -eu

mkdir -p /app/secrets

if [ -n "${GOOGLE_DRIVE_CREDENTIALS_JSON_B64:-}" ]; then
  echo "$GOOGLE_DRIVE_CREDENTIALS_JSON_B64" | base64 -d > /app/secrets/google-drive-service-account.json
fi

exec java -jar app.jar

This is acceptable in production because:

  • the secret is injected by Fly
  • the file is created at runtime
  • the secret is not baked into the image

Recommended Development Setup

Local development for Google Drive

Best default:

  • use storage.type=local

This avoids forcing every developer to configure Google Drive credentials just
to run the app.

If a developer needs to test real Google Drive integration locally:

  • use the same service account approach as production
  • store the JSON file outside the repository
  • point GOOGLE_DRIVE_CREDENTIALS_PATH at that local file

Do not rely on desktop OAuth as the long-term supported path.

Testing Plan

Dev environment tests

Google Drive

  1. Start the app with storage.type=google
  2. Set GOOGLE_DRIVE_CREDENTIALS_PATH to a valid local service account file
  3. Upload a test file through the relevant API
  4. Confirm the file appears in the expected Drive folder
  5. Verify the returned link is accessible as expected

Negative tests:

  • missing credentials path
  • malformed JSON
  • valid credentials but folder not shared with service account

Production tests

Google Drive

  1. Deploy to Fly with service-account secret configured
  2. Verify app startup succeeds without OAuth prompts
  3. Call the upload endpoint with a small test file
  4. Confirm the file is stored in the correct production Drive folder
  5. Verify permissions and returned web link

Operational checks:

  • restart the Fly machine
  • redeploy the app
  • confirm uploads still work without any reauthorization

Recommended Implementation Sequence

  1. Refactor Google Drive auth to service-account-based loading from a configured path
  2. Remove production dependence on /credentials.json classpath resource
  3. Add startup validation for missing production secrets
  4. Add or update integration tests for Drive config loading where practical
  5. Add deployment documentation for Fly secrets and startup script [done]
  6. Optionally revisit a dedicated transactional provider if Gmail or Workspace
    limits become operationally restrictive

Final Recommendation

Google Drive

Real production fix:

  • migrate from desktop OAuth to a Google service account
  • load credentials from a Fly secret written to a runtime file
  • remove token-store and browser-auth assumptions from server code


private final Drive driveService;

Expand All @@ -63,68 +57,34 @@ public GoogleDriveFileStorageRepository(
this.folders = folders;
}

/** Spring constructor: builds Drive client and reads folders from properties. */
/** Spring constructor: builds Drive client using service account credentials. */
@Autowired
public GoogleDriveFileStorageRepository(final FolderStorageProperties folders)
throws GeneralSecurityException, IOException {
final NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
this.driveService =
new Drive.Builder(httpTransport, JSON_FACTORY, getCredentials(httpTransport))
new Drive.Builder(httpTransport, JSON_FACTORY, loadServiceAccountCredentials())
.setApplicationName(APPLICATION_NAME)
.build();
this.folders = folders;
}

/** Constructor that initializes the Google Drive service (no Spring). */
public GoogleDriveFileStorageRepository() throws GeneralSecurityException, IOException {
final NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
this.driveService =
new Drive.Builder(httpTransport, JSON_FACTORY, getCredentials(httpTransport))
.setApplicationName(APPLICATION_NAME)
.build();
this.folders = new FolderStorageProperties();
}

/**
* Creates an authorized Credential object.
* Loads Google Drive credentials from a service account JSON file.
*
* @param httpTransport The network HTTP Transport.
* @return An authorized Credential object.
* @throws IOException If the credentials.json file cannot be found.
* @return An {@link HttpCredentialsAdapter} wrapping the service account credentials.
* @throws IOException If the service account file cannot be found or read.
*/
private static Credential getCredentials(final NetHttpTransport httpTransport)
throws IOException {
private static HttpCredentialsAdapter loadServiceAccountCredentials() throws IOException {
try (InputStream in =
GoogleDriveFileStorageRepository.class.getResourceAsStream(CREDS_FILE_PATH)) {
GoogleDriveFileStorageRepository.class.getResourceAsStream(SERVICE_ACCOUNT_PATH)) {
if (in == null) {
throw new FileNotFoundException("Resource not found: " + CREDS_FILE_PATH);
throw new FileNotFoundException("Resource not found: " + SERVICE_ACCOUNT_PATH);
}
final var clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
final String clientId = clientSecrets.getDetails().getClientId();
final String userKey = "user-" + (clientId == null ? "unknown" : clientId);

final var flow =
new GoogleAuthorizationCodeFlow.Builder(
httpTransport, JSON_FACTORY, clientSecrets, SCOPES)
.setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIR_PATH)))
.setAccessType("offline")
.build();

final Credential credential = flow.loadCredential(userKey);
if (credential != null) {
log.info(
"Using existing Google Drive credentials from '{}' for clientId '{}'. "
+ "No browser authorization needed.",
TOKENS_DIR_PATH,
clientId);
return credential;
}

log.info(
"No existing credentials found for clientId '{}'. Opening browser for authorization...",
clientId);
final LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
return new AuthorizationCodeInstalledApp(flow, receiver).authorize(userKey);
final GoogleCredentials credentials =
GoogleCredentials.fromStream(in).createScoped(SCOPES);
log.info("Loaded Google Drive service account credentials.");
return new HttpCredentialsAdapter(credentials);
}
}

Expand Down Expand Up @@ -159,11 +119,15 @@ public FileStored uploadFile(
new InputStreamContent(contentType, new ByteArrayInputStream(fileData));

final var file =
files().create(fileMetadata, mediaContent).setFields("id, name, webViewLink").execute();
files()
.create(fileMetadata, mediaContent)
.setSupportsAllDrives(true)
.setFields("id, name, webViewLink")
.execute();

final var permission = new Permission().setType("anyone").setRole("reader");

permissions().create(file.getId(), permission).execute();
permissions().create(file.getId(), permission).setSupportsAllDrives(true).execute();

return new FileStored(file.getId(), file.getWebViewLink());
} catch (IOException e) {
Expand Down
Loading