Fixed the issue with PlainText (Complete Anon) posting Need to fix device sign on issues. Need to make it so that the non-signed in devices can only see their equalivant level of posts. (i.e. plaintext, public-encrypted, private-encrypted)
396 lines
9.5 KiB
Go
396 lines
9.5 KiB
Go
// internal/api/http.go
|
|
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"greencoast/internal/index"
|
|
)
|
|
|
|
// ---- Contracts ----
|
|
|
|
type BlobStore interface {
|
|
Get(hash string) (io.ReadCloser, int64, error)
|
|
Put(r io.Reader, private bool) (hash string, n int64, storedAt time.Time, err error)
|
|
Delete(hash string) error
|
|
Walk(fn func(hash string, bytes int64, private bool, storedAt time.Time) error) (int, error)
|
|
}
|
|
|
|
type AuthProviders struct{}
|
|
|
|
// ---- Server ----
|
|
|
|
type Server struct {
|
|
Mux *http.ServeMux // exported for other files
|
|
mux *http.ServeMux // alias
|
|
|
|
store BlobStore
|
|
idx *index.Index
|
|
uiOn bool
|
|
|
|
devAllowUnauth bool
|
|
allowAnonPlaintext bool
|
|
|
|
StaticDir string
|
|
|
|
sseMu sync.Mutex
|
|
sseSubs map[chan []byte]struct{}
|
|
}
|
|
|
|
// New(store, idx, enableUI, devMode, providers, allowAnonPlaintext)
|
|
func New(store BlobStore, idx *index.Index, enableUI bool, devMode bool, _ AuthProviders, allowAnonPlaintext bool) *Server {
|
|
m := http.NewServeMux()
|
|
s := &Server{
|
|
Mux: m,
|
|
mux: m,
|
|
store: store,
|
|
idx: idx,
|
|
uiOn: enableUI,
|
|
devAllowUnauth: devMode,
|
|
allowAnonPlaintext: allowAnonPlaintext,
|
|
StaticDir: "./client",
|
|
sseSubs: make(map[chan []byte]struct{}),
|
|
}
|
|
|
|
// Health + caps
|
|
s.Mux.HandleFunc("/healthz", s.healthz)
|
|
s.Mux.HandleFunc("/v1/caps", s.handleCaps)
|
|
|
|
// Object I/O
|
|
s.Mux.Handle("/v1/object", s.requireAuth(http.HandlerFunc(s.handlePutObject))) // PUT
|
|
s.Mux.Handle("/v1/object/", s.requireAuth(http.HandlerFunc(s.handleObjectByHash))) // GET/DELETE
|
|
|
|
// Index (public read)
|
|
s.Mux.HandleFunc("/v1/index", s.handleIndex)
|
|
s.Mux.HandleFunc("/v1/index/stream", s.handleIndexStream)
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *Server) ListenHTTP(addr string) error {
|
|
handler := corsSecurity(s.Mux)
|
|
server := &http.Server{Addr: addr, Handler: handler}
|
|
return server.ListenAndServe()
|
|
}
|
|
|
|
// ---- Global CORS/security ----
|
|
|
|
func corsSecurity(next http.Handler) http.Handler {
|
|
allowedHeaders := "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ, X-GC-Key, X-GC-TS, X-GC-Proof"
|
|
allowedMethods := "GET, PUT, POST, DELETE, OPTIONS"
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", allowedMethods)
|
|
w.Header().Set("Access-Control-Allow-Headers", allowedHeaders)
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
w.Header().Set("Cross-Origin-Resource-Policy", "same-site")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// ---- Auth (with anon-plaintext bypass) ----
|
|
|
|
func (s *Server) isPlaintextPut(r *http.Request) bool {
|
|
if !s.allowAnonPlaintext {
|
|
return false
|
|
}
|
|
if r.Method != http.MethodPut {
|
|
return false
|
|
}
|
|
if !strings.HasPrefix(r.URL.Path, "/v1/object") {
|
|
return false
|
|
}
|
|
if r.Header.Get("X-GC-Private") == "1" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if s.isPlaintextPut(r) || s.devAllowUnauth {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
|
hasPoP := r.Header.Get("X-GC-Key") != "" && r.Header.Get("X-GC-TS") != "" && r.Header.Get("X-GC-Proof") != ""
|
|
if bearer != "" || hasPoP {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
// ---- Small utils ----
|
|
|
|
func ReadAllStrict(r io.Reader, max int64) ([]byte, error) {
|
|
if max <= 0 {
|
|
return io.ReadAll(r)
|
|
}
|
|
lr := io.LimitedReader{R: r, N: max + 1}
|
|
b, err := io.ReadAll(&lr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(b)) > max {
|
|
return nil, errors.New("payload too large")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func maxObjectBytes() int64 {
|
|
v := strings.TrimSpace(os.Getenv("GC_MAX_OBJECT_KB"))
|
|
if v == "" {
|
|
return 256 * 1024 // default 256 KiB
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n <= 0 {
|
|
return 256 * 1024
|
|
}
|
|
return int64(n) * 1024
|
|
}
|
|
|
|
// ---- Basic endpoints ----
|
|
|
|
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
|
|
type caps struct {
|
|
AllowAnonPlaintext bool `json:"allow_anon_plaintext"`
|
|
ZeroTrust bool `json:"zero_trust"`
|
|
}
|
|
|
|
func (s *Server) handleCaps(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(caps{
|
|
AllowAnonPlaintext: s.allowAnonPlaintext,
|
|
ZeroTrust: true,
|
|
})
|
|
}
|
|
|
|
// ---- Object handlers ----
|
|
|
|
type putResp struct {
|
|
Hash string `json:"hash"`
|
|
Bytes int64 `json:"bytes"`
|
|
StoredAt time.Time `json:"stored_at"`
|
|
Private bool `json:"private"`
|
|
}
|
|
|
|
func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
private := r.Header.Get("X-GC-Private") == "1"
|
|
|
|
// Strict read (prevents runaway memory and surfaces clear error)
|
|
data, err := ReadAllStrict(r.Body, maxObjectBytes())
|
|
if err != nil {
|
|
log.Printf("PUT /v1/object read error: %v", err)
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Store
|
|
hash, n, storedAt, err := s.store.Put(bytes.NewReader(data), private)
|
|
if err != nil {
|
|
log.Printf("PUT /v1/object store error: %v", err)
|
|
http.Error(w, "store failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Broadcast SSE "put"
|
|
s.broadcastEvent("put", map[string]any{
|
|
"hash": hash,
|
|
"bytes": n,
|
|
"stored_at": storedAt.UTC(),
|
|
"private": private,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(putResp{
|
|
Hash: hash,
|
|
Bytes: n,
|
|
StoredAt: storedAt.UTC(),
|
|
Private: private,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
|
|
seg := strings.TrimPrefix(r.URL.Path, "/v1/object")
|
|
seg = strings.TrimPrefix(seg, "/")
|
|
if seg == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
hash := path.Clean(seg)
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
rc, n, err := s.store.Get(hash)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer rc.Close()
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", n))
|
|
if _, err := io.Copy(w, rc); err != nil {
|
|
return
|
|
}
|
|
case http.MethodDelete:
|
|
if err := s.store.Delete(hash); err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
s.broadcastEvent("delete", map[string]any{"hash": hash})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// ---- Index handlers ----
|
|
|
|
type indexEntry struct {
|
|
Hash string `json:"hash"`
|
|
Bytes int64 `json:"bytes"`
|
|
Private bool `json:"private"`
|
|
StoredAt time.Time `json:"stored_at"`
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
out := make([]indexEntry, 0, 256)
|
|
_, err := s.store.Walk(func(hash string, bytes int64, private bool, storedAt time.Time) error {
|
|
out = append(out, indexEntry{
|
|
Hash: hash,
|
|
Bytes: bytes,
|
|
Private: private,
|
|
StoredAt: storedAt.UTC(),
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "index walk failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sortByStoredAtDesc(out)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(out)
|
|
}
|
|
|
|
func sortByStoredAtDesc(a []indexEntry) {
|
|
for i := 1; i < len(a); i++ {
|
|
j := i
|
|
for j > 0 && a[j].StoredAt.After(a[j-1].StoredAt) {
|
|
a[j], a[j-1] = a[j-1], a[j]
|
|
j--
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- SSE ----
|
|
|
|
func (s *Server) handleIndexStream(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "stream unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
ch := make(chan []byte, 32)
|
|
s.addSub(ch)
|
|
defer s.removeSub(ch)
|
|
|
|
_, _ = io.WriteString(w, ": ok\n\n")
|
|
flusher.Flush()
|
|
|
|
notify := r.Context().Done()
|
|
hb := time.NewTicker(20 * time.Second)
|
|
defer hb.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-notify:
|
|
return
|
|
case <-hb.C:
|
|
_, _ = io.WriteString(w, ": ping\n\n")
|
|
flusher.Flush()
|
|
case msg := <-ch:
|
|
_, _ = io.WriteString(w, "data: ")
|
|
_, _ = w.Write(msg)
|
|
_, _ = io.WriteString(w, "\n\n")
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) addSub(ch chan []byte) {
|
|
s.sseMu.Lock()
|
|
s.sseSubs[ch] = struct{}{}
|
|
s.sseMu.Unlock()
|
|
}
|
|
|
|
func (s *Server) removeSub(ch chan []byte) {
|
|
s.sseMu.Lock()
|
|
delete(s.sseSubs, ch)
|
|
close(ch)
|
|
s.sseMu.Unlock()
|
|
}
|
|
|
|
func (s *Server) broadcastEvent(ev string, payload any) {
|
|
body, _ := json.Marshal(map[string]any{"event": ev, "data": payload})
|
|
s.sseMu.Lock()
|
|
for ch := range s.sseSubs {
|
|
select {
|
|
case ch <- body:
|
|
default:
|
|
}
|
|
}
|
|
s.sseMu.Unlock()
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
func bufioReader(r io.Reader) *bufio.Reader {
|
|
if br, ok := r.(*bufio.Reader); ok {
|
|
return br
|
|
}
|
|
return bufio.NewReader(r)
|
|
}
|