deploy: one-command scripts to build+run from deploy/.env
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
# deploy/
|
||||||
|
|
||||||
|
Holds the **live secrets** (`deploy/.env`) and one-command scripts to build and run the stack against them. `deploy/.env` is git-ignored — it is never committed.
|
||||||
|
|
||||||
|
## deploy/.env
|
||||||
|
|
||||||
|
Required keys (see `../.env.example` for the template):
|
||||||
|
|
||||||
|
```
|
||||||
|
POSTGRES_PASSWORD=...
|
||||||
|
GOOGLE_CLIENT_ID=...
|
||||||
|
GOOGLE_CLIENT_SECRET=...
|
||||||
|
AI_MODE=Disabled # Disabled | LocalOllama | CloudOpenAi
|
||||||
|
FRONTEND_ORIGIN=http://localhost:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Windows (PowerShell), from the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./deploy/up.ps1 # build + start (detached)
|
||||||
|
./deploy/up.ps1 -Foreground # stream logs instead of detaching
|
||||||
|
./deploy/up.ps1 -Proxy # also run the top-level nginx (everything on :80)
|
||||||
|
./deploy/logs.ps1 api # tail a service's logs
|
||||||
|
./deploy/down.ps1 # stop
|
||||||
|
./deploy/down.ps1 -Volumes # stop and wipe the DB + key volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux / Ubuntu production target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/up.sh # build + start (detached)
|
||||||
|
./deploy/up.sh --foreground
|
||||||
|
./deploy/up.sh --proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Both scripts validate that `deploy/.env` exists and that the required keys are non-blank before invoking Docker, so a misconfigured env fails fast with a clear message instead of a half-started stack.
|
||||||
|
|
||||||
|
## What it runs
|
||||||
|
|
||||||
|
`docker compose --env-file deploy/.env up --build` — Postgres, the API (auto-applies EF migrations on boot), and the frontend. The `--env-file` flag feeds the `${...}` variables in `docker-compose.yml`.
|
||||||
|
|
||||||
|
After it's up:
|
||||||
|
- Frontend — http://localhost:8081
|
||||||
|
- API / Swagger — http://localhost:8080/swagger
|
||||||
|
|
||||||
|
Reminder: the Google OAuth redirect URI for this layout is `http://localhost:8081/signin-google` (see `../docs/GOOGLE_OAUTH_SETUP.md`).
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Stop the InboxIntel stack. Use -Volumes to also drop the database + key data.
|
||||||
|
.EXAMPLE
|
||||||
|
./deploy/down.ps1
|
||||||
|
./deploy/down.ps1 -Volumes
|
||||||
|
#>
|
||||||
|
param([switch]$Volumes)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env'
|
||||||
|
|
||||||
|
$composeArgs = @('compose', '--env-file', $envFile, 'down')
|
||||||
|
if ($Volumes) { $composeArgs += '--volumes' }
|
||||||
|
|
||||||
|
Push-Location $root
|
||||||
|
try { & docker @composeArgs }
|
||||||
|
finally { Pop-Location }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Tail logs for the stack (or a single service, e.g. api / frontend / postgres).
|
||||||
|
.EXAMPLE
|
||||||
|
./deploy/logs.ps1 # all services
|
||||||
|
./deploy/logs.ps1 api # just the API container
|
||||||
|
#>
|
||||||
|
param([string]$Service)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env'
|
||||||
|
|
||||||
|
$composeArgs = @('compose', '--env-file', $envFile, 'logs', '-f', '--tail', '200')
|
||||||
|
if ($Service) { $composeArgs += $Service }
|
||||||
|
|
||||||
|
Push-Location $root
|
||||||
|
try { & docker @composeArgs }
|
||||||
|
finally { Pop-Location }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build and start the full InboxIntel stack using deploy/.env for secrets.
|
||||||
|
.EXAMPLE
|
||||||
|
./deploy/up.ps1 # build + start detached
|
||||||
|
./deploy/up.ps1 -Proxy # also start the top-level nginx reverse proxy
|
||||||
|
./deploy/up.ps1 -Foreground # stream logs instead of detaching
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$Proxy,
|
||||||
|
[switch]$Foreground
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env'
|
||||||
|
|
||||||
|
if (-not (Test-Path $envFile)) {
|
||||||
|
throw "Missing $envFile. Create it from .env.example with your real secrets."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fail fast if a required key is absent or blank.
|
||||||
|
$required = 'POSTGRES_PASSWORD','GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET'
|
||||||
|
$envMap = @{}
|
||||||
|
Get-Content $envFile | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$') { $envMap[$Matches[1]] = $Matches[2].Trim() }
|
||||||
|
}
|
||||||
|
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
|
||||||
|
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
|
||||||
|
|
||||||
|
$composeArgs = @('compose', '--env-file', $envFile)
|
||||||
|
if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
|
||||||
|
$composeArgs += @('up', '--build')
|
||||||
|
if (-not $Foreground) { $composeArgs += '-d' }
|
||||||
|
|
||||||
|
Push-Location $root
|
||||||
|
try {
|
||||||
|
Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan
|
||||||
|
& docker @composeArgs
|
||||||
|
if (-not $Foreground) {
|
||||||
|
& docker compose --env-file $envFile ps
|
||||||
|
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
|
||||||
|
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { Pop-Location }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build and start the InboxIntel stack using deploy/.env for secrets.
|
||||||
|
# Usage: ./deploy/up.sh [--proxy] [--foreground]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ENV_FILE="$SCRIPT_DIR/.env"
|
||||||
|
|
||||||
|
[ -f "$ENV_FILE" ] || { echo "Missing $ENV_FILE. Create it from .env.example." >&2; exit 1; }
|
||||||
|
|
||||||
|
# Fail fast if a required key is blank.
|
||||||
|
for key in POSTGRES_PASSWORD GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET; do
|
||||||
|
val="$(grep -E "^${key}=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '[:space:]' || true)"
|
||||||
|
[ -n "$val" ] || { echo "deploy/.env is missing a value for: $key" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
PROFILE_ARGS=()
|
||||||
|
DETACH="-d"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--proxy) PROFILE_ARGS=(--profile proxy) ;;
|
||||||
|
--foreground) DETACH="" ;;
|
||||||
|
*) echo "Unknown arg: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Starting InboxIntel via docker compose (env: deploy/.env)..."
|
||||||
|
docker compose --env-file "$ENV_FILE" "${PROFILE_ARGS[@]}" up --build $DETACH
|
||||||
|
|
||||||
|
if [ -n "$DETACH" ]; then
|
||||||
|
docker compose --env-file "$ENV_FILE" ps
|
||||||
|
echo
|
||||||
|
echo "Frontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Google OAuth2 Setup
|
||||||
|
|
||||||
|
InboxIntel logs users in with Google only. The `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` you create here identify the **application** to Google — every user (you or anyone else) signs in through this one credential pair. They are required; without them nobody can log in.
|
||||||
|
|
||||||
|
## 1. Create the OAuth client
|
||||||
|
|
||||||
|
1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project.
|
||||||
|
2. **APIs & Services → Library →** enable the **Gmail API**.
|
||||||
|
3. **APIs & Services → OAuth consent screen:**
|
||||||
|
- User type: **External**.
|
||||||
|
- Fill app name, support email, developer email.
|
||||||
|
- **Scopes:** add `openid`, `email`, `profile`, `.../auth/gmail.readonly`, `.../auth/gmail.modify`.
|
||||||
|
4. **APIs & Services → Credentials → Create credentials → OAuth client ID:**
|
||||||
|
- Application type: **Web application**.
|
||||||
|
- **Authorized redirect URIs** — add the one(s) matching how you run it:
|
||||||
|
|
||||||
|
| How you run it | Redirect URI |
|
||||||
|
|---|---|
|
||||||
|
| Docker (frontend on :8081) | `http://localhost:8081/signin-google` |
|
||||||
|
| Docker + top-level nginx (`--profile proxy`, :80) | `http://localhost/signin-google` |
|
||||||
|
| Local dev (API via `dotnet run`, :5080) | `http://localhost:5080/signin-google` |
|
||||||
|
| Production | `https://your-domain/signin-google` |
|
||||||
|
|
||||||
|
- Copy the generated **Client ID** and **Client secret**.
|
||||||
|
|
||||||
|
## 2. Put the credentials where the app reads them
|
||||||
|
|
||||||
|
Docker — in `.env` at the repo root:
|
||||||
|
|
||||||
|
```
|
||||||
|
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
Local dev — use user-secrets so they never touch source control:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/InboxIntel.Api
|
||||||
|
dotnet user-secrets init
|
||||||
|
dotnet user-secrets set "GoogleOAuth:ClientId" "xxxxx.apps.googleusercontent.com"
|
||||||
|
dotnet user-secrets set "GoogleOAuth:ClientSecret" "xxxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Who is allowed to log in
|
||||||
|
|
||||||
|
This is controlled by the **consent screen publishing status**, not the app code (the code already creates a new user row per Google account — it does not restrict to one person):
|
||||||
|
|
||||||
|
- **Testing** (default): only Google accounts you add under *Test users* can sign in (max 100).
|
||||||
|
- **In production** (click **Publish app**): any Google account can sign in.
|
||||||
|
|
||||||
|
### Caveat for the Gmail scopes
|
||||||
|
|
||||||
|
`gmail.readonly` and `gmail.modify` are **restricted scopes**. A published-but-unverified app still works, but:
|
||||||
|
|
||||||
|
- users see a "Google hasn't verified this app" warning screen, and
|
||||||
|
- you're capped at **100 users** until Google verifies the app.
|
||||||
|
|
||||||
|
Removing the warning / going beyond 100 users requires Google's OAuth verification (a CASA security assessment for restricted scopes). For personal or small-team use, published-unverified (≤100 users) is usually fine.
|
||||||
|
|
||||||
|
## 4. Redirect URI must match exactly
|
||||||
|
|
||||||
|
The URI registered in the Console must character-for-character match what the app sends. The app builds it from the request's host + scheme; behind nginx, `UseForwardedHeaders` + the `X-Forwarded-Proto`/`Host` headers (already configured) make that the **external** URL, and nginx routes `/signin-google` to the API. If you get `redirect_uri_mismatch`, compare the URI in the browser's address bar during the error against what's registered.
|
||||||
@@ -15,6 +15,18 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Google OAuth2 callback + sign-out land here (not under /api) and must
|
||||||
|
# reach the backend so the cookie session is established same-origin.
|
||||||
|
location ~ ^/(signin-google|signout-google) {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Google OAuth2 callback + sign-out -> backend (same-origin session).
|
||||||
|
location ~ ^/(signin-google|signout-google) {
|
||||||
|
proxy_pass http://api_upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using InboxIntel.Infrastructure.Persistence;
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.Google;
|
using Microsoft.AspNetCore.Authentication.Google;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -89,6 +90,16 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and
|
||||||
|
// cookie Secure flags reflect the external scheme/host, not the container's.
|
||||||
|
var forwardedOptions = new ForwardedHeadersOptions
|
||||||
|
{
|
||||||
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
|
||||||
|
};
|
||||||
|
forwardedOptions.KnownNetworks.Clear();
|
||||||
|
forwardedOptions.KnownProxies.Clear();
|
||||||
|
app.UseForwardedHeaders(forwardedOptions);
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
app.UseCors("frontend");
|
app.UseCors("frontend");
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|||||||
Reference in New Issue
Block a user