Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 13 additions & 6 deletions backend/api/internal/handlers/media_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +23 to +26
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The trustProxy variable is initialized as a package-level var by calling os.Getenv("DEVBITS_TRUST_PROXY") at package init time. However, godotenv.Load(".env", ...) is called inside main(), which runs after all package-level variable initialization. This means if DEVBITS_TRUST_PROXY=true is set in the .env file (as is the case for local development), the value will never be seen by trustProxy — it will always evaluate to false, silently broken.

Other env-dependent values in the codebase (e.g. DEVBITS_JWT_SECRET in auth/jwt.go:21, DEVBITS_ADMIN_KEY in auth_middleware.go:43) call os.Getenv inside functions at request time, not at package init, which is why they work correctly. The trustProxy variable should either be read inside the handler function per-request, or be initialized in main() after godotenv.Load() and injected into the handler.

Suggested change
// 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"
// trustProxyEnabled reads DEVBITS_TRUST_PROXY at request time so that values
// loaded via godotenv in main() are visible. Set DEVBITS_TRUST_PROXY=true only
// when the backend runs behind a trusted reverse proxy (e.g. AWS ALB) that
// sets X-Forwarded-Proto.
func trustProxyEnabled() bool {
return os.Getenv("DEVBITS_TRUST_PROXY") == "true"
}

Copilot uses AI. Check for mistakes.

var allowedImageExtensions = map[string]struct{}{
".jpg": {},
".jpeg": {},
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions backend/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions backend/scripts/install-aws-systemd-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions frontend/DEEP_LINK_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ Alternative (if you use Play App Signing)

4. Update `assetlinks.json`

- Open `frontend/public/.well-known/assetlinks.json` and replace `<SHA256_CERT_FINGERPRINT>` 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 `<SHA256_CERT_FINGERPRINT>` 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

Expand Down
36 changes: 18 additions & 18 deletions frontend/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,28 +318,28 @@ const buildBaseUrlList = (...candidates: Array<string | null | undefined>) => {
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",
);
Expand Down