1package dataprovider
2
3import (
4	"encoding/json"
5	"fmt"
6	"net"
7	"strings"
8	"time"
9
10	"github.com/alexedwards/argon2id"
11	"golang.org/x/crypto/bcrypt"
12
13	"github.com/drakkan/sftpgo/v2/logger"
14	"github.com/drakkan/sftpgo/v2/util"
15)
16
17// ShareScope defines the supported share scopes
18type ShareScope int
19
20// Supported share scopes
21const (
22	ShareScopeRead ShareScope = iota + 1
23	ShareScopeWrite
24)
25
26const (
27	redactedPassword = "[**redacted**]"
28)
29
30// Share defines files and or directories shared with external users
31type Share struct {
32	// Database unique identifier
33	ID int64 `json:"-"`
34	// Unique ID used to access this object
35	ShareID     string     `json:"id"`
36	Name        string     `json:"name"`
37	Description string     `json:"description,omitempty"`
38	Scope       ShareScope `json:"scope"`
39	// Paths to files or directories, for ShareScopeWrite it must be exactly one directory
40	Paths []string `json:"paths"`
41	// Username who shared this object
42	Username  string `json:"username"`
43	CreatedAt int64  `json:"created_at"`
44	UpdatedAt int64  `json:"updated_at"`
45	// 0 means never used
46	LastUseAt int64 `json:"last_use_at,omitempty"`
47	// ExpiresAt expiration date/time as unix timestamp in milliseconds, 0 means no expiration
48	ExpiresAt int64 `json:"expires_at,omitempty"`
49	// Optional password to protect the share
50	Password string `json:"password"`
51	// Limit the available access tokens, 0 means no limit
52	MaxTokens int `json:"max_tokens,omitempty"`
53	// Used tokens
54	UsedTokens int `json:"used_tokens,omitempty"`
55	// Limit the share availability to these IPs/CIDR networks
56	AllowFrom []string `json:"allow_from,omitempty"`
57	// set for restores, we don't have to validate the expiration date
58	// otherwise we fail to restore existing shares and we have to insert
59	// all the previous values with no modifications
60	IsRestore bool `json:"-"`
61}
62
63// GetScopeAsString returns the share's scope as string.
64// Used in web pages
65func (s *Share) GetScopeAsString() string {
66	switch s.Scope {
67	case ShareScopeRead:
68		return "Read"
69	default:
70		return "Write"
71	}
72}
73
74// IsExpired returns true if the share is expired
75func (s *Share) IsExpired() bool {
76	if s.ExpiresAt > 0 {
77		return s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now())
78	}
79	return false
80}
81
82// GetInfoString returns share's info as string.
83func (s *Share) GetInfoString() string {
84	var result strings.Builder
85	if s.ExpiresAt > 0 {
86		t := util.GetTimeFromMsecSinceEpoch(s.ExpiresAt)
87		result.WriteString(fmt.Sprintf("Expiration: %v. ", t.Format("2006-01-02 15:04"))) // YYYY-MM-DD HH:MM
88	}
89	if s.LastUseAt > 0 {
90		t := util.GetTimeFromMsecSinceEpoch(s.LastUseAt)
91		result.WriteString(fmt.Sprintf("Last use: %v. ", t.Format("2006-01-02 15:04")))
92	}
93	if s.MaxTokens > 0 {
94		result.WriteString(fmt.Sprintf("Usage: %v/%v. ", s.UsedTokens, s.MaxTokens))
95	} else {
96		result.WriteString(fmt.Sprintf("Used tokens: %v. ", s.UsedTokens))
97	}
98	if len(s.AllowFrom) > 0 {
99		result.WriteString(fmt.Sprintf("Allowed IP/Mask: %v. ", len(s.AllowFrom)))
100	}
101	if s.Password != "" {
102		result.WriteString("Password protected.")
103	}
104	return result.String()
105}
106
107// GetAllowedFromAsString returns the allowed IP as comma separated string
108func (s *Share) GetAllowedFromAsString() string {
109	return strings.Join(s.AllowFrom, ",")
110}
111
112func (s *Share) getACopy() Share {
113	allowFrom := make([]string, len(s.AllowFrom))
114	copy(allowFrom, s.AllowFrom)
115
116	return Share{
117		ID:          s.ID,
118		ShareID:     s.ShareID,
119		Name:        s.Name,
120		Description: s.Description,
121		Scope:       s.Scope,
122		Paths:       s.Paths,
123		Username:    s.Username,
124		CreatedAt:   s.CreatedAt,
125		UpdatedAt:   s.UpdatedAt,
126		LastUseAt:   s.LastUseAt,
127		ExpiresAt:   s.ExpiresAt,
128		Password:    s.Password,
129		MaxTokens:   s.MaxTokens,
130		UsedTokens:  s.UsedTokens,
131		AllowFrom:   allowFrom,
132	}
133}
134
135// RenderAsJSON implements the renderer interface used within plugins
136func (s *Share) RenderAsJSON(reload bool) ([]byte, error) {
137	if reload {
138		share, err := provider.shareExists(s.ShareID, s.Username)
139		if err != nil {
140			providerLog(logger.LevelWarn, "unable to reload share before rendering as json: %v", err)
141			return nil, err
142		}
143		share.HideConfidentialData()
144		return json.Marshal(share)
145	}
146	s.HideConfidentialData()
147	return json.Marshal(s)
148}
149
150// HideConfidentialData hides share confidential data
151func (s *Share) HideConfidentialData() {
152	if s.Password != "" {
153		s.Password = redactedPassword
154	}
155}
156
157// HasRedactedPassword returns true if this share has a redacted password
158func (s *Share) HasRedactedPassword() bool {
159	return s.Password == redactedPassword
160}
161
162func (s *Share) hashPassword() error {
163	if s.Password != "" && !util.IsStringPrefixInSlice(s.Password, internalHashPwdPrefixes) {
164		if config.PasswordHashing.Algo == HashingAlgoBcrypt {
165			hashed, err := bcrypt.GenerateFromPassword([]byte(s.Password), config.PasswordHashing.BcryptOptions.Cost)
166			if err != nil {
167				return err
168			}
169			s.Password = string(hashed)
170		} else {
171			hashed, err := argon2id.CreateHash(s.Password, argon2Params)
172			if err != nil {
173				return err
174			}
175			s.Password = hashed
176		}
177	}
178	return nil
179}
180
181func (s *Share) validatePaths() error {
182	var paths []string
183	for _, p := range s.Paths {
184		p = strings.TrimSpace(p)
185		if p != "" {
186			paths = append(paths, p)
187		}
188	}
189	s.Paths = paths
190	if len(s.Paths) == 0 {
191		return util.NewValidationError("at least a shared path is required")
192	}
193	for idx := range s.Paths {
194		s.Paths[idx] = util.CleanPath(s.Paths[idx])
195	}
196	s.Paths = util.RemoveDuplicates(s.Paths)
197	if s.Scope == ShareScopeWrite && len(s.Paths) != 1 {
198		return util.NewValidationError("the write share scope requires exactly one path")
199	}
200	return nil
201}
202
203func (s *Share) validate() error {
204	if s.ShareID == "" {
205		return util.NewValidationError("share_id is mandatory")
206	}
207	if s.Name == "" {
208		return util.NewValidationError("name is mandatory")
209	}
210	if s.Scope != ShareScopeRead && s.Scope != ShareScopeWrite {
211		return util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope))
212	}
213	if err := s.validatePaths(); err != nil {
214		return err
215	}
216	if s.ExpiresAt > 0 {
217		if !s.IsRestore && s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) {
218			return util.NewValidationError("expiration must be in the future")
219		}
220	} else {
221		s.ExpiresAt = 0
222	}
223	if s.MaxTokens < 0 {
224		return util.NewValidationError("invalid max tokens")
225	}
226	if s.Username == "" {
227		return util.NewValidationError("username is mandatory")
228	}
229	if s.HasRedactedPassword() {
230		return util.NewValidationError("cannot save a share with a redacted password")
231	}
232	if err := s.hashPassword(); err != nil {
233		return err
234	}
235	s.AllowFrom = util.RemoveDuplicates(s.AllowFrom)
236	for _, IPMask := range s.AllowFrom {
237		_, _, err := net.ParseCIDR(IPMask)
238		if err != nil {
239			return util.NewValidationError(fmt.Sprintf("could not parse allow from entry %#v : %v", IPMask, err))
240		}
241	}
242	return nil
243}
244
245// CheckPassword verifies the share password if set
246func (s *Share) CheckPassword(password string) (bool, error) {
247	if s.Password == "" {
248		return true, nil
249	}
250	if password == "" {
251		return false, ErrInvalidCredentials
252	}
253	if strings.HasPrefix(s.Password, bcryptPwdPrefix) {
254		if err := bcrypt.CompareHashAndPassword([]byte(s.Password), []byte(password)); err != nil {
255			return false, ErrInvalidCredentials
256		}
257		return true, nil
258	}
259	match, err := argon2id.ComparePasswordAndHash(password, s.Password)
260	if !match || err != nil {
261		return false, ErrInvalidCredentials
262	}
263	return match, err
264}
265
266// IsUsable checks if the share is usable from the specified IP
267func (s *Share) IsUsable(ip string) (bool, error) {
268	if s.MaxTokens > 0 && s.UsedTokens >= s.MaxTokens {
269		return false, util.NewRecordNotFoundError("max share usage exceeded")
270	}
271	if s.ExpiresAt > 0 {
272		if s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) {
273			return false, util.NewRecordNotFoundError("share expired")
274		}
275	}
276	if len(s.AllowFrom) == 0 {
277		return true, nil
278	}
279	parsedIP := net.ParseIP(ip)
280	if parsedIP == nil {
281		return false, ErrLoginNotAllowedFromIP
282	}
283	for _, ipMask := range s.AllowFrom {
284		_, network, err := net.ParseCIDR(ipMask)
285		if err != nil {
286			continue
287		}
288		if network.Contains(parsedIP) {
289			return true, nil
290		}
291	}
292	return false, ErrLoginNotAllowedFromIP
293}
294