Merge pull request #2 from Andy7475/feature/speech-to-text

speech to text all working
This commit is contained in:
Andy7475 2026-02-28 16:02:45 +00:00 committed by GitHub
commit 0ad1a86a98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1289 additions and 0 deletions

View File

@ -0,0 +1,211 @@
# Implementation Plan: GCS Orchestration & Speech-to-Text
## Background & Goal
We want CyberChef to act as a **cloud-native orchestrator** for processing large media files in GCS without ever streaming raw media bytes through the browser.
**Concrete end-to-end workflow:**
```
Input: "cyber-chef-cloud-examples"
Recipe:
1. GCloud List Bucket [prefix: audio/]
→ gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3
gs://cyber-chef-cloud-examples/audio/track_02.mp3
gs://cyber-chef-cloud-examples/audio/track_03.mp3
gs://cyber-chef-cloud-examples/audio/track_04.mp3
2. Fork [\n]
3. GCloud Speech to Text [Output: Return to CyberChef |OR| Write to GCS]
→ Browser mode: "The package will be delivered at 1400..."
→ GCS mode: gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt
4. Merge
```
**Key principle:** raw audio never touches the browser — GCS URI is passed in, transcript text (or a GCS output URI) comes back.
---
## Design Decisions
- **LRO polling: internal** — the `GCloud Speech to Text` operation polls internally (every 10 seconds, up to 30 minutes max), updating the output box with progress. No separate "Poll Operation" step needed.
- **Output path convention:** `output/{media_type}/{source_filename}/{service}/text.txt`
e.g. `gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt`
---
## 1. Shared Library Changes
**File:** `src/core/lib/GoogleCloud.mjs`
### New helper: `listGCSBucket`
```
listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject)
Calls: GET https://storage.googleapis.com/storage/v1/b/{bucket}/o?prefix={prefix}
Returns: array of { name, gs_uri, size, contentType }
```
### New helper: `readGCSFile`
```
readGCSFile(gcsUri, authType, authStringObj, quotaProject)
Parses gs://bucket/object
Calls: GET https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encodedObject}?alt=media
Returns: ArrayBuffer (raw bytes)
```
### New helper: `pollLongRunningOperation`
```
pollLongRunningOperation(operationName, authString, quotaProject, maxMs, intervalMs, onProgress)
Calls: GET https://speech.googleapis.com/v1/operations/{operationName} every intervalMs
Calls onProgress(elapsedSeconds) on each tick (for progress output to CyberChef output box)
Resolves when response.done === true
Rejects on timeout or API error
Defaults: poll every 10s, timeout after 30 mins
```
---
## 2. New Operation: `GCloud List Bucket`
**File:** `src/core/operations/GCloudListBucket.mjs`
| Property | Value |
| :--- | :--- |
| Name | `GCloud List Bucket` |
| Module | `Cloud` |
| Input Type | `string` (bucket name or `gs://` prefix) |
| Output Type | `string` |
| `manualBake` | `true` |
**Arguments:**
| # | Name | Type | Default |
| :--- | :--- | :--- | :--- |
| 0 | Folder Prefix | `string` | `audio/` |
| 1 | Output Format | `option` | `GCS URIs (one per line)` / `Filenames only` / `JSON` |
| 24 | *(GCP_AUTH_ARGS)* | — | — |
**Behaviour:**
- Strips `gs://` prefix from input to normalise bucket name
- Default output = newline-separated `gs://` URIs → directly pipe into `Fork`
---
## 3. New Operation: `GCloud Read File`
**File:** `src/core/operations/GCloudReadFile.mjs`
| Property | Value |
| :--- | :--- |
| Name | `GCloud Read File` |
| Module | `Cloud` |
| Input Type | `string` (`gs://` URI) |
| Output Type | `ArrayBuffer` |
| `manualBake` | `true` |
**Arguments:**
| # | Name | Type |
| :--- | :--- | :--- |
| 02 | *(GCP_AUTH_ARGS)* | — |
> **Note:** Intended for small files (text, small images). For large audio/video, use the GCS URI mode in Speech-to-Text directly — don't stream large binaries through the browser.
---
## 4. New Operation: `GCloud Speech to Text`
**File:** `src/core/operations/GCloudSpeechToText.mjs`
| Property | Value |
| :--- | :--- |
| Name | `GCloud Speech to Text` |
| Module | `Cloud` |
| Input Type | `string` |
| Output Type | `string` |
| `manualBake` | `true` |
**Arguments:**
| # | Name | Type | Default |
| :--- | :--- | :--- | :--- |
| 0 | Input Mode | `option` | `GCS URI (gs://...)` / `Raw Audio Bytes (Base64)` |
| 1 | Language Code | `string` | `en-US` |
| 2 | Output Destination | `option` | `Return to CyberChef` / `Write to GCS` |
| 3 | Output GCS Bucket | `string` | `cyber-chef-cloud-examples` |
| 4 | Max Poll Minutes | `number` | `30` |
| 57 | *(GCP_AUTH_ARGS)* | — | — |
**Runtime Logic:**
```
IF Input Mode == "GCS URI":
Call longrunningrecognize with { audio: { uri: input }, config: { languageCode } }
→ Get operationName
transcript = await pollLongRunningOperation(operationName, ...)
(progress updates written to output box during polling)
ELSE (Raw Audio Bytes / Base64):
Call recognize with { audio: { content: base64Input }, config: { languageCode } }
transcript = joined results
IF Output Destination == "Write to GCS":
sourceFilename = last path segment of input gs:// URI (e.g. "hello_kitty.mp3")
destPath = "output/audio/{sourceFilename}/speech-to-text/text.txt"
PUT transcript → gs://{outputBucket}/{destPath}
return "gs://{outputBucket}/{destPath}" ← this is the CyberChef output
ELSE:
return transcript text
```
**Output path example:**
```
Input: gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3
Output: gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt
```
After Fork + Merge across 4 files, the CyberChef output will be 4 GCS URIs the analyst can save, come back to later, and use as the input to a new recipe.
---
## 5. Tests
**File:** `tests/browser/03_cloud_ops.js`
Following the existing pattern (skippable via missing token):
| Test | Type |
| :--- | :--- |
| `GCloud List Bucket: Missing Key Validation` | No API call |
| `GCloud List Bucket: Lists audio/ files from cyber-chef-cloud-examples` | Live, skippable |
| `GCloud Speech-to-Text: GCS URI returns transcription in browser` | Live + LRO, skippable |
| `GCloud Speech-to-Text: GCS URI writes to output/ bucket` | Live + LRO + GCS write, skippable |
---
## 6. Verification Plan
### Automated
```bash
# From /home/projects/CyberChefCloud
CYBERCHEF_GCP_TEST_TOKEN=$(gcloud auth print-access-token) npm run test:browser
```
### Manual
1. Open CyberChef at `http://localhost:8080`
2. Add **GCloud List Bucket**, input `cyber-chef-cloud-examples`, prefix `audio/`
3. Bake → confirm 4 `gs://` URIs in output
4. Add **Fork** `[\n]` + **GCloud Speech to Text** (GCS URI, Return to CyberChef) + **Merge**
5. Bake → confirm 4 transcripts separated by `---`
6. Repeat with **Write to GCS** mode
7. Verify in terminal: `gsutil cat gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt`

View File

@ -0,0 +1,43 @@
# Task List: GCS + Speech-to-Text CyberChef Operations
## Phase 1: Planning & Documentation
- [x] Review existing `GoogleTranslate.mjs`, `GoogleCloud.mjs` lib, and `03_cloud_ops.js` patterns
- [x] Write implementation plan (`GCS_SpeechToText_ImplementationPlan.md`)
- [x] Write GCP configuration guide (`GCloudGCSSetup.md`)
- [x] Get user approval on plan — **approved, internal LRO polling confirmed**
## Phase 2: Shared Library Enhancements (`src/core/lib/GoogleCloud.mjs`)
- [ ] Add `listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject)` helper
- [ ] Add `readGCSFile(gcsUri, authType, authStringObj, quotaProject)` helper
- [ ] Add `pollLongRunningOperation(operationName, authString, quotaProject, maxMs, intervalMs, onProgress)` polling helper
## Phase 3: New Operations
### 3.1 `GCloud List Bucket` (`src/core/operations/GCloudListBucket.mjs`)
- [ ] Create operation file scaffolding
- [ ] Args: Bucket Name, Prefix/Folder filter, Auth args, Output Format
- [ ] Calls GCS JSON API: `storage/v1/b/{bucket}/o?prefix=`
- [ ] Default output: newline-separated `gs://` URIs (pipe directly into `Fork`)
### 3.2 `GCloud Read File` (`src/core/operations/GCloudReadFile.mjs`)
- [ ] Create operation file scaffolding
- [ ] Args: Auth args only (input = `gs://` URI)
- [ ] Calls GCS media download endpoint
- [ ] Output: raw file bytes (`ArrayBuffer`)
### 3.3 `GCloud Speech to Text` (`src/core/operations/GCloudSpeechToText.mjs`)
- [ ] Create operation file scaffolding
- [ ] Args: Input Mode, Language Code, Output Destination, Output GCS Bucket, Max Poll Minutes, Auth args
- [ ] GCS URI mode → call `longrunningrecognize` → internal LRO polling loop
- [ ] Raw bytes mode → call synchronous `recognize` endpoint
- [ ] Output mode: `Return to CyberChef` (transcript text) OR `Write to GCS` (returns the written `gs://` URI)
- [ ] GCS output path convention: `output/audio/{filename}/speech-to-text/text.txt`
## Phase 4: Tests (`tests/browser/03_cloud_ops.js`)
- [ ] `GCloud List Bucket: Missing Key Validation` (no API call)
- [ ] `GCloud List Bucket: Lists audio/ files from cyber-chef-cloud-examples` (live, skippable)
- [ ] `GCloud Speech-to-Text: GCS URI mode returns transcription` (live, LRO, skippable)
- [ ] `GCloud Speech-to-Text: GCS URI mode writes to output/ bucket` (live, LRO, skippable)
## Phase 5: Documentation
- [ ] Update `GCloudGCSSetup.md` with any gotchas discovered during testing

232
docs/GCloudGCSSetup.md Normal file
View File

@ -0,0 +1,232 @@
# GCP Configuration Guide: GCS + Speech-to-Text
This guide ensures your Google Cloud project is correctly configured to support:
- **CyberChef `GCloud List Bucket`** — listing objects in a GCS bucket from the browser
- **CyberChef `GCloud Read File`** — downloading small files from GCS into the browser
- **CyberChef `GCloud Speech-to-Text`** — transcribing audio files stored in GCS, both returning results to the browser and writing outputs back to GCS
**Your bucket:** `cyber-chef-cloud-examples`
---
## 1. APIs to Enable
In the [Google Cloud Console](https://console.cloud.google.com/apis/library) (or via `gcloud`), ensure the following APIs are enabled for your project:
| API | Purpose | Enable via Console link |
| :--- | :--- | :--- |
| **Cloud Storage JSON API** | Listing & reading bucket objects | [Enable](https://console.cloud.google.com/apis/library/storage-component.googleapis.com) |
| **Cloud Speech-to-Text API** | Audio transcription | [Enable](https://console.cloud.google.com/apis/library/speech.googleapis.com) |
Via `gcloud`:
```bash
gcloud services enable storage-component.googleapis.com
gcloud services enable speech.googleapis.com
```
---
## 2. IAM Roles for Your User Identity
CyberChef sends API requests using **your OAuth token** (generated by `gcloud auth print-access-token`). Your user identity needs the following roles:
### On the GCS Bucket (`cyber-chef-cloud-examples`)
| Role | Why Needed |
| :--- | :--- |
| `roles/storage.objectViewer` | To list objects and read file metadata (List Bucket operation) |
| `roles/storage.objectCreator` | To write transcription output files back to the `output/` prefix |
Grant via console: **Cloud Storage → cyber-chef-cloud-examples → Permissions → Grant Access**
Or via `gcloud`:
```bash
# Replace YOUR_EMAIL with your Google account email
gcloud storage buckets add-iam-policy-binding gs://cyber-chef-cloud-examples \
--member="user:YOUR_EMAIL@gmail.com" \
--role="roles/storage.objectViewer"
gcloud storage buckets add-iam-policy-binding gs://cyber-chef-cloud-examples \
--member="user:YOUR_EMAIL@gmail.com" \
--role="roles/storage.objectCreator"
```
### On the Speech-to-Text API
Your user identity needs permission to call the Speech-to-Text API at the **project level**:
| Role | Why Needed |
| :--- | :--- |
| `roles/speech.editor` (or `roles/speech.client`) | To call `longrunningrecognize` and poll operation status |
```bash
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="user:YOUR_EMAIL@gmail.com" \
--role="roles/speech.client"
```
---
## 3. Allow the Speech-to-Text Service Account to Read Your Bucket
> [!IMPORTANT]
> This is the most commonly missed step. When you call `longrunningrecognize` with a `gcsUri`, the **Speech-to-Text API reads the file using its own internal service account**, not yours. You must explicitly grant this service account access to your bucket.
### Find your Speech-to-Text service account
The service account follows the pattern:
```
service-{PROJECT_NUMBER}@gcp-sa-speech.iam.gserviceaccount.com
```
Get your project number:
```bash
gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)"
# Example output: 123456789012
```
So your service account would be:
```
service-123456789012@gcp-sa-speech.iam.gserviceaccount.com
```
### Grant it access to the bucket
```bash
gcloud storage buckets add-iam-policy-binding gs://cyber-chef-cloud-examples \
--member="serviceAccount:service-123456789012@gcp-sa-speech.iam.gserviceaccount.com" \
--role="roles/storage.objectViewer"
```
---
## 4. Configure CORS on the Bucket
CyberChef runs in the browser and makes direct `fetch()` requests to the GCS JSON API. For the **list** and **metadata** endpoints these are generally permitted, but to be safe and to avoid issues with `OPTIONS` preflight requests, configure CORS on the bucket.
Create a file `cors.json`:
```json
[
{
"origin": [
"http://localhost:8080",
"https://YOUR_CYBERCHEF_DOMAIN.com"
],
"method": ["GET", "POST", "PUT", "HEAD"],
"responseHeader": ["Content-Type", "Authorization", "x-goog-user-project"],
"maxAgeSeconds": 3600
}
]
```
Apply it:
```bash
gcloud storage buckets update gs://cyber-chef-cloud-examples \
--cors-file=cors.json
```
Verify:
```bash
gcloud storage buckets describe gs://cyber-chef-cloud-examples --format="json(cors)"
```
---
## 5. Quota Project / ADC Setup
If you are using an **OAuth Token** (recommended), some APIs require a billing quota project. Ensure your `gcloud` environment is configured:
```bash
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
```
In CyberChef operations, always populate the **Quota Project** field with your Project ID (e.g., `cyberchefcloud`).
---
## 6. Verification Checklist
Run these commands to verify your configuration before using CyberChef:
### ✅ Can you list your bucket?
```bash
gcloud storage ls gs://cyber-chef-cloud-examples/audio/
# Expected: 4 audio file URIs
```
### ✅ Can you read a file?
```bash
gsutil cat gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3 | file -
# Expected: MPEG audio data (or similar) — confirms read access
```
### ✅ Can the Speech API access the file? (Test with REST)
```bash
TOKEN=$(gcloud auth print-access-token)
PROJECT_ID=YOUR_PROJECT_ID
curl -s -X POST \
"https://speech.googleapis.com/v1/speech:longrunningrecognize" \
-H "Authorization: Bearer $TOKEN" \
-H "x-goog-user-project: $PROJECT_ID" \
-H "Content-Type: application/json" \
-d '{
"config": { "languageCode": "en-US", "enableAutomaticPunctuation": true },
"audio": { "uri": "gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3" }
}'
# Expected: { "name": "projects/.../operations/12345" }
```
If this returns an operation name, your setup is correct.
### ✅ Can you poll the operation?
```bash
# Use the operation name from the previous step
OP_NAME="projects/YOUR_PROJECT_NUMBER/operations/12345"
curl -s \
"https://speech.googleapis.com/v1/operations/${OP_NAME}" \
-H "Authorization: Bearer $TOKEN" \
-H "x-goog-user-project: $PROJECT_ID"
# Wait a few seconds and re-run. When done: { "done": true, "response": { "results": [...] } }
```
### ✅ Can you write to the output/ prefix?
```bash
echo "Test transcript" | gsutil cp - gs://cyber-chef-cloud-examples/output/test.txt
# Expected: Copying... Operation completed
# Clean up
gsutil rm gs://cyber-chef-cloud-examples/output/test.txt
```
---
## 7. Expected Bucket Structure
```
cyber-chef-cloud-examples/
├── audio/
│ ├── hello_kitty.mp3
│ ├── track_02.mp3
│ ├── track_03.mp3
│ └── track_04.mp3
├── images/
│ └── (future image files)
├── video/
│ └── (future video files)
└── output/
└── audio/
└── hello_kitty.mp3/
└── speech-to-text/
└── text.txt ← written by CyberChef
```
---
## 8. Summary of Roles Required
| Identity | Bucket Role | Project Role |
| :--- | :--- | :--- |
| Your user OAuth token | `storage.objectViewer` + `storage.objectCreator` | `speech.client` |
| Speech-to-Text service account | `storage.objectViewer` | *(handled internally by GCP)* |

View File

@ -0,0 +1,101 @@
# GCP Lessons Learned: Service Accounts & Storage Permissions
These lessons emerged from setting up Speech-to-Text access to a GCS bucket, but they apply broadly to **any Google Cloud AI/ML API that reads from Cloud Storage** (Vision, Video Intelligence, Natural Language, etc).
---
## Lesson 1: Google-Managed Service Agents Are Not Project Service Accounts
When a Google Cloud API (e.g. Speech-to-Text, Vision) reads from GCS on your behalf, it does so using a **Google-managed service agent** — a special service account that belongs to Google's infrastructure, not your project.
| API | Service Agent Email Pattern |
| :--- | :--- |
| Cloud Speech-to-Text | `service-{PROJECT_NUMBER}@gcp-sa-speech.iam.gserviceaccount.com` |
| Cloud Vision | `service-{PROJECT_NUMBER}@gcp-sa-vision.iam.gserviceaccount.com` |
| Cloud Video Intelligence | `service-{PROJECT_NUMBER}@gcp-sa-videointelligence.iam.gserviceaccount.com` |
| Cloud Natural Language | `service-{PROJECT_NUMBER}@gcp-sa-language.iam.gserviceaccount.com` |
**These will NOT appear in your project's IAM console** (`IAM & Admin → Service Accounts`), which only lists service accounts you created. Do not waste time looking for them there.
---
## Lesson 2: Service Agents Are Provisioned Lazily
The service agent email does not exist until you **make your first successful API call**. If you try to grant IAM permissions to the service agent email before any API call, the `gcloud` command will fail with:
```
ERROR: HTTPError 400: Service account (...) does not exist.
```
**Solution:** Make a real API call first (even if it fails with a 404 on the GCS object — a permission error is not sufficient). Once the API responds, the service agent is provisioned within a few seconds.
---
## Lesson 3: Same-Project Access Is Automatic
If your GCS bucket and the Cloud API are both in the **same GCP project**, the service agent already has read access to your bucket via the project's legacy IAM bindings (`roles/storage.legacyObjectReader``projectViewer`). You do not need to grant anything explicitly.
**Explicitly granting is only required for cross-project access** (e.g. the API is in Project A but the bucket is in Project B).
```bash
# Check your current bucket IAM — if you see these, same-project APIs can read it:
# - roles/storage.legacyBucketReader → projectViewer:YOUR_PROJECT
# - roles/storage.legacyObjectReader → projectViewer:YOUR_PROJECT
gcloud storage buckets get-iam-policy gs://YOUR_BUCKET
```
---
## Lesson 4: Model Selection Matters for Accuracy
When calling Speech-to-Text (and likely other AI APIs), the default model is not always the best choice. In our testing:
| Config | Transcript | Confidence |
| :--- | :--- | :--- |
| Default (no model specified) | `"result"` | 28% |
| `model: "latest_long"` | `"She achieves great results."` | 92% |
**Always specify `model: "latest_long"` for general audio**, and `model: "latest_short"` for short utterances (under ~1 min). Enable `enableAutomaticPunctuation: true` for readable output.
---
## Lesson 5: LRO Polling Endpoint Format
For long-running operations (`longrunningrecognize`, Video Intelligence jobs, etc), the operation name returned is **just a number** (e.g. `4567056075577147015`), not a full resource path.
The correct polling URL is:
```
GET https://speech.googleapis.com/v1/operations/{OPERATION_ID}
```
Not the more verbose `projects/.../operations/...` format (that's a different API surface). Pass the same auth headers as the original request.
---
## Lesson 6: Verify With `curl` Before Building CyberChef Operations
Always test the raw API call with `curl` before writing operation code. The two-step pattern (submit → poll) is easy to verify interactively:
```bash
# Step 1: Submit job
TOKEN=$(gcloud auth print-access-token)
curl -X POST "https://speech.googleapis.com/v1/speech:longrunningrecognize" \
-H "Authorization: Bearer $TOKEN" \
-H "x-goog-user-project: YOUR_PROJECT_ID" \
-H "Content-Type: application/json" \
-d '{
"config": {
"languageCode": "en-US",
"model": "latest_long",
"enableAutomaticPunctuation": true
},
"audio": { "uri": "gs://YOUR_BUCKET/audio/file.mp3" }
}'
# → { "name": "1234567890" }
# Step 2: Poll until done
curl "https://speech.googleapis.com/v1/operations/1234567890" \
-H "Authorization: Bearer $TOKEN" \
-H "x-goog-user-project: YOUR_PROJECT_ID"
# → { "done": true, "response": { "results": [...] } }
```

View File

@ -6,6 +6,157 @@
import OperationError from "../errors/OperationError.mjs";
/**
* Lists objects in a GCS bucket under a given prefix.
*
* @param {string} bucket - The GCS bucket name (without gs://).
* @param {string} prefix - The folder prefix to filter by (e.g. "audio/").
* @param {string} authType - "API Key" or "OAuth Token".
* @param {Object|string} authStringObj - The auth credential.
* @param {string} quotaProject - Optional quota project for ADC OAuth tokens.
* @returns {Promise<Array>} Array of GCS object metadata { name, gs_uri, size, contentType }.
*/
export async function listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject) {
let url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o`;
const params = new URLSearchParams();
if (prefix) params.set("prefix", prefix);
params.set("fields", "items(name,size,contentType)");
const paramStr = params.toString();
if (paramStr) url += `?${paramStr}`;
const headers = new Headers();
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" });
let data;
try {
data = await response.json();
} catch (e) {
throw new OperationError("GCloud List Bucket: Failed to parse GCS API response.");
}
if (!response.ok) {
const msg = data?.error?.message || response.statusText;
throw new OperationError(`GCloud List Bucket: GCS API Error (${response.status}): ${msg}`);
}
const items = data.items || [];
return items
.filter(item => !item.name.endsWith("/")) // exclude folder placeholder objects
.map(item => ({
name: item.name,
gs_uri: `gs://${bucket}/${item.name}`,
size: item.size,
contentType: item.contentType
}));
}
/**
* Downloads a file from GCS and returns its raw bytes.
*
* @param {string} gcsUri - Full gs:// URI of the file.
* @param {string} authType - "API Key" or "OAuth Token".
* @param {Object|string} authStringObj - The auth credential.
* @param {string} quotaProject - Optional quota project.
* @returns {Promise<ArrayBuffer>} Raw file bytes.
*/
export async function readGCSFile(gcsUri, authType, authStringObj, quotaProject) {
const match = gcsUri.match(/^gs:\/\/([^/]+)\/(.+)$/);
if (!match) throw new OperationError(`GCloud Read File: Invalid GCS URI: ${gcsUri}`);
const [, bucket, object] = match;
const encodedObject = encodeURIComponent(object).replace(/%2F/g, "%2F");
let url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o/${encodedObject}?alt=media`;
const headers = new Headers();
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" });
if (!response.ok) {
let msg = response.statusText;
try { const d = await response.json(); msg = d?.error?.message || msg; } catch (e) { /* ignore */ }
throw new OperationError(`GCloud Read File: GCS API Error (${response.status}): ${msg}`);
}
return await response.arrayBuffer();
}
/**
* Writes text content to a GCS object.
*
* @param {string} bucket - Destination bucket name (without gs://).
* @param {string} objectPath - Destination object path within the bucket.
* @param {string} content - Text content to write.
* @param {string} authType - "API Key" or "OAuth Token".
* @param {Object|string} authStringObj - The auth credential.
* @param {string} quotaProject - Optional quota project.
* @returns {Promise<string>} The gs:// URI of the written file.
*/
export async function writeGCSFile(bucket, objectPath, content, authType, authStringObj, quotaProject) {
const encodedObject = encodeURIComponent(objectPath).replace(/%2F/g, "%2F");
let url = `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o?uploadType=media&name=${encodedObject}`;
const headers = new Headers();
headers.set("Content-Type", "text/plain; charset=utf-8");
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const response = await fetch(authed.url, {
method: "POST",
headers: authed.headers,
body: content,
mode: "cors",
cache: "no-cache"
});
if (!response.ok) {
let msg = response.statusText;
try { const d = await response.json(); msg = d?.error?.message || msg; } catch (e) { /* ignore */ }
throw new OperationError(`GCloud Write File: GCS API Error (${response.status}): ${msg}`);
}
return `gs://${bucket}/${objectPath}`;
}
/**
* Polls a Google Cloud long-running operation until it completes.
*
* @param {string} operationName - The operation ID (numeric string from the API response).
* @param {string} pollUrl - The base polling URL (e.g. https://speech.googleapis.com/v1/operations/).
* @param {string} authType - "API Key" or "OAuth Token".
* @param {Object|string} authStringObj - The auth credential.
* @param {string} quotaProject - Optional quota project.
* @param {number} maxMs - Maximum wait time in milliseconds (default 30 minutes).
* @param {number} intervalMs - Poll interval in milliseconds (default 10 seconds).
* @param {Function} onProgress - Optional callback(elapsedSeconds) called on each poll tick.
* @returns {Promise<Object>} The completed operation response object.
*/
export async function pollLongRunningOperation(operationName, pollUrl, authType, authStringObj, quotaProject, maxMs = 30 * 60 * 1000, intervalMs = 10000, onProgress = null) {
const startTime = Date.now();
const url = `${pollUrl}${operationName}`;
while (true) {
const elapsed = Date.now() - startTime;
if (elapsed > maxMs) {
throw new OperationError(`GCloud: Operation timed out after ${Math.round(elapsed / 60000)} minutes. Operation ID: ${operationName}`);
}
const headers = new Headers();
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" });
let data;
try { data = await response.json(); } catch (e) {
throw new OperationError("GCloud: Failed to parse long-running operation response.");
}
if (!response.ok) {
const msg = data?.error?.message || response.statusText;
throw new OperationError(`GCloud: Operation polling error (${response.status}): ${msg}`);
}
if (data.done) return data;
const elapsedSec = Math.round(elapsed / 1000);
if (onProgress) onProgress(elapsedSec);
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
/**
* Common arguments for Google Cloud Platform operations
*

View File

@ -0,0 +1,89 @@
/**
* @author CyberChefCloud
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { GCP_AUTH_ARGS, applyGCPAuth, listGCSBucket } from "../lib/GoogleCloud.mjs";
/**
* GCloud List Bucket operation
*/
class GCloudListBucket extends Operation {
/**
* GCloudListBucket constructor
*/
constructor() {
super();
this.name = "GCloud List Bucket";
this.module = "Cloud";
this.description = [
"Lists objects in a Google Cloud Storage bucket and returns their <code>gs://</code> URIs.",
"<br><br>",
"The output is one URI per line, making it suitable for piping directly into a <b>Fork</b> operation ",
"to batch-process multiple files (e.g. transcribing all audio files in a folder).",
"<br><br>",
"Input can be just a bucket name (e.g. <code>cyber-chef-cloud-examples</code>) or a full ",
"<code>gs://</code> URI prefix.",
].join("\n");
this.infoURL = "https://cloud.google.com/storage/docs/json_api/v1/objects/list";
this.inputType = "string";
this.outputType = "string";
this.manualBake = true;
this.args = [
{
"name": "Folder Prefix",
"type": "string",
"value": "audio/"
},
{
"name": "Output Format",
"type": "option",
"value": ["GCS URIs (one per line)", "Filenames only", "JSON"]
},
...GCP_AUTH_ARGS
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
async run(input, args) {
const [prefix, outputFormat, authType, authStringObj, quotaProject] = args;
if (!input || !input.trim()) throw new OperationError("Please provide a GCS bucket name.");
// Normalise: strip gs:// if present, strip trailing slash
let bucket = input.trim().replace(/^gs:\/\//, "").split("/")[0];
try {
const items = await listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject);
if (items.length === 0) {
return `No objects found in gs://${bucket}/${prefix || ""}`;
}
switch (outputFormat) {
case "Filenames only":
return items.map(i => i.name.split("/").pop()).join("\n");
case "JSON":
return JSON.stringify(items, null, 2);
case "GCS URIs (one per line)":
default:
return items.map(i => i.gs_uri).join("\n");
}
} catch (e) {
if (e.name === "OperationError") throw e;
throw new OperationError(e.message || e.toString());
}
}
}
export default GCloudListBucket;

View File

@ -0,0 +1,66 @@
/**
* @author CyberChefCloud
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { GCP_AUTH_ARGS, readGCSFile } from "../lib/GoogleCloud.mjs";
/**
* GCloud Read File operation
*/
class GCloudReadFile extends Operation {
/**
* GCloudReadFile constructor
*/
constructor() {
super();
this.name = "GCloud Read File";
this.module = "Cloud";
this.description = [
"Downloads a file from Google Cloud Storage and returns its raw bytes.",
"<br><br>",
"Input must be a <code>gs://</code> URI (e.g. <code>gs://my-bucket/images/photo.png</code>).",
"<br><br>",
"<b>Note:</b> This operation downloads the full file into the browser. ",
"It is best suited for small files such as text or images. ",
"For large audio or video files, use the GCS URI input mode within the Speech-to-Text or ",
"Video Intelligence operations instead — they process the file entirely within Google Cloud.",
].join("\n");
this.infoURL = "https://cloud.google.com/storage/docs/json_api/v1/objects/get";
this.inputType = "string";
this.outputType = "ArrayBuffer";
this.manualBake = true;
this.args = [
...GCP_AUTH_ARGS
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {ArrayBuffer}
*/
async run(input, args) {
const [authType, authStringObj, quotaProject] = args;
const uri = input.trim();
if (!uri.startsWith("gs://")) {
throw new OperationError("Input must be a GCS URI starting with gs://");
}
try {
return await readGCSFile(uri, authType, authStringObj, quotaProject);
} catch (e) {
if (e.name === "OperationError") throw e;
throw new OperationError(e.message || e.toString());
}
}
}
export default GCloudReadFile;

View File

@ -0,0 +1,258 @@
/**
* @author CyberChefCloud
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { GCP_AUTH_ARGS, applyGCPAuth, pollLongRunningOperation, writeGCSFile } from "../lib/GoogleCloud.mjs";
/**
* GCloud Speech to Text operation
*/
class GCloudSpeechToText extends Operation {
/**
* GCloudSpeechToText constructor
*/
constructor() {
super();
this.name = "GCloud Speech to Text";
this.module = "Cloud";
this.description = [
"Transcribes audio using the Google Cloud Speech-to-Text API.",
"<br><br>",
"<b>GCS URI mode (recommended for large files):</b> Input a <code>gs://</code> URI ",
"(e.g. <code>gs://my-bucket/audio/file.mp3</code>). The audio is processed entirely within ",
"Google Cloud — the raw audio never passes through the browser. Suitable for files of any size. ",
"Uses the asynchronous <code>longrunningrecognize</code> API with internal polling.",
"<br><br>",
"<b>Raw Audio mode:</b> Provide Base64-encoded audio bytes directly. Only suitable for short ",
"clips (under ~1 minute). Uses the synchronous <code>recognize</code> API.",
"<br><br>",
"<b>Write to GCS mode:</b> Instead of returning the transcript to CyberChef, writes it to a ",
"structured path in a GCS bucket and returns the destination <code>gs://</code> URI. This is ",
"ideal for batch processing with Fork — each fork branch writes its transcript and returns a URI, ",
"which can be saved and used as input to a later recipe.",
"<br><br>",
"Output path convention: <code>output/audio/{filename}/speech-to-text/text.txt</code>",
].join("\n");
this.infoURL = "https://cloud.google.com/speech-to-text/docs/reference/rest";
this.inputType = "string";
this.outputType = "string";
this.manualBake = true;
this.args = [
{
"name": "Input Mode",
"type": "option",
"value": ["GCS URI (gs://...)", "Raw Audio Bytes (Base64)"]
},
{
"name": "Language Code",
"type": "string",
"value": "en-US"
},
{
"name": "Model",
"type": "option",
"value": ["latest_long", "latest_short", "telephony", "medical_dictation", "default"]
},
{
"name": "Output Destination",
"type": "option",
"value": ["Return to CyberChef", "Write to GCS"]
},
{
"name": "Output GCS Bucket",
"type": "string",
"value": "cyber-chef-cloud-examples"
},
{
"name": "Max Poll Minutes",
"type": "number",
"value": 30
},
...GCP_AUTH_ARGS
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
async run(input, args) {
const [
inputMode, languageCode, model, outputDest, outputBucket, maxPollMinutes,
authType, authStringObj, quotaProject
] = args;
const uri = input.trim();
if (!uri) throw new OperationError("Please provide a GCS URI or Base64 audio input.");
const authString = typeof authStringObj === "string" ? authStringObj : (authStringObj.string || "");
if (!authString) throw new OperationError("Please provide a valid GCP Auth String (API Key or OAuth Token).");
const maxMs = maxPollMinutes * 60 * 1000;
let transcript;
if (inputMode === "GCS URI (gs://...)") {
if (!uri.startsWith("gs://")) {
throw new OperationError("Input Mode is set to GCS URI but input does not start with gs://");
}
transcript = await this._transcribeGcsUri(uri, languageCode, model, authType, authStringObj, quotaProject, maxMs);
} else {
// Raw audio bytes (Base64)
transcript = await this._transcribeRawAudio(uri, languageCode, model, authType, authStringObj, quotaProject);
}
if (outputDest === "Write to GCS") {
// Derive source filename from GCS URI (or use a default for raw audio)
const sourceFilename = inputMode === "GCS URI (gs://...)"
? uri.split("/").pop()
: "raw_audio";
const objectPath = `output/audio/${sourceFilename}/speech-to-text/text.txt`;
const destUri = await writeGCSFile(outputBucket, objectPath, transcript, authType, authStringObj, quotaProject);
return destUri;
}
return transcript;
}
/**
* Transcribes audio from a GCS URI using the longrunningrecognize API.
*
* @param {string} gcsUri
* @param {string} languageCode
* @param {string} model
* @param {string} authType
* @param {Object|string} authStringObj
* @param {string} quotaProject
* @param {number} maxMs
* @returns {Promise<string>}
*/
async _transcribeGcsUri(gcsUri, languageCode, model, authType, authStringObj, quotaProject, maxMs) {
let url = "https://speech.googleapis.com/v1/speech:longrunningrecognize";
const headers = new Headers();
headers.set("Content-Type", "application/json; charset=utf-8");
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const body = JSON.stringify({
config: {
languageCode,
model,
enableAutomaticPunctuation: true,
},
audio: { uri: gcsUri }
});
const response = await fetch(authed.url, {
method: "POST",
headers: authed.headers,
body,
mode: "cors",
cache: "no-cache"
});
let responseData;
try { responseData = await response.json(); } catch (e) {
throw new OperationError("GCloud Speech to Text: Failed to parse API response.");
}
if (!response.ok) {
const msg = responseData?.error?.message || response.statusText;
throw new OperationError(`GCloud Speech to Text: API Error (${response.status}): ${msg}`);
}
const operationName = responseData.name;
if (!operationName) throw new OperationError("GCloud Speech to Text: No operation name returned from API.");
// Poll for completion
const POLL_URL = "https://speech.googleapis.com/v1/operations/";
const completed = await pollLongRunningOperation(
operationName,
POLL_URL,
authType,
authStringObj,
quotaProject,
maxMs,
10000,
(elapsedSec) => {
// onProgress — not easily surfaced in CyberChef output mid-bake,
// but available for future UI integration
void elapsedSec;
}
);
return this._extractTranscript(completed);
}
/**
* Transcribes audio from raw Base64 bytes using the synchronous recognize API.
*
* @param {string} base64Audio
* @param {string} languageCode
* @param {string} model
* @param {string} authType
* @param {Object|string} authStringObj
* @param {string} quotaProject
* @returns {Promise<string>}
*/
async _transcribeRawAudio(base64Audio, languageCode, model, authType, authStringObj, quotaProject) {
let url = "https://speech.googleapis.com/v1/speech:recognize";
const headers = new Headers();
headers.set("Content-Type", "application/json; charset=utf-8");
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const body = JSON.stringify({
config: {
languageCode,
model,
enableAutomaticPunctuation: true,
},
audio: { content: base64Audio }
});
const response = await fetch(authed.url, {
method: "POST",
headers: authed.headers,
body,
mode: "cors",
cache: "no-cache"
});
let responseData;
try { responseData = await response.json(); } catch (e) {
throw new OperationError("GCloud Speech to Text: Failed to parse API response.");
}
if (!response.ok) {
const msg = responseData?.error?.message || response.statusText;
throw new OperationError(`GCloud Speech to Text: API Error (${response.status}): ${msg}`);
}
return this._extractTranscript({ response: responseData });
}
/**
* Extracts a joined transcript string from a completed LRO response or synchronous response.
*
* @param {Object} completed - The operation response object.
* @returns {string} Joined transcript text.
*/
_extractTranscript(completed) {
const results = completed?.response?.results;
if (!results || results.length === 0) {
return "(No speech detected)";
}
return results
.map(r => r.alternatives?.[0]?.transcript || "")
.filter(t => t.length > 0)
.join(" ")
.trim();
}
}
export default GCloudSpeechToText;

View File

@ -100,7 +100,145 @@ module.exports = {
});
},
// ─── GCloud List Bucket ────────────────────────────────────────────────────
"GCloud List Bucket: Missing Key Validation": function (browser) {
browserUtils.loadRecipe(browser, "GCloud List Bucket", "cyber-chef-cloud-examples", [
"audio/",
"GCS URIs (one per line)",
"API Key",
{ option: "UTF8", string: "" },
""
]);
browser.waitForElementNotVisible("#snackbar-container", 6000);
browserUtils.bake(browser);
browser.pause(2000);
browser.saveScreenshot("tests/browser/output/list_bucket_no_key.png");
browser.execute(function () {
return window.app.manager.output.outputEditorView.state.doc.toString();
}, [], function ({ value }) {
browser.assert.ok(value.includes("Please provide a valid GCP Auth String"));
});
},
"GCloud List Bucket: Lists audio/ files from cyber-chef-cloud-examples": function (browser) {
let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN;
if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") {
try {
testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim();
} catch (e) {
console.log("No valid token found and gcloud failed. Skipping GCloud List Bucket live test.");
return;
}
}
browserUtils.loadRecipe(browser, "GCloud List Bucket", "cyber-chef-cloud-examples", [
"audio/",
"GCS URIs (one per line)",
"OAuth Token",
{ option: "UTF8", string: testToken },
"cyberchefcloud"
]);
browser.waitForElementNotVisible("#snackbar-container", 6000);
browserUtils.bake(browser);
browser.pause(5000);
browser.saveScreenshot("tests/browser/output/list_bucket_live.png");
browser.execute(function () {
return window.app.manager.output.outputEditorView.state.doc.toString();
}, [], function ({ value }) {
browser.assert.ok(value.includes("gs://cyber-chef-cloud-examples/audio/"), `Expected gs:// URIs, got: ${value}`);
browser.assert.ok(value.includes("she_achieves_great_results_f55548.mp3"), `Expected audio filename in output, got: ${value}`);
});
},
// ─── GCloud Speech to Text ─────────────────────────────────────────────────
"GCloud Speech to Text: GCS URI mode returns transcription to browser": function (browser) {
let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN;
if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") {
try {
testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim();
} catch (e) {
console.log("No valid token found and gcloud failed. Skipping GCloud Speech to Text live test.");
return;
}
}
const gcsUri = "gs://cyber-chef-cloud-examples/audio/she_achieves_great_results_f55548.mp3";
browserUtils.loadRecipe(browser, "GCloud Speech to Text", gcsUri, [
"GCS URI (gs://...)",
"en-US",
"latest_long",
"Return to CyberChef",
"cyber-chef-cloud-examples",
30,
"OAuth Token",
{ option: "UTF8", string: testToken },
"cyberchefcloud"
]);
browser.waitForElementNotVisible("#snackbar-container", 6000);
browserUtils.bake(browser);
// LRO jobs on short files typically complete in ~5 seconds; allow up to 30s here
browser.pause(30000);
browser.saveScreenshot("tests/browser/output/speech_to_text_browser.png");
browser.execute(function () {
return window.app.manager.output.outputEditorView.state.doc.toString();
}, [], function ({ value }) {
browser.assert.ok(
value.toLowerCase().includes("she achieves great results"),
`Expected transcript, got: ${value}`
);
});
},
"GCloud Speech to Text: GCS URI mode writes transcript to GCS output/ bucket": function (browser) {
let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN;
if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") {
try {
testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim();
} catch (e) {
console.log("No valid token found and gcloud failed. Skipping GCloud Speech to Text GCS write test.");
return;
}
}
const gcsUri = "gs://cyber-chef-cloud-examples/audio/she_achieves_great_results_f55548.mp3";
browserUtils.loadRecipe(browser, "GCloud Speech to Text", gcsUri, [
"GCS URI (gs://...)",
"en-US",
"latest_long",
"Write to GCS",
"cyber-chef-cloud-examples",
30,
"OAuth Token",
{ option: "UTF8", string: testToken },
"cyberchefcloud"
]);
browser.waitForElementNotVisible("#snackbar-container", 6000);
browserUtils.bake(browser);
browser.pause(30000);
browser.saveScreenshot("tests/browser/output/speech_to_text_gcs_write.png");
browser.execute(function () {
return window.app.manager.output.outputEditorView.state.doc.toString();
}, [], function ({ value }) {
browser.assert.ok(
value.includes("gs://cyber-chef-cloud-examples/output/audio/she_achieves_great_results_f55548.mp3/speech-to-text/text.txt"),
`Expected GCS output URI, got: ${value}`
);
});
},
after: function (browser) {
browser.end();
}
};