You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
229 lines
6.2 KiB
Go
229 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.qcode.ch/nostr/noxy"
|
|
)
|
|
|
|
var noxyVersion = "dev" // overwritten by linker flags in release build
|
|
|
|
var (
|
|
listenAddr = flag.String("addr", "127.0.0.1:8889", "listen address")
|
|
cacheDir = flag.String("cachedir", "/tmp", "absolute cache dir")
|
|
maxFileSize = flag.Int64("maxfilesize", 1<<23, "refuse to handle files larger than this, in bytes")
|
|
minCacheAge = flag.Uint("mincacheage", 3600, "min cache-control max-age value, in seconds")
|
|
idleRelayTimeout = flag.Duration("idlerelaytimeout", 10*time.Minute, "remove relay connections after idling this long")
|
|
showVersion = flag.Bool("V", false, "print version and exit")
|
|
|
|
// the -relay flag, populated by parseRelayFlag.
|
|
// set to defaultKnownRelays if empty.
|
|
knownRelays []string
|
|
defaultKnownRelays = []string{
|
|
"nostr.x1ddos.ch",
|
|
// from https://nostr.info/relays/
|
|
"expensive-relay.fiatjaf.com",
|
|
"nostr-pub.semisol.dev",
|
|
"nostr-pub.wellorder.net",
|
|
"nostr-relay-dev.wlvs.space",
|
|
"nostr-relay.wlvs.space",
|
|
"nostr-verified.wellorder.net",
|
|
"nostr.bitcoiner.social",
|
|
"nostr.delo.software",
|
|
"nostr.ono.re",
|
|
"nostr.onsats.org",
|
|
"nostr.openchain.fr",
|
|
"nostr.oxtr.dev",
|
|
"nostr.rdfriedl.com",
|
|
"nostr.semisol.dev",
|
|
"nostr.zaprite.io",
|
|
"relay.cynsar.foundation",
|
|
"relay.damus.io",
|
|
"relay.farscapian.com",
|
|
"relay.minds.com",
|
|
"relay.nostr.ch",
|
|
"relay.nostr.info",
|
|
"relay.oldcity-bitcoiners.info",
|
|
"relay.sovereign-stack.org",
|
|
}
|
|
)
|
|
|
|
func init() {
|
|
flag.Func("relay", "a comma separated nostr relays noxy is allowed to connect to", parseRelayFlag)
|
|
sort.Strings(defaultKnownRelays)
|
|
}
|
|
|
|
func parseRelayFlag(v string) error {
|
|
for _, s := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' }) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid relay URL %s: %w", s, err)
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("invalid relay URL %s: no hostname", s)
|
|
}
|
|
knownRelays = append(knownRelays, host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func usage() {
|
|
w := flag.CommandLine.Output()
|
|
fmt.Fprintf(w, "usage of %s:\n", os.Args[0])
|
|
flag.PrintDefaults()
|
|
fmt.Fprintln(w, "\nthe -relay flag may be specified multiple times.")
|
|
fmt.Fprintf(w, "its default value is the following list:\n\n")
|
|
fmt.Fprintln(w, strings.Join(defaultKnownRelays, "\n"))
|
|
}
|
|
|
|
// set up in main and used by handleXxx HTTP server handlers.
|
|
var noxer *noxy.Noxer
|
|
|
|
func main() {
|
|
flag.Usage = usage
|
|
flag.Parse()
|
|
if *showVersion {
|
|
fmt.Println(noxyVersion)
|
|
return
|
|
}
|
|
if len(knownRelays) == 0 {
|
|
knownRelays = defaultKnownRelays
|
|
}
|
|
sort.Strings(knownRelays)
|
|
if !filepath.IsAbs(*cacheDir) {
|
|
log.Fatal("cache dir must be absolute path")
|
|
}
|
|
|
|
noxer = &noxy.Noxer{
|
|
MaxFileSize: *maxFileSize,
|
|
IdleRelayTimeout: *idleRelayTimeout,
|
|
KnownRelays: knownRelays,
|
|
Cache: noxy.DirCache{Root: *cacheDir},
|
|
HTTPClient: &http.Client{Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
MaxConnsPerHost: 2,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}},
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/", http.HandlerFunc(handleRoot))
|
|
mux.Handle("/meta", http.HandlerFunc(handleMeta))
|
|
mux.Handle("/data", http.HandlerFunc(handleData))
|
|
|
|
log.Printf("listening on %s", *listenAddr)
|
|
log.Printf("known relays: %s", strings.Join(knownRelays, ", "))
|
|
http.ListenAndServe(*listenAddr, logHandler(cors(mux)))
|
|
}
|
|
|
|
// handles requests to /
|
|
func handleRoot(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, "this is noxy version %s\nhttps://git.qcode.ch/nostr/noxy\n", noxyVersion)
|
|
}
|
|
|
|
// handles requests to /meta
|
|
func handleMeta(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
eventID := r.FormValue("id")
|
|
relayURL := r.FormValue("relay")
|
|
linkURL := r.FormValue("url")
|
|
meta, err := noxer.FetchLinkMeta(r.Context(), eventID, relayURL, linkURL)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
res := struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Descr string `json:"descr"`
|
|
Images []string `json:"images"`
|
|
}{
|
|
Type: meta.Type,
|
|
Title: meta.Title,
|
|
Descr: meta.Description,
|
|
Images: meta.ImageURLs,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", *minCacheAge))
|
|
json.NewEncoder(w).Encode(res)
|
|
}
|
|
|
|
// handles requests to /data
|
|
func handleData(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
eventID := r.FormValue("id")
|
|
relayURL := r.FormValue("relay")
|
|
linkURL := r.FormValue("url")
|
|
ds, err := noxer.StreamLinkData(r.Context(), eventID, relayURL, linkURL)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
defer ds.Close()
|
|
w.Header().Set("Content-Type", ds.ContentType)
|
|
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", *minCacheAge))
|
|
io.Copy(w, ds)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, err error) {
|
|
log.Printf("ERROR: %v", err)
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
switch {
|
|
default:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
case errors.Is(err, noxy.ErrNotFound):
|
|
w.WriteHeader(http.StatusNotFound)
|
|
case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled):
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
fmt.Fprint(w, err.Error())
|
|
}
|
|
|
|
func cors(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Headers", "*") // nb: wildcard prevents authentication
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
if r.Method == "OPTIONS" {
|
|
w.Header().Set("Access-Control-Max-Age", "2592000") // valid for 30 days
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func logHandler(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
log.Printf("%s %s", r.Method, r.RequestURI)
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|