Files
SpotiFLAC-Mobile/go_backend/extension_runtime_file.go
T
zarzet d819878ec7 fix(downloads): stage direct-mode outputs and serialize same-path writes
The non-SAF filesystem path (Android direct mode and all of iOS) had
none of the safeguards the SAF path gained: extension downloads
streamed straight into the final filename, every error path stranded
the partial file there, and the ISRC duplicate check (exists + size>0
with a head-only FLAC parse) then accepted the truncated file as
complete forever. On iOS this was routine, not rare: the background
task grace is ~30s, after which the process is suspended mid-stream.

- fileDownload/fileDownloadChunked now stream into a .partial sibling
  and promote to the final name with an atomic rename on success;
  failures remove the staged file and never touch the final name
- fileWrite (full-content) uses the same stage-and-rename protocol
- writes are serialized per final output path, so concurrent queue
  items that resolve to the same filename can no longer interleave
  bytes into one file

The staged suffix keeps partials invisible to extension duplicate
checks, which match on the final audio extension.
2026-07-10 10:18:03 +07:00

1104 lines
26 KiB
Go

package gobackend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
)
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
absDir, err := filepath.Abs(dir)
if err == nil {
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
}
}
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if isPathWithinBase(allowedDir, absPath) {
return true
}
}
return false
}
func isPathWithinBase(baseDir, targetPath string) bool {
baseAbs, err := filepath.Abs(baseDir)
if err != nil {
return false
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return false
}
rel, err := filepath.Rel(baseAbs, targetAbs)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
prefix := ".." + string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, prefix) {
return false
}
return true
}
func (r *extensionRuntime) validatePath(path string) (string, error) {
if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
cleanPath := filepath.Clean(path)
if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
fullPath := filepath.Join(r.dataDir, cleanPath)
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
absDataDir, _ := filepath.Abs(r.dataDir)
if !isPathWithinBase(absDataDir, absPath) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
}
return absPath, nil
}
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "URL and output path are required",
})
}
urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
fullPath, err := r.validatePath(outputPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
var onProgress goja.Callable
var headers map[string]string
var chunkedDownload bool
trackItemBytes := true
var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]any); ok {
if h, ok := opts["headers"].(map[string]any); ok {
headers = make(map[string]string)
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable
}
}
if trackBytes, ok := opts["trackItemBytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
}
if chunked, ok := opts["chunked"]; ok {
switch v := chunked.(type) {
case bool:
chunkedDownload = v
case int64:
if v > 0 {
chunkedDownload = true
chunkSize = v
}
case float64:
if v > 0 {
chunkedDownload = true
chunkSize = int64(v)
}
}
}
}
}
// Default chunk size: 1MB (YouTube CDN max without poToken)
if chunkedDownload && chunkSize <= 0 {
chunkSize = 1024 * 1024
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
client := r.downloadClient
if client == nil {
client = r.httpClient
}
ua := appUserAgent()
if h, ok := headers["User-Agent"]; ok && h != "" {
ua = h
}
if chunkedDownload {
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
}
unlock := lockDownloadOutputPath(fullPath)
defer unlock()
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", appUserAgent())
}
resp, err := client.Do(req)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
})
}
// Stream into a staged sibling and promote via rename on success so a
// killed process can never leave a partial file under the final name
// (the duplicate check would then accept it as complete forever).
stagedPath := stagedDownloadPath(fullPath)
os.Remove(stagedPath)
out, err := os.Create(stagedPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create file: %v", err),
})
}
promoted := false
defer func() {
out.Close()
if !promoted {
os.Remove(stagedPath)
}
}()
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" {
SetItemDownloading(activeItemID)
}
contentLength := resp.ContentLength
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if shouldTrackItemBytes {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
var written int64
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("invalid write result")
}
}
written += int64(nw)
if ew != nil {
if ew == ErrDownloadCancelled {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "download cancelled",
})
}
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
})
}
if nr != nw {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "short write",
})
}
if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
}
}
if er != nil {
if er != io.EOF {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to read response: %v", er),
})
}
break
}
}
if shouldTrackItemBytes {
if contentLength > 0 {
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
} else if written > 0 {
SetItemBytesReceived(activeItemID, written)
}
}
if err := out.Close(); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to finalize file: %v", err),
})
}
if err := os.Rename(stagedPath, fullPath); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to publish file: %v", err),
})
}
promoted = true
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]any{
"success": true,
"path": fullPath,
"size": written,
})
}
// fileDownloadChunked downloads a URL using sequential Range requests.
// This is needed for servers (like YouTube's googlevideo CDN) that reject
// non-ranged or large-range requests with 403 and require small chunk downloads.
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
unlock := lockDownloadOutputPath(fullPath)
defer unlock()
// First, get the total content length with a small probe request
probeReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("chunked: probe request error: %v", err),
})
}
probeReq = r.bindDownloadCancelContext(probeReq)
probeReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" { // Don't copy any existing Range header
probeReq.Header.Set(k, v)
}
}
probeReq.Header.Set("Range", "bytes=0-1")
probeResp, err := client.Do(probeReq)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("chunked: probe error: %v", err),
})
}
io.Copy(io.Discard, probeResp.Body)
probeResp.Body.Close()
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
})
}
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
var totalSize int64
contentRange := probeResp.Header.Get("Content-Range")
if contentRange != "" {
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
sizeStr := contentRange[idx+1:]
if sizeStr != "*" {
fmt.Sscanf(sizeStr, "%d", &totalSize)
}
}
}
if totalSize <= 0 {
// Fallback: try Content-Length from a HEAD-like approach
// If we can't determine size, download with unknown size
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
} else {
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
}
// Same staged-write-then-promote protocol as fileDownload: never leave a
// partial file under the final name.
stagedPath := stagedDownloadPath(fullPath)
os.Remove(stagedPath)
out, err := os.Create(stagedPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create file: %v", err),
})
}
promoted := false
defer func() {
out.Close()
if !promoted {
os.Remove(stagedPath)
}
}()
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" {
SetItemDownloading(activeItemID)
}
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && totalSize > 0 {
SetItemBytesTotal(activeItemID, totalSize)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if shouldTrackItemBytes {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
var totalWritten int64
buf := make([]byte, 32*1024)
maxRetries := 3
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
end := offset + chunkSize - 1
if totalSize > 0 && end >= totalSize {
end = totalSize - 1
}
var chunkResp *http.Response
var chunkErr error
for retry := 0; retry < maxRetries; retry++ {
chunkReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
})
}
chunkReq = r.bindDownloadCancelContext(chunkReq)
chunkReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" {
chunkReq.Header.Set(k, v)
}
}
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
chunkResp, chunkErr = client.Do(chunkReq)
if chunkErr != nil {
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * time.Second)
continue
}
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
})
}
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
break // Success
}
io.Copy(io.Discard, chunkResp.Body)
chunkResp.Body.Close()
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
continue
}
}
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
})
}
chunkWritten := int64(0)
for {
nr, er := chunkResp.Body.Read(buf)
if nr > 0 {
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("invalid write result")
}
}
chunkWritten += int64(nw)
totalWritten += int64(nw)
if ew != nil {
chunkResp.Body.Close()
if ew == ErrDownloadCancelled {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "download cancelled",
})
}
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
})
}
if nr != nw {
chunkResp.Body.Close()
return r.vm.ToValue(map[string]any{
"success": false,
"error": "short write",
})
}
if onProgress != nil && totalSize > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
}
}
if er != nil {
if er != io.EOF {
chunkResp.Body.Close()
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
})
}
break
}
}
chunkResp.Body.Close()
offset += chunkWritten
// If server returned 200 (full content) instead of 206, we're done
if chunkResp.StatusCode == 200 {
break
}
// If we got less data than expected and we know total size, check if done
if totalSize > 0 && offset >= totalSize {
break
}
// Unknown size: if we got less than chunk size, assume done
if totalSize <= 0 && chunkWritten < chunkSize {
break
}
}
if shouldTrackItemBytes {
if totalSize > 0 {
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
} else if totalWritten > 0 {
SetItemBytesReceived(activeItemID, totalWritten)
}
}
if err := out.Close(); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to finalize file: %v", err),
})
}
if err := os.Rename(stagedPath, fullPath); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to publish file: %v", err),
})
}
promoted = true
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
return r.vm.ToValue(map[string]any{
"success": true,
"path": fullPath,
"size": totalWritten,
})
}
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(false)
}
_, err = os.Stat(fullPath)
return r.vm.ToValue(err == nil)
}
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
if err := os.Remove(fullPath); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
})
}
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
data, err := os.ReadFile(fullPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
"data": string(data),
})
}
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 1)
offset := runtimeOptionInt64(options, "offset", 0)
length := runtimeOptionInt64(options, "length", -1)
encoding := runtimeOptionString(options, "encoding", "base64")
if offset < 0 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
size := info.Size()
if offset > size {
offset = size
}
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
var data []byte
switch {
case length == 0:
data = []byte{}
case length > 0:
buf := make([]byte, int(length))
n, readErr := file.Read(buf)
if readErr != nil && readErr != io.EOF {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", readErr),
})
}
data = buf[:n]
default:
data, err = io.ReadAll(file)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", err),
})
}
}
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
// large payloads under the goja interpreter.
return r.vm.ToValue(map[string]any{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
"data": encoded,
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
data := call.Arguments[1].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
// Full-content write: stage and rename so a kill mid-write cannot leave
// a truncated file under the final name.
stagedPath := stagedDownloadPath(fullPath)
if err := os.WriteFile(stagedPath, []byte(data), 0644); err != nil {
os.Remove(stagedPath)
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
if err := os.Rename(stagedPath, fullPath); err != nil {
os.Remove(stagedPath)
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
"path": fullPath,
})
}
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 2)
appendMode := runtimeOptionBool(options, "append", false)
truncate := runtimeOptionBool(options, "truncate", false)
hasOffset := runtimeOptionHasKey(options, "offset")
offset := runtimeOptionInt64(options, "offset", 0)
encoding := runtimeOptionString(options, "encoding", "base64")
if appendMode && hasOffset {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "append and offset cannot be used together",
})
}
if offset < 0 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "offset must be >= 0",
})
}
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
flags := os.O_CREATE | os.O_WRONLY
if appendMode {
flags |= os.O_APPEND
}
if truncate {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(fullPath, flags, 0644)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
if hasOffset && !appendMode {
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
}
written, err := file.Write(data)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
info, statErr := file.Stat()
size := int64(0)
if statErr == nil {
size = info.Size()
}
return r.vm.ToValue(map[string]any{
"success": true,
"path": fullPath,
"bytes_written": written,
"size": size,
})
}
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "source and destination paths are required",
})
}
srcPath := call.Arguments[0].String()
dstPath := call.Arguments[1].String()
fullSrc, err := r.validatePath(srcPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
fullDst, err := r.validatePath(dstPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
srcFile, err := os.Open(fullSrc)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to read source: %v", err),
})
}
defer srcFile.Close()
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to open destination: %v", err),
})
}
if _, err := io.Copy(dstFile, srcFile); err != nil {
_ = dstFile.Close()
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to copy file: %v", err),
})
}
if err := dstFile.Close(); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to finalize destination: %v", err),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
"path": fullDst,
})
}
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "source and destination paths are required",
})
}
srcPath := call.Arguments[0].String()
dstPath := call.Arguments[1].String()
fullSrc, err := r.validatePath(srcPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
fullDst, err := r.validatePath(dstPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
if err := os.Rename(fullSrc, fullDst); err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": fmt.Sprintf("failed to move file: %v", err),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
"path": fullDst,
})
}
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]any{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
info, err := os.Stat(fullPath)
if err != nil {
return r.vm.ToValue(map[string]any{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]any{
"success": true,
"size": info.Size(),
})
}