package main import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "math" "math/big" "mime" "net" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" _ "github.com/jackc/pgx/v5/stdlib" "golang.org/x/crypto/bcrypt" ) type stateEntry struct { Verifier string CreatedAt time.Time UserID string ClientID string ClientSecret string RedirectURI string Scopes string ScopesMin string ScopeDefault string APIVersion string } type tokenSet struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` Scope string `json:"scope"` TokenType string `json:"token_type"` } type oauthToken struct { AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` TokenType string `json:"token_type,omitempty"` Scope string `json:"scope,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` } type platformCreds struct { ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` RedirectURI string `json:"redirect_uri,omitempty"` } type socialAuth struct { YouTube oauthToken `json:"youtube,omitempty"` YTConfig platformCreds `json:"yt_config,omitempty"` TikTok oauthToken `json:"tiktok,omitempty"` TikTokConfig platformCreds `json:"tiktok_config,omitempty"` X oauthToken `json:"x,omitempty"` XConfig platformCreds `json:"x_config,omitempty"` TikTokOpenID string `json:"tiktok_open_id,omitempty"` Facebook oauthToken `json:"facebook,omitempty"` FBConfig platformCreds `json:"facebook_config,omitempty"` FBPageID string `json:"facebook_page_id,omitempty"` FBPageToken string `json:"facebook_page_token,omitempty"` Instagram oauthToken `json:"instagram,omitempty"` IGUserID string `json:"instagram_user_id,omitempty"` } type sessionData struct { UserID string `json:"user_id,omitempty"` Tokens tokenSet `json:"tokens"` Profile json.RawMessage `json:"profile,omitempty"` Created time.Time `json:"created_at"` Social socialAuth `json:"social,omitempty"` CanvaClientID string `json:"canva_client_id,omitempty"` CanvaClientSecret string `json:"canva_client_secret,omitempty"` CanvaRedirectURI string `json:"canva_redirect_uri,omitempty"` CanvaScopes string `json:"canva_scopes,omitempty"` CanvaScopesMin string `json:"canva_scopes_min,omitempty"` CanvaScopeDefault string `json:"canva_scope_default,omitempty"` CanvaAPIVersion string `json:"canva_api_version,omitempty"` } type designStart struct { Session string State string Created time.Time } type oauthState struct { Provider string SessionID string Verifier string ClientID string ClientSecret string RedirectURI string Scopes string Created time.Time } type authUser struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name,omitempty"` PasswordHash string `json:"password_hash"` Created time.Time `json:"created"` } var ( stateStore = sync.Map{} oauthStateStore = sync.Map{} sessionStore = sync.Map{} sessionFileMu sync.Mutex userStore = sync.Map{} userFileMu sync.Mutex userSettingsStore = sync.Map{} userSettingsFileMu sync.Mutex db *sql.DB dbEnabled bool encryptionKey []byte ) const ( // As per https://www.canva.dev/docs/connect/authentication/ (Dec 2025) canvaAPIBase = "https://api.canva.com/rest/v1" canvaAuthURL = "https://www.canva.com/api/oauth/authorize" canvaTokenURL = canvaAPIBase + "/oauth/token" canvaProfileURL = canvaAPIBase + "/users/me" canvaDesignsURL = canvaAPIBase + "/designs" canvaExportsURL = canvaAPIBase + "/exports" ) var httpClient = &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ForceAttemptHTTP2: false, // avoids sporadic HTTP/2 "unexpected EOF" issues behind some proxies/NATs MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, } var tokenHTTPClient = func() *http.Client { forceIPv4 := strings.EqualFold(os.Getenv("FORCE_IPV4"), "true") dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} dialContext := dialer.DialContext if forceIPv4 { dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.DialContext(ctx, "tcp4", addr) } } transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: dialContext, ForceAttemptHTTP2: false, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: true, TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, } return &http.Client{Timeout: 30 * time.Second, Transport: transport} }() var uploadHTTPClient = func() *http.Client { forceIPv4 := strings.EqualFold(os.Getenv("FORCE_IPV4"), "true") dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} dialContext := dialer.DialContext if forceIPv4 { dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.DialContext(ctx, "tcp4", addr) } } transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: dialContext, ForceAttemptHTTP2: false, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: true, TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, } return &http.Client{Timeout: 10 * time.Minute, Transport: transport} }() var apiVersionHeader string // Auth0 / OIDC globals var ( authDomain string authAudience string jwksOnce sync.Once jwksErr error jwksKeys map[string]*rsa.PublicKey // Social upload envs ytClientID string ytClientSecret string ytRefreshToken string xBearerToken string xUserToken string tiktokAccessToken string tiktokOpenID string tiktokPublishURL string tiktokStatusURL string fbPageID string fbAccessToken string fbGraphVersion string igUserID string igAccessToken string igGraphVersion string // OAuth app config sessionStorePath string ytRedirectURI string ytScopes string xClientID string xClientSecret string xRedirectURI string xScopes string tiktokClientKey string tiktokClientSecret string tiktokRedirectURI string tiktokScopes string fbAppID string fbAppSecret string fbRedirectURI string fbScopes string frontendSettingsURL string authJWTSecret string authJWTTTL time.Duration authUserStorePath string userSettingsStorePath string allowSessionAuth bool canvaClientID string canvaClientSecret string ) func main() { port := env("PORT", "4000") clientID := envOrFail("CANVA_CLIENT_ID") clientSecret := envOrFail("CANVA_CLIENT_SECRET") canvaClientID = clientID canvaClientSecret = clientSecret redirectURI := env("CANVA_REDIRECT_URI", fmt.Sprintf("http://127.0.0.1:%s/auth/canva/callback", port)) successURL := env("FRONTEND_SUCCESS_URL", "http://localhost:3000/connect") // Scopes must come from env to match exactly what your Canva app is approved for. canvaScopesFull := normalizeScopes(envOrFail("CANVA_SCOPES")) canvaScopesMin := normalizeScopes(env("CANVA_SCOPES_MIN", "")) canvaScopeDefault := strings.ToLower(env("CANVA_SCOPE_DEFAULT", "full")) fmt.Printf("CANVA_SCOPES: %v\n", canvaScopesFull) apiVersionHeader = os.Getenv("CANVA_API_VERSION") authJWTSecret = os.Getenv("AUTH_JWT_SECRET") authJWTTTL = time.Duration(envInt("AUTH_JWT_TTL_HOURS", 24)) * time.Hour authUserStorePath = env("AUTH_USER_STORE_PATH", "data/users.json") userSettingsStorePath = env("USER_SETTINGS_STORE_PATH", "data/user_settings.json") allowSessionAuth = strings.ToLower(env("ALLOW_SESSION_AUTH", "true")) != "false" if authJWTSecret == "" { log.Printf("warning: AUTH_JWT_SECRET not set; email/password auth is disabled") } if key := parseEncryptionKey(os.Getenv("ENCRYPTION_KEY")); len(key) > 0 { encryptionKey = key } else { log.Printf("warning: ENCRYPTION_KEY not set or invalid; sensitive tokens will be stored in plaintext") } if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" { if err := initDB(dbURL); err != nil { log.Fatalf("database init failed: %v", err) } dbEnabled = true } else { log.Printf("warning: DATABASE_URL not set; falling back to file stores") } // Optional Auth0 enforcement for API routes authDomain = os.Getenv("AUTH0_DOMAIN") authAudience = os.Getenv("AUTH0_AUDIENCE") // Social envs ytClientID = os.Getenv("YT_CLIENT_ID") ytClientSecret = os.Getenv("YT_CLIENT_SECRET") ytRefreshToken = os.Getenv("YT_REFRESH_TOKEN") xBearerToken = os.Getenv("X_BEARER_TOKEN") xUserToken = os.Getenv("X_USER_ACCESS_TOKEN") tiktokAccessToken = os.Getenv("TIKTOK_ACCESS_TOKEN") tiktokOpenID = os.Getenv("TIKTOK_OPEN_ID") tiktokPublishURL = env("TIKTOK_PUBLISH_URL", "https://open.tiktokapis.com/v2/post/publish/video/init/") tiktokStatusURL = env("TIKTOK_PUBLISH_STATUS_URL", "https://open.tiktokapis.com/v2/post/publish/status/fetch/") fbPageID = os.Getenv("FB_PAGE_ID") fbAccessToken = os.Getenv("FB_PAGE_ACCESS_TOKEN") fbGraphVersion = env("FB_GRAPH_VERSION", "v19.0") igUserID = os.Getenv("IG_USER_ID") igAccessToken = os.Getenv("IG_ACCESS_TOKEN") igGraphVersion = env("IG_GRAPH_VERSION", "v19.0") // OAuth app config sessionStorePath = env("SESSION_STORE_PATH", "data/sessions.json") frontendSettingsURL = env("FRONTEND_SETTINGS_URL", "http://localhost:3000/settings") ytRedirectURI = env("YT_REDIRECT_URI", fmt.Sprintf("http://127.0.0.1:%s/auth/youtube/callback", port)) ytScopes = env("YT_SCOPES", "https://www.googleapis.com/auth/youtube.upload") xClientID = os.Getenv("X_CLIENT_ID") xClientSecret = os.Getenv("X_CLIENT_SECRET") xRedirectURI = env("X_REDIRECT_URI", fmt.Sprintf("http://127.0.0.1:%s/auth/x/callback", port)) xScopes = env("X_SCOPES", "tweet.read tweet.write users.read offline.access") tiktokClientKey = os.Getenv("TIKTOK_CLIENT_KEY") tiktokClientSecret = os.Getenv("TIKTOK_CLIENT_SECRET") tiktokRedirectURI = env("TIKTOK_REDIRECT_URI", fmt.Sprintf("http://127.0.0.1:%s/auth/tiktok/callback", port)) tiktokScopes = env("TIKTOK_SCOPES", "video.publish") fbAppID = os.Getenv("FB_APP_ID") fbAppSecret = os.Getenv("FB_APP_SECRET") fbRedirectURI = env("FB_REDIRECT_URI", fmt.Sprintf("http://127.0.0.1:%s/auth/facebook/callback", port)) fbScopes = env("FB_SCOPES", "pages_show_list,pages_read_engagement,pages_manage_posts,instagram_basic,instagram_content_publish") // Restore sessions from previous runs (persists while container lives). if err := loadSessionsFromDisk(sessionStorePath); err != nil { log.Printf("warning: could not load session store: %v", err) } if err := loadUsersFromDisk(authUserStorePath); err != nil { log.Printf("warning: could not load user store: %v", err) } if err := loadUserSettingsFromDisk(userSettingsStorePath); err != nil { log.Printf("warning: could not load user settings store: %v", err) } // Startup probe: validate outbound HTTPS egress so OAuth token exchange doesn't fail silently. go func() { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, canvaAPIBase, nil) resp, err := httpClient.Do(req) if err != nil { log.Printf("warning: outbound HTTPS probe failed: %v (OAuth token exchange may fail). If running in containers, verify HTTPS egress; for podman, host networking or MTU/DNS tweaks may be required.", err) return } io.Copy(io.Discard, resp.Body) resp.Body.Close() }() mux := http.NewServeMux() mux.HandleFunc("/health", jsonOK) mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) // Minimal readiness: envs must be present if clientID == "" || clientSecret == "" || canvaScopesFull == "" { http.Error(w, "not ready: missing canva env", http.StatusServiceUnavailable) return } if dbEnabled && db != nil { if err := db.Ping(); err != nil { http.Error(w, "not ready: database unavailable", http.StatusServiceUnavailable) return } } jsonOK(w, r) }) mux.HandleFunc("/debug/request", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) dump, _ := httputil.DumpRequest(r, true) w.Header().Set("Content-Type", "text/plain") w.Write(dump) }) resolveCanva := func(r *http.Request, userID string) (string, string, string, string, string, string, string) { q := r.URL.Query() cid := q.Get("client_id") csec := q.Get("client_secret") ruri := q.Get("redirect_uri") scopesFull := normalizeScopes(q.Get("scopes")) scopesMinOverride := normalizeScopes(q.Get("scopes_min")) scopeDefaultOverride := strings.ToLower(strings.TrimSpace(q.Get("scope_default"))) apiVersion := strings.TrimSpace(q.Get("api_version")) // Merge user settings if provided if userID != "" { if raw, ok, _ := loadUserSettings(userID); ok && len(raw) > 0 { var payload map[string]any _ = json.Unmarshal(raw, &payload) if creds, ok := payload["credentials"].(map[string]any); ok { if c, ok := creds["canva"].(map[string]any); ok { if cid == "" { cid, _ = c["clientId"].(string) } if csec == "" { csec, _ = c["clientSecret"].(string) } if ruri == "" { ruri, _ = c["redirectUri"].(string) } if scopesFull == "" { scopesFull, _ = c["scopes"].(string) scopesFull = normalizeScopes(scopesFull) } if scopesMinOverride == "" { scopesMinOverride, _ = c["scopesMin"].(string) scopesMinOverride = normalizeScopes(scopesMinOverride) } if scopeDefaultOverride == "" { scopeDefaultOverride, _ = c["scopeDefault"].(string) scopeDefaultOverride = strings.ToLower(scopeDefaultOverride) } if apiVersion == "" { apiVersion, _ = c["apiVersion"].(string) } } } } } if cid == "" { cid = canvaClientID } if csec == "" { csec = canvaClientSecret } if ruri == "" { ruri = redirectURI } if scopesFull == "" { scopesFull = canvaScopesFull } if scopesMinOverride == "" { scopesMinOverride = canvaScopesMin } if scopeDefaultOverride == "" { scopeDefaultOverride = canvaScopeDefault } if apiVersion == "" { apiVersion = apiVersionHeader } return cid, csec, ruri, scopesFull, scopesMinOverride, scopeDefaultOverride, apiVersion } mux.HandleFunc("/auth/canva/start", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } w.Header().Set("Content-Type", "application/json") userID, _ := requireUserID(w, r) // Canva app may allow anonymous start if session auth permitted state := randomURLSafe(16) verifier := randomURLSafe(64) challenge := codeChallenge(verifier) preset := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("preset"))) cid, csec, ruri, scopesFull, scopesMinOverride, scopeDefaultOverride, apiVersion := resolveCanva(r, userID) selectedScopes := scopesFull if preset == "" { preset = scopeDefaultOverride } if preset == "minimal" || preset == "min" { if scopesMinOverride != "" { selectedScopes = scopesMinOverride } } stateStore.Store(state, stateEntry{ Verifier: verifier, CreatedAt: time.Now(), UserID: userID, ClientID: cid, ClientSecret: csec, RedirectURI: ruri, Scopes: selectedScopes, ScopesMin: scopesMinOverride, ScopeDefault: scopeDefaultOverride, APIVersion: apiVersion, }) params := url.Values{} params.Set("client_id", cid) params.Set("redirect_uri", ruri) params.Set("response_type", "code") params.Set("scope", selectedScopes) params.Set("state", state) params.Set("code_challenge", challenge) params.Set("code_challenge_method", "S256") authURL := fmt.Sprintf("%s?%s", canvaAuthURL, params.Encode()) writeJSON(w, http.StatusOK, map[string]string{"url": authURL, "scopes": selectedScopes}) }) mux.HandleFunc("/auth/canva/callback", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) w.Header().Set("Content-Type", "application/json") q := r.URL.Query() if errStr := q.Get("error"); errStr != "" { http.Redirect(w, r, successURL+"?error="+url.QueryEscape(errStr), http.StatusFound) return } code := q.Get("code") state := q.Get("state") raw, ok := stateStore.LoadAndDelete(state) if !ok { http.Error(w, "invalid or expired state", http.StatusBadRequest) return } entry := raw.(stateEntry) verifier := entry.Verifier userID := entry.UserID tokens, err := exchangeCode(entry.ClientID, entry.ClientSecret, entry.RedirectURI, code, verifier) if err != nil { log.Println("token exchange failed:", err) http.Error(w, "token exchange failed", http.StatusInternalServerError) return } profile, _ := fetchProfile(tokens.AccessToken) sessionID := randomURLSafe(24) persistSession(sessionID, sessionData{ UserID: userID, Tokens: tokens, Profile: profile, Created: time.Now(), CanvaClientID: entry.ClientID, CanvaClientSecret: entry.ClientSecret, CanvaRedirectURI: entry.RedirectURI, CanvaScopes: entry.Scopes, CanvaScopesMin: entry.ScopesMin, CanvaScopeDefault: entry.ScopeDefault, CanvaAPIVersion: entry.APIVersion, }) http.Redirect(w, r, successURL+"?session="+sessionID, http.StatusFound) }) mux.HandleFunc("/auth/session", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, sess) }) mux.HandleFunc("/auth/register", authRegisterHandler) mux.HandleFunc("/auth/login", authLoginHandler) mux.HandleFunc("/auth/logout", authLogoutHandler) mux.HandleFunc("/auth/me", authMeHandler) mux.HandleFunc("/user/settings", userSettingsHandler) // Social OAuth flows mux.HandleFunc("/auth/youtube/start", youtubeAuthStart) mux.HandleFunc("/auth/youtube/callback", youtubeAuthCallback) // Google OAuth console often whitelists /auth/google/callback; alias for convenience. mux.HandleFunc("/auth/google/callback", youtubeAuthCallback) mux.HandleFunc("/auth/youtube/disconnect", socialDisconnect("youtube")) mux.HandleFunc("/auth/tiktok/start", tiktokAuthStart) mux.HandleFunc("/auth/tiktok/callback", tiktokAuthCallback) mux.HandleFunc("/auth/tiktok/disconnect", socialDisconnect("tiktok")) mux.HandleFunc("/auth/x/start", xAuthStart) mux.HandleFunc("/auth/x/callback", xAuthCallback) mux.HandleFunc("/auth/x/disconnect", socialDisconnect("x")) mux.HandleFunc("/auth/facebook/start", facebookAuthStart) mux.HandleFunc("/auth/facebook/callback", facebookAuthCallback) mux.HandleFunc("/auth/facebook/disconnect", socialDisconnect("facebook")) mux.HandleFunc("/auth/facebook/pages", facebookPages) mux.HandleFunc("/auth/facebook/select-page", facebookSelectPage) // App configuration status for OAuth providers. mux.HandleFunc("/auth/config", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } build := func(missing []string, redirectURI, scopes string) map[string]any { return map[string]any{ "configured": len(missing) == 0, "missing": missing, "redirect_uri": redirectURI, "scopes": scopes, } } missingIf := func(list *[]string, cond bool, name string) { if cond { *list = append(*list, name) } } ytMissing := []string{} missingIf(&ytMissing, ytClientID == "", "YT_CLIENT_ID") missingIf(&ytMissing, ytClientSecret == "", "YT_CLIENT_SECRET") missingIf(&ytMissing, ytRedirectURI == "", "YT_REDIRECT_URI") missingIf(&ytMissing, ytScopes == "", "YT_SCOPES") xMissing := []string{} missingIf(&xMissing, xClientID == "", "X_CLIENT_ID") missingIf(&xMissing, xClientSecret == "", "X_CLIENT_SECRET") missingIf(&xMissing, xRedirectURI == "", "X_REDIRECT_URI") missingIf(&xMissing, xScopes == "", "X_SCOPES") tiktokMissing := []string{} missingIf(&tiktokMissing, tiktokClientKey == "", "TIKTOK_CLIENT_KEY") missingIf(&tiktokMissing, tiktokClientSecret == "", "TIKTOK_CLIENT_SECRET") missingIf(&tiktokMissing, tiktokRedirectURI == "", "TIKTOK_REDIRECT_URI") missingIf(&tiktokMissing, tiktokScopes == "", "TIKTOK_SCOPES") fbMissing := []string{} missingIf(&fbMissing, fbAppID == "", "FB_APP_ID") missingIf(&fbMissing, fbAppSecret == "", "FB_APP_SECRET") missingIf(&fbMissing, fbRedirectURI == "", "FB_REDIRECT_URI") missingIf(&fbMissing, fbScopes == "", "FB_SCOPES") canvaMissing := []string{} missingIf(&canvaMissing, clientID == "", "CANVA_CLIENT_ID") missingIf(&canvaMissing, clientSecret == "", "CANVA_CLIENT_SECRET") missingIf(&canvaMissing, redirectURI == "", "CANVA_REDIRECT_URI") missingIf(&canvaMissing, canvaScopesFull == "", "CANVA_SCOPES") if canvaScopeDefault == "minimal" && canvaScopesMin == "" { missingIf(&canvaMissing, true, "CANVA_SCOPES_MIN") } writeJSON(w, http.StatusOK, map[string]any{ "canva": map[string]any{ "configured": len(canvaMissing) == 0, "missing": canvaMissing, "redirect_uri": redirectURI, "default_preset": canvaScopeDefault, "api_version": apiVersionHeader, "scopes_full": canvaScopesFull, "scopes_min": canvaScopesMin, }, "youtube": build(ytMissing, ytRedirectURI, normalizeScopes(ytScopes)), "x": build(xMissing, xRedirectURI, normalizeScopes(xScopes)), "tiktok": build(tiktokMissing, tiktokRedirectURI, normalizeScopesCSV(tiktokScopes)), "facebook": build(fbMissing, fbRedirectURI, normalizeScopes(fbScopes)), }) }) mux.HandleFunc("/auth/instagram/disconnect", socialDisconnect("instagram")) mux.HandleFunc("/auth/canva/refresh", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } w.Header().Set("Content-Type", "application/json") var body struct { RefreshToken string `json:"refresh_token"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RefreshToken == "" { http.Error(w, "refresh_token required", http.StatusBadRequest) return } tokens, err := refreshToken(clientID, clientSecret, body.RefreshToken) if err != nil { http.Error(w, "refresh failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, tokens) }) // List or create designs (passes through to Canva REST) // Start a new design session: generate state token and return a Canva URL to open. mux.HandleFunc("/designs/start-session", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } target := canvaAPIBase + "/designs/start-session" forwardCanvaGeneric(w, r, sessionID, sess, target) }) designsHandler := func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } switch r.Method { case http.MethodGet: forwardCanva(w, r, sessionID, sess, canvaDesignsURL) case http.MethodPost: handleCreateDesign(w, r, sessionID, sess, canvaDesignsURL) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } mux.HandleFunc("/designs", withAuth(designsHandler)) // Get single design metadata designItemHandler := func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } id := strings.TrimPrefix(r.URL.Path, "/designs/") if id == "start-session" { http.Error(w, "invalid design id", http.StatusBadRequest) return } if id == "" { http.Error(w, "design id required", http.StatusBadRequest) return } // Support subresources: /designs/{id}/pages, /designs/{id}/export-formats parts := strings.Split(id, "/") if len(parts) > 1 { designID := url.PathEscape(parts[0]) sub := strings.Join(parts[1:], "/") switch sub { case "pages": forwardCanva(w, r, sessionID, sess, canvaDesignsURL+"/"+designID+"/pages") return case "export-formats": forwardCanva(w, r, sessionID, sess, canvaDesignsURL+"/"+designID+"/export-formats") return default: http.Error(w, "unsupported design subresource", http.StatusNotFound) return } } target := canvaDesignsURL + "/" + url.PathEscape(id) forwardCanva(w, r, sessionID, sess, target) } mux.HandleFunc("/designs/", withAuth(designItemHandler)) // Exports: create export job or fetch export job status exportsHandler := func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } switch r.Method { case http.MethodPost: body, _ := io.ReadAll(r.Body) forwardCanvaWithJSON(w, r, sessionID, sess, canvaExportsURL, body) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } mux.HandleFunc("/exports", withAuth(exportsHandler)) exportsItemHandler := func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } exportID := strings.TrimPrefix(r.URL.Path, "/exports/") if exportID == "" { http.Error(w, "export id required", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: target := canvaExportsURL + "/" + url.PathEscape(exportID) forwardCanva(w, r, sessionID, sess, target) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } mux.HandleFunc("/exports/", withAuth(exportsItemHandler)) // Imports (design imports via Canva jobs) importsHandler := canvaProxy("imports", http.MethodGet, http.MethodPost) mux.HandleFunc("/imports", withAuth(importsHandler)) mux.HandleFunc("/imports/", withAuth(canvaProxy("imports", http.MethodGet))) // Assets assetsHandler := canvaProxy("assets", http.MethodGet, http.MethodPost) mux.HandleFunc("/assets", withAuth(assetsHandler)) mux.HandleFunc("/assets/", withAuth(canvaProxy("assets", http.MethodGet, http.MethodDelete, http.MethodPatch, http.MethodPost))) // Asset upload jobs (binary upload and URL upload) mux.HandleFunc("/asset-uploads", withAuth(canvaProxy("asset-uploads", http.MethodPost))) mux.HandleFunc("/asset-uploads/", withAuth(canvaProxy("asset-uploads", http.MethodGet))) mux.HandleFunc("/url-asset-uploads", withAuth(canvaProxy("url-asset-uploads", http.MethodPost))) mux.HandleFunc("/url-asset-uploads/", withAuth(canvaProxy("url-asset-uploads", http.MethodGet))) // Folders and nested items foldersHandler := canvaProxy("folders", http.MethodGet, http.MethodPost) mux.HandleFunc("/folders", withAuth(foldersHandler)) mux.HandleFunc("/folders/", withAuth(canvaProxy("folders", http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete))) // URL imports (import design from remote file) mux.HandleFunc("/url-imports", withAuth(canvaProxy("url-imports", http.MethodGet, http.MethodPost))) mux.HandleFunc("/url-imports/", withAuth(canvaProxy("url-imports", http.MethodGet))) // Comments (preview; guard with ENABLE_CANVA_COMMENTS=true) if strings.EqualFold(os.Getenv("ENABLE_CANVA_COMMENTS"), "true") { commentsHandler := canvaProxy("comments", http.MethodGet, http.MethodPost, http.MethodPatch) mux.HandleFunc("/comments", withAuth(commentsHandler)) mux.HandleFunc("/comments/", withAuth(commentsHandler)) } // Brand templates (read-only) mux.HandleFunc("/brand-templates", withAuth(canvaProxy("brand-templates", http.MethodGet))) mux.HandleFunc("/brand-templates/", withAuth(canvaProxy("brand-templates", http.MethodGet))) // Resizes + autofills (design transformations) mux.HandleFunc("/resizes", withAuth(canvaProxy("resizes", http.MethodPost, http.MethodGet))) mux.HandleFunc("/resizes/", withAuth(canvaProxy("resizes", http.MethodGet))) mux.HandleFunc("/autofills", withAuth(canvaProxy("autofills", http.MethodPost, http.MethodGet))) mux.HandleFunc("/autofills/", withAuth(canvaProxy("autofills", http.MethodGet))) // Users (me, teams) mux.HandleFunc("/users", withAuth(canvaProxy("users", http.MethodGet))) mux.HandleFunc("/users/", withAuth(canvaProxy("users", http.MethodGet))) // Social publishing (YouTube, TikTok, Facebook, Instagram, X) mux.HandleFunc("/publish", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { URL string `json:"url"` Text string `json:"text"` PlatformTexts map[string]string `json:"platform_texts,omitempty"` Platforms []string `json:"platforms"` Mime string `json:"mime,omitempty"` Title string `json:"title,omitempty"` Visibility string `json:"visibility,omitempty"` // public|unlisted|private TikTokPrivacy string `json:"tiktok_privacy,omitempty"` TikTok struct { Privacy string `json:"privacy,omitempty"` AllowComments bool `json:"allowComments,omitempty"` AllowDuet bool `json:"allowDuet,omitempty"` AllowStitch bool `json:"allowStitch,omitempty"` } `json:"tiktok,omitempty"` FacebookMode string `json:"facebook_mode,omitempty"` InstagramMode string `json:"instagram_mode,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { http.Error(w, "invalid body", http.StatusBadRequest) return } if len(body.Platforms) == 0 { body.Platforms = []string{"youtube", "x"} } textFor := func(platform string) string { if body.PlatformTexts != nil { if t := strings.TrimSpace(body.PlatformTexts[strings.ToLower(platform)]); t != "" { return t } } return body.Text } tikTokPrivacy := body.TikTok.Privacy if tikTokPrivacy == "" { tikTokPrivacy = body.TikTokPrivacy } allowComments := body.TikTok.AllowComments allowDuet := body.TikTok.AllowDuet allowStitch := body.TikTok.AllowStitch if body.TikTok.Privacy == "" && !allowComments && !allowDuet && !allowStitch { allowComments = true } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } social := sess.Social results := map[string]any{} for _, p := range body.Platforms { switch strings.ToLower(p) { case "youtube": ytTok := social.YouTube if ytTok.RefreshToken == "" { ytTok.RefreshToken = ytRefreshToken } res, err := uploadYouTube(body.URL, textFor("youtube"), body.Title, body.Visibility, body.Mime, ytTok, social.YTConfig) if err != nil { results["youtube"] = map[string]any{"ok": false, "error": err.Error()} } else { results["youtube"] = map[string]any{ "ok": true, "id": res, "url": "https://youtu.be/" + res, "studio_url": "https://studio.youtube.com/video/" + res, } } case "tiktok": tikTokToken := social.TikTok openID := social.TikTokOpenID if sessionID != "" { if refreshed, newOpenID, err := ensureTikTokToken(sessionID, tikTokToken, social.TikTokConfig, openID); err == nil { tikTokToken = refreshed if newOpenID != "" { openID = newOpenID } } } if tikTokToken.AccessToken == "" { tikTokToken.AccessToken = tiktokAccessToken } if openID == "" { openID = tiktokOpenID } res, err := postToTikTok( body.URL, textFor("tiktok"), tikTokPrivacy, body.Mime, tikTokToken.AccessToken, openID, allowComments, allowDuet, allowStitch, ) if err != nil { results["tiktok"] = map[string]any{"ok": false, "error": err.Error()} } else { results["tiktok"] = map[string]any{"ok": true, "id": res} } case "facebook": pageID := social.FBPageID pageToken := social.FBPageToken if pageID == "" { pageID = fbPageID } if pageToken == "" { pageToken = fbAccessToken } res, err := postToFacebook(body.URL, textFor("facebook"), body.Mime, pageID, pageToken, body.FacebookMode) if err != nil { results["facebook"] = map[string]any{"ok": false, "error": err.Error()} } else { results["facebook"] = map[string]any{"ok": true, "id": res} } case "instagram": igID := social.IGUserID igToken := social.Instagram.AccessToken if igID == "" { igID = igUserID } if igToken == "" { igToken = igAccessToken } res, err := postToInstagram(body.URL, textFor("instagram"), body.Mime, igID, igToken, body.InstagramMode) if err != nil { results["instagram"] = map[string]any{"ok": false, "error": err.Error()} } else { results["instagram"] = map[string]any{ "ok": true, "id": res.MediaID, "container_id": res.ContainerID, } } case "x", "twitter": xTok := social.X if sessionID != "" { if refreshed, err := ensureXToken(sessionID, xTok, social.XConfig); err == nil { xTok = refreshed } } if xTok.AccessToken == "" { xTok.AccessToken = xUserToken } err := postToX(body.URL, textFor("x"), xTok.AccessToken) if err != nil { results["x"] = map[string]any{"ok": false, "error": err.Error()} } else { results["x"] = map[string]any{"ok": true} } default: results[p] = map[string]any{"ok": false, "error": "unsupported platform"} } } writeJSON(w, http.StatusOK, results) }) // Publish status polling (TikTok + Instagram) mux.HandleFunc("/publish/status", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Platform string `json:"platform"` ID string `json:"id"` ContainerID string `json:"container_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Platform == "" { http.Error(w, "invalid body", http.StatusBadRequest) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } social := sess.Social switch strings.ToLower(body.Platform) { case "tiktok": tok := social.TikTok if sessionID != "" { if refreshed, _, err := ensureTikTokToken(sessionID, tok, social.TikTokConfig, social.TikTokOpenID); err == nil { tok = refreshed } } if tok.AccessToken == "" { tok.AccessToken = tiktokAccessToken } if tok.AccessToken == "" || body.ID == "" { http.Error(w, "tiktok credentials or id missing", http.StatusBadRequest) return } status, raw, err := fetchTikTokPublishStatus(tok.AccessToken, body.ID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]any{ "platform": "tiktok", "status": status, "done": isTerminalStatus(status), "details": raw, }) case "instagram": containerID := body.ContainerID if containerID == "" { containerID = body.ID } if containerID == "" { http.Error(w, "container_id required", http.StatusBadRequest) return } igTok := social.Instagram.AccessToken if igTok == "" { igTok = igAccessToken } if igTok == "" { http.Error(w, "instagram credentials missing", http.StatusBadRequest) return } status, raw, err := fetchInstagramContainerStatus(containerID, igTok) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]any{ "platform": "instagram", "status": status, "done": isTerminalStatus(status), "details": raw, }) default: http.Error(w, "unsupported platform", http.StatusBadRequest) } }) // Publish config: expose which platforms are configured (no secret values). mux.HandleFunc("/publish/config", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } social := sess.Social cfg := map[string]bool{ "youtube": (social.YouTube.RefreshToken != "") || (social.YouTube.AccessToken != "" && !tokenExpiring(social.YouTube)) || (ytClientID != "" && ytClientSecret != "" && ytRefreshToken != ""), "tiktok": ((social.TikTok.AccessToken != "" || social.TikTok.RefreshToken != "") && social.TikTokOpenID != "") || (tiktokAccessToken != "" && tiktokOpenID != ""), "facebook": (social.FBPageID != "" && social.FBPageToken != "") || (fbPageID != "" && fbAccessToken != ""), "instagram": (social.IGUserID != "" && social.Instagram.AccessToken != "") || (igUserID != "" && igAccessToken != ""), "x": (social.X.AccessToken != "" || social.X.RefreshToken != "") || (xUserToken != ""), } writeJSON(w, http.StatusOK, cfg) }) // Media inspector for export URLs. mux.HandleFunc("/publish/inspect", func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { http.Error(w, "invalid body", http.StatusBadRequest) return } fallback := guessMimeType(body.URL, "") ct, size, err := fetchMediaInfo(body.URL, fallback) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]any{ "url": body.URL, "content_type": ct, "content_length": size, "is_video": isVideoMedia(body.URL, ct), "is_image": isImageMedia(body.URL, ct), }) }) srv := &http.Server{ Addr: ":" + port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } log.Printf("Canva Go OAuth API running on http://127.0.0.1:%s\n", port) log.Printf("Redirect URI: %s\n", redirectURI) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } } func env(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func envOrFail(key string) string { v := os.Getenv(key) if v == "" { log.Fatalf("missing required env %s", key) } return v } func envInt(key string, def int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } parsed, err := strconv.Atoi(v) if err != nil { return def } return parsed } func jsonOK(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func randomURLSafe(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { log.Fatal(err) } return base64.RawURLEncoding.EncodeToString(b) } func codeChallenge(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } func exchangeCode(clientID, clientSecret, redirectURI, code, verifier string) (tokenSet, error) { data := url.Values{} data.Set("grant_type", "authorization_code") data.Set("code", code) data.Set("client_id", clientID) data.Set("client_secret", clientSecret) data.Set("redirect_uri", redirectURI) data.Set("code_verifier", verifier) var resp *http.Response var err error for attempt := 0; attempt < 4; attempt++ { req, _ := http.NewRequest(http.MethodPost, canvaTokenURL, strings.NewReader(data.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err = tokenHTTPClient.Do(req) if err != nil { if isRetryableNetErr(err) && attempt < 3 { sleepBackoff(attempt) continue } return tokenSet{}, err } break } if resp == nil { return tokenSet{}, fmt.Errorf("token exchange failed: no response") } defer resp.Body.Close() var tok tokenSet if resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return tok, fmt.Errorf("token exchange status %d: %s", resp.StatusCode, string(body)) } if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { return tok, err } return tok, nil } func refreshToken(clientID, clientSecret, refresh string) (tokenSet, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refresh) data.Set("client_id", clientID) data.Set("client_secret", clientSecret) req, _ := http.NewRequest(http.MethodPost, canvaTokenURL, strings.NewReader(data.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := tokenHTTPClient.Do(req) if err != nil { return tokenSet{}, err } defer resp.Body.Close() var tok tokenSet if resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return tok, fmt.Errorf("refresh status %d: %s", resp.StatusCode, string(body)) } if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { return tok, err } return tok, nil } func fetchProfile(accessToken string) (json.RawMessage, error) { req, _ := http.NewRequest(http.MethodGet, canvaProfileURL, nil) req.Header.Set("Authorization", "Bearer "+accessToken) applyAPIVersionHeader(req) resp, err := tokenHTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("profile status %d: %s", resp.StatusCode, string(body)) } b, err := io.ReadAll(resp.Body) return json.RawMessage(b), err } // applyAPIVersionHeader optionally sets the Canva version header when configured. func applyAPIVersionHeader(req *http.Request) { if apiVersionHeader != "" { req.Header.Set("X-Canva-API-Version", apiVersionHeader) } } func forwardCanva(w http.ResponseWriter, r *http.Request, sessionID string, sess sessionData, target string) { reqURL := target if q := sanitizedQuery(r); q != "" { reqURL = target + "?" + q } doCanvaRequestWithRetry(w, r, sessionID, sess, tokenHTTPClient, func(accessToken string) (*http.Request, error) { req, _ := http.NewRequest(r.Method, reqURL, nil) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") applyAPIVersionHeader(req) return req, nil }) } func forwardCanvaWithBody(w http.ResponseWriter, r *http.Request, sessionID string, sess sessionData, target string) { body, _ := io.ReadAll(r.Body) reqURL := target if q := sanitizedQuery(r); q != "" { reqURL = target + "?" + q } doCanvaRequestWithRetry(w, r, sessionID, sess, tokenHTTPClient, func(accessToken string) (*http.Request, error) { req, _ := http.NewRequest(r.Method, reqURL, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") applyAPIVersionHeader(req) return req, nil }) } // forwardCanvaWithJSON allows us to modify the JSON body before forwarding. func forwardCanvaWithJSON(w http.ResponseWriter, r *http.Request, sessionID string, sess sessionData, target string, body []byte) { reqURL := target if q := sanitizedQuery(r); q != "" { reqURL = target + "?" + q } doCanvaRequestWithRetry(w, r, sessionID, sess, tokenHTTPClient, func(accessToken string) (*http.Request, error) { req, _ := http.NewRequest(r.Method, reqURL, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") applyAPIVersionHeader(req) return req, nil }) } // forwardCanvaGeneric forwards any method, mirroring headers/body and adding auth/version headers. func forwardCanvaGeneric(w http.ResponseWriter, r *http.Request, sessionID string, sess sessionData, target string) { reqURL := target if q := sanitizedQuery(r); q != "" { reqURL = target + "?" + q } var body []byte if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch { body, _ = io.ReadAll(r.Body) } doCanvaRequestWithRetry(w, r, sessionID, sess, httpClient, func(accessToken string) (*http.Request, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, _ := http.NewRequest(r.Method, reqURL, reader) for name, vals := range r.Header { if strings.EqualFold(name, "Authorization") || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Content-Length") || strings.EqualFold(name, "X-Session-ID") { continue } for _, v := range vals { req.Header.Add(name, v) } } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") if body != nil { ct := r.Header.Get("Content-Type") if ct == "" { ct = "application/json" } req.Header.Set("Content-Type", ct) } applyAPIVersionHeader(req) return req, nil }) } func doCanvaRequestWithRetry( w http.ResponseWriter, r *http.Request, sessionID string, sess sessionData, client *http.Client, build func(accessToken string) (*http.Request, error), ) { accessToken := sess.Tokens.AccessToken req, _ := build(accessToken) resp, err := client.Do(req) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } if resp.StatusCode != http.StatusUnauthorized { defer resp.Body.Close() copyStatusAndBody(w, resp) return } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if !shouldRefreshCanvaToken(resp.StatusCode, body) { w.WriteHeader(resp.StatusCode) w.Write(body) return } if sess.Tokens.RefreshToken == "" { w.WriteHeader(resp.StatusCode) w.Write(body) return } cid := sess.CanvaClientID if cid == "" { cid = canvaClientID } csec := sess.CanvaClientSecret if csec == "" { csec = canvaClientSecret } refreshed, err := refreshToken(cid, csec, sess.Tokens.RefreshToken) if err != nil { w.WriteHeader(resp.StatusCode) w.Write(body) return } if refreshed.RefreshToken == "" { refreshed.RefreshToken = sess.Tokens.RefreshToken } _ = updateSession(sessionID, func(s *sessionData) { s.Tokens = refreshed }) req2, _ := build(refreshed.AccessToken) resp2, err := client.Do(req2) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } defer resp2.Body.Close() copyStatusAndBody(w, resp2) } func shouldRefreshCanvaToken(status int, body []byte) bool { if status != http.StatusUnauthorized { return false } if len(body) == 0 { return true } lower := strings.ToLower(string(body)) return strings.Contains(lower, "invalid_access_token") || strings.Contains(lower, "invalid access token") || strings.Contains(lower, "expired") } func copyStatusAndBody(w http.ResponseWriter, resp *http.Response) { for k, vals := range resp.Header { for _, v := range vals { w.Header().Add(k, v) } } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } func sanitizedQuery(r *http.Request) string { q := r.URL.Query() q.Del("session") return q.Encode() } func getSessionID(r *http.Request) string { if v := r.Header.Get("X-Session-ID"); v != "" { return v } return r.URL.Query().Get("session") } func requireSessionID(w http.ResponseWriter, r *http.Request) (string, bool) { sessionID := getSessionID(r) if sessionID == "" { http.Error(w, "session required", http.StatusBadRequest) return "", false } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return "", false } if authJWTSecret != "" { authz := r.Header.Get("Authorization") if authz != "" || !allowSessionAuth { userID, ok := requireUserID(w, r) if !ok { return "", false } if sess.UserID != "" && userID != "" && sess.UserID != userID { http.Error(w, "forbidden", http.StatusForbidden) return "", false } } } return sessionID, true } func storeOAuthState(provider, sessionID, verifier, clientId, clientSecret, redirectUri, scopes string) string { state := randomURLSafe(16) oauthStateStore.Store(state, oauthState{ Provider: provider, SessionID: sessionID, Verifier: verifier, ClientID: clientId, ClientSecret: clientSecret, RedirectURI: redirectUri, Scopes: scopes, Created: time.Now(), }) return state } func consumeOAuthState(state, provider string) (oauthState, bool) { raw, ok := oauthStateStore.LoadAndDelete(state) if !ok { return oauthState{}, false } entry := raw.(oauthState) if entry.Provider != provider { return oauthState{}, false } return entry, true } func redirectToSettings(w http.ResponseWriter, r *http.Request, provider string, connected bool, errMsg string) { q := url.Values{} q.Set("provider", provider) if connected { q.Set("connected", "1") } if errMsg != "" { q.Set("error", errMsg) } target := frontendSettingsURL if strings.Contains(target, "?") { target = target + "&" + q.Encode() } else { target = target + "?" + q.Encode() } http.Redirect(w, r, target, http.StatusFound) } func normalizeScopes(scopes string) string { if scopes == "" { return "" } scopes = strings.ReplaceAll(scopes, ",", " ") parts := strings.Fields(scopes) return strings.Join(parts, " ") } func normalizeScopesCSV(scopes string) string { if scopes == "" { return "" } scopes = strings.ReplaceAll(scopes, ",", " ") parts := strings.Fields(scopes) return strings.Join(parts, ",") } func oauthCommonStart(w http.ResponseWriter, r *http.Request, provider string, authURL string, params url.Values, usePKCE bool) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sessionID, ok := requireSessionID(w, r) if !ok { return } q := r.URL.Query() cid := q.Get("client_id") csec := q.Get("client_secret") ruri := q.Get("redirect_uri") scps := q.Get("scopes") verifier := "" if usePKCE { verifier = randomURLSafe(64) challenge := codeChallenge(verifier) params.Set("code_challenge", challenge) params.Set("code_challenge_method", "S256") } state := storeOAuthState(provider, sessionID, verifier, cid, csec, ruri, scps) params.Set("state", state) redirectURL := authURL + "?" + params.Encode() if q.Get("redirect") == "1" { http.Redirect(w, r, redirectURL, http.StatusFound) return } writeJSON(w, http.StatusOK, map[string]string{"url": redirectURL}) } func youtubeAuthStart(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } q := r.URL.Query() cid := q.Get("client_id") if cid == "" { cid = ytClientID } csec := q.Get("client_secret") if csec == "" { csec = ytClientSecret } ruri := q.Get("redirect_uri") if ruri == "" { ruri = ytRedirectURI } scps := q.Get("scopes") if scps == "" { scps = ytScopes } // Sanity check: if scopes look like X/Twitter scopes, force reset to default. // This handles cases where environment variables might be cross-wired or frontend sends wrong scopes. if strings.Contains(scps, "tweet.read") { log.Println("warning: youtube scopes contain x/twitter scopes (tweet.read), resetting to default") scps = "https://www.googleapis.com/auth/youtube.upload" } if cid == "" || csec == "" { http.Error(w, "youtube client not configured", http.StatusInternalServerError) return } params := url.Values{} params.Set("client_id", cid) params.Set("redirect_uri", ruri) params.Set("response_type", "code") params.Set("scope", normalizeScopes(scps)) params.Set("access_type", "offline") params.Set("prompt", "consent") oauthCommonStart(w, r, "youtube", "https://accounts.google.com/o/oauth2/v2/auth", params, true) } func youtubeAuthCallback(w http.ResponseWriter, r *http.Request) { if errStr := r.URL.Query().Get("error"); errStr != "" { redirectToSettings(w, r, "youtube", false, errStr) return } code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") if code == "" || state == "" { http.Error(w, "code/state required", http.StatusBadRequest) return } entry, ok := consumeOAuthState(state, "youtube") if !ok { http.Error(w, "invalid state", http.StatusBadRequest) return } tok, err := exchangeGoogleCode(code, entry.Verifier, entry.ClientID, entry.ClientSecret, entry.RedirectURI) if err != nil { redirectToSettings(w, r, "youtube", false, err.Error()) return } _ = updateSession(entry.SessionID, func(sess *sessionData) { if tok.RefreshToken == "" { tok.RefreshToken = sess.Social.YouTube.RefreshToken } sess.Social.YouTube = tok sess.Social.YTConfig = platformCreds{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, RedirectURI: entry.RedirectURI, } }) redirectToSettings(w, r, "youtube", true, "") } func xAuthStart(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } q := r.URL.Query() cid := q.Get("client_id") if cid == "" { cid = xClientID } ruri := q.Get("redirect_uri") if ruri == "" { ruri = xRedirectURI } scps := q.Get("scopes") if scps == "" { scps = xScopes } if cid == "" { http.Error(w, "x client not configured", http.StatusInternalServerError) return } params := url.Values{} params.Set("client_id", cid) params.Set("redirect_uri", ruri) params.Set("response_type", "code") params.Set("scope", normalizeScopes(scps)) oauthCommonStart(w, r, "x", "https://x.com/i/oauth2/authorize", params, true) } func xAuthCallback(w http.ResponseWriter, r *http.Request) { if errStr := r.URL.Query().Get("error"); errStr != "" { redirectToSettings(w, r, "x", false, errStr) return } code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") if code == "" || state == "" { http.Error(w, "code/state required", http.StatusBadRequest) return } entry, ok := consumeOAuthState(state, "x") if !ok { http.Error(w, "invalid state", http.StatusBadRequest) return } tok, err := exchangeXCode(code, entry.Verifier, entry.ClientID, entry.ClientSecret, entry.RedirectURI) if err != nil { redirectToSettings(w, r, "x", false, err.Error()) return } _ = updateSession(entry.SessionID, func(sess *sessionData) { sess.Social.X = tok sess.Social.XConfig = platformCreds{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, RedirectURI: entry.RedirectURI, } }) redirectToSettings(w, r, "x", true, "") } func tiktokAuthStart(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } q := r.URL.Query() ckey := q.Get("client_id") // standard param name from frontend if ckey == "" { ckey = tiktokClientKey } ruri := q.Get("redirect_uri") if ruri == "" { ruri = tiktokRedirectURI } scps := q.Get("scopes") if scps == "" { scps = tiktokScopes } if ckey == "" || (tiktokClientSecret == "" && q.Get("client_secret") == "") { http.Error(w, "tiktok client not configured", http.StatusInternalServerError) return } params := url.Values{} params.Set("client_key", ckey) params.Set("redirect_uri", ruri) params.Set("response_type", "code") params.Set("scope", normalizeScopesCSV(scps)) oauthCommonStart(w, r, "tiktok", "https://www.tiktok.com/v2/auth/authorize/", params, true) } func tiktokAuthCallback(w http.ResponseWriter, r *http.Request) { if errStr := r.URL.Query().Get("error"); errStr != "" { redirectToSettings(w, r, "tiktok", false, errStr) return } code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") if code == "" || state == "" { http.Error(w, "code/state required", http.StatusBadRequest) return } entry, ok := consumeOAuthState(state, "tiktok") if !ok { http.Error(w, "invalid state", http.StatusBadRequest) return } tok, openID, err := exchangeTikTokCode(code, entry.Verifier, entry.ClientID, entry.ClientSecret, entry.RedirectURI) if err != nil { redirectToSettings(w, r, "tiktok", false, err.Error()) return } _ = updateSession(entry.SessionID, func(sess *sessionData) { sess.Social.TikTok = tok if openID != "" { sess.Social.TikTokOpenID = openID } sess.Social.TikTokConfig = platformCreds{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, RedirectURI: entry.RedirectURI, } }) redirectToSettings(w, r, "tiktok", true, "") } func facebookAuthStart(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } q := r.URL.Query() cid := q.Get("client_id") if cid == "" { cid = fbAppID } ruri := q.Get("redirect_uri") if ruri == "" { ruri = fbRedirectURI } scps := q.Get("scopes") if scps == "" { scps = fbScopes } if cid == "" { http.Error(w, "facebook app not configured", http.StatusInternalServerError) return } params := url.Values{} params.Set("client_id", cid) params.Set("redirect_uri", ruri) params.Set("response_type", "code") params.Set("scope", normalizeScopes(scps)) oauthCommonStart(w, r, "facebook", "https://www.facebook.com/"+fbGraphVersion+"/dialog/oauth", params, false) } func facebookAuthCallback(w http.ResponseWriter, r *http.Request) { if errStr := r.URL.Query().Get("error"); errStr != "" { redirectToSettings(w, r, "facebook", false, errStr) return } code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") if code == "" || state == "" { http.Error(w, "code/state required", http.StatusBadRequest) return } entry, ok := consumeOAuthState(state, "facebook") if !ok { http.Error(w, "invalid state", http.StatusBadRequest) return } tok, err := exchangeFacebookCode(code, entry.ClientID, entry.ClientSecret, entry.RedirectURI) if err != nil { redirectToSettings(w, r, "facebook", false, err.Error()) return } if longTok, err := exchangeFacebookLongLivedToken(tok.AccessToken, entry.ClientID, entry.ClientSecret); err == nil { tok = longTok } _ = updateSession(entry.SessionID, func(sess *sessionData) { sess.Social.Facebook = tok sess.Social.FBConfig = platformCreds{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, RedirectURI: entry.RedirectURI, } }) redirectToSettings(w, r, "facebook", true, "") } func socialDisconnect(provider string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost && r.Method != http.MethodDelete { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sessionID, ok := requireSessionID(w, r) if !ok { return } _ = updateSession(sessionID, func(sess *sessionData) { switch provider { case "youtube": sess.Social.YouTube = oauthToken{} case "tiktok": sess.Social.TikTok = oauthToken{} sess.Social.TikTokOpenID = "" case "x": sess.Social.X = oauthToken{} case "facebook": sess.Social.Facebook = oauthToken{} sess.Social.FBPageID = "" sess.Social.FBPageToken = "" sess.Social.Instagram = oauthToken{} sess.Social.IGUserID = "" case "instagram": sess.Social.Instagram = oauthToken{} sess.Social.IGUserID = "" } }) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } } func exchangeGoogleCode(code, verifier, cid, csec, ruri string) (oauthToken, error) { if cid == "" { cid = ytClientID } if csec == "" { csec = ytClientSecret } if ruri == "" { ruri = ytRedirectURI } values := url.Values{} values.Set("client_id", cid) values.Set("client_secret", csec) values.Set("code", code) values.Set("grant_type", "authorization_code") values.Set("redirect_uri", ruri) values.Set("code_verifier", verifier) req, _ := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := tokenHTTPClient.Do(req) if err != nil { return oauthToken{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, fmt.Errorf("google token status %d: %s", resp.StatusCode, string(body)) } var data struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` TokenType string `json:"token_type"` Scope string `json:"scope"` } if err := json.Unmarshal(body, &data); err != nil { return oauthToken{}, err } tok := oauthToken{ AccessToken: data.AccessToken, RefreshToken: data.RefreshToken, TokenType: data.TokenType, Scope: data.Scope, } if data.ExpiresIn > 0 { tok.ExpiresAt = time.Now().Add(time.Duration(data.ExpiresIn) * time.Second) } return tok, nil } func exchangeXCode(code, verifier, cid, csec, ruri string) (oauthToken, error) { if cid == "" { cid = xClientID } if csec == "" { csec = xClientSecret } if ruri == "" { ruri = xRedirectURI } values := url.Values{} values.Set("client_id", cid) values.Set("code", code) values.Set("grant_type", "authorization_code") values.Set("redirect_uri", ruri) values.Set("code_verifier", verifier) req, _ := http.NewRequest(http.MethodPost, "https://api.x.com/2/oauth2/token", strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if csec != "" { req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(cid+":"+csec))) } resp, err := tokenHTTPClient.Do(req) if err != nil { return oauthToken{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, fmt.Errorf("x token status %d: %s", resp.StatusCode, string(body)) } return parseOAuthToken(body) } func refreshXToken(refreshToken, cid, csec string) (oauthToken, error) { if cid == "" { cid = xClientID } if csec == "" { csec = xClientSecret } values := url.Values{} values.Set("client_id", cid) values.Set("refresh_token", refreshToken) values.Set("grant_type", "refresh_token") req, _ := http.NewRequest(http.MethodPost, "https://api.x.com/2/oauth2/token", strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if csec != "" { req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(cid+":"+csec))) } resp, err := tokenHTTPClient.Do(req) if err != nil { return oauthToken{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, fmt.Errorf("x refresh status %d: %s", resp.StatusCode, string(body)) } return parseOAuthToken(body) } func exchangeTikTokCode(code, verifier, cid, csec, ruri string) (oauthToken, string, error) { if cid == "" { cid = tiktokClientKey } if csec == "" { csec = tiktokClientSecret } if ruri == "" { ruri = tiktokRedirectURI } values := url.Values{} values.Set("client_key", cid) values.Set("client_secret", csec) values.Set("code", code) values.Set("grant_type", "authorization_code") values.Set("redirect_uri", ruri) values.Set("code_verifier", verifier) req, _ := http.NewRequest(http.MethodPost, "https://open.tiktokapis.com/v2/oauth/token/", strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := tokenHTTPClient.Do(req) if err != nil { return oauthToken{}, "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, "", fmt.Errorf("tiktok token status %d: %s", resp.StatusCode, string(body)) } tok, openID, err := parseTikTokToken(body) if err != nil { return oauthToken{}, "", err } return tok, openID, nil } func refreshTikTokToken(refreshToken, cid, csec string) (oauthToken, string, error) { if cid == "" { cid = tiktokClientKey } if csec == "" { csec = tiktokClientSecret } values := url.Values{} values.Set("client_key", cid) values.Set("client_secret", csec) values.Set("refresh_token", refreshToken) values.Set("grant_type", "refresh_token") req, _ := http.NewRequest(http.MethodPost, "https://open.tiktokapis.com/v2/oauth/token/", strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := tokenHTTPClient.Do(req) if err != nil { return oauthToken{}, "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, "", fmt.Errorf("tiktok refresh status %d: %s", resp.StatusCode, string(body)) } return parseTikTokToken(body) } func exchangeFacebookCode(code, cid, csec, ruri string) (oauthToken, error) { if cid == "" { cid = fbAppID } if csec == "" { csec = fbAppSecret } if ruri == "" { ruri = fbRedirectURI } values := url.Values{} values.Set("client_id", cid) values.Set("client_secret", csec) values.Set("redirect_uri", ruri) values.Set("code", code) endpoint := fmt.Sprintf("https://graph.facebook.com/%s/oauth/access_token?%s", fbGraphVersion, values.Encode()) resp, err := tokenHTTPClient.Get(endpoint) if err != nil { return oauthToken{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, fmt.Errorf("facebook token status %d: %s", resp.StatusCode, string(body)) } return parseOAuthToken(body) } func exchangeFacebookLongLivedToken(shortToken, cid, csec string) (oauthToken, error) { if cid == "" { cid = fbAppID } if csec == "" { csec = fbAppSecret } if shortToken == "" || cid == "" || csec == "" { return oauthToken{}, fmt.Errorf("facebook app not configured") } values := url.Values{} values.Set("grant_type", "fb_exchange_token") values.Set("client_id", cid) values.Set("client_secret", csec) values.Set("fb_exchange_token", shortToken) endpoint := fmt.Sprintf("https://graph.facebook.com/%s/oauth/access_token?%s", fbGraphVersion, values.Encode()) resp, err := tokenHTTPClient.Get(endpoint) if err != nil { return oauthToken{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oauthToken{}, fmt.Errorf("facebook long-lived token status %d: %s", resp.StatusCode, string(body)) } return parseOAuthToken(body) } func parseOAuthToken(body []byte) (oauthToken, error) { var data struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` TokenType string `json:"token_type"` Scope string `json:"scope"` } if err := json.Unmarshal(body, &data); err != nil { return oauthToken{}, err } tok := oauthToken{ AccessToken: data.AccessToken, RefreshToken: data.RefreshToken, TokenType: data.TokenType, Scope: data.Scope, } if data.ExpiresIn > 0 { tok.ExpiresAt = time.Now().Add(time.Duration(data.ExpiresIn) * time.Second) } return tok, nil } func parseTikTokToken(body []byte) (oauthToken, string, error) { var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { return oauthToken{}, "", err } payload := raw if d, ok := raw["data"].(map[string]any); ok { payload = d } getString := func(key string) string { if v, ok := payload[key]; ok { if s, ok := v.(string); ok { return s } } return "" } getInt := func(key string) int64 { if v, ok := payload[key]; ok { switch t := v.(type) { case float64: return int64(t) case int64: return t } } return 0 } tok := oauthToken{ AccessToken: getString("access_token"), RefreshToken: getString("refresh_token"), TokenType: getString("token_type"), Scope: getString("scope"), } if exp := getInt("expires_in"); exp > 0 { tok.ExpiresAt = time.Now().Add(time.Duration(exp) * time.Second) } openID := getString("open_id") return tok, openID, nil } func tokenExpiring(tok oauthToken) bool { if tok.ExpiresAt.IsZero() { return false } return time.Now().After(tok.ExpiresAt.Add(-30 * time.Second)) } func ensureXToken(sessionID string, tok oauthToken, config platformCreds) (oauthToken, error) { if tok.AccessToken != "" && !tokenExpiring(tok) { return tok, nil } if tok.RefreshToken == "" { return tok, nil } refreshed, err := refreshXToken(tok.RefreshToken, config.ClientID, config.ClientSecret) if err != nil { return tok, err } if sessionID != "" { _ = updateSession(sessionID, func(sess *sessionData) { sess.Social.X = refreshed }) } return refreshed, nil } func ensureTikTokToken(sessionID string, tok oauthToken, config platformCreds, openID string) (oauthToken, string, error) { if tok.AccessToken != "" && !tokenExpiring(tok) { return tok, openID, nil } if tok.RefreshToken == "" { return tok, openID, nil } refreshed, newOpenID, err := refreshTikTokToken(tok.RefreshToken, config.ClientID, config.ClientSecret) if err != nil { return tok, openID, err } if newOpenID == "" { newOpenID = openID } if sessionID != "" { _ = updateSession(sessionID, func(sess *sessionData) { sess.Social.TikTok = refreshed sess.Social.TikTokOpenID = newOpenID }) } return refreshed, newOpenID, nil } func ensureFacebookToken(sessionID string, tok oauthToken, config platformCreds) (oauthToken, error) { if tok.AccessToken == "" || !tokenExpiring(tok) { return tok, nil } refreshed, err := exchangeFacebookLongLivedToken(tok.AccessToken, config.ClientID, config.ClientSecret) if err != nil { return tok, err } if sessionID != "" { _ = updateSession(sessionID, func(sess *sessionData) { sess.Social.Facebook = refreshed }) } return refreshed, nil } type fbPage struct { ID string `json:"id"` Name string `json:"name"` AccessToken string `json:"access_token"` InstagramBusinessAccount struct { ID string `json:"id"` } `json:"instagram_business_account"` ConnectedInstagramAccount struct { ID string `json:"id"` } `json:"connected_instagram_account"` } func fetchFacebookPagesWithToken(token string) ([]fbPage, error) { endpoint := fmt.Sprintf("https://graph.facebook.com/%s/me/accounts", fbGraphVersion) values := url.Values{} values.Set("fields", "id,name,access_token,instagram_business_account,connected_instagram_account") values.Set("access_token", token) req, _ := http.NewRequest(http.MethodGet, endpoint+"?"+values.Encode(), nil) resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return nil, fmt.Errorf("facebook pages status %d: %s", resp.StatusCode, string(body)) } var data struct { Data []fbPage `json:"data"` } if err := json.Unmarshal(body, &data); err != nil { return nil, err } return data.Data, nil } func facebookPages(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok || sess.Social.Facebook.AccessToken == "" { http.Error(w, "facebook not connected", http.StatusBadRequest) return } tok := sess.Social.Facebook if refreshed, err := ensureFacebookToken(sessionID, tok, sess.Social.FBConfig); err == nil { tok = refreshed } pages, err := fetchFacebookPagesWithToken(tok.AccessToken) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } out := []map[string]any{} for _, p := range pages { igID := "" if p.InstagramBusinessAccount.ID != "" { igID = p.InstagramBusinessAccount.ID } else if p.ConnectedInstagramAccount.ID != "" { igID = p.ConnectedInstagramAccount.ID } out = append(out, map[string]any{ "id": p.ID, "name": p.Name, "has_instagram": igID != "", "instagram_user_id": igID, }) } writeJSON(w, http.StatusOK, map[string]any{ "pages": out, "selected_page_id": sess.Social.FBPageID, "instagram_user_id": sess.Social.IGUserID, }) } func facebookSelectPage(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sessionID, ok := requireSessionID(w, r) if !ok { return } var body struct { PageID string `json:"page_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PageID == "" { http.Error(w, "page_id required", http.StatusBadRequest) return } sess, ok := loadSession(sessionID) if !ok || sess.Social.Facebook.AccessToken == "" { http.Error(w, "facebook not connected", http.StatusBadRequest) return } tok := sess.Social.Facebook if refreshed, err := ensureFacebookToken(sessionID, tok, sess.Social.FBConfig); err == nil { tok = refreshed } pages, err := fetchFacebookPagesWithToken(tok.AccessToken) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var selected *fbPage var igID string for _, p := range pages { if p.ID == body.PageID { selected = &p if p.InstagramBusinessAccount.ID != "" { igID = p.InstagramBusinessAccount.ID } else if p.ConnectedInstagramAccount.ID != "" { igID = p.ConnectedInstagramAccount.ID } break } } if selected == nil { http.Error(w, "page not found", http.StatusNotFound) return } _ = updateSession(sessionID, func(s *sessionData) { s.Social.FBPageID = selected.ID s.Social.FBPageToken = selected.AccessToken if igID != "" { s.Social.IGUserID = igID s.Social.Instagram = oauthToken{AccessToken: selected.AccessToken} } }) writeJSON(w, http.StatusOK, map[string]any{ "page_id": selected.ID, "instagram_user_id": igID, }) } // canvaProxy forwards requests for a given resource prefix (e.g., assets, folders). // It enforces session binding and optional method allowlists. func canvaProxy(resource string, allowedMethods ...string) http.HandlerFunc { allowed := map[string]bool{} for _, m := range allowedMethods { allowed[strings.ToUpper(m)] = true } return func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if len(allowed) > 0 && !allowed[strings.ToUpper(r.Method)] { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sessionID, ok := requireSessionID(w, r) if !ok { return } sess, ok := loadSession(sessionID) if !ok { http.Error(w, "session not found", http.StatusNotFound) return } suffix := strings.TrimPrefix(r.URL.Path, "/"+resource) target := canvaAPIBase + "/" + resource + suffix forwardCanvaGeneric(w, r, sessionID, sess, target) } } // Ensure a valid design_type is present; inject a default preset if missing. func ensureDesignType(body map[string]any) { if _, hasDesignType := body["design_type"]; hasDesignType { // Normalize string -> preset object if s, ok := body["design_type"].(string); ok && s != "" { body["design_type"] = map[string]string{"type": "preset", "name": s} } return } // If asset-based import is provided, leave untouched if _, hasAsset := body["asset_id"]; hasAsset { return } def := env("DEFAULT_DESIGN_PRESET", "presentation") body["design_type"] = map[string]string{"type": "preset", "name": def} } // Handle design creation: enforce design_type presence and forward. func handleCreateDesign(w http.ResponseWriter, r *http.Request, sessionID string, sess sessionData, target string) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json body", http.StatusBadRequest) return } if body == nil { body = map[string]any{} } ensureDesignType(body) buf, _ := json.Marshal(body) forwardCanvaWithJSON(w, r, sessionID, sess, target, buf) } func loadSession(id string) (sessionData, bool) { if dbEnabled { sess, err := loadSessionFromDB(id) if err != nil { return sessionData{}, false } return sess, true } raw, ok := sessionStore.Load(id) if !ok { return sessionData{}, false } return raw.(sessionData), true } func persistSession(id string, sess sessionData) { if dbEnabled { if err := upsertSessionInDB(id, sess); err != nil { log.Printf("warning: could not persist session to db: %v", err) } return } sessionStore.Store(id, sess) if sessionStorePath == "" { return } if err := saveSessionsToDisk(sessionStorePath); err != nil { log.Printf("warning: could not persist sessions: %v", err) } } func updateSession(id string, fn func(*sessionData)) error { sess, ok := loadSession(id) if !ok { return fmt.Errorf("session not found") } fn(&sess) persistSession(id, sess) return nil } func authRegisterHandler(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if authJWTSecret == "" { http.Error(w, "auth not configured", http.StatusServiceUnavailable) return } var payload struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } email := normalizeEmail(payload.Email) if email == "" || !strings.Contains(email, "@") { http.Error(w, "valid email required", http.StatusBadRequest) return } if len(payload.Password) < 8 { http.Error(w, "password must be at least 8 characters", http.StatusBadRequest) return } if _, exists := userStore.Load(email); exists { http.Error(w, "account already exists", http.StatusConflict) return } hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost) if err != nil { http.Error(w, "password hashing failed", http.StatusInternalServerError) return } user := authUser{ Email: email, Name: strings.TrimSpace(payload.Name), PasswordHash: string(hash), Created: time.Now(), } created, err := createUser(user) if err != nil { status := http.StatusInternalServerError if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(strings.ToLower(err.Error()), "exists") { status = http.StatusConflict } http.Error(w, err.Error(), status) return } token, err := signAuthToken(created.ID, created.Email) if err != nil { http.Error(w, "token creation failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, map[string]any{ "token": token, "user": publicUser(created), }) } func authLoginHandler(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if authJWTSecret == "" { http.Error(w, "auth not configured", http.StatusServiceUnavailable) return } var payload struct { Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } email := normalizeEmail(payload.Email) user, ok, err := getUserByEmail(email) if err != nil { http.Error(w, "login failed", http.StatusInternalServerError) return } if !ok { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } token, err := signAuthToken(user.ID, user.Email) if err != nil { http.Error(w, "token creation failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{ "token": token, "user": publicUser(user), }) } func authLogoutHandler(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func authMeHandler(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if authJWTSecret == "" { http.Error(w, "auth not configured", http.StatusServiceUnavailable) return } userID, err := parseAuthToken(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } user, ok, err := getUserByID(userID) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if !ok { http.Error(w, "user not found", http.StatusUnauthorized) return } writeJSON(w, http.StatusOK, map[string]any{ "user": publicUser(user), }) } func saveSessionsToDisk(path string) error { if dbEnabled { return nil } sessionFileMu.Lock() defer sessionFileMu.Unlock() sessions := make(map[string]sessionData) sessionStore.Range(func(key, value any) bool { sessions[key.(string)] = value.(sessionData) return true }) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" f, err := os.Create(tmp) if err != nil { return err } enc := json.NewEncoder(f) if err := enc.Encode(sessions); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } return os.Rename(tmp, path) } func loadSessionsFromDisk(path string) error { if dbEnabled { return nil } _, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil } return err } b, err := os.ReadFile(path) if err != nil { return err } if len(b) == 0 { return nil } var sessions map[string]sessionData if err := json.Unmarshal(b, &sessions); err != nil { return err } for k, v := range sessions { sessionStore.Store(k, v) } return nil } func saveUsersToDisk(path string) error { if dbEnabled { return nil } if path == "" { return nil } userFileMu.Lock() defer userFileMu.Unlock() users := make(map[string]authUser) userStore.Range(func(key, value any) bool { users[key.(string)] = value.(authUser) return true }) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" f, err := os.Create(tmp) if err != nil { return err } enc := json.NewEncoder(f) if err := enc.Encode(users); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } return os.Rename(tmp, path) } func loadUsersFromDisk(path string) error { if dbEnabled { return nil } if path == "" { return nil } _, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil } return err } b, err := os.ReadFile(path) if err != nil { return err } if len(b) == 0 { return nil } var users map[string]authUser if err := json.Unmarshal(b, &users); err != nil { return err } for k, v := range users { userStore.Store(k, v) } return nil } func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } func publicUser(user authUser) map[string]any { return map[string]any{ "id": user.ID, "email": user.Email, "name": user.Name, "created": user.Created, } } func signAuthToken(userID, email string) (string, error) { if authJWTSecret == "" { return "", errors.New("auth secret missing") } now := time.Now() claims := jwt.MapClaims{ "sub": userID, "email": email, "iat": now.Unix(), "exp": now.Add(authJWTTTL).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(authJWTSecret)) } func parseAuthToken(r *http.Request) (string, error) { authz := r.Header.Get("Authorization") if authz == "" { return "", errors.New("missing authorization") } if !strings.HasPrefix(strings.ToLower(authz), "bearer ") { return "", errors.New("invalid authorization") } tokenStr := strings.TrimSpace(authz[7:]) token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method") } return []byte(authJWTSecret), nil }) if err != nil || !token.Valid { return "", errors.New("invalid token") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return "", errors.New("invalid claims") } if exp, ok := claims["exp"].(float64); ok { if time.Now().Unix() > int64(exp) { return "", errors.New("token expired") } } sub, _ := claims["sub"].(string) if sub == "" { return "", errors.New("missing subject") } return sub, nil } func requireUserID(w http.ResponseWriter, r *http.Request) (string, bool) { if authJWTSecret == "" { return "", true } userID, err := parseAuthToken(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return "", false } return userID, true } func initDB(dsn string) error { db, _ = sql.Open("pgx", dsn) db.SetMaxOpenConns(10) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(30 * time.Minute) if err := db.Ping(); err != nil { return err } return ensureSchema() } func ensureSchema() error { stmts := []string{ `CREATE EXTENSION IF NOT EXISTS pgcrypto`, `CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT UNIQUE NOT NULL, name TEXT, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, `CREATE TABLE IF NOT EXISTS user_settings ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, settings JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, `CREATE TABLE IF NOT EXISTS canva_sessions ( id TEXT PRIMARY KEY, user_id UUID REFERENCES users(id) ON DELETE CASCADE, tokens_enc TEXT, profile_enc TEXT, social_enc TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_used_at TIMESTAMPTZ )`, `CREATE INDEX IF NOT EXISTS canva_sessions_user_id_idx ON canva_sessions(user_id)`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { return err } } return nil } func createUser(user authUser) (authUser, error) { if dbEnabled { var id string err := db.QueryRow( `INSERT INTO users (email, name, password_hash, created_at, updated_at) VALUES ($1, $2, $3, $4, $4) RETURNING id`, user.Email, user.Name, user.PasswordHash, time.Now(), ).Scan(&id) if err != nil { return authUser{}, err } user.ID = id return user, nil } if _, exists := userStore.Load(user.Email); exists { return authUser{}, fmt.Errorf("account already exists") } user.ID = user.Email userStore.Store(user.Email, user) if err := saveUsersToDisk(authUserStorePath); err != nil { log.Printf("warning: could not persist user store: %v", err) } return user, nil } func getUserByEmail(email string) (authUser, bool, error) { if dbEnabled { var user authUser var created time.Time err := db.QueryRow( `SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`, email, ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash, &created) if err != nil { if errors.Is(err, sql.ErrNoRows) { return authUser{}, false, nil } return authUser{}, false, err } user.Created = created return user, true, nil } raw, ok := userStore.Load(email) if !ok { return authUser{}, false, nil } return raw.(authUser), true, nil } func getUserByID(id string) (authUser, bool, error) { if dbEnabled { var user authUser var created time.Time err := db.QueryRow( `SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`, id, ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash, &created) if err != nil { if errors.Is(err, sql.ErrNoRows) { return authUser{}, false, nil } return authUser{}, false, err } user.Created = created return user, true, nil } raw, ok := userStore.Load(id) if !ok { return authUser{}, false, nil } return raw.(authUser), true, nil } func userSettingsHandler(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } userID, ok := requireUserID(w, r) if !ok { return } switch r.Method { case http.MethodGet: settings, ok, err := loadUserSettings(userID) if err != nil { http.Error(w, "settings load failed", http.StatusInternalServerError) return } if !ok { settings = json.RawMessage([]byte("{}")) } writeJSON(w, http.StatusOK, map[string]any{ "settings": json.RawMessage(settings), }) case http.MethodPut: var raw json.RawMessage if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { http.Error(w, "invalid settings body", http.StatusBadRequest) return } if len(raw) == 0 { raw = json.RawMessage([]byte("{}")) } if err := saveUserSettings(userID, raw); err != nil { http.Error(w, "settings save failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func loadUserSettings(userID string) (json.RawMessage, bool, error) { if dbEnabled { var raw []byte err := db.QueryRow(`SELECT settings FROM user_settings WHERE user_id = $1`, userID).Scan(&raw) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, false, nil } return nil, false, err } return json.RawMessage(raw), true, nil } raw, ok := userSettingsStore.Load(userID) if !ok { return nil, false, nil } return raw.(json.RawMessage), true, nil } func saveUserSettings(userID string, payload json.RawMessage) error { if dbEnabled { _, err := db.Exec( `INSERT INTO user_settings (user_id, settings, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (user_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now()`, userID, payload, ) return err } userSettingsStore.Store(userID, payload) return saveUserSettingsToDisk(userSettingsStorePath) } func loadUserSettingsFromDisk(path string) error { if dbEnabled { return nil } if path == "" { return nil } _, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil } return err } b, err := os.ReadFile(path) if err != nil { return err } if len(b) == 0 { return nil } var settings map[string]json.RawMessage if err := json.Unmarshal(b, &settings); err != nil { return err } for k, v := range settings { userSettingsStore.Store(k, v) } return nil } func saveUserSettingsToDisk(path string) error { if dbEnabled { return nil } if path == "" { return nil } userSettingsFileMu.Lock() defer userSettingsFileMu.Unlock() settings := map[string]json.RawMessage{} userSettingsStore.Range(func(key, value any) bool { settings[key.(string)] = value.(json.RawMessage) return true }) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" f, err := os.Create(tmp) if err != nil { return err } enc := json.NewEncoder(f) if err := enc.Encode(settings); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } return os.Rename(tmp, path) } func loadSessionFromDB(id string) (sessionData, error) { var ( userID sql.NullString tokensEnc, profileEnc, socialEnc sql.NullString createdAt time.Time ) err := db.QueryRow( `SELECT user_id, tokens_enc, profile_enc, social_enc, created_at FROM canva_sessions WHERE id = $1`, id, ).Scan(&userID, &tokensEnc, &profileEnc, &socialEnc, &createdAt) if err != nil { return sessionData{}, err } sess := sessionData{ UserID: userID.String, Created: createdAt, } if tokensEnc.Valid && tokensEnc.String != "" { var tokens tokenSet if err := decodePayload(tokensEnc.String, &tokens); err == nil { sess.Tokens = tokens } } if profileEnc.Valid && profileEnc.String != "" { var raw json.RawMessage if err := decodePayload(profileEnc.String, &raw); err == nil { sess.Profile = raw } } if socialEnc.Valid && socialEnc.String != "" { var social socialAuth if err := decodePayload(socialEnc.String, &social); err == nil { sess.Social = social } } return sess, nil } func upsertSessionInDB(id string, sess sessionData) error { tokensEnc, err := encodePayload(sess.Tokens) if err != nil { return err } profileEnc, err := encodePayload(sess.Profile) if err != nil { return err } socialEnc, err := encodePayload(sess.Social) if err != nil { return err } var userID any = nil if sess.UserID != "" { userID = sess.UserID } created := sess.Created if created.IsZero() { created = time.Now() } _, err = db.Exec( `INSERT INTO canva_sessions (id, user_id, tokens_enc, profile_enc, social_enc, created_at, updated_at, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, now(), now()) ON CONFLICT (id) DO UPDATE SET user_id = EXCLUDED.user_id, tokens_enc = EXCLUDED.tokens_enc, profile_enc = EXCLUDED.profile_enc, social_enc = EXCLUDED.social_enc, updated_at = now(), last_used_at = now()`, id, userID, tokensEnc, profileEnc, socialEnc, created, ) return err } func parseEncryptionKey(raw string) []byte { raw = strings.TrimSpace(raw) if raw == "" { return nil } if decoded, err := hex.DecodeString(raw); err == nil { if len(decoded) == 32 { return decoded } } if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil { if len(decoded) == 32 { return decoded } } return nil } func encodePayload(value any) (string, error) { if value == nil { return "", nil } b, err := json.Marshal(value) if err != nil { return "", err } if len(encryptionKey) == 32 { enc, err := encryptPayload(b) if err != nil { return "", err } return "enc:" + base64.StdEncoding.EncodeToString(enc), nil } return "plain:" + base64.StdEncoding.EncodeToString(b), nil } func decodePayload(payload string, out any) error { if payload == "" { return nil } if strings.HasPrefix(payload, "enc:") { if len(encryptionKey) != 32 { return errors.New("encryption key missing") } raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(payload, "enc:")) if err != nil { return err } dec, err := decryptPayload(raw) if err != nil { return err } return json.Unmarshal(dec, out) } if strings.HasPrefix(payload, "plain:") { raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(payload, "plain:")) if err != nil { return err } return json.Unmarshal(raw, out) } raw, err := base64.StdEncoding.DecodeString(payload) if err != nil { return err } return json.Unmarshal(raw, out) } func encryptPayload(plain []byte) ([]byte, error) { block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, err } ciphertext := gcm.Seal(nil, nonce, plain, nil) return append(nonce, ciphertext...), nil } func decryptPayload(enc []byte) ([]byte, error) { block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonceSize := gcm.NonceSize() if len(enc) < nonceSize { return nil, errors.New("ciphertext too short") } nonce := enc[:nonceSize] ciphertext := enc[nonceSize:] return gcm.Open(nil, nonce, ciphertext, nil) } func allowCORS(w http.ResponseWriter, r *http.Request) { allowed := []string{ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8080", "http://127.0.0.1:8080", "http://localhost:8081", "http://127.0.0.1:8081", "http://localhost:5173", "http://127.0.0.1:5173", } if envOrigin := os.Getenv("FRONTEND_ORIGIN"); envOrigin != "" { for _, entry := range strings.Split(envOrigin, ",") { entry = strings.TrimSpace(entry) if entry != "" { allowed = append(allowed, entry) } } } if extra := os.Getenv("ALLOWED_ORIGINS"); extra != "" { for _, entry := range strings.Split(extra, ",") { entry = strings.TrimSpace(entry) if entry != "" { allowed = append(allowed, entry) } } } origin := r.Header.Get("Origin") for _, o := range allowed { if origin == o { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") break } } w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func isRetryableNetErr(err error) bool { // Unwrap url.Error and check net.Error and EOF-ish errors. var nerr net.Error if errors.As(err, &nerr) { if nerr.Timeout() || nerr.Temporary() { return true } } if errors.Is(err, io.EOF) { return true } if strings.Contains(strings.ToLower(err.Error()), "unexpected eof") { return true } if strings.Contains(strings.ToLower(err.Error()), "connection reset") { return true } return false } func sleepBackoff(attempt int) { // Exponential backoff with jitter: 0.3s, 0.6s, 1.2s, 2.4s (cap 3s) base := 0.3 seconds := math.Min(3, base*math.Pow(2, float64(attempt))) time.Sleep(time.Duration(seconds*1000) * time.Millisecond) } // ---------- Social uploads ---------- func guessMimeType(fileURL, override string) string { if override != "" { return override } ext := strings.ToLower(filepath.Ext(fileURL)) if ext == "" { return "" } return mime.TypeByExtension(ext) } func fetchMediaInfo(fileURL, fallback string) (string, int64, error) { req, _ := http.NewRequest(http.MethodHead, fileURL, nil) resp, err := uploadHTTPClient.Do(req) if err != nil || resp == nil || resp.StatusCode >= 400 { if resp != nil { resp.Body.Close() } // Fallback to GET to inspect headers reqGet, _ := http.NewRequest(http.MethodGet, fileURL, nil) resp, err = uploadHTTPClient.Do(reqGet) if err != nil { return "", 0, err } } defer resp.Body.Close() ct := resp.Header.Get("Content-Type") if ct == "" { ct = fallback } return ct, resp.ContentLength, nil } func isVideoMedia(fileURL, mimeType string) bool { mt := strings.ToLower(guessMimeType(fileURL, mimeType)) if strings.HasPrefix(mt, "video/") { return true } switch strings.ToLower(filepath.Ext(fileURL)) { case ".mp4", ".mov", ".webm", ".mkv": return true } return false } func isImageMedia(fileURL, mimeType string) bool { mt := strings.ToLower(guessMimeType(fileURL, mimeType)) if strings.HasPrefix(mt, "image/") { return true } switch strings.ToLower(filepath.Ext(fileURL)) { case ".png", ".jpg", ".jpeg", ".gif", ".webp": return true } return false } func uploadYouTube(fileURL, text, title, visibility, mimeType string, tok oauthToken, config platformCreds) (string, error) { cid := config.ClientID if cid == "" { cid = ytClientID } csec := config.ClientSecret if csec == "" { csec = ytClientSecret } if tok.RefreshToken == "" { tok.RefreshToken = ytRefreshToken } if cid == "" || csec == "" || (tok.RefreshToken == "" && tok.AccessToken == "") { return "", fmt.Errorf("youtube credentials not configured") } if visibility == "" { visibility = "unlisted" } if title == "" { title = "Canva Export" } access := tok.AccessToken if access == "" || tokenExpiring(tok) { if tok.RefreshToken == "" { return "", fmt.Errorf("youtube refresh token missing") } accessToken, err := refreshGoogleToken(cid, csec, tok.RefreshToken) if err != nil { return "", fmt.Errorf("token: %w", err) } access = accessToken } // Init resumable session meta := map[string]any{ "snippet": map[string]any{ "title": title, "description": text, "categoryId": "22", // People & Blogs }, "status": map[string]any{ "privacyStatus": visibility, }, } buf, _ := json.Marshal(meta) if mimeType == "" { // best-effort mimeType = mime.TypeByExtension(filepath.Ext(fileURL)) } contentType, contentLength, err := fetchMediaInfo(fileURL, mimeType) if err != nil { return "", fmt.Errorf("fetch media info: %w", err) } if contentType != "" { mimeType = contentType } if !isVideoMedia(fileURL, mimeType) { return "", fmt.Errorf("youtube requires a video export; got content-type %q", mimeType) } req, _ := http.NewRequest(http.MethodPost, "https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", strings.NewReader(string(buf))) req.Header.Set("Authorization", "Bearer "+access) req.Header.Set("Content-Type", "application/json; charset=UTF-8") req.Header.Set("X-Upload-Content-Type", mimeType) resp, err := uploadHTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("youtube init status %d: %s", resp.StatusCode, string(b)) } uploadURL := resp.Header.Get("Location") if uploadURL == "" { return "", fmt.Errorf("youtube upload url missing") } // download file fileResp, err := uploadHTTPClient.Get(fileURL) if err != nil { return "", fmt.Errorf("download export: %w", err) } defer fileResp.Body.Close() if fileResp.StatusCode >= 300 { b, _ := io.ReadAll(fileResp.Body) return "", fmt.Errorf("download export status %d: %s", fileResp.StatusCode, string(b)) } uploadReq, _ := http.NewRequest(http.MethodPut, uploadURL, fileResp.Body) uploadReq.Header.Set("Content-Type", mimeType) if contentLength > 0 { uploadReq.ContentLength = contentLength } else if fileResp.ContentLength > 0 { uploadReq.ContentLength = fileResp.ContentLength } upResp, err := uploadHTTPClient.Do(uploadReq) if err != nil { return "", fmt.Errorf("youtube upload: %w", err) } defer upResp.Body.Close() if upResp.StatusCode >= 300 { b, _ := io.ReadAll(upResp.Body) return "", fmt.Errorf("youtube upload status %d: %s", upResp.StatusCode, string(b)) } var res struct { ID string `json:"id"` } if err := json.NewDecoder(upResp.Body).Decode(&res); err != nil { return "", err } return res.ID, nil } func refreshGoogleToken(cid, csec, refresh string) (string, error) { if cid == "" { cid = ytClientID } if csec == "" { csec = ytClientSecret } values := url.Values{} values.Set("client_id", cid) values.Set("client_secret", csec) values.Set("refresh_token", refresh) values.Set("grant_type", "refresh_token") req, _ := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := tokenHTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var data struct { AccessToken string `json:"access_token"` } if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return "", err } return data.AccessToken, nil } func postToX(fileURL, text, accessToken string) error { msg := strings.TrimSpace(text + " " + fileURL) payload := map[string]string{"text": msg} buf, _ := json.Marshal(payload) // Prefer user-context token if accessToken == "" { accessToken = xUserToken } if accessToken != "" { req, _ := http.NewRequest(http.MethodPost, "https://api.twitter.com/2/tweets", strings.NewReader(string(buf))) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("x post status %d: %s", resp.StatusCode, string(b)) } return nil } // Fallback: app-only bearer (read-only) -> return explicit error if xBearerToken != "" { return fmt.Errorf("x user access token missing; app-only bearer cannot post") } return fmt.Errorf("x credentials not configured") } func postToFacebook(fileURL, text, mimeType, pageID, pageToken, mode string) (string, error) { if pageID == "" { pageID = fbPageID } if pageToken == "" { pageToken = fbAccessToken } if pageID == "" || pageToken == "" { return "", fmt.Errorf("facebook credentials not configured") } endpoint := "" data := url.Values{} data.Set("access_token", pageToken) if strings.EqualFold(mode, "reel") || strings.EqualFold(mode, "reels") { if !isVideoMedia(fileURL, mimeType) { return "", fmt.Errorf("facebook reels require a video export") } endpoint = fmt.Sprintf("https://graph.facebook.com/%s/%s/video_reels", fbGraphVersion, pageID) data.Set("video_url", fileURL) if text != "" { data.Set("description", text) } } else if isVideoMedia(fileURL, mimeType) { endpoint = fmt.Sprintf("https://graph.facebook.com/%s/%s/videos", fbGraphVersion, pageID) data.Set("file_url", fileURL) if text != "" { data.Set("description", text) } } else if isImageMedia(fileURL, mimeType) { endpoint = fmt.Sprintf("https://graph.facebook.com/%s/%s/photos", fbGraphVersion, pageID) data.Set("url", fileURL) if text != "" { data.Set("caption", text) } } else { endpoint = fmt.Sprintf("https://graph.facebook.com/%s/%s/feed", fbGraphVersion, pageID) data.Set("link", fileURL) if text != "" { data.Set("message", text) } } client := &http.Client{Timeout: 20 * time.Second} resp, err := client.PostForm(endpoint, data) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", fmt.Errorf("facebook post status %d: %s", resp.StatusCode, string(body)) } var parsed map[string]any _ = json.Unmarshal(body, &parsed) if id, ok := parsed["id"].(string); ok && id != "" { return id, nil } if id, ok := parsed["post_id"].(string); ok && id != "" { return id, nil } return "", nil } type instagramPublishResult struct { MediaID string `json:"id"` ContainerID string `json:"container_id"` } func postToInstagram(fileURL, text, mimeType, userID, accessToken, mode string) (instagramPublishResult, error) { if userID == "" { userID = igUserID } if accessToken == "" { accessToken = igAccessToken } if userID == "" || accessToken == "" { return instagramPublishResult{}, fmt.Errorf("instagram credentials not configured") } isVideo := isVideoMedia(fileURL, mimeType) isImage := isImageMedia(fileURL, mimeType) if !isVideo && !isImage { return instagramPublishResult{}, fmt.Errorf("instagram requires an image or video export") } mode = strings.ToLower(mode) createURL := fmt.Sprintf("https://graph.facebook.com/%s/%s/media", igGraphVersion, userID) data := url.Values{} data.Set("access_token", accessToken) if text != "" { data.Set("caption", text) } switch mode { case "reel", "reels": if !isVideo { return instagramPublishResult{}, fmt.Errorf("instagram reels require a video export") } data.Set("media_type", "REELS") data.Set("video_url", fileURL) case "story", "stories": data.Set("media_type", "STORIES") if isVideo { data.Set("video_url", fileURL) } else { data.Set("image_url", fileURL) } default: if isVideo { data.Set("media_type", "VIDEO") data.Set("video_url", fileURL) } else { data.Set("image_url", fileURL) } } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.PostForm(createURL, data) if err != nil { return instagramPublishResult{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return instagramPublishResult{}, fmt.Errorf("instagram create status %d: %s", resp.StatusCode, string(body)) } var created struct { ID string `json:"id"` } if err := json.Unmarshal(body, &created); err != nil || created.ID == "" { return instagramPublishResult{}, fmt.Errorf("instagram create failed: %s", string(body)) } publishURL := fmt.Sprintf("https://graph.facebook.com/%s/%s/media_publish", igGraphVersion, userID) pubData := url.Values{} pubData.Set("access_token", accessToken) pubData.Set("creation_id", created.ID) pubResp, err := client.PostForm(publishURL, pubData) if err != nil { return instagramPublishResult{}, err } defer pubResp.Body.Close() pubBody, _ := io.ReadAll(pubResp.Body) if pubResp.StatusCode >= 300 { return instagramPublishResult{}, fmt.Errorf("instagram publish status %d: %s", pubResp.StatusCode, string(pubBody)) } var published struct { ID string `json:"id"` } if err := json.Unmarshal(pubBody, &published); err != nil { return instagramPublishResult{}, err } return instagramPublishResult{ MediaID: published.ID, ContainerID: created.ID, }, nil } func mapTikTokPrivacy(v string) string { switch strings.ToLower(v) { case "private": return "SELF_ONLY" case "friends": return "FRIENDS" default: return "PUBLIC_TO_EVERYONE" } } func postToTikTok(fileURL, text, privacy, mimeType, accessToken, openID string, allowComments, allowDuet, allowStitch bool) (string, error) { if accessToken == "" { accessToken = tiktokAccessToken } if openID == "" { openID = tiktokOpenID } if accessToken == "" || openID == "" { return "", fmt.Errorf("tiktok credentials not configured") } if !isVideoMedia(fileURL, mimeType) { return "", fmt.Errorf("tiktok requires a video export") } if privacy == "" { privacy = "public" } postInfo := map[string]any{ "title": text, "privacy_level": mapTikTokPrivacy(privacy), "disable_comment": !allowComments, "disable_duet": !allowDuet, "disable_stitch": !allowStitch, } payload := map[string]any{ "open_id": openID, "post_info": postInfo, "source_info": map[string]any{ "source": "PULL_FROM_URL", "video_url": fileURL, }, } buf, _ := json.Marshal(payload) req, _ := http.NewRequest(http.MethodPost, tiktokPublishURL, bytes.NewReader(buf)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", fmt.Errorf("tiktok publish status %d: %s", resp.StatusCode, string(body)) } var parsed map[string]any _ = json.Unmarshal(body, &parsed) if data, ok := parsed["data"].(map[string]any); ok { if id, ok := data["publish_id"].(string); ok && id != "" { return id, nil } if id, ok := data["id"].(string); ok && id != "" { return id, nil } } if id, ok := parsed["publish_id"].(string); ok && id != "" { return id, nil } return "", nil } func fetchTikTokPublishStatus(accessToken, publishID string) (string, map[string]any, error) { if accessToken == "" || publishID == "" { return "", nil, fmt.Errorf("tiktok credentials not configured") } payload := map[string]any{"publish_id": publishID} buf, _ := json.Marshal(payload) req, _ := http.NewRequest(http.MethodPost, tiktokStatusURL, bytes.NewReader(buf)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(req) if err != nil { return "", nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", nil, fmt.Errorf("tiktok status %d: %s", resp.StatusCode, string(body)) } var parsed map[string]any _ = json.Unmarshal(body, &parsed) status := "" if data, ok := parsed["data"].(map[string]any); ok { if s, ok := data["status"].(string); ok { status = s } else if s, ok := data["publish_status"].(string); ok { status = s } } if status == "" { if s, ok := parsed["status"].(string); ok { status = s } } return status, parsed, nil } func fetchInstagramContainerStatus(containerID, accessToken string) (string, map[string]any, error) { if containerID == "" || accessToken == "" { return "", nil, fmt.Errorf("instagram credentials not configured") } endpoint := fmt.Sprintf("https://graph.facebook.com/%s/%s", igGraphVersion, containerID) values := url.Values{} values.Set("fields", "status_code") values.Set("access_token", accessToken) resp, err := httpClient.Get(endpoint + "?" + values.Encode()) if err != nil { return "", nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", nil, fmt.Errorf("instagram status %d: %s", resp.StatusCode, string(body)) } var parsed map[string]any _ = json.Unmarshal(body, &parsed) status := "" if s, ok := parsed["status_code"].(string); ok { status = s } return status, parsed, nil } func isTerminalStatus(status string) bool { switch strings.ToUpper(strings.TrimSpace(status)) { case "FINISHED", "SUCCESS", "COMPLETED", "DONE", "PUBLISHED": return true case "FAILED", "ERROR", "REJECTED": return true default: return false } } // ---------- Auth0 helpers ---------- func withAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { allowCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if authJWTSecret != "" { authz := r.Header.Get("Authorization") if authz == "" && allowSessionAuth && getSessionID(r) != "" { next(w, r) return } if _, ok := requireUserID(w, r); !ok { return } next(w, r) return } // Fall back to Auth0 if configured. if authDomain == "" || authAudience == "" { next(w, r) return } authz := r.Header.Get("Authorization") if !strings.HasPrefix(strings.ToLower(authz), "bearer ") { http.Error(w, "missing bearer token", http.StatusUnauthorized) return } tokenStr := strings.TrimSpace(authz[7:]) claims, err := validateJWT(tokenStr) if err != nil { http.Error(w, "invalid token: "+err.Error(), http.StatusUnauthorized) return } _ = claims next(w, r) } } func validateJWT(tokenStr string) (jwt.MapClaims, error) { if err := ensureJWKS(); err != nil { return nil, err } keyFunc := func(token *jwt.Token) (any, error) { if token.Header == nil { return nil, fmt.Errorf("missing header") } kid, _ := token.Header["kid"].(string) if kid == "" { return nil, fmt.Errorf("missing kid") } if k := jwksKeys[kid]; k != nil { return k, nil } return nil, fmt.Errorf("unknown kid") } token, err := jwt.Parse(tokenStr, keyFunc, jwt.WithAudience(authAudience), jwt.WithIssuer("https://"+authDomain+"/")) if err != nil { return nil, err } if !token.Valid { return nil, fmt.Errorf("token invalid") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, fmt.Errorf("claims not map") } return claims, nil } func ensureJWKS() error { jwksOnce.Do(func() { if authDomain == "" { jwksErr = fmt.Errorf("auth domain not set") return } jwksURL := fmt.Sprintf("https://%s/.well-known/jwks.json", authDomain) resp, err := http.Get(jwksURL) if err != nil { jwksErr = err return } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) jwksErr = fmt.Errorf("jwks status %d: %s", resp.StatusCode, string(body)) return } var jwks struct { Keys []struct { Kty string `json:"kty"` Kid string `json:"kid"` Use string `json:"use"` N string `json:"n"` E string `json:"e"` } `json:"keys"` } if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { jwksErr = err return } keys := make(map[string]*rsa.PublicKey) for _, k := range jwks.Keys { if k.Kty != "RSA" || k.N == "" || k.E == "" || k.Kid == "" { continue } nb, errN := base64.RawURLEncoding.DecodeString(k.N) eb, errE := base64.RawURLEncoding.DecodeString(k.E) if errN != nil || errE != nil { continue } var eInt int for _, b := range eb { eInt = eInt<<8 + int(b) } pub := &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: eInt} keys[k.Kid] = pub } if len(keys) == 0 { jwksErr = fmt.Errorf("no RSA keys in jwks") return } jwksKeys = keys }) return jwksErr }