325 lines
9.4 KiB
Go
325 lines
9.4 KiB
Go
package usecase
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"time"
|
||
|
||
"apps/backend/internal/module/threads/domain"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// TokenRenewScheduler schedules delayed threads_token_renew jobs (optional).
|
||
type TokenRenewScheduler interface {
|
||
ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error
|
||
}
|
||
|
||
// Service implements Threads OAuth + account lifecycle.
|
||
type Service struct {
|
||
Repo domain.Repository
|
||
Provider domain.Provider
|
||
TokenSecret string
|
||
// APIPublicBase for building fake authorize → callback (e.g. http://127.0.0.1:8888)
|
||
APIPublicBase string
|
||
// CallbackPath relative or absolute redirect_uri registered with Meta
|
||
CallbackPath string // default /api/v1/threads-accounts/oauth/callback
|
||
// WebBase for post-callback browser redirect
|
||
WebBase string
|
||
// Renew schedules worker job ~30d after bind (optional)
|
||
Renew TokenRenewScheduler
|
||
}
|
||
|
||
func New(repo domain.Repository, provider domain.Provider, tokenSecret string) *Service {
|
||
return &Service{
|
||
Repo: repo, Provider: provider, TokenSecret: tokenSecret,
|
||
CallbackPath: "/api/v1/threads-accounts/oauth/callback",
|
||
}
|
||
}
|
||
|
||
func (s *Service) callbackURI() string {
|
||
if s.CallbackPath == "" {
|
||
s.CallbackPath = "/api/v1/threads-accounts/oauth/callback"
|
||
}
|
||
if len(s.CallbackPath) > 0 && s.CallbackPath[0] == '/' {
|
||
base := trimSlash(s.APIPublicBase)
|
||
if base == "" {
|
||
return s.CallbackPath
|
||
}
|
||
return base + s.CallbackPath
|
||
}
|
||
return s.CallbackPath
|
||
}
|
||
|
||
func trimSlash(s string) string {
|
||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||
s = s[:len(s)-1]
|
||
}
|
||
return s
|
||
}
|
||
|
||
func randomState() string {
|
||
var b [16]byte
|
||
_, _ = rand.Read(b[:])
|
||
return hex.EncodeToString(b[:])
|
||
}
|
||
|
||
// OAuthStart — TH-01
|
||
func (s *Service) OAuthStart(ctx context.Context, ownerUID int64) (authorizeURL, state string, err error) {
|
||
if ownerUID <= 0 {
|
||
return "", "", domain.ErrForbidden
|
||
}
|
||
state = randomState()
|
||
now := domain.NowNano()
|
||
st := &domain.OAuthState{
|
||
State: state, OwnerUID: ownerUID,
|
||
CreatedAt: now, ExpiresAt: now + int64(15*time.Minute),
|
||
}
|
||
if err := s.Repo.SaveState(ctx, st); err != nil {
|
||
return "", "", err
|
||
}
|
||
uri := s.callbackURI()
|
||
return s.Provider.AuthorizeURL(state, uri), state, nil
|
||
}
|
||
|
||
// OAuthCallback — TH-02 / TH-03 / TH-04
|
||
func (s *Service) OAuthCallback(ctx context.Context, code, state, oauthErr string) (*domain.Account, error) {
|
||
if oauthErr != "" {
|
||
return nil, domain.ErrOAuthDenied
|
||
}
|
||
if state == "" {
|
||
return nil, domain.ErrInvalidState
|
||
}
|
||
st, err := s.Repo.TakeState(ctx, state)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if code == "" {
|
||
return nil, domain.ErrOAuthExchange
|
||
}
|
||
tok, err := s.Provider.ExchangeCode(ctx, code, s.callbackURI())
|
||
if err != nil {
|
||
return nil, domain.ErrOAuthExchange
|
||
}
|
||
accEnc, err := domain.Seal(s.TokenSecret, tok.AccessToken)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
refEnc, err := domain.Seal(s.TokenSecret, tok.RefreshToken)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if tok.RefreshToken == "" {
|
||
// long-lived access doubles as refresh handle
|
||
refEnc, _ = domain.Seal(s.TokenSecret, tok.AccessToken)
|
||
}
|
||
now := domain.NowNano()
|
||
exp := expiresAtFromSeconds(now, tok.ExpiresIn)
|
||
username := tok.Username
|
||
if username == "" {
|
||
username = "user_" + tok.UserID
|
||
}
|
||
display := tok.DisplayName
|
||
if display == "" {
|
||
display = username
|
||
}
|
||
|
||
// Same Threads user under same member → overwrite session (do not create duplicates).
|
||
if tok.UserID != "" {
|
||
if existing, ferr := s.Repo.FindByOwnerAndThreadsUser(ctx, st.OwnerUID, tok.UserID); ferr == nil && existing != nil {
|
||
existing.Username = username
|
||
existing.DisplayName = display
|
||
existing.ThreadsUserID = tok.UserID
|
||
existing.Connection = domain.ConnectionConnected
|
||
existing.IsUsable = true
|
||
existing.ErrorMessage = ""
|
||
existing.AvatarColor = domain.AvatarColorFor(username)
|
||
if tok.AvatarURL != "" {
|
||
existing.AvatarURL = tok.AvatarURL
|
||
}
|
||
existing.SessionExpiresAt = exp
|
||
existing.SessionRefreshedAt = now
|
||
existing.AccessTokenEnc = accEnc
|
||
existing.RefreshTokenEnc = refEnc
|
||
existing.UpdatedAt = now
|
||
if err := s.Repo.Update(ctx, existing); err != nil {
|
||
return nil, err
|
||
}
|
||
s.scheduleRenew(ctx, existing)
|
||
return existing, nil
|
||
}
|
||
}
|
||
|
||
a := &domain.Account{
|
||
ID: uuid.NewString(), OwnerUID: st.OwnerUID,
|
||
Username: username, DisplayName: display, ThreadsUserID: tok.UserID,
|
||
Connection: domain.ConnectionConnected, IsUsable: true,
|
||
AvatarColor: domain.AvatarColorFor(username), AvatarURL: tok.AvatarURL,
|
||
SessionExpiresAt: exp, SessionRefreshedAt: now,
|
||
AccessTokenEnc: accEnc, RefreshTokenEnc: refEnc,
|
||
CreatedAt: now, UpdatedAt: now,
|
||
}
|
||
if err := s.Repo.Insert(ctx, a); err != nil {
|
||
return nil, err
|
||
}
|
||
s.scheduleRenew(ctx, a)
|
||
return a, nil
|
||
}
|
||
|
||
func (s *Service) scheduleRenew(ctx context.Context, a *domain.Account) {
|
||
if s == nil || s.Renew == nil || a == nil {
|
||
return
|
||
}
|
||
// 約 30 天後 renew(長效 token ~60 天;若到期更近則提前)
|
||
runAfter := domain.NowNano() + int64(30*24*time.Hour)
|
||
if a.SessionExpiresAt > 0 {
|
||
// 到期前 30 天;若不足 7 天則到期前 1 天
|
||
early := a.SessionExpiresAt - int64(30*24*time.Hour)
|
||
minLead := a.SessionExpiresAt - int64(7*24*time.Hour)
|
||
if early > domain.NowNano() {
|
||
runAfter = early
|
||
} else if minLead > domain.NowNano() {
|
||
runAfter = minLead
|
||
} else {
|
||
runAfter = domain.NowNano() + int64(time.Hour) // 幾乎到期,盡快
|
||
}
|
||
}
|
||
_ = s.Renew.ScheduleTokenRenew(ctx, a.OwnerUID, a.ID, runAfter)
|
||
}
|
||
|
||
// List — TH-05
|
||
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Account, error) {
|
||
return s.Repo.ListByOwner(ctx, ownerUID)
|
||
}
|
||
|
||
// Get returns account by id (no ownership check — caller enforces).
|
||
func (s *Service) Get(ctx context.Context, accountID string) (*domain.Account, error) {
|
||
return s.Repo.FindByID(ctx, accountID)
|
||
}
|
||
|
||
// AccessToken decrypts stored access token for publish transport.
|
||
func (s *Service) AccessToken(_ context.Context, a *domain.Account) (string, error) {
|
||
if a == nil {
|
||
return "", domain.ErrNotFound
|
||
}
|
||
return domain.Open(s.TokenSecret, a.AccessTokenEnc)
|
||
}
|
||
|
||
// Refresh — TH-06 / TH-07 / TH-10
|
||
// 用庫裡已存的長效 token 向 Threads 換新 token(不開授權頁、不重新 OAuth)。
|
||
// 若 token 已失效無法 refresh,標記 error,需使用者再按「連帳」走授權。
|
||
func (s *Service) Refresh(ctx context.Context, ownerUID int64, accountID string) (*domain.Account, error) {
|
||
a, err := s.Repo.FindByID(ctx, accountID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if a.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
// 優先用 refresh 把手;沒有則退回 access(兩者在長效模型下通常相同)
|
||
ref, err := domain.Open(s.TokenSecret, a.RefreshTokenEnc)
|
||
if err != nil || ref == "" {
|
||
ref, err = domain.Open(s.TokenSecret, a.AccessTokenEnc)
|
||
}
|
||
if err != nil || ref == "" {
|
||
a.Connection = domain.ConnectionError
|
||
a.IsUsable = false
|
||
a.ErrorMessage = "no stored token — reconnect account"
|
||
_ = s.Repo.Update(ctx, a)
|
||
return a, domain.ErrRefreshFailed
|
||
}
|
||
tok, err := s.Provider.Refresh(ctx, ref)
|
||
if err != nil {
|
||
a.Connection = domain.ConnectionError
|
||
a.IsUsable = false
|
||
a.ErrorMessage = "token refresh failed — reconnect account"
|
||
_ = s.Repo.Update(ctx, a)
|
||
return a, domain.ErrRefreshFailed
|
||
}
|
||
a.AccessTokenEnc, _ = domain.Seal(s.TokenSecret, tok.AccessToken)
|
||
// 長效 refresh:新 token 同時當下次 refresh 把手
|
||
handle := tok.RefreshToken
|
||
if handle == "" {
|
||
handle = tok.AccessToken
|
||
}
|
||
a.RefreshTokenEnc, _ = domain.Seal(s.TokenSecret, handle)
|
||
now := domain.NowNano()
|
||
a.SessionRefreshedAt = now
|
||
a.SessionExpiresAt = expiresAtFromSeconds(now, tok.ExpiresIn)
|
||
a.Connection = domain.ConnectionConnected
|
||
a.IsUsable = true
|
||
a.ErrorMessage = ""
|
||
if tok.Username != "" {
|
||
a.Username = tok.Username
|
||
}
|
||
if tok.DisplayName != "" {
|
||
a.DisplayName = tok.DisplayName
|
||
}
|
||
a.UpdatedAt = now
|
||
if err := s.Repo.Update(ctx, a); err != nil {
|
||
return nil, err
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// Disconnect — TH-08 / TH-10:真刪綁定(列表不再出現)
|
||
func (s *Service) Disconnect(ctx context.Context, ownerUID int64, accountID string) error {
|
||
a, err := s.Repo.FindByID(ctx, accountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if a.OwnerUID != ownerUID {
|
||
return domain.ErrForbidden
|
||
}
|
||
return s.Repo.Delete(ctx, accountID)
|
||
}
|
||
|
||
// WebRedirect builds FE redirect after callback.
|
||
func (s *Service) WebRedirect(ok bool, msg string) string {
|
||
base := trimSlash(s.WebBase)
|
||
if base == "" {
|
||
base = "http://127.0.0.1:5173"
|
||
}
|
||
if ok {
|
||
return base + "/app/crew?oauth=ok"
|
||
}
|
||
if msg == "" {
|
||
msg = "error"
|
||
}
|
||
return fmt.Sprintf("%s/app/crew?oauth=error&msg=%s", base, urlQueryEscape(msg))
|
||
}
|
||
|
||
// expiresAtFromSeconds: nowUnixNano + expiresInSec → absolute unix nanoseconds.
|
||
// expiresInSec<=0 → 預設 60 天(Threads 長效常見)。
|
||
func expiresAtFromSeconds(nowNano, expiresInSec int64) int64 {
|
||
const nsPerSec = int64(1_000_000_000)
|
||
if expiresInSec <= 0 {
|
||
expiresInSec = 60 * 24 * 3600
|
||
}
|
||
// 防呆:若誤傳毫秒級(> 10 年以秒計)當毫秒
|
||
if expiresInSec > 10*365*24*3600 {
|
||
expiresInSec = expiresInSec / 1000
|
||
}
|
||
return nowNano + expiresInSec*nsPerSec
|
||
}
|
||
|
||
func urlQueryEscape(s string) string {
|
||
// minimal escape
|
||
r := make([]byte, 0, len(s)*2)
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||
r = append(r, c)
|
||
} else if c == ' ' {
|
||
r = append(r, '+')
|
||
} else {
|
||
r = append(r, '%')
|
||
const hex = "0123456789ABCDEF"
|
||
r = append(r, hex[c>>4], hex[c&15])
|
||
}
|
||
}
|
||
return string(r)
|
||
}
|