draft
This commit is contained in:
753
internal/modules/common/helpers.go
Normal file
753
internal/modules/common/helpers.go
Normal file
@@ -0,0 +1,753 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
videodomain "stream.api/internal/video/runtime/domain"
|
||||
runtimeservices "stream.api/internal/video/runtime/services"
|
||||
)
|
||||
|
||||
type APIErrorBody struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func AdminPageLimitOffset(pageValue int32, limitValue int32) (int32, int32, int) {
|
||||
page := pageValue
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := limitValue
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset := int((page - 1) * limit)
|
||||
return page, limit, offset
|
||||
}
|
||||
|
||||
func BuildAdminJob(job *videodomain.Job) *appv1.AdminJob {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.AdminJob{
|
||||
Id: job.ID,
|
||||
Status: string(job.Status),
|
||||
Priority: int32(job.Priority),
|
||||
UserId: job.UserID,
|
||||
Name: job.Name,
|
||||
TimeLimit: job.TimeLimit,
|
||||
InputUrl: job.InputURL,
|
||||
OutputUrl: job.OutputURL,
|
||||
TotalDuration: job.TotalDuration,
|
||||
CurrentTime: job.CurrentTime,
|
||||
Progress: job.Progress,
|
||||
AgentId: job.AgentID,
|
||||
Logs: job.Logs,
|
||||
Config: job.Config,
|
||||
Cancelled: job.Cancelled,
|
||||
RetryCount: int32(job.RetryCount),
|
||||
MaxRetries: int32(job.MaxRetries),
|
||||
CreatedAt: timestamppb.New(job.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(job.UpdatedAt),
|
||||
VideoId: StringPointerOrNil(job.VideoID),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAdminAgent(agent *runtimeservices.AgentWithStats) *appv1.AdminAgent {
|
||||
if agent == nil || agent.Agent == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.AdminAgent{
|
||||
Id: agent.ID,
|
||||
Name: agent.Name,
|
||||
Platform: agent.Platform,
|
||||
Backend: agent.Backend,
|
||||
Version: agent.Version,
|
||||
Capacity: agent.Capacity,
|
||||
Status: string(agent.Status),
|
||||
Cpu: agent.CPU,
|
||||
Ram: agent.RAM,
|
||||
LastHeartbeat: timestamppb.New(agent.LastHeartbeat),
|
||||
CreatedAt: timestamppb.New(agent.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(agent.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeAdminRoleValue(value string) string {
|
||||
role := strings.ToUpper(strings.TrimSpace(value))
|
||||
if role == "" {
|
||||
return "USER"
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func IsValidAdminRoleValue(role string) bool {
|
||||
switch NormalizeAdminRoleValue(role) {
|
||||
case "USER", "ADMIN", "BLOCK":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ReferralUserEligible(user *model.User) bool {
|
||||
if user == nil || user.ReferralEligible == nil {
|
||||
return true
|
||||
}
|
||||
return *user.ReferralEligible
|
||||
}
|
||||
|
||||
func EffectiveReferralRewardBps(value *int32) int32 {
|
||||
if value == nil {
|
||||
return DefaultReferralRewardBps
|
||||
}
|
||||
if *value < 0 {
|
||||
return 0
|
||||
}
|
||||
if *value > 10000 {
|
||||
return 10000
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ReferralRewardBpsToPercent(value int32) float64 {
|
||||
return float64(value) / 100
|
||||
}
|
||||
|
||||
func ReferralRewardProcessed(user *model.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
if user.ReferralRewardGrantedAt != nil {
|
||||
return true
|
||||
}
|
||||
if user.ReferralRewardPaymentID != nil && strings.TrimSpace(*user.ReferralRewardPaymentID) != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func BoolValue(value *bool) bool {
|
||||
return value != nil && *value
|
||||
}
|
||||
|
||||
func StringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func NullableTrimmedStringPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func NullableTrimmedString(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func Int32PtrToInt64Ptr(value *int32) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
converted := int64(*value)
|
||||
return &converted
|
||||
}
|
||||
|
||||
func Int64PtrToInt32Ptr(value *int64) *int32 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
converted := int32(*value)
|
||||
return &converted
|
||||
}
|
||||
|
||||
func Int32Ptr(value int32) *int32 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func ProtoStringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func SafeRole(role *string) string {
|
||||
if role == nil || strings.TrimSpace(*role) == "" {
|
||||
return "USER"
|
||||
}
|
||||
return *role
|
||||
}
|
||||
|
||||
func StringPointerOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func TimeToProto(value *time.Time) *timestamppb.Timestamp {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return timestamppb.New(value.UTC())
|
||||
}
|
||||
|
||||
func MaxFloat(left, right float64) float64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func FormatOptionalTimestamp(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func MustMarshalJSON(value any) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func NormalizeNotificationType(value string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
switch {
|
||||
case strings.Contains(lower, "video"):
|
||||
return "video"
|
||||
case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"):
|
||||
return "payment"
|
||||
case strings.Contains(lower, "warning"):
|
||||
return "warning"
|
||||
case strings.Contains(lower, "error"):
|
||||
return "error"
|
||||
case strings.Contains(lower, "success"):
|
||||
return "success"
|
||||
case strings.Contains(lower, "system"):
|
||||
return "system"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeDomain(value string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
normalized = strings.TrimPrefix(normalized, "https://")
|
||||
normalized = strings.TrimPrefix(normalized, "http://")
|
||||
normalized = strings.TrimPrefix(normalized, "www.")
|
||||
normalized = strings.TrimSuffix(normalized, "/")
|
||||
return normalized
|
||||
}
|
||||
|
||||
func NormalizeAdFormat(value string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "mid-roll", "post-roll":
|
||||
return strings.TrimSpace(strings.ToLower(value))
|
||||
default:
|
||||
return "pre-roll"
|
||||
}
|
||||
}
|
||||
|
||||
func AdTemplateIsActive(value *bool) bool {
|
||||
return value == nil || *value
|
||||
}
|
||||
|
||||
func PlayerConfigIsActive(value *bool) bool {
|
||||
return value == nil || *value
|
||||
}
|
||||
|
||||
func UnsetDefaultTemplates(tx *gorm.DB, userID, excludeID string) error {
|
||||
query := tx.Model(&model.AdTemplate{}).Where("user_id = ?", userID)
|
||||
if excludeID != "" {
|
||||
query = query.Where("id <> ?", excludeID)
|
||||
}
|
||||
return query.Update("is_default", false).Error
|
||||
}
|
||||
|
||||
func UnsetDefaultPlayerConfigs(tx *gorm.DB, userID, excludeID string) error {
|
||||
query := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", userID)
|
||||
if excludeID != "" {
|
||||
query = query.Where("id <> ?", excludeID)
|
||||
}
|
||||
return query.Update("is_default", false).Error
|
||||
}
|
||||
|
||||
func NormalizePaymentStatus(statusValue *string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(StringValue(statusValue)))
|
||||
switch value {
|
||||
case "success", "succeeded", "paid":
|
||||
return "success"
|
||||
case "failed", "error", "canceled", "cancelled":
|
||||
return "failed"
|
||||
case "pending", "processing":
|
||||
return "pending"
|
||||
default:
|
||||
if value == "" {
|
||||
return "success"
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeCurrency(currency *string) string {
|
||||
value := strings.ToUpper(strings.TrimSpace(StringValue(currency)))
|
||||
if value == "" {
|
||||
return "USD"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func NormalizePaymentMethod(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case PaymentMethodWallet:
|
||||
return PaymentMethodWallet
|
||||
case PaymentMethodTopup:
|
||||
return PaymentMethodTopup
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeOptionalPaymentMethod(value *string) *string {
|
||||
normalized := NormalizePaymentMethod(StringValue(value))
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
return &normalized
|
||||
}
|
||||
|
||||
func BuildInvoiceID(id string) string {
|
||||
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
|
||||
if len(trimmed) > 12 {
|
||||
trimmed = trimmed[:12]
|
||||
}
|
||||
return "INV-" + strings.ToUpper(trimmed)
|
||||
}
|
||||
|
||||
func BuildTransactionID(prefix string) string {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func BuildInvoiceFilename(id string) string {
|
||||
return fmt.Sprintf("invoice-%s.txt", id)
|
||||
}
|
||||
|
||||
func BuildTopupInvoice(transaction *model.WalletTransaction) string {
|
||||
createdAt := FormatOptionalTimestamp(transaction.CreatedAt)
|
||||
return strings.Join([]string{
|
||||
"Stream API Wallet Top-up Invoice",
|
||||
fmt.Sprintf("Invoice ID: %s", BuildInvoiceID(transaction.ID)),
|
||||
fmt.Sprintf("Wallet Transaction ID: %s", transaction.ID),
|
||||
fmt.Sprintf("User ID: %s", transaction.UserID),
|
||||
fmt.Sprintf("Amount: %.2f %s", transaction.Amount, NormalizeCurrency(transaction.Currency)),
|
||||
"Status: SUCCESS",
|
||||
fmt.Sprintf("Type: %s", strings.ToUpper(transaction.Type)),
|
||||
fmt.Sprintf("Note: %s", model.StringValue(transaction.Note)),
|
||||
fmt.Sprintf("Created At: %s", createdAt),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func StatusErrorWithBody(ctx context.Context, grpcCode codes.Code, httpCode int, message string, data any) error {
|
||||
body := APIErrorBody{Code: httpCode, Message: message, Data: data}
|
||||
encoded, err := json.Marshal(body)
|
||||
if err == nil {
|
||||
_ = grpc.SetTrailer(ctx, metadata.Pairs("x-error-body", string(encoded)))
|
||||
}
|
||||
return status.Error(grpcCode, message)
|
||||
}
|
||||
|
||||
func IsAllowedTermMonths(value int32) bool {
|
||||
_, ok := AllowedTermMonths[value]
|
||||
return ok
|
||||
}
|
||||
|
||||
func LockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) {
|
||||
if tx.Dialector.Name() == "sqlite" {
|
||||
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", userID)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", userID).
|
||||
First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func MessageResponse(message string) *appv1.MessageResponse {
|
||||
return &appv1.MessageResponse{Message: message}
|
||||
}
|
||||
|
||||
func EnsurePaidPlan(user *model.User) error {
|
||||
if user == nil {
|
||||
return status.Error(codes.Unauthenticated, "Unauthorized")
|
||||
}
|
||||
if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" {
|
||||
return status.Error(codes.PermissionDenied, AdTemplateUpgradeRequiredMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PlayerConfigActionAllowed(user *model.User, configCount int64, action string) error {
|
||||
if user == nil {
|
||||
return status.Error(codes.Unauthenticated, "Unauthorized")
|
||||
}
|
||||
if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "create":
|
||||
if configCount > 0 {
|
||||
return status.Error(codes.FailedPrecondition, PlayerConfigFreePlanLimitMessage)
|
||||
}
|
||||
case "update", "set-default", "toggle-active":
|
||||
if configCount > 1 {
|
||||
return status.Error(codes.FailedPrecondition, PlayerConfigFreePlanReconciliationMessage)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateOAuthState() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func GoogleOAuthStateCacheKey(state string) string {
|
||||
return "google_oauth_state:" + state
|
||||
}
|
||||
|
||||
func ToProtoVideo(item *model.Video, jobID ...string) *appv1.Video {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
statusValue := StringValue(item.Status)
|
||||
if statusValue == "" {
|
||||
statusValue = "ready"
|
||||
}
|
||||
var linkedJobID *string
|
||||
if len(jobID) > 0 {
|
||||
linkedJobID = StringPointerOrNil(jobID[0])
|
||||
}
|
||||
return &appv1.Video{
|
||||
Id: item.ID,
|
||||
UserId: item.UserID,
|
||||
Title: item.Title,
|
||||
Description: item.Description,
|
||||
Url: item.URL,
|
||||
Status: strings.ToLower(statusValue),
|
||||
Size: item.Size,
|
||||
Duration: item.Duration,
|
||||
Format: item.Format,
|
||||
Thumbnail: item.Thumbnail,
|
||||
ProcessingStatus: item.ProcessingStatus,
|
||||
StorageType: item.StorageType,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
|
||||
JobId: linkedJobID,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeVideoStatusValue(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "processing", "pending":
|
||||
return "processing"
|
||||
case "failed", "error":
|
||||
return "failed"
|
||||
default:
|
||||
return "ready"
|
||||
}
|
||||
}
|
||||
|
||||
func DetectStorageType(rawURL string) string {
|
||||
if ShouldDeleteStoredObject(rawURL) {
|
||||
return "S3"
|
||||
}
|
||||
return "WORKER"
|
||||
}
|
||||
|
||||
func ShouldDeleteStoredObject(rawURL string) bool {
|
||||
trimmed := strings.TrimSpace(rawURL)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return !strings.HasPrefix(trimmed, "/")
|
||||
}
|
||||
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
|
||||
}
|
||||
|
||||
func ExtractObjectKey(rawURL string) string {
|
||||
trimmed := strings.TrimSpace(rawURL)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return trimmed
|
||||
}
|
||||
return strings.TrimPrefix(parsed.Path, "/")
|
||||
}
|
||||
|
||||
func ToProtoUser(user *UserPayload) *appv1.User {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.User{
|
||||
Id: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Role: user.Role,
|
||||
GoogleId: user.GoogleID,
|
||||
StorageUsed: user.StorageUsed,
|
||||
PlanId: user.PlanID,
|
||||
PlanStartedAt: TimeToProto(user.PlanStartedAt),
|
||||
PlanExpiresAt: TimeToProto(user.PlanExpiresAt),
|
||||
PlanTermMonths: user.PlanTermMonths,
|
||||
PlanPaymentMethod: user.PlanPaymentMethod,
|
||||
PlanExpiringSoon: user.PlanExpiringSoon,
|
||||
WalletBalance: user.WalletBalance,
|
||||
Language: user.Language,
|
||||
Locale: user.Locale,
|
||||
CreatedAt: TimeToProto(user.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(user.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoPreferences(pref *model.UserPreference) *appv1.Preferences {
|
||||
if pref == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.Preferences{
|
||||
EmailNotifications: BoolValue(pref.EmailNotifications),
|
||||
PushNotifications: BoolValue(pref.PushNotifications),
|
||||
MarketingNotifications: pref.MarketingNotifications,
|
||||
TelegramNotifications: pref.TelegramNotifications,
|
||||
Language: model.StringValue(pref.Language),
|
||||
Locale: model.StringValue(pref.Locale),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoNotification(item model.Notification) *appv1.Notification {
|
||||
return &appv1.Notification{
|
||||
Id: item.ID,
|
||||
Type: NormalizeNotificationType(item.Type),
|
||||
Title: item.Title,
|
||||
Message: item.Message,
|
||||
Read: item.IsRead,
|
||||
ActionUrl: item.ActionURL,
|
||||
ActionLabel: item.ActionLabel,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoDomain(item *model.Domain) *appv1.Domain {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.Domain{
|
||||
Id: item.ID,
|
||||
Name: item.Name,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: TimeToProto(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.AdTemplate{
|
||||
Id: item.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
VastTagUrl: item.VastTagURL,
|
||||
AdFormat: model.StringValue(item.AdFormat),
|
||||
Duration: Int64PtrToInt32Ptr(item.Duration),
|
||||
IsActive: BoolValue(item.IsActive),
|
||||
IsDefault: item.IsDefault,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: TimeToProto(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.PlayerConfig{
|
||||
Id: item.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Autoplay: item.Autoplay,
|
||||
Loop: item.Loop,
|
||||
Muted: item.Muted,
|
||||
ShowControls: BoolValue(item.ShowControls),
|
||||
Pip: BoolValue(item.Pip),
|
||||
Airplay: BoolValue(item.Airplay),
|
||||
Chromecast: BoolValue(item.Chromecast),
|
||||
IsActive: BoolValue(item.IsActive),
|
||||
IsDefault: item.IsDefault,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: TimeToProto(&item.UpdatedAt),
|
||||
EncrytionM3U8: BoolValue(item.EncrytionM3u8),
|
||||
LogoUrl: NullableTrimmedString(item.LogoURL),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.AdminPlayerConfig{
|
||||
Id: item.ID,
|
||||
UserId: item.UserID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Autoplay: item.Autoplay,
|
||||
Loop: item.Loop,
|
||||
Muted: item.Muted,
|
||||
ShowControls: BoolValue(item.ShowControls),
|
||||
Pip: BoolValue(item.Pip),
|
||||
Airplay: BoolValue(item.Airplay),
|
||||
Chromecast: BoolValue(item.Chromecast),
|
||||
IsActive: BoolValue(item.IsActive),
|
||||
IsDefault: item.IsDefault,
|
||||
OwnerEmail: ownerEmail,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: TimeToProto(&item.UpdatedAt),
|
||||
EncrytionM3U8: BoolValue(item.EncrytionM3u8),
|
||||
LogoUrl: NullableTrimmedString(item.LogoURL),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoPlan(item *model.Plan) *appv1.Plan {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.Plan{
|
||||
Id: item.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Price: item.Price,
|
||||
Cycle: item.Cycle,
|
||||
StorageLimit: item.StorageLimit,
|
||||
UploadLimit: item.UploadLimit,
|
||||
DurationLimit: item.DurationLimit,
|
||||
QualityLimit: item.QualityLimit,
|
||||
Features: item.Features,
|
||||
IsActive: BoolValue(item.IsActive),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoPayment(item *model.Payment) *appv1.Payment {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.Payment{
|
||||
Id: item.ID,
|
||||
UserId: item.UserID,
|
||||
PlanId: item.PlanID,
|
||||
Amount: item.Amount,
|
||||
Currency: NormalizeCurrency(item.Currency),
|
||||
Status: NormalizePaymentStatus(item.Status),
|
||||
Provider: strings.ToUpper(StringValue(item.Provider)),
|
||||
TransactionId: item.TransactionID,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.PlanSubscription{
|
||||
Id: item.ID,
|
||||
UserId: item.UserID,
|
||||
PaymentId: item.PaymentID,
|
||||
PlanId: item.PlanID,
|
||||
TermMonths: item.TermMonths,
|
||||
PaymentMethod: item.PaymentMethod,
|
||||
WalletAmount: item.WalletAmount,
|
||||
TopupAmount: item.TopupAmount,
|
||||
StartedAt: timestamppb.New(item.StartedAt.UTC()),
|
||||
ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()),
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: TimeToProto(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &appv1.WalletTransaction{
|
||||
Id: item.ID,
|
||||
UserId: item.UserID,
|
||||
Type: item.Type,
|
||||
Amount: item.Amount,
|
||||
Currency: NormalizeCurrency(item.Currency),
|
||||
Note: item.Note,
|
||||
PaymentId: item.PaymentID,
|
||||
PlanId: item.PlanID,
|
||||
TermMonths: item.TermMonths,
|
||||
CreatedAt: TimeToProto(item.CreatedAt),
|
||||
UpdatedAt: TimeToProto(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
187
internal/modules/common/runtime.go
Normal file
187
internal/modules/common/runtime.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/middleware"
|
||||
videodomain "stream.api/internal/video"
|
||||
"stream.api/pkg/cache"
|
||||
"stream.api/pkg/logger"
|
||||
"stream.api/pkg/storage"
|
||||
"stream.api/pkg/token"
|
||||
)
|
||||
|
||||
const (
|
||||
AdTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
|
||||
DefaultGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
PlayerConfigFreePlanLimitMessage = "Free plan supports only 1 player config"
|
||||
PlayerConfigFreePlanReconciliationMessage = "Delete extra player configs to continue managing player configs on the free plan"
|
||||
WalletTransactionTypeTopup = "topup"
|
||||
WalletTransactionTypeSubscriptionDebit = "subscription_debit"
|
||||
WalletTransactionTypeReferralReward = "referral_reward"
|
||||
PaymentMethodWallet = "wallet"
|
||||
PaymentMethodTopup = "topup"
|
||||
PaymentKindSubscription = "subscription"
|
||||
PaymentKindWalletTopup = "wallet_topup"
|
||||
DefaultReferralRewardBps = int32(500)
|
||||
)
|
||||
|
||||
var AllowedTermMonths = map[int32]struct{}{
|
||||
1: {},
|
||||
3: {},
|
||||
6: {},
|
||||
12: {},
|
||||
}
|
||||
|
||||
type RuntimeOptions struct {
|
||||
DB *gorm.DB
|
||||
Logger logger.Logger
|
||||
Authenticator *middleware.Authenticator
|
||||
TokenProvider token.Provider
|
||||
Cache cache.Cache
|
||||
GoogleOauth *oauth2.Config
|
||||
GoogleStateTTL time.Duration
|
||||
GoogleUserInfoURL string
|
||||
FrontendBaseURL string
|
||||
StorageProvider func() storage.Provider
|
||||
VideoService func() *videodomain.Service
|
||||
AgentRuntime func() videodomain.AgentRuntime
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
db *gorm.DB
|
||||
logger logger.Logger
|
||||
authenticator *middleware.Authenticator
|
||||
tokenProvider token.Provider
|
||||
cache cache.Cache
|
||||
googleOauth *oauth2.Config
|
||||
googleStateTTL time.Duration
|
||||
googleUserInfoURL string
|
||||
frontendBaseURL string
|
||||
storageProvider func() storage.Provider
|
||||
videoService func() *videodomain.Service
|
||||
agentRuntime func() videodomain.AgentRuntime
|
||||
}
|
||||
|
||||
func NewRuntime(opts RuntimeOptions) *Runtime {
|
||||
googleStateTTL := opts.GoogleStateTTL
|
||||
if googleStateTTL <= 0 {
|
||||
googleStateTTL = 10 * time.Minute
|
||||
}
|
||||
googleUserInfoURL := strings.TrimSpace(opts.GoogleUserInfoURL)
|
||||
if googleUserInfoURL == "" {
|
||||
googleUserInfoURL = DefaultGoogleUserInfoURL
|
||||
}
|
||||
return &Runtime{
|
||||
db: opts.DB,
|
||||
logger: opts.Logger,
|
||||
authenticator: opts.Authenticator,
|
||||
tokenProvider: opts.TokenProvider,
|
||||
cache: opts.Cache,
|
||||
googleOauth: opts.GoogleOauth,
|
||||
googleStateTTL: googleStateTTL,
|
||||
googleUserInfoURL: googleUserInfoURL,
|
||||
frontendBaseURL: strings.TrimSpace(opts.FrontendBaseURL),
|
||||
storageProvider: opts.StorageProvider,
|
||||
videoService: opts.VideoService,
|
||||
agentRuntime: opts.AgentRuntime,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) DB() *gorm.DB { return r.db }
|
||||
func (r *Runtime) Logger() logger.Logger { return r.logger }
|
||||
func (r *Runtime) Authenticator() *middleware.Authenticator { return r.authenticator }
|
||||
func (r *Runtime) TokenProvider() token.Provider { return r.tokenProvider }
|
||||
func (r *Runtime) Cache() cache.Cache { return r.cache }
|
||||
func (r *Runtime) GoogleOauth() *oauth2.Config { return r.googleOauth }
|
||||
func (r *Runtime) GoogleStateTTL() time.Duration { return r.googleStateTTL }
|
||||
func (r *Runtime) GoogleUserInfoURL() string { return r.googleUserInfoURL }
|
||||
func (r *Runtime) FrontendBaseURL() string { return r.frontendBaseURL }
|
||||
|
||||
func (r *Runtime) StorageProvider() storage.Provider {
|
||||
if r == nil || r.storageProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return r.storageProvider()
|
||||
}
|
||||
|
||||
func (r *Runtime) VideoService() *videodomain.Service {
|
||||
if r == nil || r.videoService == nil {
|
||||
return nil
|
||||
}
|
||||
return r.videoService()
|
||||
}
|
||||
|
||||
func (r *Runtime) AgentRuntime() videodomain.AgentRuntime {
|
||||
if r == nil || r.agentRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
return r.agentRuntime()
|
||||
}
|
||||
|
||||
func (r *Runtime) Authenticate(ctx context.Context) (*middleware.AuthResult, error) {
|
||||
if r == nil || r.authenticator == nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
|
||||
}
|
||||
return r.authenticator.Authenticate(ctx)
|
||||
}
|
||||
|
||||
func (r *Runtime) RequireAdmin(ctx context.Context) (*middleware.AuthResult, error) {
|
||||
result, err := r.Authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.User == nil || result.User.Role == nil || strings.ToUpper(strings.TrimSpace(*result.User.Role)) != "ADMIN" {
|
||||
return nil, status.Error(codes.PermissionDenied, "Admin access required")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) IssueSessionCookies(ctx context.Context, user *model.User) error {
|
||||
if user == nil {
|
||||
return status.Error(codes.Unauthenticated, "Unauthorized")
|
||||
}
|
||||
if r == nil || r.tokenProvider == nil || r.cache == nil {
|
||||
return status.Error(codes.Internal, "Error storing session")
|
||||
}
|
||||
tokenPair, err := r.tokenProvider.GenerateTokenPair(user.ID, user.Email, SafeRole(user.Role))
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error("Token generation failed", "error", err)
|
||||
}
|
||||
return status.Error(codes.Internal, "Error generating tokens")
|
||||
}
|
||||
if err := r.cache.Set(ctx, "refresh_uuid:"+tokenPair.RefreshUUID, user.ID, time.Until(time.Unix(tokenPair.RtExpires, 0))); err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error("Session storage failed", "error", err)
|
||||
}
|
||||
return status.Error(codes.Internal, "Error storing session")
|
||||
}
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs(
|
||||
"set-cookie", BuildTokenCookie("access_token", tokenPair.AccessToken, int(tokenPair.AtExpires-time.Now().Unix())),
|
||||
"set-cookie", BuildTokenCookie("refresh_token", tokenPair.RefreshToken, int(tokenPair.RtExpires-time.Now().Unix())),
|
||||
)); err != nil && r.logger != nil {
|
||||
r.logger.Error("Failed to set gRPC auth headers", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildTokenCookie(name string, value string, maxAge int) string {
|
||||
return (&http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
}).String()
|
||||
}
|
||||
120
internal/modules/common/user_payload.go
Normal file
120
internal/modules/common/user_payload.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
)
|
||||
|
||||
type UserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
GoogleID *string `json:"google_id,omitempty"`
|
||||
StorageUsed int64 `json:"storage_used"`
|
||||
PlanID *string `json:"plan_id,omitempty"`
|
||||
PlanStartedAt *time.Time `json:"plan_started_at,omitempty"`
|
||||
PlanExpiresAt *time.Time `json:"plan_expires_at,omitempty"`
|
||||
PlanTermMonths *int32 `json:"plan_term_months,omitempty"`
|
||||
PlanPaymentMethod *string `json:"plan_payment_method,omitempty"`
|
||||
PlanExpiringSoon bool `json:"plan_expiring_soon"`
|
||||
WalletBalance float64 `json:"wallet_balance"`
|
||||
Language string `json:"language"`
|
||||
Locale string `json:"locale"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func BuildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*UserPayload, error) {
|
||||
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walletBalance, err := model.GetWalletBalance(ctx, db, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
language := strings.TrimSpace(model.StringValue(pref.Language))
|
||||
if language == "" {
|
||||
language = "en"
|
||||
}
|
||||
locale := strings.TrimSpace(model.StringValue(pref.Locale))
|
||||
if locale == "" {
|
||||
locale = language
|
||||
}
|
||||
|
||||
effectivePlanID := user.PlanID
|
||||
var planStartedAt *time.Time
|
||||
var planExpiresAt *time.Time
|
||||
var planTermMonths *int32
|
||||
var planPaymentMethod *string
|
||||
planExpiringSoon := false
|
||||
now := time.Now().UTC()
|
||||
|
||||
subscription, err := model.GetLatestPlanSubscription(ctx, db, user.ID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
startedAt := subscription.StartedAt.UTC()
|
||||
expiresAt := subscription.ExpiresAt.UTC()
|
||||
termMonths := subscription.TermMonths
|
||||
paymentMethod := normalizePlanPaymentMethod(subscription.PaymentMethod)
|
||||
|
||||
planStartedAt = &startedAt
|
||||
planExpiresAt = &expiresAt
|
||||
planTermMonths = &termMonths
|
||||
planPaymentMethod = &paymentMethod
|
||||
|
||||
if expiresAt.After(now) {
|
||||
effectivePlanID = &subscription.PlanID
|
||||
planExpiringSoon = isPlanExpiringSoon(expiresAt, now)
|
||||
} else {
|
||||
effectivePlanID = nil
|
||||
}
|
||||
}
|
||||
|
||||
return &UserPayload{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Role: user.Role,
|
||||
GoogleID: user.GoogleID,
|
||||
StorageUsed: user.StorageUsed,
|
||||
PlanID: effectivePlanID,
|
||||
PlanStartedAt: planStartedAt,
|
||||
PlanExpiresAt: planExpiresAt,
|
||||
PlanTermMonths: planTermMonths,
|
||||
PlanPaymentMethod: planPaymentMethod,
|
||||
PlanExpiringSoon: planExpiringSoon,
|
||||
WalletBalance: walletBalance,
|
||||
Language: language,
|
||||
Locale: locale,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizePlanPaymentMethod(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "topup":
|
||||
return "topup"
|
||||
default:
|
||||
return "wallet"
|
||||
}
|
||||
}
|
||||
|
||||
func isPlanExpiringSoon(expiresAt time.Time, now time.Time) bool {
|
||||
hoursUntilExpiry := expiresAt.Sub(now).Hours()
|
||||
const thresholdHours = 7 * 24
|
||||
return hoursUntilExpiry > 0 && hoursUntilExpiry <= thresholdHours
|
||||
}
|
||||
Reference in New Issue
Block a user