git » gofer » next » tree

[next] / server / write.go

package server

import (
	"io"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"time"

	"blitiri.com.ar/go/gofer/trace"
)

// handlePut implements PUT for the dir route.
//
// Required diropts: put: { "<prefix>": true } for the request path.
// On success, returns 201 Created (new file) or 204 No Content (replaced),
// with a Last-Modified header reflecting the stored file.
// PUT onto an existing directory returns 409 Conflict.
// Parent directories are created automatically.
// If-Unmodified-Since is honored when the target file already exists.
func handlePut(fs *FileSystem, w http.ResponseWriter, r *http.Request) {
	tr, _ := trace.FromContext(r.Context())

	name := path.Clean(r.URL.Path)
	if !PutEnabled(&fs.opts, name) {
		tr.Printf("PUT not enabled for %q", name)
		http.Error(w, "405 Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}
	if fs.excluded(name) {
		tr.Printf("excluded path %q", name)
		http.Error(w, "404 Not found", http.StatusNotFound)
		return
	}

	abs, err := fs.localPath(name)
	if err != nil {
		tr.Printf("localPath %q: %v", name, err)
		toHTTPError(w, err)
		return
	}

	existed := false
	if fi, err := os.Stat(abs); err == nil {
		if fi.IsDir() {
			tr.Printf("PUT onto directory %q", abs)
			http.Error(w, "409 Conflict (is a directory)",
				http.StatusConflict)
			return
		}
		existed = true
		if !checkIfUnmodifiedSince(tr, r, fi.ModTime()) {
			http.Error(w, "412 Precondition Failed",
				http.StatusPreconditionFailed)
			return
		}
	}

	if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil {
		tr.Printf("mkdirall %q: %v", filepath.Dir(abs), err)
		toHTTPError(w, err)
		return
	}

	// Atomic write: temp file in target directory, then rename.
	tmp, err := os.CreateTemp(filepath.Dir(abs), ".gofer-put-*.tmp")
	if err != nil {
		tr.Printf("createtemp: %v", err)
		toHTTPError(w, err)
		return
	}
	tmpName := tmp.Name()
	cleanup := true
	defer func() {
		if cleanup {
			os.Remove(tmpName)
		}
	}()

	if _, err := io.Copy(tmp, r.Body); err != nil {
		tmp.Close()
		tr.Printf("copy body to %q: %v", tmpName, err)
		toHTTPError(w, err)
		return
	}
	if err := tmp.Close(); err != nil {
		tr.Printf("close %q: %v", tmpName, err)
		toHTTPError(w, err)
		return
	}
	if err := os.Rename(tmpName, abs); err != nil {
		tr.Printf("rename %q -> %q: %v", tmpName, abs, err)
		toHTTPError(w, err)
		return
	}
	cleanup = false

	// Return Last-Modified reflecting the stored file, so clients have the
	// validator to use for subsequent If-Unmodified-Since requests without an
	// extra round trip (RFC 9110 §9.3.4). The body is stored untransformed.
	if fi, err := os.Stat(abs); err == nil {
		w.Header().Set("Last-Modified",
			fi.ModTime().UTC().Format(http.TimeFormat))
	}

	if existed {
		tr.Printf("PUT %q: replaced", abs)
		w.WriteHeader(http.StatusNoContent)
	} else {
		tr.Printf("PUT %q: created", abs)
		w.WriteHeader(http.StatusCreated)
	}
}

// handleDelete implements DELETE for the dir route.
//
// Required diropts: delete: { "<prefix>": true } for the request path.
// Files only: DELETE on a directory returns 409 Conflict.
// If-Unmodified-Since is honored.
func handleDelete(fs *FileSystem, w http.ResponseWriter, r *http.Request) {
	tr, _ := trace.FromContext(r.Context())

	name := path.Clean(r.URL.Path)
	if !DeleteEnabled(&fs.opts, name) {
		tr.Printf("DELETE not enabled for %q", name)
		http.Error(w, "405 Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}
	if fs.excluded(name) {
		tr.Printf("excluded path %q", name)
		http.Error(w, "404 Not found", http.StatusNotFound)
		return
	}

	abs, err := fs.localPath(name)
	if err != nil {
		tr.Printf("localPath %q: %v", name, err)
		toHTTPError(w, err)
		return
	}

	fi, err := os.Lstat(abs)
	if err != nil {
		tr.Printf("lstat %q: %v", abs, err)
		toHTTPError(w, err)
		return
	}
	if fi.IsDir() {
		tr.Printf("DELETE on directory %q", abs)
		http.Error(w, "409 Conflict (is a directory)", http.StatusConflict)
		return
	}
	if !checkIfUnmodifiedSince(tr, r, fi.ModTime()) {
		http.Error(w, "412 Precondition Failed",
			http.StatusPreconditionFailed)
		return
	}

	if err := os.Remove(abs); err != nil {
		tr.Printf("remove %q: %v", abs, err)
		toHTTPError(w, err)
		return
	}

	tr.Printf("DELETE %q", abs)
	w.WriteHeader(http.StatusNoContent)
}

// checkIfUnmodifiedSince returns true if the request may proceed: either the
// header is absent/malformed, or the file's mtime is at or before the value
// in the header (compared at 1-second granularity, per RFC 9110 §13.1.4).
func checkIfUnmodifiedSince(tr *trace.Trace, r *http.Request, mtime time.Time) bool {
	h := r.Header.Get("If-Unmodified-Since")
	if h == "" {
		return true
	}
	t, err := http.ParseTime(h)
	if err != nil {
		tr.Printf("if-unmodified-since parse error %q: %v", h, err)
		return true
	}
	tr.Printf("if-unmodified-since: %v, mtime: %v", t, mtime)
	return mtime.Truncate(time.Second).Compare(t) <= 0
}