Files
spoolweight/spooldb/client.go
T

233 lines
7.4 KiB
Go

// Package spooldb is an isolated client for 3dfilamentprofiles.com (the site
// behind the spooldb.com QR codes). The site has no public API and sits behind
// Vercel's bot challenge, so a plain HTTP client can't reach it. This package
// drives a headless Chromium (via chromedp) which solves the challenge
// naturally, logs in, and reads/writes spool data. All site- and
// browser-specific details are confined here.
package spooldb
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/chromedp/chromedp"
)
const (
baseURL = "https://3dfilamentprofiles.com"
challengeText = "Security Checkpoint"
)
// Client is a logged-in session against 3dfilamentprofiles.com. It owns a
// headless browser; call Close when done. Methods are not safe for concurrent
// use — the underlying browser tab is single-threaded.
type Client struct {
browserCtx context.Context
cancels []context.CancelFunc
loggedIn bool
}
type config struct {
chromePath string
headless bool
}
// Option configures a Client.
type Option func(*config)
// WithChromePath sets an explicit Chrome/Chromium executable path.
func WithChromePath(p string) Option { return func(c *config) { c.chromePath = p } }
// WithHeadful runs a visible browser window (useful for debugging).
func WithHeadful() Option { return func(c *config) { c.headless = false } }
// New launches a browser and returns a Client. It does not log in yet.
func New(opts ...Option) (*Client, error) {
cfg := config{headless: true}
for _, o := range opts {
o(&cfg)
}
chromePath, err := resolveChrome(cfg.chromePath)
if err != nil {
return nil, err
}
execOpts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(chromePath),
chromedp.Flag("disable-blink-features", "AutomationControlled"),
// A real-looking UA; the default headless UA advertises "HeadlessChrome".
chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"),
chromedp.WindowSize(1280, 900),
)
if cfg.headless {
execOpts = append(execOpts, chromedp.Flag("headless", "new"))
} else {
execOpts = append(execOpts, chromedp.Flag("headless", false))
}
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...)
browserCtx, browserCancel := chromedp.NewContext(allocCtx)
// Allocate the browser bound to the long-lived browserCtx. chromedp ties the
// Chrome process lifetime to the context of the first Run, so this must not
// be a short-lived per-call context (otherwise the browser dies after it).
if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil {
browserCancel()
allocCancel()
return nil, fmt.Errorf("start browser: %w", err)
}
return &Client{
browserCtx: browserCtx,
cancels: []context.CancelFunc{browserCancel, allocCancel},
}, nil
}
// Close shuts down the browser.
func (c *Client) Close() {
for _, cancel := range c.cancels {
cancel()
}
}
// run executes actions against the browser, bounded by timeout, and also
// cancels if the caller's ctx is done.
func (c *Client) run(ctx context.Context, timeout time.Duration, actions ...chromedp.Action) error {
runCtx, cancel := context.WithTimeout(c.browserCtx, timeout)
defer cancel()
stop := context.AfterFunc(ctx, cancel)
defer stop()
return chromedp.Run(runCtx, actions...)
}
// awaitChallenge waits until the Vercel checkpoint clears (the headless browser
// solves it automatically by running the page's JavaScript).
func (c *Client) awaitChallenge(ctx context.Context) error {
deadline := time.Now().Add(45 * time.Second)
for time.Now().Before(deadline) {
var title string
if err := c.run(ctx, 10*time.Second, chromedp.Title(&title)); err != nil {
return err
}
if !strings.Contains(title, challengeText) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
return errors.New("vercel challenge did not clear in time")
}
// Login authenticates with email/password. Safe to call once per Client.
func (c *Client) Login(ctx context.Context, email, password string) error {
if err := c.run(ctx, 45*time.Second, chromedp.Navigate(baseURL+"/login")); err != nil {
return fmt.Errorf("navigate to login: %w", err)
}
if err := c.awaitChallenge(ctx); err != nil {
return err
}
if err := c.run(ctx, 45*time.Second,
chromedp.WaitVisible("#email", chromedp.ByID),
chromedp.SendKeys("#email", email, chromedp.ByID),
chromedp.SendKeys("#password", password, chromedp.ByID),
chromedp.Click(`//button[normalize-space()='Sign In']`, chromedp.BySearch),
); err != nil {
return fmt.Errorf("submit login form: %w", err)
}
// Login succeeds once we navigate away from /login.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
var loc string
if err := c.run(ctx, 10*time.Second, chromedp.Location(&loc)); err != nil {
return err
}
if !strings.Contains(loc, "/login") {
c.loggedIn = true
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
return errors.New("login did not complete (check credentials)")
}
// SpoolLocation returns the storage location assigned to a spool (e.g. "H"),
// or "" if the spool has no location. spoolID is the code from the QR URL
// (spooldb.com/s/<id>).
func (c *Client) SpoolLocation(ctx context.Context, spoolID string) (string, error) {
if !c.loggedIn {
return "", errors.New("not logged in")
}
if err := c.run(ctx, 45*time.Second, chromedp.Navigate(baseURL+"/my/spool/"+spoolID)); err != nil {
return "", fmt.Errorf("navigate to spool: %w", err)
}
if err := c.awaitChallenge(ctx); err != nil {
return "", err
}
// The spool's status card has a panel header whose first line is the
// location (a box icon followed by the location name).
const js = `(() => {
const hdr = [...document.querySelectorAll('div.bg-panel')].find(h => h.querySelector('div.font-medium'));
if (!hdr) return null;
return (hdr.querySelector('div.font-medium').textContent || '').trim();
})()`
var loc *string
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(js, &loc)); err != nil {
return "", err
}
if loc != nil {
return strings.TrimSpace(*loc), nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Second):
}
}
return "", errors.New("spool page did not render (wrong id or not logged in?)")
}
// resolveChrome finds a Chrome/Chromium binary: explicit path, then $CHROME,
// then a Chrome-for-Testing install, then common installed browsers.
func resolveChrome(explicit string) (string, error) {
candidates := []string{explicit, os.Getenv("CHROME")}
if home, err := os.UserHomeDir(); err == nil {
pattern := filepath.Join(home, ".cache/chrome-for-testing/chrome/*/chrome-*/*.app/Contents/MacOS/*")
if m, _ := filepath.Glob(pattern); len(m) > 0 {
candidates = append(candidates, m[len(m)-1]) // newest-sorted last
}
}
candidates = append(candidates,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
)
for _, p := range candidates {
if p == "" {
continue
}
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p, nil
}
}
return "", errors.New("no Chrome/Chromium found; install Chrome for Testing or pass WithChromePath")
}