diff --git a/backend/.env.example b/backend/.env.example index 090bad1..3fe41c9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,7 @@ DEVBITS_JWT_SECRET=replace-with-64+char-random-secret DEVBITS_ADMIN_KEY=replace-with-base64-or-random-admin-key DEVBITS_ADMIN_LOCAL_ONLY=0 DEVBITS_API_ADDR=0.0.0.0:8080 +# Set to "true" when running behind a trusted reverse proxy (e.g. AWS ALB/nginx) +# that sets the X-Forwarded-Proto header. Do NOT enable if the backend is directly +# internet-facing, as the header would be client-controlled. +DEVBITS_TRUST_PROXY=false diff --git a/backend/api/internal/handlers/media_routes.go b/backend/api/internal/handlers/media_routes.go index 3b886a5..8589398 100644 --- a/backend/api/internal/handlers/media_routes.go +++ b/backend/api/internal/handlers/media_routes.go @@ -20,6 +20,11 @@ import ( const uploadDir = "uploads" +// trustProxy is evaluated once at startup to avoid repeated os.Getenv calls +// on every upload request. Set DEVBITS_TRUST_PROXY=true only when the backend +// runs behind a trusted reverse proxy (e.g. AWS ALB) that sets X-Forwarded-Proto. +var trustProxy = os.Getenv("DEVBITS_TRUST_PROXY") == "true" + var allowedImageExtensions = map[string]struct{}{ ".jpg": {}, ".jpeg": {}, @@ -207,13 +212,15 @@ func UploadMedia(context *gin.Context) { } scheme := "http" - if forwardedProto := strings.TrimSpace(context.GetHeader("X-Forwarded-Proto")); forwardedProto != "" { - scheme = strings.ToLower(strings.TrimSpace(strings.Split(forwardedProto, ",")[0])) - if scheme != "http" && scheme != "https" { - scheme = "http" - } - } else if context.Request.TLS != nil { + if context.Request.TLS != nil { scheme = "https" + } else if trustProxy { + if forwardedProto := strings.TrimSpace(context.GetHeader("X-Forwarded-Proto")); forwardedProto != "" { + scheme = strings.ToLower(strings.TrimSpace(strings.Split(forwardedProto, ",")[0])) + if scheme != "http" && scheme != "https" { + scheme = "http" + } + } } relativeURL := fmt.Sprintf("/%s/%s", uploadDir, filename) absoluteURL := fmt.Sprintf("%s://%s%s", scheme, context.Request.Host, relativeURL) diff --git a/backend/api/main.go b/backend/api/main.go index cfb40b7..a90cf78 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -195,8 +195,18 @@ func main() { } else { log.Printf("INFO: admin UI available at /admin (key-protected)") } - router.StaticFile("/apple-app-site-association", "./api/static/apple-app-site-association") - router.StaticFile("/.well-known/assetlinks.json", "./api/static/assetlinks.json") + router.GET("/apple-app-site-association", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.File("./api/static/apple-app-site-association") + }) + router.GET("/.well-known/apple-app-site-association", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.File("./api/static/apple-app-site-association") + }) + router.GET("/.well-known/assetlinks.json", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.File("./api/static/assetlinks.json") + }) router.StaticFile("/privacy-policy", "./api/static/privacy-policy.html") router.StaticFile("/account-deletion", "./api/static/account-deletion.html") diff --git a/backend/go.mod b/backend/go.mod index af52653..ff6845e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.11.2 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 @@ -28,7 +29,6 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/backend/scripts/install-aws-systemd-service.sh b/backend/scripts/install-aws-systemd-service.sh index 8a772b0..96db2ad 100644 --- a/backend/scripts/install-aws-systemd-service.sh +++ b/backend/scripts/install-aws-systemd-service.sh @@ -30,6 +30,12 @@ if [[ ! -x "$BINARY_PATH" ]]; then exit 1 fi +if [[ ! -f "$TEMPLATE_PATH" ]]; then + echo "Missing systemd service template: $TEMPLATE_PATH" >&2 + echo "Ensure the deploy/systemd/devbits-api.service template is present." >&2 + exit 1 +fi + if ! id "$SERVICE_USER" >/dev/null 2>&1; then echo "Service user does not exist: $SERVICE_USER" >&2 exit 1 diff --git a/frontend/DEEP_LINK_SETUP.md b/frontend/DEEP_LINK_SETUP.md index 4e72f8e..15f56da 100644 --- a/frontend/DEEP_LINK_SETUP.md +++ b/frontend/DEEP_LINK_SETUP.md @@ -55,13 +55,14 @@ Alternative (if you use Play App Signing) 4. Update `assetlinks.json` -- Open `frontend/public/.well-known/assetlinks.json` and replace `` with the SHA-256 fingerprint (format: uppercase hex with colons or without; either is accepted by Android). +- Open `backend/api/static/assetlinks.json` and replace `` with the SHA-256 fingerprint (format: uppercase hex with colons or without; either is accepted by Android). 5. Deploy static files to your server/domain -- Host `frontend/public/apple-app-site-association` and `frontend/public/.well-known/assetlinks.json` at: - - https://devbits.app/apple-app-site-association - - https://devbits.app/.well-known/assetlinks.json +- The Go backend now serves these files directly from `backend/api/static/`. No separate static hosting is needed. + - `https://devbits.app/apple-app-site-association` → served from `backend/api/static/apple-app-site-association` (also available at `/.well-known/apple-app-site-association`) + - `https://devbits.app/.well-known/assetlinks.json` → served from `backend/api/static/assetlinks.json` +- To update these files, edit them in `backend/api/static/` and redeploy the backend. 6. Verify diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 57018e8..899fdc0 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -318,28 +318,28 @@ const buildBaseUrlList = (...candidates: Array) => { return urls; }; -export const API_BASE_URL = normalizeBaseUrl(getDefaultBaseUrl()); - -// Defensive runtime validation: if the resolved URL is malformed (e.g. "http://" -// with no host) fall back to the public host and log an error so developers -// can see the problem in the Metro/Expo console. -try { - const checkUrl = new URL(API_BASE_URL); - if (!checkUrl.hostname) { - // eslint-disable-next-line no-console - console.error("Invalid API_BASE_URL resolved; falling back to https://devbits.app", API_BASE_URL); - // normalize and overwrite - (exports as any).API_BASE_URL = normalizeBaseUrl("https://devbits.app"); - } -} catch (e) { - // If parsing fails entirely, fallback and log. +// Validate the resolved URL at module load time; if it is malformed (e.g. +// "http://" with no host) fall back to the public host and log an error so +// developers can see the problem in the Metro/Expo console. +function getValidatedBaseUrl(): string { + const raw = normalizeBaseUrl(getDefaultBaseUrl()); try { + const checkUrl = new URL(raw); + if (!checkUrl.hostname) { + // eslint-disable-next-line no-console + console.error("Invalid API_BASE_URL resolved; falling back to https://devbits.app", raw); + return normalizeBaseUrl("https://devbits.app"); + } + return raw; + } catch (e) { // eslint-disable-next-line no-console - console.error("Failed to parse API_BASE_URL; falling back to https://devbits.app", API_BASE_URL, String(e)); - } catch {} - (exports as any).API_BASE_URL = normalizeBaseUrl("https://devbits.app"); + console.error("Failed to parse API_BASE_URL; falling back to https://devbits.app", raw, String(e)); + return normalizeBaseUrl("https://devbits.app"); + } } +export const API_BASE_URL = getValidatedBaseUrl(); + const API_FALLBACK_URL = normalizeBaseUrl( __DEV__ ? "" : "https://devbits.app", );