first commit

This commit is contained in:
2026-03-28 15:43:18 +08:00
commit e5611df24e
54 changed files with 11065 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
package service
import (
"context"
"errors"
"time"
"filefast/backend/internal/config"
"filefast/backend/internal/model"
"filefast/backend/internal/store"
"github.com/google/uuid"
)
type AdminService struct {
store *store.MemoryStore
config config.AdminConfig
sessionStore adminSessionStore
authStore adminCredentialStore
sessionTTL time.Duration
}
type adminSessionStore interface {
SaveAdminSession(context.Context, model.AdminSession, time.Duration) error
HasAdminSession(context.Context, string) (bool, error)
}
type adminCredentialStore interface {
ValidateAdminCredentials(context.Context, string, string) (bool, error)
}
func NewAdminService(store *store.MemoryStore, cfg config.AdminConfig, sessionStore adminSessionStore, authStore adminCredentialStore) *AdminService {
return &AdminService{
store: store,
config: cfg,
sessionStore: sessionStore,
authStore: authStore,
sessionTTL: 24 * time.Hour,
}
}
func (s *AdminService) Login(username, password string) (model.AdminSession, error) {
if s.authStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ok, err := s.authStore.ValidateAdminCredentials(ctx, username, password)
if err == nil {
if !ok {
return model.AdminSession{}, errors.New("invalid admin credentials")
}
return s.issueSession()
}
}
if username != s.config.Username || password != s.config.Password {
return model.AdminSession{}, errors.New("invalid admin credentials")
}
return s.issueSession()
}
func (s *AdminService) issueSession() (model.AdminSession, error) {
session := model.AdminSession{
Token: uuid.NewString(),
CreatedAt: time.Now(),
}
session = s.store.SaveAdminSession(session)
if s.sessionStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.sessionStore.SaveAdminSession(ctx, session, s.sessionTTL); err != nil {
return model.AdminSession{}, err
}
}
return session, nil
}
func (s *AdminService) ValidateToken(token string) bool {
if s.store.HasAdminSession(token) {
return true
}
if s.sessionStore == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ok, err := s.sessionStore.HasAdminSession(ctx, token)
return err == nil && ok
}

View File

@@ -0,0 +1,210 @@
package service
import (
"context"
"sort"
"strings"
"time"
"filefast/backend/internal/model"
"filefast/backend/internal/store"
"github.com/google/uuid"
)
type DeviceService struct {
store *store.MemoryStore
presenceStore devicePresenceStore
sessionStore deviceSessionStore
presenceTTL time.Duration
sessionTTL time.Duration
}
type devicePresenceStore interface {
SetDevicePresence(context.Context, string, bool, time.Time, time.Duration) error
GetDevicePresence(context.Context, []string) (map[string]bool, error)
}
type deviceSessionStore interface {
SaveDeviceSession(context.Context, model.DeviceSession, time.Duration) error
ValidateDeviceSession(context.Context, string, string) (bool, error)
}
type RegisterDeviceInput struct {
DeviceID string `json:"device_id"`
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
NetworkGroupKey string `json:"network_group_key"`
PublicIPHash string `json:"public_ip_hash"`
}
func NewDeviceService(store *store.MemoryStore, presenceStore devicePresenceStore, sessionStore deviceSessionStore) *DeviceService {
return &DeviceService{
store: store,
presenceStore: presenceStore,
sessionStore: sessionStore,
presenceTTL: 45 * time.Second,
sessionTTL: 30 * 24 * time.Hour,
}
}
func (s *DeviceService) Register(input RegisterDeviceInput, userAgent, claimedToken string) (model.Device, model.DeviceSession) {
now := time.Now()
id := strings.TrimSpace(input.DeviceID)
if id == "" {
id = uuid.NewString()
}
device, exists := s.store.GetDevice(id)
if exists && !s.ValidateSession(id, strings.TrimSpace(claimedToken)) {
id = uuid.NewString()
exists = false
}
if !exists {
device = model.Device{
ID: id,
CreatedAt: now,
}
}
device.Name = input.Name
device.Type = input.Type
device.UserAgent = userAgent
device.NetworkGroupKey = input.NetworkGroupKey
device.PublicIPHash = input.PublicIPHash
device.LastSeenAt = now
device.IsOnline = true
device = s.store.UpsertDevice(device)
session := s.issueSession(device.ID)
s.syncPresence(device)
return device, session
}
func (s *DeviceService) Heartbeat(deviceID string) (model.Device, bool) {
device, ok := s.store.GetDevice(deviceID)
if !ok {
return model.Device{}, false
}
device.LastSeenAt = time.Now()
device.IsOnline = true
device = s.store.UpsertDevice(device)
s.syncPresence(device)
return device, true
}
func (s *DeviceService) SetOnline(deviceID string, online bool) (model.Device, bool) {
device, ok := s.store.GetDevice(deviceID)
if !ok {
return model.Device{}, false
}
device.IsOnline = online
device.LastSeenAt = time.Now()
device = s.store.UpsertDevice(device)
s.syncPresence(device)
return device, true
}
func (s *DeviceService) ListCandidates(currentDeviceID string) []model.Device {
current, _ := s.store.GetDevice(currentDeviceID)
devices := s.store.ListDevices()
s.applyPresence(devices)
candidates := make([]model.Device, 0, len(devices))
for _, device := range devices {
if device.ID == currentDeviceID || !device.IsOnline {
continue
}
candidates = append(candidates, device)
}
sort.SliceStable(candidates, func(i, j int) bool {
leftSameNetwork := current.NetworkGroupKey != "" && candidates[i].NetworkGroupKey == current.NetworkGroupKey
rightSameNetwork := current.NetworkGroupKey != "" && candidates[j].NetworkGroupKey == current.NetworkGroupKey
if leftSameNetwork != rightSameNetwork {
return leftSameNetwork
}
return candidates[i].LastSeenAt.After(candidates[j].LastSeenAt)
})
return candidates
}
func (s *DeviceService) ValidateSession(deviceID, token string) bool {
deviceID = strings.TrimSpace(deviceID)
token = strings.TrimSpace(token)
if deviceID == "" || token == "" {
return false
}
if s.store.ValidateDeviceSession(deviceID, token) {
return true
}
if s.sessionStore == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ok, err := s.sessionStore.ValidateDeviceSession(ctx, deviceID, token)
return err == nil && ok
}
func (s *DeviceService) issueSession(deviceID string) model.DeviceSession {
session := model.DeviceSession{
DeviceID: deviceID,
Token: uuid.NewString(),
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(s.sessionTTL),
}
session = s.store.SaveDeviceSession(session)
if s.sessionStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.sessionStore.SaveDeviceSession(ctx, session, s.sessionTTL)
}
return session
}
func (s *DeviceService) syncPresence(device model.Device) {
if s.presenceStore == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.presenceStore.SetDevicePresence(ctx, device.ID, device.IsOnline, device.LastSeenAt, s.presenceTTL)
}
func (s *DeviceService) applyPresence(devices []model.Device) {
if s.presenceStore == nil || len(devices) == 0 {
return
}
deviceIDs := make([]string, 0, len(devices))
for _, device := range devices {
if device.ID != "" {
deviceIDs = append(deviceIDs, device.ID)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
statuses, err := s.presenceStore.GetDevicePresence(ctx, deviceIDs)
if err != nil {
return
}
for index := range devices {
if online, ok := statuses[devices[index].ID]; ok {
devices[index].IsOnline = online
}
}
}

View File

@@ -0,0 +1,58 @@
package service
import (
"testing"
"filefast/backend/internal/model"
"filefast/backend/internal/store"
)
func TestRegisterReusesKnownDeviceOnlyWithValidToken(t *testing.T) {
memStore := store.NewMemoryStore(model.RuntimeConfig{})
deviceService := NewDeviceService(memStore, nil, nil)
device, session := deviceService.Register(RegisterDeviceInput{
DeviceID: "known-device",
Name: "Alpha",
Type: "desktop",
}, "ua/1.0", "")
if device.ID != "known-device" {
t.Fatalf("expected first registration to keep requested device id, got %q", device.ID)
}
if !deviceService.ValidateSession(device.ID, session.Token) {
t.Fatal("expected issued device token to validate")
}
hijacked, hijackedSession := deviceService.Register(RegisterDeviceInput{
DeviceID: "known-device",
Name: "Mallory",
Type: "desktop",
}, "ua/1.0", "")
if hijacked.ID == device.ID {
t.Fatal("expected registration without token to receive a new device id")
}
if !deviceService.ValidateSession(hijacked.ID, hijackedSession.Token) {
t.Fatal("expected replacement device token to validate")
}
restored, rotatedSession := deviceService.Register(RegisterDeviceInput{
DeviceID: "known-device",
Name: "Alpha",
Type: "desktop",
}, "ua/1.0", session.Token)
if restored.ID != device.ID {
t.Fatalf("expected valid token to reclaim original device id, got %q", restored.ID)
}
if rotatedSession.Token == session.Token {
t.Fatal("expected registration to rotate the device token")
}
if deviceService.ValidateSession(restored.ID, session.Token) {
t.Fatal("expected rotated token to invalidate the old token")
}
if !deviceService.ValidateSession(restored.ID, rotatedSession.Token) {
t.Fatal("expected rotated device token to validate")
}
}

View File

@@ -0,0 +1,84 @@
package service
import (
"errors"
"fmt"
"math/rand/v2"
"time"
"filefast/backend/internal/model"
"filefast/backend/internal/store"
)
type RoomService struct {
store *store.MemoryStore
ttl time.Duration
}
func NewRoomService(store *store.MemoryStore, ttl time.Duration) *RoomService {
return &RoomService{
store: store,
ttl: ttl,
}
}
func (s *RoomService) CreateRoom(creatorDeviceID string) (model.Room, error) {
if creatorDeviceID == "" {
return model.Room{}, errors.New("creator_device_id is required")
}
now := time.Now()
for range 20 {
code := fmt.Sprintf("%04d", rand.IntN(10000))
if room, ok := s.store.GetRoom(code); ok && room.ExpiresAt.After(now) && room.Status != model.RoomStatusExpired {
continue
}
room := model.Room{
Code: code,
CreatorDeviceID: creatorDeviceID,
Status: model.RoomStatusWaiting,
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
}
return s.store.UpsertRoom(room), nil
}
return model.Room{}, errors.New("failed to allocate room code")
}
func (s *RoomService) JoinRoom(code, joinerDeviceID string) (model.Room, error) {
room, ok := s.store.GetRoom(code)
if !ok {
return model.Room{}, errors.New("room not found")
}
now := time.Now()
if !room.ExpiresAt.After(now) {
room.Status = model.RoomStatusExpired
s.store.UpsertRoom(room)
return model.Room{}, errors.New("room expired")
}
if room.Status != model.RoomStatusWaiting {
return model.Room{}, errors.New("room unavailable")
}
room.JoinerDeviceID = joinerDeviceID
room.Status = model.RoomStatusJoined
return s.store.UpsertRoom(room), nil
}
func (s *RoomService) CancelRoom(code, requesterID string) (model.Room, error) {
room, ok := s.store.GetRoom(code)
if !ok {
return model.Room{}, errors.New("room not found")
}
if room.CreatorDeviceID != requesterID {
return model.Room{}, errors.New("only creator can cancel room")
}
room.Status = model.RoomStatusCanceled
return s.store.UpsertRoom(room), nil
}

View File

@@ -0,0 +1,138 @@
package service
import (
"errors"
"fmt"
"time"
"filefast/backend/internal/model"
"filefast/backend/internal/store"
"github.com/google/uuid"
)
type TransferService struct {
store *store.MemoryStore
}
type CreateTransferInput struct {
SessionID string `json:"session_id"`
Kind string `json:"kind" binding:"required"`
Name string `json:"name"`
Content string `json:"content"`
SizeBytes int64 `json:"size_bytes"`
SenderDeviceID string `json:"sender_device_id" binding:"required"`
ReceiverDeviceID string `json:"receiver_device_id" binding:"required"`
}
type UpdateTransferStatusInput struct {
CurrentChannel string `json:"current_channel"`
FinalStatus string `json:"final_status"`
FallbackReason string `json:"fallback_reason"`
}
func NewTransferService(store *store.MemoryStore) *TransferService {
return &TransferService{store: store}
}
func (s *TransferService) Create(input CreateTransferInput) (model.Transfer, error) {
switch input.Kind {
case "file":
if input.Name == "" {
return model.Transfer{}, errors.New("file name is required")
}
if input.SizeBytes <= 0 {
return model.Transfer{}, errors.New("file size must be greater than zero")
}
case "text":
if input.Content == "" {
return model.Transfer{}, errors.New("text content is required")
}
if input.Name == "" {
input.Name = "text-message"
}
default:
return model.Transfer{}, errors.New("unsupported transfer kind")
}
runtime := s.store.RuntimeConfig()
now := time.Now()
fallbackAllowed := input.Kind == "file" && runtime.MinIOFallbackEnabled
strategy := "p2p_turn"
if fallbackAllowed {
strategy = "p2p_turn_minio"
}
transfer := model.Transfer{
ID: uuid.NewString(),
SessionID: input.SessionID,
Kind: input.Kind,
Name: input.Name,
Content: input.Content,
SizeBytes: input.SizeBytes,
SenderDeviceID: input.SenderDeviceID,
ReceiverDeviceID: input.ReceiverDeviceID,
TransferStrategy: strategy,
CurrentChannel: model.ChannelP2P,
FallbackAllowed: fallbackAllowed,
FinalStatus: model.TransferPending,
CreatedAt: now,
UpdatedAt: now,
}
return s.store.UpsertTransfer(transfer), nil
}
func (s *TransferService) UpdateStatus(transferID string, input UpdateTransferStatusInput) (model.Transfer, error) {
transfer, ok := s.store.GetTransfer(transferID)
if !ok {
return model.Transfer{}, errors.New("transfer not found")
}
if input.CurrentChannel != "" {
transfer.CurrentChannel = input.CurrentChannel
}
if input.FinalStatus != "" {
transfer.FinalStatus = input.FinalStatus
}
if input.FallbackReason != "" {
transfer.FallbackReason = input.FallbackReason
}
transfer.UpdatedAt = time.Now()
return s.store.UpsertTransfer(transfer), nil
}
func (s *TransferService) PrepareFallback(transferID string) (model.Transfer, model.FallbackObject, error) {
transfer, ok := s.store.GetTransfer(transferID)
if !ok {
return model.Transfer{}, model.FallbackObject{}, errors.New("transfer not found")
}
if !transfer.FallbackAllowed {
return model.Transfer{}, model.FallbackObject{}, errors.New("transfer cannot use minio fallback")
}
if object, ok := s.store.GetFallbackObject(transfer.ID); ok && object.CleanedAt == nil && object.ExpiresAt.After(time.Now()) {
return transfer, object, nil
}
runtime := s.store.RuntimeConfig()
now := time.Now()
expireAt := now.Add(time.Duration(runtime.MinIORetentionHours) * time.Hour)
objectKey := fmt.Sprintf("fallback/%s/%d-%s", now.Format("20060102"), now.Unix(), transfer.ID)
transfer.ObjectKey = objectKey
transfer.ExpiresAt = &expireAt
transfer.UpdatedAt = now
transfer = s.store.UpsertTransfer(transfer)
object := model.FallbackObject{
TransferID: transfer.ID,
ObjectKey: objectKey,
SizeBytes: transfer.SizeBytes,
CreatedAt: now,
ExpiresAt: expireAt,
CleanupState: "uploading",
}
object = s.store.SaveFallbackObject(object)
return transfer, object, nil
}