feat(server): add project sharing access control

This commit is contained in:
2026-07-10 14:46:18 +08:00
parent 60130b3d9b
commit 9d47cb9bbd
41 changed files with 3069 additions and 17 deletions
+163
View File
@@ -0,0 +1,163 @@
package sharing
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
)
const shareIDLength = 27
var (
base62Alphabet = []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
shareIDPattern = regexp.MustCompile(`^[0-9A-Za-z]{27}$`)
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
)
type secureCodec struct {
aead cipher.AEAD
rand io.Reader
}
func newSecureCodec(secret string) (*secureCodec, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, fmt.Errorf("%w: sharing encryption secret is required", ErrInvalidInput)
}
key := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &secureCodec{aead: aead, rand: rand.Reader}, nil
}
func (c *secureCodec) newShareID() (string, error) {
result := make([]byte, 0, shareIDLength)
buffer := make([]byte, 64)
for len(result) < shareIDLength {
if _, err := io.ReadFull(c.rand, buffer); err != nil {
return "", err
}
for _, value := range buffer {
if value >= 248 {
continue
}
result = append(result, base62Alphabet[int(value)%len(base62Alphabet)])
if len(result) == shareIDLength {
break
}
}
}
return string(result), nil
}
func (c *secureCodec) encrypt(value string) ([]byte, error) {
nonce := make([]byte, c.aead.NonceSize())
if _, err := io.ReadFull(c.rand, nonce); err != nil {
return nil, err
}
return c.aead.Seal(nonce, nonce, []byte(value), nil), nil
}
func (c *secureCodec) decrypt(value []byte) (string, error) {
if len(value) < c.aead.NonceSize() {
return "", fmt.Errorf("%w: encrypted share value is invalid", ErrInvalidInput)
}
nonce := value[:c.aead.NonceSize()]
plaintext, err := c.aead.Open(nil, nonce, value[c.aead.NonceSize():], nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func hashValue(value string) []byte {
digest := sha256.Sum256([]byte(value))
return digest[:]
}
func hashID(value []byte) string {
if len(value) > 16 {
value = value[:16]
}
return hex.EncodeToString(value)
}
func hashesEqual(left []byte, right []byte) bool {
return len(left) == len(right) && subtle.ConstantTimeCompare(left, right) == 1
}
func validShareID(value string) bool {
return shareIDPattern.MatchString(value)
}
func normalizeIdentifier(value string) (string, string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", "", fmt.Errorf("%w: email or phone is required", ErrInvalidInput)
}
if strings.Contains(value, "@") {
address, err := mail.ParseAddress(value)
if err != nil || !strings.EqualFold(strings.TrimSpace(address.Address), value) {
return "", "", fmt.Errorf("%w: email is invalid", ErrInvalidInput)
}
return "email", strings.ToLower(address.Address), nil
}
phone := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(value)
if !phonePattern.MatchString(phone) {
return "", "", fmt.Errorf("%w: phone is invalid", ErrInvalidInput)
}
return "phone", phone, nil
}
func actorIdentifiers(actor Actor) []string {
seen := make(map[string]struct{})
appendIdentifier := func(values *[]string, value string) {
kind, normalized, err := normalizeIdentifier(value)
if err != nil || kind == "" {
return
}
key := kind + ":" + normalized
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
*values = append(*values, key)
}
values := make([]string, 0, 3)
appendIdentifier(&values, actor.Email)
appendIdentifier(&values, actor.Phone)
if strings.TrimSpace(actor.CountryCode) != "" && strings.TrimSpace(actor.Phone) != "" {
countryCode := strings.TrimPrefix(strings.TrimSpace(actor.CountryCode), "+")
phone := strings.TrimPrefix(strings.TrimSpace(actor.Phone), "+")
appendIdentifier(&values, "+"+countryCode+phone)
}
return values
}
func ActorOwnsIdentifier(actor Actor, identifier string) bool {
identifierType, normalized, err := normalizeIdentifier(identifier)
if err != nil {
return false
}
candidateHash := hashValue(identifierType + ":" + normalized)
for _, actorIdentifier := range actorIdentifiers(actor) {
if hashesEqual(candidateHash, hashValue(actorIdentifier)) {
return true
}
}
return false
}