first commit
This commit is contained in:
141
backend/internal/storage/minio.go
Normal file
141
backend/internal/storage/minio.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filefast/backend/internal/config"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type MinIOClient struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
presignExpiry time.Duration
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func NewMinIOClient(cfg config.MinIOConfig) (*MinIOClient, error) {
|
||||
if cfg.Endpoint == "" || cfg.AccessKey == "" || cfg.SecretKey == "" {
|
||||
return &MinIOClient{
|
||||
bucket: cfg.Bucket,
|
||||
presignExpiry: cfg.PresignExpiry,
|
||||
enabled: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MinIOClient{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
presignExpiry: cfg.PresignExpiry,
|
||||
enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *MinIOClient) Enabled() bool {
|
||||
return c != nil && c.enabled
|
||||
}
|
||||
|
||||
func (c *MinIOClient) EnsureBucket(ctx context.Context) error {
|
||||
if !c.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists, err := c.client.BucketExists(ctx, c.bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return c.client.MakeBucket(ctx, c.bucket, minio.MakeBucketOptions{})
|
||||
}
|
||||
|
||||
func (c *MinIOClient) PresignUpload(ctx context.Context, objectKey string) (*url.URL, error) {
|
||||
if !c.Enabled() {
|
||||
return nil, errors.New("minio is disabled")
|
||||
}
|
||||
return c.client.PresignedPutObject(ctx, c.bucket, objectKey, c.presignExpiry)
|
||||
}
|
||||
|
||||
func (c *MinIOClient) PresignDownload(ctx context.Context, objectKey string) (*url.URL, error) {
|
||||
return c.PresignDownloadWithFilename(ctx, objectKey, "")
|
||||
}
|
||||
|
||||
func (c *MinIOClient) PresignDownloadWithFilename(ctx context.Context, objectKey, filename string) (*url.URL, error) {
|
||||
if !c.Enabled() {
|
||||
return nil, errors.New("minio is disabled")
|
||||
}
|
||||
|
||||
var reqParams url.Values
|
||||
if filename = sanitizeDownloadFilename(filename); filename != "" {
|
||||
reqParams = make(url.Values)
|
||||
reqParams.Set("response-content-disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
return c.client.PresignedGetObject(ctx, c.bucket, objectKey, c.presignExpiry, reqParams)
|
||||
}
|
||||
|
||||
func (c *MinIOClient) UploadObject(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error {
|
||||
if !c.Enabled() {
|
||||
return errors.New("minio is disabled")
|
||||
}
|
||||
|
||||
options := minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
}
|
||||
_, err := c.client.PutObject(ctx, c.bucket, objectKey, reader, size, options)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *MinIOClient) OpenObject(ctx context.Context, objectKey string) (*minio.Object, minio.ObjectInfo, error) {
|
||||
if !c.Enabled() {
|
||||
return nil, minio.ObjectInfo{}, errors.New("minio is disabled")
|
||||
}
|
||||
|
||||
object, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, minio.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
info, err := object.Stat()
|
||||
if err != nil {
|
||||
_ = object.Close()
|
||||
return nil, minio.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
return object, info, nil
|
||||
}
|
||||
|
||||
func (c *MinIOClient) RemoveObject(ctx context.Context, objectKey string) error {
|
||||
if !c.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return c.client.RemoveObject(ctx, c.bucket, objectKey, minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
func sanitizeDownloadFilename(filename string) string {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
filename = strings.ReplaceAll(filename, `"`, "")
|
||||
filename = strings.ReplaceAll(filename, "\r", "")
|
||||
filename = strings.ReplaceAll(filename, "\n", "")
|
||||
return filename
|
||||
}
|
||||
205
backend/internal/storage/redis.go
Normal file
205
backend/internal/storage/redis.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"filefast/backend/internal/config"
|
||||
"filefast/backend/internal/model"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
RedisRealtimeRelayChannel = "filefast:ws:relay"
|
||||
RedisRealtimePresenceChannel = "filefast:ws:presence"
|
||||
)
|
||||
|
||||
type RedisClient struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
type RealtimeMessage struct {
|
||||
Channel string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func NewRedisClient(cfg config.RedisConfig) *RedisClient {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
MaxRetries: 1,
|
||||
DialTimeout: 3 * time.Second,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
})
|
||||
|
||||
return &RedisClient{client: client}
|
||||
}
|
||||
|
||||
func (c *RedisClient) Ping(ctx context.Context) error {
|
||||
if !c.available() {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
return c.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func (c *RedisClient) SaveAdminSession(ctx context.Context, session model.AdminSession, ttl time.Duration) error {
|
||||
if !c.available() {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.client.Set(ctx, adminSessionKey(session.Token), payload, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *RedisClient) HasAdminSession(ctx context.Context, token string) (bool, error) {
|
||||
if !c.available() {
|
||||
return false, redis.ErrClosed
|
||||
}
|
||||
|
||||
result, err := c.client.Exists(ctx, adminSessionKey(token)).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return result > 0, nil
|
||||
}
|
||||
|
||||
func (c *RedisClient) SaveDeviceSession(ctx context.Context, session model.DeviceSession, ttl time.Duration) error {
|
||||
if !c.available() {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
|
||||
return c.client.Set(ctx, deviceSessionKey(session.DeviceID), session.Token, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *RedisClient) ValidateDeviceSession(ctx context.Context, deviceID, token string) (bool, error) {
|
||||
if !c.available() {
|
||||
return false, redis.ErrClosed
|
||||
}
|
||||
|
||||
value, err := c.client.Get(ctx, deviceSessionKey(deviceID)).Result()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return value == token, nil
|
||||
}
|
||||
|
||||
func (c *RedisClient) SetDevicePresence(ctx context.Context, deviceID string, online bool, lastSeen time.Time, ttl time.Duration) error {
|
||||
if !c.available() {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
|
||||
key := devicePresenceKey(deviceID)
|
||||
if !online {
|
||||
return c.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
return c.client.Set(ctx, key, lastSeen.UTC().Format(time.RFC3339Nano), ttl).Err()
|
||||
}
|
||||
|
||||
func (c *RedisClient) GetDevicePresence(ctx context.Context, deviceIDs []string) (map[string]bool, error) {
|
||||
if !c.available() {
|
||||
return nil, redis.ErrClosed
|
||||
}
|
||||
|
||||
statuses := make(map[string]bool, len(deviceIDs))
|
||||
if len(deviceIDs) == 0 {
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(deviceIDs))
|
||||
for _, id := range deviceIDs {
|
||||
keys = append(keys, devicePresenceKey(id))
|
||||
}
|
||||
|
||||
values, err := c.client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for index, id := range deviceIDs {
|
||||
statuses[id] = values[index] != nil
|
||||
}
|
||||
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
func (c *RedisClient) PublishRealtime(ctx context.Context, channel string, payload []byte) error {
|
||||
if !c.available() {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
return c.client.Publish(ctx, channel, payload).Err()
|
||||
}
|
||||
|
||||
func (c *RedisClient) SubscribeRealtime(ctx context.Context, channels ...string) (<-chan RealtimeMessage, error) {
|
||||
if !c.available() {
|
||||
return nil, redis.ErrClosed
|
||||
}
|
||||
|
||||
pubsub := c.client.Subscribe(ctx, channels...)
|
||||
if _, err := pubsub.Receive(ctx); err != nil {
|
||||
_ = pubsub.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
source := pubsub.Channel()
|
||||
messages := make(chan RealtimeMessage, 64)
|
||||
|
||||
go func() {
|
||||
defer close(messages)
|
||||
defer pubsub.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case message, ok := <-source:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
messages <- RealtimeMessage{
|
||||
Channel: message.Channel,
|
||||
Payload: []byte(message.Payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (c *RedisClient) Close() error {
|
||||
if !c.available() {
|
||||
return nil
|
||||
}
|
||||
return c.client.Close()
|
||||
}
|
||||
|
||||
func (c *RedisClient) available() bool {
|
||||
return c != nil && c.client != nil
|
||||
}
|
||||
|
||||
func adminSessionKey(token string) string {
|
||||
return fmt.Sprintf("filefast:admin:session:%s", token)
|
||||
}
|
||||
|
||||
func devicePresenceKey(deviceID string) string {
|
||||
return fmt.Sprintf("filefast:device:online:%s", deviceID)
|
||||
}
|
||||
|
||||
func deviceSessionKey(deviceID string) string {
|
||||
return fmt.Sprintf("filefast:device:session:%s", deviceID)
|
||||
}
|
||||
125
backend/internal/storage/shared.go
Normal file
125
backend/internal/storage/shared.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"filefast/backend/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeConfigKey = "transfer_policy"
|
||||
)
|
||||
|
||||
type runtimeConfigPayload struct {
|
||||
MaxMinIOFallbackSizeBytes *int64 `json:"max_minio_fallback_size_bytes"`
|
||||
MaxMinIOFallbackGB *int64 `json:"max_minio_fallback_gb"`
|
||||
MinIOCapacityBytes *int64 `json:"minio_capacity_bytes"`
|
||||
MinIOCapacityGB *int64 `json:"minio_capacity_gb"`
|
||||
MinIORetentionHours *int `json:"minio_retention_hours"`
|
||||
MinIOUsageAlertPercent *int `json:"minio_usage_alert_percent"`
|
||||
P2PConnectTimeoutSec *int `json:"p2p_connect_timeout_sec"`
|
||||
TURNConnectTimeoutSec *int `json:"turn_connect_timeout_sec"`
|
||||
MinIOFallbackEnabled *bool `json:"minio_fallback_enabled"`
|
||||
TURNURLs []string `json:"turn_urls"`
|
||||
TURNUsername *string `json:"turn_username"`
|
||||
TURNPassword *string `json:"turn_password"`
|
||||
}
|
||||
|
||||
func decodeRuntimeConfig(raw []byte, fallback model.RuntimeConfig) (model.RuntimeConfig, error) {
|
||||
cfg := fallback
|
||||
if len(raw) == 0 {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
var payload runtimeConfigPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return model.RuntimeConfig{}, err
|
||||
}
|
||||
|
||||
if payload.MaxMinIOFallbackSizeBytes != nil {
|
||||
cfg.MaxMinIOFallbackSizeBytes = *payload.MaxMinIOFallbackSizeBytes
|
||||
} else if payload.MaxMinIOFallbackGB != nil {
|
||||
cfg.MaxMinIOFallbackSizeBytes = *payload.MaxMinIOFallbackGB * 1024 * 1024 * 1024
|
||||
}
|
||||
if payload.MinIOCapacityBytes != nil {
|
||||
cfg.MinIOCapacityBytes = *payload.MinIOCapacityBytes
|
||||
} else if payload.MinIOCapacityGB != nil {
|
||||
cfg.MinIOCapacityBytes = *payload.MinIOCapacityGB * 1024 * 1024 * 1024
|
||||
}
|
||||
if payload.MinIORetentionHours != nil {
|
||||
cfg.MinIORetentionHours = *payload.MinIORetentionHours
|
||||
}
|
||||
if payload.MinIOUsageAlertPercent != nil {
|
||||
cfg.MinIOUsageAlertPercent = *payload.MinIOUsageAlertPercent
|
||||
}
|
||||
if payload.P2PConnectTimeoutSec != nil {
|
||||
cfg.P2PConnectTimeoutSec = *payload.P2PConnectTimeoutSec
|
||||
}
|
||||
if payload.TURNConnectTimeoutSec != nil {
|
||||
cfg.TURNConnectTimeoutSec = *payload.TURNConnectTimeoutSec
|
||||
}
|
||||
if payload.MinIOFallbackEnabled != nil {
|
||||
cfg.MinIOFallbackEnabled = *payload.MinIOFallbackEnabled
|
||||
}
|
||||
if payload.TURNURLs != nil {
|
||||
cfg.TURNURLs = payload.TURNURLs
|
||||
}
|
||||
if payload.TURNUsername != nil {
|
||||
cfg.TURNUsername = *payload.TURNUsername
|
||||
}
|
||||
if payload.TURNPassword != nil {
|
||||
cfg.TURNPassword = *payload.TURNPassword
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func readFirstExistingFile(paths ...string) ([]byte, error) {
|
||||
candidates := make([]string, 0, len(paths)*8)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
current := cwd
|
||||
for {
|
||||
for _, path := range paths {
|
||||
candidate := filepath.Clean(filepath.Join(current, path))
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
candidate := filepath.Clean(path)
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
617
backend/internal/storage/sqlite.go
Normal file
617
backend/internal/storage/sqlite.go
Normal file
@@ -0,0 +1,617 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filefast/backend/internal/config"
|
||||
"filefast/backend/internal/model"
|
||||
"filefast/backend/internal/store"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type SQLiteClient struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
func NewSQLiteClient(cfg config.SQLiteConfig) (*SQLiteClient, error) {
|
||||
path := strings.TrimSpace(cfg.Path)
|
||||
if path == "" {
|
||||
return nil, errors.New("sqlite path is required")
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
client := &SQLiteClient{
|
||||
db: db,
|
||||
path: path,
|
||||
}
|
||||
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) Ping(ctx context.Context) error {
|
||||
return c.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) EnsureSchema(ctx context.Context) error {
|
||||
sqlBytes, err := readFirstExistingFile(
|
||||
filepath.Join("sql", "init_sqlite.sql"),
|
||||
filepath.Join("backend", "sql", "init_sqlite.sql"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = c.db.ExecContext(ctx, string(sqlBytes))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) ResetOnlineDevices(ctx context.Context) error {
|
||||
_, err := c.db.ExecContext(ctx, `UPDATE devices SET is_online = 0 WHERE is_online = 1`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) ResetOperationalData(ctx context.Context) error {
|
||||
tx, err := c.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, statement := range []string{
|
||||
`DELETE FROM fallback_objects`,
|
||||
`DELETE FROM transfers`,
|
||||
`DELETE FROM sessions`,
|
||||
`DELETE FROM rooms`,
|
||||
`DELETE FROM devices`,
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) LoadSnapshot(ctx context.Context, fallbackRuntime model.RuntimeConfig) (store.Snapshot, error) {
|
||||
snapshot := store.Snapshot{
|
||||
Runtime: &fallbackRuntime,
|
||||
}
|
||||
|
||||
devices, err := c.loadDevices(ctx)
|
||||
if err != nil {
|
||||
return store.Snapshot{}, err
|
||||
}
|
||||
snapshot.Devices = devices
|
||||
|
||||
rooms, err := c.loadRooms(ctx)
|
||||
if err != nil {
|
||||
return store.Snapshot{}, err
|
||||
}
|
||||
snapshot.Rooms = rooms
|
||||
|
||||
transfers, err := c.loadTransfers(ctx)
|
||||
if err != nil {
|
||||
return store.Snapshot{}, err
|
||||
}
|
||||
snapshot.Transfers = transfers
|
||||
|
||||
fallbackObjects, err := c.loadFallbackObjects(ctx)
|
||||
if err != nil {
|
||||
return store.Snapshot{}, err
|
||||
}
|
||||
snapshot.FallbackObjects = fallbackObjects
|
||||
|
||||
runtimeCfg, err := c.loadRuntimeConfig(ctx, fallbackRuntime)
|
||||
if err != nil {
|
||||
return store.Snapshot{}, err
|
||||
}
|
||||
snapshot.Runtime = &runtimeCfg
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) PersistDevice(ctx context.Context, device model.Device) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO devices (
|
||||
id, device_code, name, type, user_agent, network_group_key, public_ip_hash,
|
||||
is_online, last_seen_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
device_code = excluded.device_code,
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
user_agent = excluded.user_agent,
|
||||
network_group_key = excluded.network_group_key,
|
||||
public_ip_hash = excluded.public_ip_hash,
|
||||
is_online = excluded.is_online,
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
device.ID,
|
||||
device.ID,
|
||||
device.Name,
|
||||
device.Type,
|
||||
nullableString(device.UserAgent),
|
||||
nullableString(device.NetworkGroupKey),
|
||||
nullableString(device.PublicIPHash),
|
||||
boolToInt(device.IsOnline),
|
||||
encodeTime(device.LastSeenAt),
|
||||
encodeTime(device.CreatedAt),
|
||||
encodeTime(time.Now()),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) PersistRoom(ctx context.Context, room model.Room) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO rooms (
|
||||
code, creator_device_id, joiner_device_id, status, created_at, expires_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
creator_device_id = excluded.creator_device_id,
|
||||
joiner_device_id = excluded.joiner_device_id,
|
||||
status = excluded.status,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
room.Code,
|
||||
room.CreatorDeviceID,
|
||||
nullableString(room.JoinerDeviceID),
|
||||
room.Status,
|
||||
encodeTime(room.CreatedAt),
|
||||
encodeTime(room.ExpiresAt),
|
||||
encodeTime(time.Now()),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) PersistTransfer(ctx context.Context, transfer model.Transfer) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO transfers (
|
||||
id, session_id, kind, name, content, size_bytes,
|
||||
sender_device_id, receiver_device_id, transfer_strategy, current_channel,
|
||||
fallback_allowed, final_status, fallback_reason, object_key, expires_at,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
kind = excluded.kind,
|
||||
name = excluded.name,
|
||||
content = excluded.content,
|
||||
size_bytes = excluded.size_bytes,
|
||||
sender_device_id = excluded.sender_device_id,
|
||||
receiver_device_id = excluded.receiver_device_id,
|
||||
transfer_strategy = excluded.transfer_strategy,
|
||||
current_channel = excluded.current_channel,
|
||||
fallback_allowed = excluded.fallback_allowed,
|
||||
final_status = excluded.final_status,
|
||||
fallback_reason = excluded.fallback_reason,
|
||||
object_key = excluded.object_key,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
transfer.ID,
|
||||
nullableString(transfer.SessionID),
|
||||
transfer.Kind,
|
||||
transfer.Name,
|
||||
nullableString(transfer.Content),
|
||||
transfer.SizeBytes,
|
||||
transfer.SenderDeviceID,
|
||||
transfer.ReceiverDeviceID,
|
||||
transfer.TransferStrategy,
|
||||
transfer.CurrentChannel,
|
||||
boolToInt(transfer.FallbackAllowed),
|
||||
transfer.FinalStatus,
|
||||
nullableString(transfer.FallbackReason),
|
||||
nullableString(transfer.ObjectKey),
|
||||
nullableTime(transfer.ExpiresAt),
|
||||
encodeTime(transfer.CreatedAt),
|
||||
encodeTime(transfer.UpdatedAt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) PersistFallbackObject(ctx context.Context, object model.FallbackObject) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO fallback_objects (
|
||||
transfer_id, bucket, object_key, size_bytes, cleanup_state, created_at, expires_at, cleaned_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transfer_id) DO UPDATE SET
|
||||
bucket = excluded.bucket,
|
||||
object_key = excluded.object_key,
|
||||
size_bytes = excluded.size_bytes,
|
||||
cleanup_state = excluded.cleanup_state,
|
||||
expires_at = excluded.expires_at,
|
||||
cleaned_at = excluded.cleaned_at
|
||||
`,
|
||||
object.TransferID,
|
||||
"filefast-fallback",
|
||||
object.ObjectKey,
|
||||
object.SizeBytes,
|
||||
object.CleanupState,
|
||||
encodeTime(object.CreatedAt),
|
||||
encodeTime(object.ExpiresAt),
|
||||
nullableTime(object.CleanedAt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) PersistRuntimeConfig(ctx context.Context, runtime model.RuntimeConfig) error {
|
||||
payload, err := json.Marshal(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO system_configs (config_key, config_value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(config_key) DO UPDATE SET
|
||||
config_value = excluded.config_value,
|
||||
updated_at = excluded.updated_at
|
||||
`, runtimeConfigKey, string(payload), encodeTime(time.Now()))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) EnsureAdminUser(ctx context.Context, username, password string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
password = strings.TrimSpace(password)
|
||||
if username == "" || password == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO admin_users (username, password_hash, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(username) DO NOTHING
|
||||
`, username, string(hash), encodeTime(time.Now()), encodeTime(time.Now()))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) ValidateAdminCredentials(ctx context.Context, username, password string) (bool, error) {
|
||||
var passwordHash string
|
||||
var isActive int
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT password_hash, is_active
|
||||
FROM admin_users
|
||||
WHERE username = ?
|
||||
`, strings.TrimSpace(username)).Scan(&passwordHash, &isActive)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if isActive == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(strings.TrimSpace(password))) == nil, nil
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) loadDevices(ctx context.Context) ([]model.Device, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
COALESCE(user_agent, ''),
|
||||
COALESCE(network_group_key, ''),
|
||||
COALESCE(public_ip_hash, ''),
|
||||
is_online,
|
||||
COALESCE(last_seen_at, created_at),
|
||||
created_at
|
||||
FROM devices
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []model.Device
|
||||
for rows.Next() {
|
||||
var (
|
||||
device model.Device
|
||||
isOnline int
|
||||
lastSeen string
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&device.ID,
|
||||
&device.Name,
|
||||
&device.Type,
|
||||
&device.UserAgent,
|
||||
&device.NetworkGroupKey,
|
||||
&device.PublicIPHash,
|
||||
&isOnline,
|
||||
&lastSeen,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device.IsOnline = isOnline != 0
|
||||
device.LastSeenAt, err = decodeTime(lastSeen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device.CreatedAt, err = decodeTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) loadRooms(ctx context.Context) ([]model.Room, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
code,
|
||||
creator_device_id,
|
||||
COALESCE(joiner_device_id, ''),
|
||||
status,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM rooms
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rooms []model.Room
|
||||
for rows.Next() {
|
||||
var (
|
||||
room model.Room
|
||||
createdAt string
|
||||
expiresAt string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&room.Code,
|
||||
&room.CreatorDeviceID,
|
||||
&room.JoinerDeviceID,
|
||||
&room.Status,
|
||||
&createdAt,
|
||||
&expiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
room.CreatedAt, err = decodeTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
room.ExpiresAt, err = decodeTime(expiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rooms = append(rooms, room)
|
||||
}
|
||||
|
||||
return rooms, rows.Err()
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) loadTransfers(ctx context.Context) ([]model.Transfer, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(session_id, ''),
|
||||
kind,
|
||||
name,
|
||||
COALESCE(content, ''),
|
||||
size_bytes,
|
||||
sender_device_id,
|
||||
receiver_device_id,
|
||||
transfer_strategy,
|
||||
current_channel,
|
||||
fallback_allowed,
|
||||
final_status,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(fallback_reason, ''),
|
||||
COALESCE(object_key, ''),
|
||||
expires_at
|
||||
FROM transfers
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var transfers []model.Transfer
|
||||
for rows.Next() {
|
||||
var (
|
||||
transfer model.Transfer
|
||||
createdAt string
|
||||
updatedAt string
|
||||
expiresAt sql.NullString
|
||||
canFallback int
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&transfer.ID,
|
||||
&transfer.SessionID,
|
||||
&transfer.Kind,
|
||||
&transfer.Name,
|
||||
&transfer.Content,
|
||||
&transfer.SizeBytes,
|
||||
&transfer.SenderDeviceID,
|
||||
&transfer.ReceiverDeviceID,
|
||||
&transfer.TransferStrategy,
|
||||
&transfer.CurrentChannel,
|
||||
&canFallback,
|
||||
&transfer.FinalStatus,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&transfer.FallbackReason,
|
||||
&transfer.ObjectKey,
|
||||
&expiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transfer.FallbackAllowed = canFallback != 0
|
||||
transfer.CreatedAt, err = decodeTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transfer.UpdatedAt, err = decodeTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid && strings.TrimSpace(expiresAt.String) != "" {
|
||||
parsed, err := decodeTime(expiresAt.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transfer.ExpiresAt = &parsed
|
||||
}
|
||||
transfers = append(transfers, transfer)
|
||||
}
|
||||
|
||||
return transfers, rows.Err()
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) loadFallbackObjects(ctx context.Context) ([]model.FallbackObject, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
transfer_id,
|
||||
object_key,
|
||||
size_bytes,
|
||||
created_at,
|
||||
expires_at,
|
||||
cleaned_at,
|
||||
cleanup_state
|
||||
FROM fallback_objects
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var objects []model.FallbackObject
|
||||
for rows.Next() {
|
||||
var (
|
||||
object model.FallbackObject
|
||||
createdAt string
|
||||
expiresAt string
|
||||
cleanedAt sql.NullString
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&object.TransferID,
|
||||
&object.ObjectKey,
|
||||
&object.SizeBytes,
|
||||
&createdAt,
|
||||
&expiresAt,
|
||||
&cleanedAt,
|
||||
&object.CleanupState,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object.CreatedAt, err = decodeTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object.ExpiresAt, err = decodeTime(expiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cleanedAt.Valid && strings.TrimSpace(cleanedAt.String) != "" {
|
||||
parsed, err := decodeTime(cleanedAt.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object.CleanedAt = &parsed
|
||||
}
|
||||
objects = append(objects, object)
|
||||
}
|
||||
|
||||
return objects, rows.Err()
|
||||
}
|
||||
|
||||
func (c *SQLiteClient) loadRuntimeConfig(ctx context.Context, fallback model.RuntimeConfig) (model.RuntimeConfig, error) {
|
||||
var raw string
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT config_value
|
||||
FROM system_configs
|
||||
WHERE config_key = ?
|
||||
`, runtimeConfigKey).Scan(&raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fallback, nil
|
||||
}
|
||||
return model.RuntimeConfig{}, err
|
||||
}
|
||||
|
||||
return decodeRuntimeConfig([]byte(raw), fallback)
|
||||
}
|
||||
|
||||
func encodeTime(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func nullableTime(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return encodeTime(*value)
|
||||
}
|
||||
|
||||
func decodeTime(value string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339Nano, value)
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user