This repository has been archived on 2025-08-23. You can view files and clone it, but cannot push or open issues or pull requests.
Files
GreenCoast/internal/api/http.go
Dani d87e9322b5 Added example/dropin replacements for .env.example
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)
2025-08-22 22:59:05 -04:00

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)
}