Skip to content
Open
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
24 changes: 24 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.git
.github
.context
.conductor

.env
.env.*
!.env.example

.venv
venv
__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
htmlcov
*.sqlite3

media
staticfiles
node_modules
theme/static_src/node_modules
10 changes: 10 additions & 0 deletions .omo/run-continuation/ses_09086707effe8Q5lE1eol9wCrU.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"sessionID": "ses_09086707effe8Q5lE1eol9wCrU",
"updatedAt": "2026-07-19T04:44:33.319Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-19T04:44:33.319Z"
}
}
}
50 changes: 50 additions & 0 deletions apps/common/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Middleware to allow Fly.io internal health checks through ALLOWED_HOSTS validation.

Fly.io health checks hit the app via internal IPs (172.19.x.x) which are not in
ALLOWED_HOSTS. This middleware adds those IPs to the allowed hosts for health
check requests only.
"""

import re

from django.conf import settings
from django.http import HttpResponseForbidden

FLY_INTERNAL_IP_PATTERN = re.compile(r"^172\.19\.\d+\.\d+$")
HEALTH_CHECK_PATHS = {"/health/", "/healthz/", "/ready/"}


class FlyHealthCheckMiddleware:
"""
Allow Fly.io internal health checks to bypass ALLOWED_HOSTS restriction.

Fly.io's internal load balancer sends health checks from internal IPs
(172.19.x.x) which are not in ALLOWED_HOSTS. This middleware checks if
the request is a health check from a Fly internal IP and allows it through.
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
# Check if this is a health check request from Fly internal IP
# Use META directly to avoid triggering DisallowedHost in get_host()
host_header = request.META.get("HTTP_HOST", "").split(":")[0]
path = request.path

if path in HEALTH_CHECK_PATHS and FLY_INTERNAL_IP_PATTERN.match(host_header):
# Temporarily add the host to ALLOWED_HOSTS for this request
# This is safe because it's only for known health check paths
# from known internal IP range
original_allowed = settings.ALLOWED_HOSTS
if host_header not in original_allowed:
settings.ALLOWED_HOSTS = list(original_allowed) + [host_header]

response = self.get_response(request)

# Restore original ALLOWED_HOSTS
if path in HEALTH_CHECK_PATHS and FLY_INTERNAL_IP_PATTERN.match(host_header):
settings.ALLOWED_HOSTS = original_allowed

return response
9 changes: 7 additions & 2 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,13 @@

# Platform credentials env vars (cloud version)
_META_CREDENTIALS = {
"app_id": env("PLATFORM_FACEBOOK_APP_ID", default=""),
"app_secret": env("PLATFORM_FACEBOOK_APP_SECRET", default=""),
# The existing PnC Meta app is configured through the Instagram Business
# use case. Keep the dedicated Instagram variables as a compatibility
# fallback so an existing deployment can use the same app without a
# destructive secret rotation.
"app_id": env("PLATFORM_FACEBOOK_APP_ID", default="") or env("PLATFORM_INSTAGRAM_APP_ID", default=""),
"app_secret": env("PLATFORM_FACEBOOK_APP_SECRET", default="")
or env("PLATFORM_INSTAGRAM_APP_SECRET", default=""),
}
_GOOGLE_CREDENTIALS = {
"client_id": env("PLATFORM_GOOGLE_CLIENT_ID", default=""),
Expand Down
14 changes: 14 additions & 0 deletions config/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_REDIRECT_EXEMPT = [r"^health/$"]

# Allow Fly.io internal health checks (internal IPs in 172.19.x.x range)
import re
FLY_INTERNAL_IP_PATTERN = re.compile(r"^172\.19\.\d+\.\d+$")

# Add Fly health check middleware BEFORE CommonMiddleware to allow internal IPs
# for health checks before host validation runs
MIDDLEWARE = [
"apps.common.middleware.FlyHealthCheckMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
] + MIDDLEWARE[5:]

# Logging
LOGGING = {
"version": 1,
Expand Down
45 changes: 45 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
app = 'brightbean-pnc'
primary_region = 'lax'

[build]

[processes]
app = 'sh -c "gunicorn config.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --threads 2"'
worker = 'python manage.py process_tasks'

[env]
PORT = '8000'

[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ['app']

[[http_service.checks]]
grace_period = '10s'
interval = '30s'
method = 'GET'
timeout = '5s'
path = '/health/'

[[restart]]
policy = 'always'
processes = ['worker']

[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
processes = ['app']

[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
processes = ['worker']

[deploy]
release_command = 'python manage.py migrate --noinput'
11 changes: 7 additions & 4 deletions providers/instagram.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,14 @@ def supported_media_types(self) -> list[MediaType]:

@property
def required_scopes(self) -> list[str]:
# Meta renamed Instagram permissions with an "instagram_business_"
# prefix; the old names (instagram_basic, instagram_content_publish,
# etc.) are now rejected as invalid scopes.
return [
"instagram_basic",
"instagram_content_publish",
"instagram_manage_comments",
"instagram_manage_insights",
"instagram_business_basic",
"instagram_business_content_publish",
"instagram_business_manage_comments",
"instagram_business_manage_insights",
"pages_show_list",
"pages_read_engagement",
]
Expand Down
107 changes: 107 additions & 0 deletions scripts/INGEST_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# BrightBean Asset Ingestion - Run Locally on Your Mac

## Prerequisites

You need the R2 credentials from the API token you just created in Cloudflare:

```bash
# From Cloudflare Dashboard → R2 → Manage R2 API tokens → your token
export S3_ENDPOINT_URL="https://<YOUR_ACCOUNT_ID>.r2.cloudflarestorage.com"
export S3_ACCESS_KEY_ID="<YOUR_ACCESS_KEY_ID>"
export S3_SECRET_ACCESS_KEY="<YOUR_SECRET_ACCESS_KEY>"
export S3_BUCKET_NAME="brightbean-assets"
export S3_REGION_NAME="auto"
```

Find your **Account ID** at: https://dash.cloudflare.com/ (top right, copy the 32-char ID)

---

## Run the Ingestion

```bash
cd /Users/mfdoom/conductor/workspaces/brightbean-studio/brazzaville

# 1. Install boto3 (one-time)
pip3 install boto3

# 2. Preview what will be uploaded (dry run)
python3 scripts/ingest_assets_standalone.py --dry-run

# 3. Actual upload to R2
python3 scripts/ingest_assets_standalone.py
```

---

## What Happens

1. **Scans 4 source directories** (from `.context/brightbean-social-readiness.md`)
2. **Deduplicates by SHA-256** (skips identical files)
3. **Uploads to R2** at `assets/<project>/<sha256-prefix>/<filename>`
4. **Creates `asset_manifest.json`** with all metadata for later MediaAsset creation

---

## After Upload: Create MediaAsset Records

The upload puts files in R2. To make them usable in BrightBean:

### Option A: BrightBean API (once social accounts connected)
```bash
# Get API key from BrightBean UI → Organization → API Keys
export BB_API_KEY="bb_studio_..."
export BB_URL="https://brightbean-pnc.fly.dev"

# Use the manifest to create records
python3 -c "
import json, requests
manifest = json.load(open('asset_manifest.json'))
for asset in manifest:
r = requests.post(f'{BB_URL}/api/v1/media', headers={'Authorization': f'Bearer {BB_API_KEY}'}, json={
'title': asset['title'],
'file': asset['r2_key'],
'sha256': asset['sha256'],
'file_size': asset['file_size'],
'mime_type': asset['mime_type'],
'project': asset['project'],
'tags': [asset['project']],
'alt_text': asset['title'],
})
print(asset['filename'], r.status_code)
"
```

### Option B: Django Admin (immediate)
1. Go to https://brightbean-pnc.fly.dev/admin/
2. Login with superuser
3. Media Library → Media Assets → Add
4. Fill in from `asset_manifest.json`

### Option C: BrightBean UI
1. Go to Media Library in BrightBean
2. Click "Add" → the files are already in R2, just need records

---

## Expected Results

| Category | Files |
|----------|-------|
| Social-ready set | 26 PNGs |
| Content pool | ~152 unique (deduped from 259) |
| Originals (PAC) | 2 PNGs |
| Reels | 2 MP4s |
| **Total unique** | **~182 assets** |

---

## Troubleshooting

**"Source directory not found"** — The paths in `SOURCE_DIRS` are from your `.context/brightbean-social-readiness.md`. If files moved, update the script.

**"Missing R2 credentials"** — Re-export the 5 environment variables above.

**"Access Denied"** — R2 token needs "Object Read & Write" permissions on the bucket.

**"Bucket not found"** — Create bucket `brightbean-assets` in R2 first, or update `S3_BUCKET_NAME`.
Loading