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:
2025-08-22 22:59:05 -04:00
parent 6a274f4259
commit d87e9322b5
10 changed files with 1239 additions and 1410 deletions

View File

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