Files
SpotiFLAC-Mobile/go_backend/extension_runtime_storage.go
T

418 lines
9.5 KiB
Go

package gobackend
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"github.com/dop251/goja"
)
// Isolated per-download runtimes of the same extension share the storage,
// credentials, and salt files on disk, so writers must be serialized
// process-wide; the per-runtime mutexes only cover a single VM.
var extensionFileMus sync.Map // file path -> *sync.Mutex
func extensionFileMu(path string) *sync.Mutex {
mu, _ := extensionFileMus.LoadOrStore(path, &sync.Mutex{})
return mu.(*sync.Mutex)
}
// writeExtensionFileLocked writes data via a temp file + rename so a reader
// never observes a torn write. Callers must hold extensionFileMu(path).
func writeExtensionFileLocked(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func (r *extensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
func readJSONMapFile(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
}
return nil, err
}
result := make(map[string]any)
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
if result == nil {
result = make(map[string]any)
}
return result, nil
}
func (r *extensionRuntime) refreshStorage() error {
path := r.getStoragePath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := readJSONMapFile(path)
fileMu.Unlock()
if err != nil {
return err
}
r.storageMu.Lock()
r.storageCache = snapshot
r.storageMu.Unlock()
return nil
}
func (r *extensionRuntime) mutateStorage(mutate func(map[string]any) bool) error {
r.storageMu.RLock()
closed := r.storageClosed
r.storageMu.RUnlock()
if closed {
return fmt.Errorf("storage is closed")
}
path := r.getStoragePath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := readJSONMapFile(path)
if err == nil && mutate(snapshot) {
var data []byte
data, err = json.Marshal(snapshot)
if err == nil {
err = writeExtensionFileLocked(path, data)
}
}
fileMu.Unlock()
if err != nil {
return err
}
r.storageMu.Lock()
r.storageCache = snapshot
r.storageMu.Unlock()
return nil
}
func (r *extensionRuntime) flushStorageNow() error {
// Mutations are persisted synchronously under the process-wide file lock.
return nil
}
func (r *extensionRuntime) closeStorageFlusher() {
r.storageMu.Lock()
r.storageClosed = true
r.storageMu.Unlock()
}
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
key := call.Arguments[0].String()
if err := r.refreshStorage(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
r.storageMu.RLock()
value, exists := r.storageCache[key]
r.storageMu.RUnlock()
if !exists {
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
return goja.Undefined()
}
return r.vm.ToValue(value)
}
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
if err := r.mutateStorage(func(storage map[string]any) bool {
storage[key] = value
return true
}); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
return r.vm.ToValue(true)
}
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
if err := r.mutateStorage(func(storage map[string]any) bool {
if _, exists := storage[key]; !exists {
return false
}
delete(storage, key)
return true
}); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
return r.vm.ToValue(true)
}
func (r *extensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc")
}
func (r *extensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt")
}
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
// Serialize concurrent runtimes: if two generated different salts, the
// loser's credentials would become undecryptable.
mu := extensionFileMu(saltPath)
mu.Lock()
defer mu.Unlock()
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
}
salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
if err := writeExtensionFileLocked(saltPath, salt); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
return salt, nil
}
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt()
if err != nil {
return nil, err
}
combined := append([]byte(r.extensionID), salt...)
hash := sha256.Sum256(combined)
return hash[:], nil
}
func (r *extensionRuntime) readCredentialsFileLocked() (map[string]any, error) {
data, err := os.ReadFile(r.getCredentialsPath())
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
}
return nil, err
}
key, err := r.getEncryptionKey()
if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err)
}
decrypted, err := decryptAES(data, key)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
}
creds := make(map[string]any)
if err := json.Unmarshal(decrypted, &creds); err != nil {
return nil, err
}
if creds == nil {
creds = make(map[string]any)
}
return creds, nil
}
func (r *extensionRuntime) refreshCredentials() error {
path := r.getCredentialsPath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := r.readCredentialsFileLocked()
fileMu.Unlock()
if err != nil {
return err
}
r.credentialsMu.Lock()
r.credentialsCache = snapshot
r.credentialsMu.Unlock()
return nil
}
func (r *extensionRuntime) mutateCredentials(mutate func(map[string]any)) error {
path := r.getCredentialsPath()
fileMu := extensionFileMu(path)
fileMu.Lock()
snapshot, err := r.readCredentialsFileLocked()
if err == nil {
mutate(snapshot)
var data []byte
data, err = json.Marshal(snapshot)
if err == nil {
var key []byte
key, err = r.getEncryptionKey()
if err == nil {
data, err = encryptAES(data, key)
}
}
if err == nil {
err = writeExtensionFileLocked(path, data)
}
}
fileMu.Unlock()
if err != nil {
return err
}
r.credentialsMu.Lock()
r.credentialsCache = snapshot
r.credentialsMu.Unlock()
return nil
}
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.jsError("key and value are required")
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
if err := r.mutateCredentials(func(credentials map[string]any) {
credentials[key] = value
}); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.jsError("%s", err.Error())
}
return r.jsSuccess(nil)
}
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
key := call.Arguments[0].String()
if err := r.refreshCredentials(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
r.credentialsMu.RLock()
value, exists := r.credentialsCache[key]
r.credentialsMu.RUnlock()
if !exists {
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
return goja.Undefined()
}
return r.vm.ToValue(value)
}
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
if err := r.mutateCredentials(func(credentials map[string]any) {
delete(credentials, key)
}); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
return r.vm.ToValue(true)
}
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
key := call.Arguments[0].String()
if err := r.refreshCredentials(); err != nil {
return r.vm.ToValue(false)
}
r.credentialsMu.RLock()
_, exists := r.credentialsCache[key]
r.credentialsMu.RUnlock()
return r.vm.ToValue(exists)
}
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}