4138 lines
118 KiB
Go
4138 lines
118 KiB
Go
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
|
|
}
|