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)
This commit is contained in:
@@ -1,163 +1,241 @@
|
||||
// cmd/shard/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"greencoast/internal/api"
|
||||
"greencoast/internal/index"
|
||||
"greencoast/internal/storage"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func getenvBool(key string, def bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
type cfgPrivacy struct {
|
||||
AllowAnonPlaintext bool `yaml:"allow_anon_plaintext"`
|
||||
}
|
||||
type shardConfig struct {
|
||||
Privacy cfgPrivacy `yaml:"privacy"`
|
||||
}
|
||||
|
||||
func boolEnv(keys ...string) bool {
|
||||
for _, k := range keys {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
|
||||
if v == "1" || v == "true" || v == "yes" || v == "on" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
return false
|
||||
}
|
||||
|
||||
func loadYAMLAllow(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return def
|
||||
return false
|
||||
}
|
||||
return b
|
||||
defer f.Close()
|
||||
var sc shardConfig
|
||||
if err := yaml.NewDecoder(f).Decode(&sc); err != nil {
|
||||
return false
|
||||
}
|
||||
return sc.Privacy.AllowAnonPlaintext
|
||||
}
|
||||
|
||||
func staticHeaders(next http.Handler) http.Handler {
|
||||
onion := os.Getenv("GC_ONION_LOCATION") // optional: e.g., http://xxxxxxxx.onion/
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Security headers + strict CSP (no inline) + COEP
|
||||
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=()")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload")
|
||||
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
// Allow only self + HTTPS for fetch/SSE; no inline styles/scripts
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self'; "+
|
||||
"style-src 'self'; "+
|
||||
"img-src 'self' data:; "+
|
||||
"connect-src 'self' https:; "+
|
||||
"frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; "+
|
||||
"require-trusted-types-for 'script'")
|
||||
if onion != "" {
|
||||
w.Header().Set("Onion-Location", onion)
|
||||
}
|
||||
/* -------------------------
|
||||
Minimal FS blob store (implements api.BlobStore)
|
||||
Layout:
|
||||
/var/lib/greencoast/objects/<hash> content
|
||||
/var/lib/greencoast/objects/<hash>.priv empty sidecar => private
|
||||
--------------------------*/
|
||||
|
||||
// Basic CORS for static (GET only effectively)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
type fsStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func newFSStore(root string) *fsStore { return &fsStore{root: root} }
|
||||
|
||||
func (s *fsStore) ensureRoot() error {
|
||||
// create both parent and leaf to be safe on fresh volumes
|
||||
if err := os.MkdirAll(filepath.Dir(s.root), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(s.root, 0o755)
|
||||
}
|
||||
func (s *fsStore) pathFor(hash string) string { return filepath.Join(s.root, hash) }
|
||||
func (s *fsStore) privPathFor(hash string) string { return filepath.Join(s.root, hash+".priv") }
|
||||
|
||||
func (s *fsStore) Get(hash string) (io.ReadCloser, int64, error) {
|
||||
if err := s.ensureRoot(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f, err := os.Open(s.pathFor(hash))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, st.Size(), nil
|
||||
}
|
||||
|
||||
func (s *fsStore) Put(r io.Reader, private bool) (string, int64, time.Time, error) {
|
||||
if err := s.ensureRoot(); err != nil {
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(s.root, "put-*")
|
||||
if err != nil {
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
// best-effort cleanup of temp path (original name)
|
||||
_ = os.Remove(tmpName)
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
w := io.MultiWriter(tmp, h)
|
||||
n, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
|
||||
// IMPORTANT on Windows bind mounts: flush & close before rename
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
final := s.pathFor(hash)
|
||||
|
||||
// If a previous file with this hash exists, remove it first (idempotent writes)
|
||||
_ = os.Remove(final)
|
||||
|
||||
if err := os.Rename(tmpName, final); err != nil {
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
|
||||
// Optional: fsync directory to harden the rename on some filesystems
|
||||
if df, err := os.Open(s.root); err == nil {
|
||||
_ = syscall.Fsync(int(df.Fd()))
|
||||
_ = df.Close()
|
||||
}
|
||||
|
||||
st, err := os.Stat(final)
|
||||
if err != nil {
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
|
||||
// create sidecar only after main content is durable
|
||||
if private {
|
||||
if err := os.WriteFile(s.privPathFor(hash), nil, 0o600); err != nil {
|
||||
_ = os.Remove(final)
|
||||
return "", 0, time.Time{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return hash, n, st.ModTime().UTC(), nil
|
||||
}
|
||||
|
||||
func (s *fsStore) Delete(hash string) error {
|
||||
if err := s.ensureRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(s.privPathFor(hash))
|
||||
return os.Remove(s.pathFor(hash))
|
||||
}
|
||||
|
||||
func (s *fsStore) Walk(fn func(hash string, bytes int64, private bool, storedAt time.Time) error) (int, error) {
|
||||
if err := s.ensureRoot(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ents, err := os.ReadDir(s.root)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
// skip sidecars and non-64-hex filenames
|
||||
if strings.HasSuffix(name, ".priv") || len(name) != 64 || !isHex(name) {
|
||||
continue
|
||||
}
|
||||
full := s.pathFor(name)
|
||||
st, err := os.Stat(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
private := false
|
||||
if _, err := os.Stat(s.privPathFor(name)); err == nil {
|
||||
private = true
|
||||
}
|
||||
if err := fn(name, st.Size(), private, st.ModTime().UTC()); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func isHex(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
main
|
||||
--------------------------*/
|
||||
|
||||
func main() {
|
||||
// ---- Config ----
|
||||
httpAddr := os.Getenv("GC_HTTP_ADDR")
|
||||
if httpAddr == "" {
|
||||
httpAddr = ":9080"
|
||||
}
|
||||
httpsAddr := os.Getenv("GC_HTTPS_ADDR")
|
||||
certFile := os.Getenv("GC_TLS_CERT")
|
||||
keyFile := os.Getenv("GC_TLS_KEY")
|
||||
// Store & index
|
||||
store := newFSStore("/var/lib/greencoast/objects")
|
||||
idx := index.New()
|
||||
|
||||
staticAddr := os.Getenv("GC_STATIC_ADDR")
|
||||
if staticAddr == "" {
|
||||
staticAddr = ":9082"
|
||||
}
|
||||
staticDir := os.Getenv("GC_STATIC_DIR")
|
||||
if staticDir == "" {
|
||||
staticDir = "/opt/greencoast/client"
|
||||
}
|
||||
|
||||
dataDir := os.Getenv("GC_DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "/var/lib/greencoast"
|
||||
}
|
||||
|
||||
coarseTS := getenvBool("GC_COARSE_TS", true) // safer default (less precise metadata)
|
||||
zeroTrust := getenvBool("GC_ZERO_TRUST", true)
|
||||
encRequired := getenvBool("GC_ENCRYPTION_REQUIRED", true) // operator-blind by default
|
||||
requirePOP := getenvBool("GC_REQUIRE_POP", true) // logged only here
|
||||
|
||||
signingSecretHex := os.Getenv("GC_SIGNING_SECRET_HEX")
|
||||
if len(signingSecretHex) < 64 {
|
||||
log.Printf("WARN: GC_SIGNING_SECRET_HEX length=%d (need >=64 hex chars)", len(signingSecretHex))
|
||||
} else {
|
||||
log.Printf("GC_SIGNING_SECRET_HEX OK (len=%d)", len(signingSecretHex))
|
||||
}
|
||||
|
||||
discID := os.Getenv("GC_DISCORD_CLIENT_ID")
|
||||
discSecret := os.Getenv("GC_DISCORD_CLIENT_SECRET")
|
||||
discRedirect := os.Getenv("GC_DISCORD_REDIRECT_URI")
|
||||
|
||||
// ---- Storage & Index ----
|
||||
store, err := storage.NewFS(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("storage init: %v", err)
|
||||
}
|
||||
ix := index.New()
|
||||
|
||||
// Reindex on boot from existing files (coarse time if enabled)
|
||||
if err := store.Walk(func(hash string, size int64, mod time.Time) error {
|
||||
when := mod.UTC()
|
||||
if coarseTS {
|
||||
when = when.Truncate(time.Minute)
|
||||
// Flags: env wins, else YAML (/app/shard.yaml), else false
|
||||
allowAnon := boolEnv("GC_ALLOW_ANON_PLAINTEXT")
|
||||
if !allowAnon {
|
||||
if st, err := os.Stat("/app/shard.yaml"); err == nil && !st.IsDir() {
|
||||
allowAnon = loadYAMLAllow("/app/shard.yaml")
|
||||
}
|
||||
return ix.Put(index.Entry{
|
||||
Hash: hash,
|
||||
Bytes: size,
|
||||
StoredAt: when.Format(time.RFC3339Nano),
|
||||
Private: false, // unknown here
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("reindex on boot: %v", err)
|
||||
}
|
||||
devMode := boolEnv("GC_DEV_ALLOW_UNAUTH")
|
||||
|
||||
// ---- Auth providers ----
|
||||
providers := api.AuthProviders{
|
||||
SigningSecretHex: signingSecretHex,
|
||||
Discord: api.DiscordProvider{
|
||||
Enabled: discID != "" && discSecret != "" && discRedirect != "",
|
||||
ClientID: discID,
|
||||
ClientSecret: discSecret,
|
||||
RedirectURI: discRedirect,
|
||||
},
|
||||
}
|
||||
log.Printf("boot: privacy.allow_anon_plaintext=%v dev=%v at=%s", allowAnon, devMode, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// ---- API server ----
|
||||
srv := api.New(store, ix, coarseTS, zeroTrust, providers, encRequired)
|
||||
var providers api.AuthProviders
|
||||
srv := api.New(store, idx, true, devMode, providers, allowAnon)
|
||||
|
||||
// ---- Static file server (separate listener) ----
|
||||
// Frontend (static)
|
||||
go func() {
|
||||
fs := http.FileServer(http.Dir(staticDir))
|
||||
h := staticHeaders(fs)
|
||||
log.Printf("static listening on %s (dir=%s)", staticAddr, staticDir)
|
||||
if err := http.ListenAndServe(staticAddr, h); err != nil {
|
||||
log.Fatalf("static server: %v", err)
|
||||
if err := srv.ListenFrontend("0.0.0.0:9082"); err != nil {
|
||||
log.Printf("frontend server exited: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// ---- Start API (HTTP or HTTPS) ----
|
||||
if httpsAddr != "" && certFile != "" && keyFile != "" {
|
||||
log.Printf("API HTTPS %s POP:%v ENC_REQUIRED:%v", httpsAddr, requirePOP, encRequired)
|
||||
if err := srv.ListenHTTPS(httpsAddr, certFile, keyFile); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("API HTTP %s POP:%v ENC_REQUIRED:%v", httpAddr, requirePOP, encRequired)
|
||||
if err := srv.ListenHTTP(httpAddr); err != nil {
|
||||
// API
|
||||
if err := srv.ListenHTTP("0.0.0.0:9080"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user