mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 14:57:27 +00:00
bbs.profullstack.com launch kit: provisioner, custom domains, email verify, ascii-live
- setup.sh: idempotent one-shot droplet provisioner — agentbbs on :22 (admin OpenSSH moved to :2202), rootless podman, Caddy front end for https://bbs.profullstack.com with tilde-style /~user homepages - internal/sites + domain@ SSH route: self-service custom domains (ssh domain@host add example.com) backed by a symlink farm and an on-demand-TLS ask endpoint so Caddy only issues certs for mapped hosts - internal/mail + join@ email verification: optional email at signup, confirmation link served by a loopback /verify endpoint behind Caddy - internal/source + cmd/ascii-live: live video → terminal ASCII groundwork (docs/ascii-live.md) - store: additive sqlite migrations (email/verify columns, domains table) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9b0f465946
commit
f3f8e70996
14 changed files with 2005 additions and 27 deletions
|
|
@ -36,6 +36,10 @@ var PodNames = map[string]bool{"pod": true}
|
|||
// visitor's public key, print instructions, and disconnect.
|
||||
var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
|
||||
|
||||
// DomainNames are usernames that route to the custom-domain self-service flow:
|
||||
// list/add/remove the domains pointed at a member's homepage.
|
||||
var DomainNames = map[string]bool{"domain": true, "domains": true}
|
||||
|
||||
// IsGuestName reports whether the SSH username requests anonymous hub access.
|
||||
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
|
||||
|
||||
|
|
@ -45,6 +49,9 @@ func IsPodName(u string) bool { return PodNames[strings.ToLower(u)] }
|
|||
// IsJoinName reports whether the SSH username requests onboarding.
|
||||
func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
|
||||
|
||||
// IsDomainName reports whether the SSH username requests the custom-domain flow.
|
||||
func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
||||
|
||||
// KindFor infers the identity kind from a (non-guest) username.
|
||||
// Usernames prefixed "agent-" are automated clients (PRD §3).
|
||||
func KindFor(username string) Kind {
|
||||
|
|
|
|||
60
internal/mail/mail.go
Normal file
60
internal/mail/mail.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Package mail sends transactional email (account confirmation) over SMTP.
|
||||
// It is intentionally tiny: standard net/smtp with STARTTLS, configured from
|
||||
// AGENTBBS_SMTP_* env vars. When unconfigured, Configured() reports false and
|
||||
// the caller logs the confirmation link instead of sending it.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is an SMTP relay. Host+From are the minimum for Configured().
|
||||
type Config struct {
|
||||
Host string // smtp server host (no port)
|
||||
Port string // default 587 (STARTTLS)
|
||||
User string // auth user; empty = no auth
|
||||
Pass string
|
||||
From string // envelope + From: header
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads AGENTBBS_SMTP_{HOST,PORT,USER,PASS,FROM}.
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
Host: os.Getenv("AGENTBBS_SMTP_HOST"),
|
||||
Port: os.Getenv("AGENTBBS_SMTP_PORT"),
|
||||
User: os.Getenv("AGENTBBS_SMTP_USER"),
|
||||
Pass: os.Getenv("AGENTBBS_SMTP_PASS"),
|
||||
From: os.Getenv("AGENTBBS_SMTP_FROM"),
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether email can actually be sent.
|
||||
func (c Config) Configured() bool { return c.Host != "" && c.From != "" }
|
||||
|
||||
// Send delivers a plain-text message. net/smtp negotiates STARTTLS when the
|
||||
// server advertises it (the common case on :587). Implicit-TLS :465 is not
|
||||
// supported — use a STARTTLS port.
|
||||
func (c Config) Send(to, subject, body string) error {
|
||||
if !c.Configured() {
|
||||
return fmt.Errorf("smtp not configured")
|
||||
}
|
||||
port := c.Port
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
var auth smtp.Auth
|
||||
if c.User != "" {
|
||||
auth = smtp.PlainAuth("", c.User, c.Pass, c.Host)
|
||||
}
|
||||
msg := "From: " + c.From + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"\r\n" +
|
||||
strings.ReplaceAll(body, "\n", "\r\n") + "\r\n"
|
||||
return smtp.SendMail(c.Host+":"+port, auth, c.From, []string{to}, []byte(msg))
|
||||
}
|
||||
149
internal/sites/sites.go
Normal file
149
internal/sites/sites.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Package sites maps members' custom domains onto their homepage (the
|
||||
// public_html that is also served at /~name).
|
||||
//
|
||||
// How it works without a custom Caddy module:
|
||||
//
|
||||
// - The DB (store.domains) is the source of truth for domain→user.
|
||||
// - A symlink farm at <data>/domains/<domain> -> <data>/users/<name>/public_html
|
||||
// lets Caddy serve any mapped domain with `root * <data>/domains/{host}`.
|
||||
// - AskHandler answers Caddy's on-demand-TLS query so certificates are only
|
||||
// issued for domains that are actually mapped (no open cert relay).
|
||||
//
|
||||
// A member points DNS (CNAME or A) at the BBS host; the moment a TLS handshake
|
||||
// for that domain arrives, Caddy asks us, gets a 200, provisions a cert, and
|
||||
// serves their homepage. See docs/custom-domains.md.
|
||||
package sites
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
// ErrInvalidDomain is returned for syntactically invalid hostnames.
|
||||
var ErrInvalidDomain = errors.New("invalid domain")
|
||||
|
||||
// A conservative DNS hostname: lowercase labels, a real TLD, no scheme/path.
|
||||
var domainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
|
||||
|
||||
// Normalize lowercases and trims a domain (drops a trailing dot, any scheme).
|
||||
func Normalize(d string) string {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
d = strings.TrimPrefix(d, "https://")
|
||||
d = strings.TrimPrefix(d, "http://")
|
||||
d = strings.TrimSuffix(d, "/")
|
||||
return strings.TrimSuffix(d, ".")
|
||||
}
|
||||
|
||||
// Valid reports whether d is a usable custom domain.
|
||||
func Valid(d string) bool { return len(d) <= 253 && domainRe.MatchString(d) }
|
||||
|
||||
// Manager owns the symlink farm and the on-demand-TLS ask endpoint.
|
||||
type Manager struct {
|
||||
st store.Store
|
||||
usersDir string // <data>/users
|
||||
domDir string // <data>/domains (the symlink farm Caddy serves)
|
||||
}
|
||||
|
||||
// NewManager prepares the symlink farm under dataDir/domains.
|
||||
func NewManager(st store.Store, dataDir string) (*Manager, error) {
|
||||
domDir := filepath.Join(dataDir, "domains")
|
||||
if err := os.MkdirAll(domDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{
|
||||
st: st,
|
||||
usersDir: filepath.Join(dataDir, "users"),
|
||||
domDir: domDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Add maps domain→username (DB row + symlink). Returns store.ErrDomainTaken if
|
||||
// another member already owns it, or ErrInvalidDomain on a malformed host.
|
||||
func (m *Manager) Add(domain, username string) (string, error) {
|
||||
domain = Normalize(domain)
|
||||
if !Valid(domain) {
|
||||
return "", ErrInvalidDomain
|
||||
}
|
||||
if err := m.st.MapDomain(domain, username); err != nil {
|
||||
return domain, err
|
||||
}
|
||||
return domain, m.link(domain, username)
|
||||
}
|
||||
|
||||
// Remove unmaps a domain owned by username (DB row + symlink).
|
||||
func (m *Manager) Remove(domain, username string) (string, error) {
|
||||
domain = Normalize(domain)
|
||||
if err := m.st.UnmapDomain(domain, username); err != nil {
|
||||
return domain, err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(m.domDir, domain))
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// List returns the domains mapped to username.
|
||||
func (m *Manager) List(username string) ([]string, error) {
|
||||
return m.st.DomainsForUser(username)
|
||||
}
|
||||
|
||||
// Sync rebuilds the symlink farm from the DB. Run at startup so the farm
|
||||
// survives a wiped data dir or hand edits, and so the DB stays authoritative.
|
||||
func (m *Manager) Sync() error {
|
||||
all, err := m.st.AllDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dm := range all {
|
||||
if err := m.link(dm.Domain, dm.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// link points <domDir>/<domain> at the user's public_html, replacing any stale
|
||||
// link. The public_html is created if absent so the cert + serve path is ready
|
||||
// before the member has logged in to seed a homepage.
|
||||
func (m *Manager) link(domain, username string) error {
|
||||
if !Valid(domain) {
|
||||
return ErrInvalidDomain
|
||||
}
|
||||
target := filepath.Join(m.usersDir, username, "public_html")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(m.domDir, domain)
|
||||
_ = os.Remove(link)
|
||||
return os.Symlink(target, link)
|
||||
}
|
||||
|
||||
// AskHandler answers Caddy's on-demand-TLS query: 200 when the domain is
|
||||
// mapped (so a cert may be issued), 404 otherwise. Caddy passes the requested
|
||||
// host as ?domain=. Bind this to loopback only.
|
||||
func (m *Manager) AskHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
d := Normalize(r.URL.Query().Get("domain"))
|
||||
if d == "" || !Valid(d) {
|
||||
http.Error(w, "bad domain", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok, err := m.st.DomainUser(d); err != nil {
|
||||
http.Error(w, "lookup error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !ok {
|
||||
http.Error(w, "unknown domain", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
// ServeAsk runs the ask endpoint (blocking). Intended for a goroutine.
|
||||
func (m *Manager) ServeAsk(addr string) error {
|
||||
return http.ListenAndServe(addr, m.AskHandler())
|
||||
}
|
||||
112
internal/sites/sites_test.go
Normal file
112
internal/sites/sites_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package sites
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeAndValid(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
norm string
|
||||
valid bool
|
||||
}{
|
||||
{"Chovy.com", "chovy.com", true},
|
||||
{" https://Example.COM/ ", "example.com", true},
|
||||
{"sub.example.co.uk.", "sub.example.co.uk", true},
|
||||
{"localhost", "localhost", false}, // no TLD
|
||||
{"bad_domain.com", "bad_domain.com", false}, // underscore
|
||||
{"../etc/passwd", "../etc/passwd", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Normalize(c.in); got != c.norm {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", c.in, got, c.norm)
|
||||
}
|
||||
if got := Valid(Normalize(c.in)); got != c.valid {
|
||||
t.Errorf("Valid(Normalize(%q)) = %v, want %v", c.in, got, c.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerAddRemoveSyncAsk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
m, err := NewManager(st, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add creates the DB row and a symlink to the user's public_html.
|
||||
if _, err := m.Add("Chovy.com", "chovy"); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "domains", "chovy.com")
|
||||
target, err := os.Readlink(link)
|
||||
if err != nil {
|
||||
t.Fatalf("expected symlink at %s: %v", link, err)
|
||||
}
|
||||
if want := filepath.Join(dir, "users", "chovy", "public_html"); target != want {
|
||||
t.Errorf("symlink target = %q, want %q", target, want)
|
||||
}
|
||||
|
||||
// A different user cannot steal a mapped domain.
|
||||
if _, err := m.Add("chovy.com", "someoneelse"); err != store.ErrDomainTaken {
|
||||
t.Errorf("expected ErrDomainTaken, got %v", err)
|
||||
}
|
||||
|
||||
// Invalid domains are rejected.
|
||||
if _, err := m.Add("not a domain", "chovy"); err != ErrInvalidDomain {
|
||||
t.Errorf("expected ErrInvalidDomain, got %v", err)
|
||||
}
|
||||
|
||||
// Ask endpoint: 200 for mapped, 404 for unmapped, 400 for junk.
|
||||
h := m.AskHandler()
|
||||
for _, c := range []struct {
|
||||
domain string
|
||||
code int
|
||||
}{
|
||||
{"chovy.com", http.StatusOK},
|
||||
{"unmapped.com", http.StatusNotFound},
|
||||
{"localhost", http.StatusBadRequest},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/check?domain="+c.domain, nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != c.code {
|
||||
t.Errorf("ask %q = %d, want %d", c.domain, rec.Code, c.code)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync rebuilds the farm from the DB after the link is removed out-of-band.
|
||||
if err := os.Remove(link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Sync(); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if _, err := os.Readlink(link); err != nil {
|
||||
t.Errorf("Sync did not restore symlink: %v", err)
|
||||
}
|
||||
|
||||
// Remove drops both the row and the link.
|
||||
if _, err := m.Remove("chovy.com", "chovy"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(link); !os.IsNotExist(err) {
|
||||
t.Errorf("expected symlink gone, got err=%v", err)
|
||||
}
|
||||
if _, ok, _ := st.DomainUser("chovy.com"); ok {
|
||||
t.Error("expected domain unmapped in store")
|
||||
}
|
||||
}
|
||||
268
internal/source/source.go
Normal file
268
internal/source/source.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
// Package source turns a user-supplied URL (YouTube Live or direct HLS) into
|
||||
// a stream of packed RGB24 frames, sized for the half-block terminal renderer
|
||||
// in internal/ascii. It is the URL counterpart to internal/calls, which
|
||||
// ingests LiveKit tracks: same RGB24 frame contract, different front end.
|
||||
//
|
||||
// A YouTube URL is resolved to a playable stream with `yt-dlp -g`; a direct
|
||||
// .m3u8 is used as-is. The resolved URL is then decoded by ffmpeg into raw
|
||||
// rgb24 frames. Every URL — the one the user typed and the one yt-dlp returns —
|
||||
// is run through an SSRF guard (guardURL) before any connection is made.
|
||||
package source
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind classifies a source URL.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindYouTube Kind = "youtube"
|
||||
KindHLS Kind = "hls"
|
||||
)
|
||||
|
||||
func (k Kind) String() string { return string(k) }
|
||||
|
||||
// Options configures a worker. PW/PH are the pixel dimensions the decoder
|
||||
// scales to — compute them with ascii.FitEven(cols, rows).
|
||||
type Options struct {
|
||||
URL string
|
||||
FPS int
|
||||
PW, PH int
|
||||
AllowHLS bool // permit direct .m3u8 / http(s) inputs (not just YouTube)
|
||||
}
|
||||
|
||||
// Worker is one running decode pipeline: ffmpeg reading the resolved stream
|
||||
// and emitting RGB24 frames on Frames. Mirrors internal/calls.session so the
|
||||
// Phase 1 fan-out multiplexer can drive either source identically.
|
||||
type Worker struct {
|
||||
Frames chan []byte // each frame is PW*PH*3 bytes
|
||||
Status chan string // human-readable status, best-effort (non-blocking)
|
||||
Kind Kind // resolved source kind
|
||||
ffmpeg *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// youtubeHosts are the hostnames routed through yt-dlp resolution.
|
||||
var youtubeHosts = map[string]bool{
|
||||
"youtube.com": true,
|
||||
"www.youtube.com": true,
|
||||
"m.youtube.com": true,
|
||||
"music.youtube.com": true,
|
||||
"youtu.be": true,
|
||||
}
|
||||
|
||||
// Classify decides how a raw URL is handled, and rejects anything that is not
|
||||
// http(s). It does not touch the network.
|
||||
func Classify(raw string) (Kind, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported URL scheme %q (only http/https are allowed)", u.Scheme)
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if youtubeHosts[host] {
|
||||
return KindYouTube, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(u.Path), ".m3u8") {
|
||||
return KindHLS, nil
|
||||
}
|
||||
// Default unknown http(s) sources to HLS handling; the caller decides
|
||||
// whether AllowHLS permits them.
|
||||
return KindHLS, nil
|
||||
}
|
||||
|
||||
// isBlockedIP reports whether an address must not be dialed: loopback,
|
||||
// link-local (incl. the 169.254.169.254 cloud-metadata endpoint), private
|
||||
// (RFC1918 / fc00::/7), multicast, or unspecified.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsPrivate()
|
||||
}
|
||||
|
||||
// guardURL validates scheme and resolves the host, rejecting any URL that
|
||||
// points at a private, loopback, link-local, or metadata address. It is the
|
||||
// SSRF gate for both the user URL and the yt-dlp-resolved URL.
|
||||
//
|
||||
// Note: ffmpeg follows HTTP redirects internally, so a public host that 302s
|
||||
// to a private one is not caught here. Re-checking post-redirect is a Phase 1
|
||||
// item (see docs/ascii-live.md §3); for the local CLI the pre-DNS guard holds.
|
||||
func guardURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
default:
|
||||
return fmt.Errorf("refusing %q: only http/https are allowed", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("refusing URL with no host")
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve host %q: %w", host, err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("refusing to connect to %s (%s): private or reserved address", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve guards the input URL, runs yt-dlp for YouTube sources, and guards the
|
||||
// resolved URL too. It returns the playable stream URL and the source kind.
|
||||
func resolve(ctx context.Context, opts Options) (streamURL string, kind Kind, err error) {
|
||||
kind, err = Classify(opts.URL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if kind == KindHLS && !opts.AllowHLS {
|
||||
return "", "", fmt.Errorf("direct HLS/URL input is disabled on this host")
|
||||
}
|
||||
if err := guardURL(opts.URL); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if kind != KindYouTube {
|
||||
return opts.URL, kind, nil
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
||||
return "", "", fmt.Errorf("yt-dlp is not installed — required to resolve YouTube URLs")
|
||||
}
|
||||
// -g prints the direct media URL(s); the format preference keeps the
|
||||
// terminal-resolution stream small.
|
||||
cmd := exec.CommandContext(ctx, "yt-dlp",
|
||||
"-f", "best[height<=480]/best", "-g", "--no-warnings", opts.URL)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("could not resolve YouTube stream")
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
streamURL = strings.TrimSpace(line)
|
||||
if streamURL != "" {
|
||||
break // first URL is the (combined) video stream
|
||||
}
|
||||
}
|
||||
if streamURL == "" {
|
||||
return "", "", fmt.Errorf("could not resolve YouTube stream")
|
||||
}
|
||||
if err := guardURL(streamURL); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return streamURL, kind, nil
|
||||
}
|
||||
|
||||
// Start resolves the source and launches the ffmpeg decode pipeline. The
|
||||
// returned Worker emits frames until the stream ends or Close is called.
|
||||
func Start(ctx context.Context, opts Options) (*Worker, error) {
|
||||
if opts.FPS <= 0 {
|
||||
opts.FPS = 10
|
||||
}
|
||||
if opts.PW <= 0 || opts.PH <= 0 {
|
||||
return nil, fmt.Errorf("invalid frame geometry %dx%d", opts.PW, opts.PH)
|
||||
}
|
||||
streamURL, kind, err := resolve(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
w := &Worker{
|
||||
Frames: make(chan []byte, 2),
|
||||
Status: make(chan string, 8),
|
||||
Kind: kind,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// ffmpeg: resolved stream → fps-limited, scaled, rgb24 raw frames.
|
||||
// format=rgb24 is mandatory — a yuv420p stream would otherwise fail the
|
||||
// rawvideo muxer.
|
||||
cmd := exec.CommandContext(cctx, "ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-i", streamURL,
|
||||
"-vf", fmt.Sprintf("fps=%d,scale=%d:%d:flags=lanczos,format=rgb24", opts.FPS, opts.PW, opts.PH),
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1",
|
||||
)
|
||||
cmd.Stderr = os.Stderr // ffmpeg decode errors surface in the host log
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("ffmpeg: %w", err)
|
||||
}
|
||||
w.ffmpeg = cmd
|
||||
w.status(fmt.Sprintf("decoding %s source…", kind))
|
||||
|
||||
go w.pump(out, opts.PW*opts.PH*3)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// pump reads fixed-size frames from ffmpeg and forwards them, dropping rather
|
||||
// than blocking when a viewer lags.
|
||||
func (w *Worker) pump(out io.Reader, size int) {
|
||||
defer close(w.Frames)
|
||||
r := bufio.NewReaderSize(out, size)
|
||||
for {
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
w.status("stream ended")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.Frames <- buf:
|
||||
case <-w.done:
|
||||
return
|
||||
default: // drop frame; keep latency low
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) status(msg string) {
|
||||
select {
|
||||
case w.Status <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears down ffmpeg and stops the pump.
|
||||
func (w *Worker) Close() {
|
||||
select {
|
||||
case <-w.done:
|
||||
default:
|
||||
close(w.done)
|
||||
}
|
||||
if w.cancel != nil {
|
||||
w.cancel()
|
||||
}
|
||||
if w.ffmpeg != nil && w.ffmpeg.Process != nil {
|
||||
_ = w.ffmpeg.Process.Kill()
|
||||
_ = w.ffmpeg.Wait()
|
||||
}
|
||||
}
|
||||
80
internal/source/source_test.go
Normal file
80
internal/source/source_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package source
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want Kind
|
||||
wantErr bool
|
||||
}{
|
||||
{"https://youtube.com/live/abc123", KindYouTube, false},
|
||||
{"https://www.youtube.com/watch?v=abc", KindYouTube, false},
|
||||
{"https://youtu.be/abc123", KindYouTube, false},
|
||||
{"https://example.com/path/stream.m3u8", KindHLS, false},
|
||||
{"http://cdn.example.com/live.m3u8?token=x", KindHLS, false},
|
||||
{"https://example.com/whatever", KindHLS, false}, // unknown http(s) → HLS handling
|
||||
{"file:///etc/passwd", "", true},
|
||||
{"rtmp://example.com/live", "", true},
|
||||
{"ftp://example.com/x", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := Classify(c.raw)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Classify(%q): expected error, got kind %q", c.raw, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Classify(%q): unexpected error: %v", c.raw, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("Classify(%q) = %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockedIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", // loopback
|
||||
"::1", // loopback v6
|
||||
"10.0.0.5", // private
|
||||
"172.16.3.4", // private
|
||||
"192.168.1.1", // private
|
||||
"169.254.169.254", // link-local / cloud metadata
|
||||
"fe80::1", // link-local v6
|
||||
"fc00::1", // unique-local v6 (private)
|
||||
"0.0.0.0", // unspecified
|
||||
"224.0.0.1", // multicast
|
||||
}
|
||||
for _, s := range blocked {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad test IP %q", s)
|
||||
}
|
||||
if !isBlockedIP(ip) {
|
||||
t.Errorf("isBlockedIP(%s) = false, want true", s)
|
||||
}
|
||||
}
|
||||
|
||||
allowed := []string{
|
||||
"8.8.8.8", // public
|
||||
"1.1.1.1", // public
|
||||
"142.250.72.46", // public (googlevideo-ish)
|
||||
"2606:4700:4700::1111", // public v6
|
||||
}
|
||||
for _, s := range allowed {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad test IP %q", s)
|
||||
}
|
||||
if isBlockedIP(ip) {
|
||||
t.Errorf("isBlockedIP(%s) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,11 +12,30 @@ import (
|
|||
|
||||
// User is a persisted account (member or agent; guests are never stored).
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
Kind string
|
||||
PubKeyFP string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
Name string
|
||||
Kind string
|
||||
PubKeyFP string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// userCols is the column list (in struct order) for every user SELECT, kept in
|
||||
// sync with scanUser.
|
||||
const userCols = `id, name, kind, pubkey_fp, email, email_verified, created_at`
|
||||
|
||||
// scanUser reads one user row selected with userCols.
|
||||
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
||||
var u User
|
||||
var verified int
|
||||
var created string
|
||||
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &created); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.EmailVerified = verified != 0
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Score is one leaderboard entry.
|
||||
|
|
@ -40,6 +59,13 @@ type Store interface {
|
|||
// LastSeen reports the start of the user's most recent session.
|
||||
LastSeen(userID int64) (time.Time, bool, error)
|
||||
|
||||
// SetEmailVerification records the account's email and a fresh
|
||||
// confirmation token, marking it unverified until the token is used.
|
||||
SetEmailVerification(userID int64, email, token string) error
|
||||
// VerifyEmail consumes a confirmation token: on match it marks the
|
||||
// account verified, clears the token, and returns the account.
|
||||
VerifyEmail(token string) (User, bool, error)
|
||||
|
||||
RecordSession(userID int64, username, remote, route string) (int64, error)
|
||||
EndSession(sessionID int64) error
|
||||
|
||||
|
|
@ -54,6 +80,15 @@ type Store interface {
|
|||
AddChat(userID int64, username, role, text string) error
|
||||
RecentChats(username string, n int) ([]ChatMessage, error)
|
||||
|
||||
// Custom domains mapped to a member's homepage (public_html).
|
||||
// MapDomain binds domain→username, returning ErrDomainTaken if it is
|
||||
// already claimed by someone else (re-binding to the same owner is a no-op).
|
||||
MapDomain(domain, username string) error
|
||||
UnmapDomain(domain, username string) error
|
||||
DomainUser(domain string) (string, bool, error)
|
||||
DomainsForUser(username string) ([]string, error)
|
||||
AllDomains() ([]DomainMap, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
|
|
@ -64,9 +99,21 @@ type ChatMessage struct {
|
|||
At time.Time
|
||||
}
|
||||
|
||||
// DomainMap binds a custom domain to a member's homepage (the public_html that
|
||||
// is also served at /~name). Used to serve, e.g., https://chovy.com from
|
||||
// users/chovy/public_html.
|
||||
type DomainMap struct {
|
||||
Domain string
|
||||
Username string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// ErrKeyMismatch means a username is already registered with another key.
|
||||
var ErrKeyMismatch = errors.New("username registered with a different key")
|
||||
|
||||
// ErrDomainTaken means a domain is already mapped to a different member.
|
||||
var ErrDomainTaken = errors.New("domain already mapped to another account")
|
||||
|
||||
type sqliteStore struct{ db *sql.DB }
|
||||
|
||||
// Open opens (and migrates) the SQLite store at path.
|
||||
|
|
@ -79,9 +126,54 @@ func Open(path string) (Store, error) {
|
|||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
// migrate applies additive schema changes that must not fail on existing
|
||||
// databases. New columns live here (not in schema) so there is one code path.
|
||||
func migrate(db *sql.DB) error {
|
||||
return ensureColumns(db, "users", [][2]string{
|
||||
{"email", "email TEXT NOT NULL DEFAULT ''"},
|
||||
{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
|
||||
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
|
||||
})
|
||||
}
|
||||
|
||||
// ensureColumns adds any missing {name, "name TYPE …"} columns to table.
|
||||
func ensureColumns(db *sql.DB, table string, cols [][2]string) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, ctype string
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cols {
|
||||
if !have[c[0]] {
|
||||
if _, err := db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + c[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
|
@ -122,15 +214,18 @@ CREATE TABLE IF NOT EXISTS pod_subscriptions (
|
|||
payment_ref TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
|
|
@ -143,7 +238,6 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
|||
if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
|
||||
return User{}, ErrKeyMismatch
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
|
|
@ -151,17 +245,37 @@ func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
|
|||
if fp == "" {
|
||||
return User{}, false, nil
|
||||
}
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE pubkey_fp = ?`, fp).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE pubkey_fp = ?`, fp))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SetEmailVerification(userID int64, email, token string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET email = ?, verify_token = ?, email_verified = 0 WHERE id = ?`,
|
||||
email, token, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) {
|
||||
if token == "" {
|
||||
return User{}, false, nil
|
||||
}
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE verify_token = ?`, token))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.EmailVerified = true
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
|
|
@ -236,17 +350,13 @@ func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error
|
|||
}
|
||||
|
||||
func (s *sqliteStore) UserByName(name string) (User, bool, error) {
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
|
|
@ -296,4 +406,73 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MapDomain(domain, username string) error {
|
||||
var owner string
|
||||
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)
|
||||
switch {
|
||||
case err == nil:
|
||||
if owner != username {
|
||||
return ErrDomainTaken
|
||||
}
|
||||
return nil // already ours
|
||||
case err != sql.ErrNoRows:
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO domains (domain, username) VALUES (?,?)`, domain, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UnmapDomain(domain, username string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM domains WHERE domain = ? AND username = ?`, domain, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DomainUser(domain string) (string, bool, error) {
|
||||
var username string
|
||||
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&username)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return username, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DomainsForUser(username string) ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT domain FROM domains WHERE username = ? ORDER BY domain`, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) AllDomains() ([]DomainMap, error) {
|
||||
rows, err := s.db.Query(`SELECT domain, username, created_at FROM domains ORDER BY domain`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DomainMap
|
||||
for rows.Next() {
|
||||
var dm DomainMap
|
||||
var at string
|
||||
if err := rows.Scan(&dm.Domain, &dm.Username, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dm.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, dm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||
|
|
|
|||
68
internal/store/store_email_test.go
Normal file
68
internal/store/store_email_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmailVerificationRoundtrip(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
u, err := st.EnsureUser("alice", "member", "SHA256:aaa")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if u.EmailVerified {
|
||||
t.Fatal("new user should be unverified")
|
||||
}
|
||||
|
||||
if err := st.SetEmailVerification(u.ID, "alice@example.com", "tok123"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
// Email is stored but not yet verified.
|
||||
got, _, err := st.UserByFingerprint("SHA256:aaa")
|
||||
if err != nil {
|
||||
t.Fatalf("byfp: %v", err)
|
||||
}
|
||||
if got.Email != "alice@example.com" || got.EmailVerified {
|
||||
t.Fatalf("pre-verify state wrong: %+v", got)
|
||||
}
|
||||
|
||||
// Wrong token does nothing.
|
||||
if _, ok, _ := st.VerifyEmail("nope"); ok {
|
||||
t.Fatal("bad token should not verify")
|
||||
}
|
||||
|
||||
// Correct token verifies and returns the account.
|
||||
vu, ok, err := st.VerifyEmail("tok123")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("verify: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if vu.Name != "alice" || !vu.EmailVerified {
|
||||
t.Fatalf("verify returned wrong user: %+v", vu)
|
||||
}
|
||||
|
||||
// Token is single-use (cleared after verification).
|
||||
if _, ok, _ := st.VerifyEmail("tok123"); ok {
|
||||
t.Fatal("token should be consumed after first use")
|
||||
}
|
||||
final, _, _ := st.UserByName("alice")
|
||||
if !final.EmailVerified {
|
||||
t.Fatal("user should remain verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailEmptyToken(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, ok, err := st.VerifyEmail(""); ok || err != nil {
|
||||
t.Fatalf("empty token must be a clean miss: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue