mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-29 11:01:29 +02:00
Files move directly between peers over the overlay, with no server in the path. The receiver listens on the WireGuard address only, so the port is unreachable from outside the tunnel, and every offer is matched to a known peer before anything is read. Consent is the default: an offer carries metadata alone, and no payload moves until the receiver accepts. Policy is per profile and device-local — off, ask, or auto-accept, with per-sender exceptions on top. Policy and history live in the profile's preferences, so removing a profile takes its file drop state with it. Transfers interrupted by a restart are settled on load; nothing survives to finish them, and left alone they would sit in the log as permanently pending. The Android bindings pull payload bytes through a chunk-returning stream: gomobile copies a []byte argument into a fresh Java array and never copies it back, so a fill-my-buffer method would hand back the right length with no data.
283 lines
6.1 KiB
Go
283 lines
6.1 KiB
Go
package filedrop
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Offer is one incoming transfer as tracked by the receiver.
|
|
type Offer struct {
|
|
ID OfferID
|
|
Sender PeerKey
|
|
SenderName string
|
|
Files []FileMeta
|
|
Decision Decision
|
|
State State
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
Progress []int64
|
|
}
|
|
|
|
type offerEntry struct {
|
|
offer Offer
|
|
decided chan struct{}
|
|
}
|
|
|
|
// OfferStore tracks incoming offers and their decisions.
|
|
type OfferStore struct {
|
|
mu sync.RWMutex
|
|
offers map[OfferID]*offerEntry
|
|
ttl time.Duration
|
|
newID func() OfferID
|
|
nowFunc func() time.Time
|
|
}
|
|
|
|
// NewOfferStore returns an empty store using ttl as the decision deadline.
|
|
func NewOfferStore(ttl time.Duration) *OfferStore {
|
|
if ttl <= 0 {
|
|
ttl = DefaultOfferTTL
|
|
}
|
|
return &OfferStore{
|
|
offers: make(map[OfferID]*offerEntry),
|
|
ttl: ttl,
|
|
newID: func() OfferID { return OfferID(uuid.NewString()) },
|
|
nowFunc: time.Now,
|
|
}
|
|
}
|
|
|
|
// Add registers a new offer with the given initial decision and returns its snapshot.
|
|
func (s *OfferStore) Add(sender PeerKey, senderName string, files []FileMeta, decision Decision) Offer {
|
|
now := s.nowFunc()
|
|
|
|
entry := &offerEntry{
|
|
offer: Offer{
|
|
ID: s.newID(),
|
|
Sender: sender,
|
|
SenderName: senderName,
|
|
Files: files,
|
|
Decision: decision,
|
|
State: stateForDecision(decision),
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(s.ttl),
|
|
Progress: make([]int64, len(files)),
|
|
},
|
|
decided: make(chan struct{}),
|
|
}
|
|
if decision != DecisionPending {
|
|
close(entry.decided)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.offers[entry.offer.ID] = entry
|
|
s.mu.Unlock()
|
|
|
|
return entry.offer.clone()
|
|
}
|
|
|
|
// Get returns a snapshot of one offer belonging to sender.
|
|
func (s *OfferStore) Get(sender PeerKey, id OfferID) (Offer, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entry, ok := s.offers[id]
|
|
if !ok || entry.offer.Sender != sender {
|
|
return Offer{}, false
|
|
}
|
|
return entry.offer.clone(), true
|
|
}
|
|
|
|
// List returns snapshots of every tracked offer.
|
|
func (s *OfferStore) List() []Offer {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
offers := make([]Offer, 0, len(s.offers))
|
|
for _, entry := range s.offers {
|
|
offers = append(offers, entry.offer.clone())
|
|
}
|
|
return offers
|
|
}
|
|
|
|
// Decide records the receiver's answer; a made decision is final.
|
|
func (s *OfferStore) Decide(id OfferID, decision Decision) (Offer, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, ok := s.offers[id]
|
|
if !ok || entry.offer.Decision != DecisionPending {
|
|
return Offer{}, false
|
|
}
|
|
|
|
entry.offer.Decision = decision
|
|
entry.offer.State = stateForDecision(decision)
|
|
if decision == DecisionAccepted && entry.offer.awaitsNoUpload() {
|
|
entry.offer.State = StateCompleted
|
|
}
|
|
close(entry.decided)
|
|
|
|
return entry.offer.clone(), true
|
|
}
|
|
|
|
// Await blocks until a decision, expiry, or ctx cancellation.
|
|
func (s *OfferStore) Await(ctx context.Context, sender PeerKey, id OfferID) (Offer, error) {
|
|
s.mu.RLock()
|
|
entry, ok := s.offers[id]
|
|
if ok && entry.offer.Sender != sender {
|
|
ok = false
|
|
}
|
|
var decided chan struct{}
|
|
var expiresAt time.Time
|
|
if ok {
|
|
decided = entry.decided
|
|
expiresAt = entry.offer.ExpiresAt
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return Offer{}, ErrOfferNotFound
|
|
}
|
|
|
|
timer := time.NewTimer(time.Until(expiresAt))
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-decided:
|
|
case <-timer.C:
|
|
s.Decide(id, DecisionExpired)
|
|
case <-ctx.Done():
|
|
offer, _ := s.Get(sender, id)
|
|
return offer, ctx.Err()
|
|
}
|
|
|
|
offer, ok := s.Get(sender, id)
|
|
if !ok {
|
|
return Offer{}, ErrOfferNotFound
|
|
}
|
|
return offer, nil
|
|
}
|
|
|
|
// SetProgress records the staged byte count for one file of an offer.
|
|
func (s *OfferStore) SetProgress(id OfferID, index int, received int64) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, ok := s.offers[id]
|
|
if !ok || index < 0 || index >= len(entry.offer.Progress) {
|
|
return
|
|
}
|
|
entry.offer.Progress[index] = received
|
|
if entry.offer.State == StatePending {
|
|
entry.offer.State = StateTransferring
|
|
}
|
|
}
|
|
|
|
// SetState overrides the transfer state, for completion and failure reporting.
|
|
func (s *OfferStore) SetState(id OfferID, state State) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if entry, ok := s.offers[id]; ok {
|
|
entry.offer.State = state
|
|
}
|
|
}
|
|
|
|
// Remove drops an offer from the store.
|
|
func (s *OfferStore) Remove(id OfferID) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.offers, id)
|
|
}
|
|
|
|
// ExpireOverdue marks every pending offer past its deadline as expired and returns them.
|
|
func (s *OfferStore) ExpireOverdue() []Offer {
|
|
now := s.nowFunc()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var expired []Offer
|
|
for _, entry := range s.offers {
|
|
if entry.offer.Decision != DecisionPending || now.Before(entry.offer.ExpiresAt) {
|
|
continue
|
|
}
|
|
entry.offer.Decision = DecisionExpired
|
|
entry.offer.State = StateExpired
|
|
close(entry.decided)
|
|
expired = append(expired, entry.offer.clone())
|
|
}
|
|
return expired
|
|
}
|
|
|
|
// Complete marks an offer completed once every file reached its announced size.
|
|
func (s *OfferStore) Complete(id OfferID) (Offer, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, ok := s.offers[id]
|
|
if !ok || entry.offer.State == StateCompleted {
|
|
return Offer{}, false
|
|
}
|
|
|
|
if !entry.offer.fullyStaged() {
|
|
return Offer{}, false
|
|
}
|
|
|
|
entry.offer.State = StateCompleted
|
|
return entry.offer.clone(), true
|
|
}
|
|
|
|
func (o Offer) awaitsNoUpload() bool {
|
|
for _, f := range o.Files {
|
|
if f.Kind != KindText {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (o Offer) fullyStaged() bool {
|
|
for i, f := range o.Files {
|
|
if f.Kind == KindText {
|
|
continue
|
|
}
|
|
if i >= len(o.Progress) || o.Progress[i] < f.Size {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (o Offer) clone() Offer {
|
|
c := o
|
|
c.Files = make([]FileMeta, len(o.Files))
|
|
copy(c.Files, o.Files)
|
|
c.Progress = make([]int64, len(o.Progress))
|
|
copy(c.Progress, o.Progress)
|
|
return c
|
|
}
|
|
|
|
// TotalSize is the announced byte count across every file of the offer.
|
|
func (o Offer) TotalSize() int64 {
|
|
var total int64
|
|
for _, f := range o.Files {
|
|
total += f.Size
|
|
}
|
|
return total
|
|
}
|
|
|
|
func stateForDecision(d Decision) State {
|
|
switch d {
|
|
case DecisionAccepted:
|
|
return StateTransferring
|
|
case DecisionDeclined:
|
|
return StateDeclined
|
|
case DecisionExpired:
|
|
return StateExpired
|
|
default:
|
|
return StatePending
|
|
}
|
|
}
|