649 lines
18 KiB
Go
649 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/metazla/meta-core/internal/leader"
|
|
"github.com/metazla/meta-core/internal/meshdisco"
|
|
)
|
|
|
|
// contentTypeByExt maps file extensions to MIME types
|
|
var contentTypeByExt = map[string]string{
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".mp4": "video/mp4",
|
|
".mkv": "video/x-matroska",
|
|
".avi": "video/x-msvideo",
|
|
".webm": "video/webm",
|
|
".mov": "video/quicktime",
|
|
".ts": "video/mp2t",
|
|
".m3u8": "application/vnd.apple.mpegurl",
|
|
}
|
|
|
|
// HealthResponse is the response for /health
|
|
type HealthResponse struct {
|
|
Status string `json:"status"`
|
|
Redis bool `json:"redis"`
|
|
Timestamp string `json:"timestamp"`
|
|
Leader *leader.LeaderLockInfo `json:"leader,omitempty"`
|
|
}
|
|
|
|
// StatusResponse is the response for /status
|
|
type StatusResponse struct {
|
|
Status string `json:"status"`
|
|
Redis bool `json:"redis"`
|
|
ServiceName string `json:"serviceName"`
|
|
Version string `json:"version"`
|
|
Uptime int64 `json:"uptimeSeconds"`
|
|
FileCount int `json:"fileCount"`
|
|
Leader *leader.LeaderLockInfo `json:"leader,omitempty"`
|
|
}
|
|
|
|
// MetadataResponse is the response for /meta/{hash}
|
|
type MetadataResponse struct {
|
|
HashID string `json:"hashId"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
// DataPathResponse is the response for /data/{hash}/path
|
|
type DataPathResponse struct {
|
|
HashID string `json:"hashId"`
|
|
Path string `json:"path"`
|
|
Exists bool `json:"exists"`
|
|
}
|
|
|
|
// ErrorResponse is the typed error envelope returned on every 4xx/5xx.
|
|
//
|
|
// Shape and slug vocabulary documented in docs/api-mediated-access.md
|
|
// ("Error envelope"). Stable slug strings let clients switch on the failure
|
|
// category without parsing the message; `retryable` tells callers whether
|
|
// a backoff-and-retry has any chance of succeeding.
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message,omitempty"`
|
|
Retryable bool `json:"retryable"`
|
|
}
|
|
|
|
// Stable error-slug strings. Pinned in the doc; consumers may match on
|
|
// these. Do not rename without a deprecation cycle.
|
|
const (
|
|
ErrAliasCollision = "alias_collision"
|
|
ErrUnknownRoot = "unknown_root"
|
|
ErrUnknownCID = "unknown_cid"
|
|
ErrSchemaViolation = "schema_violation"
|
|
ErrStorageUnavailable = "storage_unavailable"
|
|
ErrInternal = "internal"
|
|
ErrDeprecatedField = "deprecated_field"
|
|
)
|
|
|
|
// defaultSlugFor maps an HTTP status code to the most likely error slug.
|
|
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
|
|
// instead of "unknown_root" on 404) call writeErrorSlug directly.
|
|
func defaultSlugFor(status int) (slug string, retryable bool) {
|
|
switch status {
|
|
case http.StatusConflict:
|
|
return ErrAliasCollision, false
|
|
case http.StatusNotFound:
|
|
return ErrUnknownRoot, false
|
|
case http.StatusBadRequest:
|
|
return ErrSchemaViolation, false
|
|
case http.StatusServiceUnavailable:
|
|
return ErrStorageUnavailable, true
|
|
case http.StatusInternalServerError:
|
|
return ErrInternal, true
|
|
default:
|
|
// 4xx → not retryable, 5xx → retryable. Conservative default.
|
|
return ErrInternal, status >= 500
|
|
}
|
|
}
|
|
|
|
var startTime = time.Now()
|
|
|
|
// handleHealth handles GET /health
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
response := HealthResponse{
|
|
Status: "ok",
|
|
Redis: s.storage.Health(),
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Leader: s.leaderProvider.LeaderInfo(),
|
|
}
|
|
|
|
if !response.Redis {
|
|
response.Status = "degraded"
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleStatus handles GET /status
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
fileCount := 0
|
|
if s.storage.IsConnected() {
|
|
count, _ := s.storage.CountFiles()
|
|
fileCount = count
|
|
}
|
|
|
|
response := StatusResponse{
|
|
Status: "ok",
|
|
Redis: s.storage.Health(),
|
|
ServiceName: s.config.ServiceName,
|
|
Version: s.config.ServiceVersion,
|
|
Uptime: int64(time.Since(startTime).Seconds()),
|
|
FileCount: fileCount,
|
|
Leader: s.leaderProvider.LeaderInfo(),
|
|
}
|
|
|
|
if !response.Redis {
|
|
response.Status = "degraded"
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleLeader handles GET /leader
|
|
func (s *Server) handleLeader(w http.ResponseWriter, r *http.Request) {
|
|
info := s.leaderProvider.LeaderInfo()
|
|
if info == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "no leader available")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, info)
|
|
}
|
|
|
|
// URLsResponse is the response for /urls
|
|
type URLsResponse struct {
|
|
Hostname string `json:"hostname"`
|
|
BaseUrl string `json:"baseUrl"`
|
|
ApiUrl string `json:"apiUrl"`
|
|
RedisUrl string `json:"redisUrl"`
|
|
WebdavUrl string `json:"webdavUrl"`
|
|
WebdavUrlInternal string `json:"webdavUrlInternal"`
|
|
}
|
|
|
|
// handleURLs handles GET /urls
|
|
// Returns URLs for this instance (always leader since Go binary only runs as leader)
|
|
func (s *Server) handleURLs(w http.ResponseWriter, r *http.Request) {
|
|
info := s.leaderProvider.LeaderInfo()
|
|
if info == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "no leader available")
|
|
return
|
|
}
|
|
|
|
response := URLsResponse{
|
|
Hostname: info.Hostname,
|
|
BaseUrl: info.BaseUrl,
|
|
ApiUrl: info.ApiUrl,
|
|
RedisUrl: info.RedisUrl,
|
|
WebdavUrl: info.WebdavUrl,
|
|
WebdavUrlInternal: info.WebdavUrlInternal,
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleGetMeta handles GET /meta/{hash}
|
|
func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
hashID := vars["hash"]
|
|
|
|
if hashID == "" {
|
|
writeError(w, http.StatusBadRequest, "hash is required")
|
|
return
|
|
}
|
|
|
|
if !s.storage.IsConnected() {
|
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
return
|
|
}
|
|
|
|
// Resolve CID → UUID via the reverse index so reads find data wherever
|
|
// the watcher minted the root, regardless of which CID the caller knows.
|
|
hashID = s.storage.ResolveRoot(hashID)
|
|
|
|
metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if metadata == nil {
|
|
writeError(w, http.StatusNotFound, "metadata not found")
|
|
return
|
|
}
|
|
|
|
response := MetadataResponse{
|
|
HashID: hashID,
|
|
Metadata: metadata,
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleListMeta handles GET /meta
|
|
func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) {
|
|
if !s.storage.IsConnected() {
|
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
return
|
|
}
|
|
|
|
hashIDs, err := s.storage.GetAllHashIDs()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"hashIds": hashIDs,
|
|
"count": len(hashIDs),
|
|
})
|
|
}
|
|
|
|
// handleGetDataPath handles GET /data/{hash}/path
|
|
func (s *Server) handleGetDataPath(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
hashID := vars["hash"]
|
|
|
|
if hashID == "" {
|
|
writeError(w, http.StatusBadRequest, "hash is required")
|
|
return
|
|
}
|
|
|
|
if !s.storage.IsConnected() {
|
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
return
|
|
}
|
|
|
|
// Get the file path from metadata
|
|
filePath, err := s.storage.GetProperty(hashID, "filePath")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if filePath == "" {
|
|
writeError(w, http.StatusNotFound, "file path not found")
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
fullPath := s.config.FilesPath + "/" + filePath
|
|
_, statErr := os.Stat(fullPath)
|
|
exists := statErr == nil
|
|
|
|
response := DataPathResponse{
|
|
HashID: hashID,
|
|
Path: fullPath,
|
|
Exists: exists,
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleHeadData handles HEAD /data/{hash}
|
|
func (s *Server) handleHeadData(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
hashID := vars["hash"]
|
|
|
|
if hashID == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !s.storage.IsConnected() {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Get the file path from metadata
|
|
filePath, err := s.storage.GetProperty(hashID, "filePath")
|
|
if err != nil || filePath == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
fullPath := s.config.FilesPath + "/" + filePath
|
|
if _, err := os.Stat(fullPath); err != nil {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// handleGetFileByCIDInfo handles GET /api/file/{cid}/info.
|
|
// Returns lightweight metadata for the file (existence, content type, size,
|
|
// relative path) without streaming any bytes. Used by the editor to decide
|
|
// whether to render a preview (image/video) or a download-only button.
|
|
func (s *Server) handleGetFileByCIDInfo(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
cid := vars["cid"]
|
|
|
|
type infoResp struct {
|
|
Exists bool `json:"exists"`
|
|
ContentType string `json:"contentType,omitempty"`
|
|
Size int64 `json:"size,omitempty"`
|
|
FilePath string `json:"filePath,omitempty"`
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if cid == "" {
|
|
_ = json.NewEncoder(w).Encode(infoResp{Exists: false})
|
|
return
|
|
}
|
|
if !s.storage.IsConnected() {
|
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
return
|
|
}
|
|
|
|
relPath, err := s.storage.LookupPathByCID(cid)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if relPath == "" {
|
|
_ = json.NewEncoder(w).Encode(infoResp{Exists: false})
|
|
return
|
|
}
|
|
|
|
fullPath := filepath.Join(s.config.FilesPath, relPath)
|
|
fi, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
_ = json.NewEncoder(w).Encode(infoResp{Exists: false, FilePath: relPath})
|
|
return
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
|
contentType, ok := contentTypeByExt[ext]
|
|
if !ok {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(infoResp{
|
|
Exists: true,
|
|
ContentType: contentType,
|
|
Size: fi.Size(),
|
|
FilePath: relPath,
|
|
})
|
|
}
|
|
|
|
// handleGetFileByCID handles GET /file/{cid}
|
|
// Serves a file by looking up its CID in poster/backdrop metadata
|
|
func (s *Server) handleGetFileByCID(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
cid := vars["cid"]
|
|
|
|
if cid == "" {
|
|
writeError(w, http.StatusBadRequest, "cid is required")
|
|
return
|
|
}
|
|
|
|
if !s.storage.IsConnected() {
|
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
return
|
|
}
|
|
|
|
// Look up the file path by CID
|
|
relPath, err := s.storage.LookupPathByCID(cid)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if relPath == "" {
|
|
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "file not found for CID", false)
|
|
return
|
|
}
|
|
|
|
// Construct full path
|
|
fullPath := filepath.Join(s.config.FilesPath, relPath)
|
|
|
|
// Check if file exists
|
|
fileInfo, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "file does not exist on disk", false)
|
|
return
|
|
}
|
|
|
|
// Determine content type from extension
|
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
|
contentType, ok := contentTypeByExt[ext]
|
|
if !ok {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
// Open the file
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to open file")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Set content type header
|
|
w.Header().Set("Content-Type", contentType)
|
|
|
|
// Serve the file (supports range requests)
|
|
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
|
|
}
|
|
|
|
// handleListNeighbors handles GET /neighbors and GET /api/neighbors.
|
|
//
|
|
// Beacon v2 (docs/project-architecture/beacon-v2.md). The list is this node's
|
|
// in-memory view of the UDP group, so it works across stacks that share a
|
|
// network but no volume. Instances of one name collapse to the most recent
|
|
// (one row per service, the nav menu's shape); `?all=1` returns every
|
|
// instance, `?cap=<pattern>` keeps only nodes with a matching capability.
|
|
func (s *Server) handleListNeighbors(w http.ResponseWriter, r *http.Request) {
|
|
response := map[string]interface{}{
|
|
"current": s.config.ServiceName,
|
|
"neighbors": []interface{}{},
|
|
"count": 0,
|
|
}
|
|
|
|
if s.mesh == nil {
|
|
// Discovery off or failed to bind. Report empty rather than 503 —
|
|
// the nav is decoration and callers already treat [] as "hide".
|
|
response["enabled"] = false
|
|
writeJSON(w, http.StatusOK, response)
|
|
return
|
|
}
|
|
|
|
var list []meshdisco.Neighbor
|
|
if r.URL.Query().Get("all") != "" {
|
|
list = s.mesh.Neighbors()
|
|
} else {
|
|
list = s.mesh.NeighborsByName()
|
|
}
|
|
if pattern := r.URL.Query().Get("cap"); pattern != "" {
|
|
kept := list[:0]
|
|
for _, nb := range list {
|
|
if nb.Matches(pattern) {
|
|
kept = append(kept, nb)
|
|
}
|
|
}
|
|
list = kept
|
|
}
|
|
|
|
response["enabled"] = true
|
|
response["neighbors"] = list
|
|
response["count"] = len(list)
|
|
response["self"] = s.mesh.Self()
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// writeJSON writes a JSON response
|
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
// writeError writes the typed error envelope. The slug + retryable bit
|
|
// are inferred from the status (see defaultSlugFor); call writeErrorSlug
|
|
// directly when a more specific slug applies.
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
slug, retryable := defaultSlugFor(status)
|
|
writeJSON(w, status, ErrorResponse{
|
|
Error: slug,
|
|
Message: message,
|
|
Retryable: retryable,
|
|
})
|
|
}
|
|
|
|
// writeErrorSlug writes the typed envelope with an explicit slug and
|
|
// retryable bit. Use this for endpoint-specific slugs (alias_collision,
|
|
// unknown_cid) where the default mapping from HTTP status would be wrong.
|
|
func writeErrorSlug(w http.ResponseWriter, status int, slug, message string, retryable bool) {
|
|
writeJSON(w, status, ErrorResponse{
|
|
Error: slug,
|
|
Message: message,
|
|
Retryable: retryable,
|
|
})
|
|
}
|
|
|
|
// handleGetProperty handles GET /meta/{hash}/{key}
|
|
// Gets a single property value
|
|
func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
hashID := vars["hash"]
|
|
key := vars["key"]
|
|
|
|
if hashID == "" {
|
|
writeError(w, http.StatusBadRequest, "hash is required")
|
|
return
|
|
}
|
|
|
|
if key == "" {
|
|
writeError(w, http.StatusBadRequest, "key is required")
|
|
return
|
|
}
|
|
|
|
if !s.storage.IsConnected() {
|
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
return
|
|
}
|
|
|
|
hashID = s.storage.ResolveRoot(hashID)
|
|
|
|
value, err := s.storage.GetProperty(hashID, key)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if value == "" {
|
|
writeError(w, http.StatusNotFound, "property not found")
|
|
return
|
|
}
|
|
|
|
// Return plain text for single property
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(value))
|
|
}
|
|
|
|
// PropertyBody is the request body for PUT /meta/{hash}/{key}
|
|
type PropertyBody struct {
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// CIDRequest is the request body for POST /file/cid
|
|
type CIDRequest struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// CIDResponse is the response for POST /file/cid
|
|
type CIDResponse struct {
|
|
CID string `json:"cid"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// handleComputeFileCID handles POST /file/cid
|
|
// Computes the IPFS-compatible CIDv1 (sha256) for a file
|
|
func (s *Server) handleComputeFileCID(w http.ResponseWriter, r *http.Request) {
|
|
var req CIDRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
if req.Path == "" {
|
|
writeError(w, http.StatusBadRequest, "path is required")
|
|
return
|
|
}
|
|
|
|
// Construct full path - path should be relative to FILES_PATH
|
|
fullPath := filepath.Join(s.config.FilesPath, req.Path)
|
|
|
|
// Security check: ensure path is within FILES_PATH
|
|
absFilesPath, _ := filepath.Abs(s.config.FilesPath)
|
|
absFullPath, _ := filepath.Abs(fullPath)
|
|
if !strings.HasPrefix(absFullPath, absFilesPath) {
|
|
writeError(w, http.StatusBadRequest, "path must be within files directory")
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
fileInfo, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "file not found")
|
|
return
|
|
}
|
|
|
|
if fileInfo.IsDir() {
|
|
writeError(w, http.StatusBadRequest, "path is a directory, not a file")
|
|
return
|
|
}
|
|
|
|
// Open and hash the file
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to open file")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Compute SHA-256 hash
|
|
hasher := sha256.New()
|
|
if _, err := io.Copy(hasher, file); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read file")
|
|
return
|
|
}
|
|
hashBytes := hasher.Sum(nil)
|
|
|
|
// Build CIDv1 with raw codec (0x55) and sha256 multihash
|
|
// CIDv1 format: version (0x01) + codec (0x55) + multihash
|
|
// Multihash format: hash-code (0x12) + length (0x20) + hash
|
|
cidBytes := make([]byte, 0, 2+2+32)
|
|
cidBytes = append(cidBytes, 0x01) // CIDv1
|
|
cidBytes = append(cidBytes, 0x55) // raw codec
|
|
cidBytes = append(cidBytes, 0x12) // sha256 code
|
|
cidBytes = append(cidBytes, 0x20) // 32 bytes
|
|
cidBytes = append(cidBytes, hashBytes...)
|
|
|
|
// Encode as base32lower with 'b' prefix (multibase)
|
|
cid := "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(cidBytes))
|
|
|
|
writeJSON(w, http.StatusOK, CIDResponse{
|
|
CID: cid,
|
|
Path: req.Path,
|
|
Size: fileInfo.Size(),
|
|
})
|
|
}
|