178 lines
5.4 KiB
Go
178 lines
5.4 KiB
Go
package audio
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
|
)
|
|
|
|
const audioCacheIdentityVersion = 1
|
|
|
|
type audioCacheIdentity struct {
|
|
Version int `json:"version"`
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
Generation string `json:"generation"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
func cacheIdentityPath(cachePath string) string {
|
|
return cachePath + ".identity.json"
|
|
}
|
|
|
|
func cacheIdentityForRequest(req S3MaterializeRequest, checksum string) audioCacheIdentity {
|
|
return audioCacheIdentity{
|
|
Version: audioCacheIdentityVersion,
|
|
Bucket: strings.TrimSpace(req.Bucket),
|
|
ObjectKey: strings.TrimSpace(req.Object.Key),
|
|
Generation: strings.TrimSpace(req.Object.ETag),
|
|
Size: req.Object.Size,
|
|
SHA256: strings.ToLower(strings.TrimSpace(checksum)),
|
|
}
|
|
}
|
|
|
|
func (identity audioCacheIdentity) matchesRequest(req S3MaterializeRequest) bool {
|
|
return identity.Version == audioCacheIdentityVersion &&
|
|
identity.Bucket == strings.TrimSpace(req.Bucket) &&
|
|
identity.ObjectKey == strings.TrimSpace(req.Object.Key) &&
|
|
identity.Generation != "" && identity.Generation == strings.TrimSpace(req.Object.ETag) &&
|
|
identity.Size > 0 && identity.Size == req.Object.Size &&
|
|
validSHA256(identity.SHA256)
|
|
}
|
|
|
|
func cacheIdentityEligible(req S3MaterializeRequest) bool {
|
|
return strings.TrimSpace(req.Object.Key) != "" && strings.TrimSpace(req.Bucket) != "" &&
|
|
strings.TrimSpace(req.Object.ETag) != "" && req.Object.Size > 0
|
|
}
|
|
|
|
func loadAudioCacheIdentity(path string) (audioCacheIdentity, bool, error) {
|
|
data, err := fileops.ReadRegularFile(path, 64*1024)
|
|
if os.IsNotExist(err) {
|
|
return audioCacheIdentity{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return audioCacheIdentity{}, false, nil
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
var identity audioCacheIdentity
|
|
if err := decoder.Decode(&identity); err != nil {
|
|
return audioCacheIdentity{}, false, nil
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF || !validSHA256(identity.SHA256) {
|
|
return audioCacheIdentity{}, false, nil
|
|
}
|
|
return identity, true, nil
|
|
}
|
|
|
|
func writeAudioCacheIdentity(path string, identity audioCacheIdentity) error {
|
|
data, err := json.Marshal(identity)
|
|
if err != nil {
|
|
return fmt.Errorf("encode audio cache identity: %w", err)
|
|
}
|
|
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
|
|
return fmt.Errorf("write audio cache identity: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func materializeVerifiedCachedAudio(ctx context.Context, req S3MaterializeRequest, cachePath string) (string, bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return "", false, err
|
|
}
|
|
identity, ok, err := loadAudioCacheIdentity(cacheIdentityPath(cachePath))
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if !ok || !identity.matchesRequest(req) {
|
|
return "", false, nil
|
|
}
|
|
|
|
relativePath, err := cacheRelativePath(req.CacheRoot, cachePath)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
source, err := fileops.OpenConfinedRegularFile(req.CacheRoot, relativePath)
|
|
if err != nil {
|
|
return "", false, nil
|
|
}
|
|
defer func() { _ = source.Close() }()
|
|
info, err := source.Stat()
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("inspect cached audio: %w", err)
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() != identity.Size {
|
|
return "", false, nil
|
|
}
|
|
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(req.DestPath)); err != nil {
|
|
return "", false, fmt.Errorf("create audio destination directory: %w", err)
|
|
}
|
|
temporary, err := fileops.DownloadToSiblingTemp(req.DestPath, func(destination io.Writer) error {
|
|
_, err := io.Copy(destination, source)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("copy verified cached audio: %w", err)
|
|
}
|
|
defer func() { _ = temporary.Cleanup() }()
|
|
checksum, size, err := temporaryChecksum(temporary)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if size != identity.Size || checksum != identity.SHA256 {
|
|
return "", false, nil
|
|
}
|
|
if err := temporary.Install(filepath.Base(req.DestPath), fileops.WorkspaceFileMode); err != nil {
|
|
return "", false, fmt.Errorf("install verified cached audio: %w", err)
|
|
}
|
|
return checksum, true, nil
|
|
}
|
|
|
|
func cacheRelativePath(cacheRoot, cachePath string) (string, error) {
|
|
relativePath, err := filepath.Rel(filepath.Clean(cacheRoot), filepath.Clean(cachePath))
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve audio cache path: %w", err)
|
|
}
|
|
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(relativePath))
|
|
if err != nil {
|
|
return "", fmt.Errorf("audio cache path escapes cache root: %w", err)
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func temporaryChecksum(temporary *fileops.DownloadedTempFile) (string, int64, error) {
|
|
file, err := temporary.Open()
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("open cached audio copy: %w", err)
|
|
}
|
|
digest := sha256.New()
|
|
size, copyErr := io.Copy(digest, file)
|
|
closeErr := file.Close()
|
|
if copyErr != nil {
|
|
return "", 0, fmt.Errorf("checksum cached audio copy: %w", copyErr)
|
|
}
|
|
if closeErr != nil {
|
|
return "", 0, fmt.Errorf("close cached audio copy: %w", closeErr)
|
|
}
|
|
return hex.EncodeToString(digest.Sum(nil)), size, nil
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != sha256.Size*2 {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(value)
|
|
return err == nil
|
|
}
|