diff --git a/cmd/tapir/invite.go b/cmd/tapir/invite.go new file mode 100644 index 0000000..004540a --- /dev/null +++ b/cmd/tapir/invite.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" + "gitea.d-ma.be/mathias/tapir/internal/config" +) + +// inviteTTL is how long a minted invite stays claimable. A week is generous for a +// human to act on an emailed link without leaving a stale capability around. +const inviteTTL = 7 * 24 * time.Hour + +// inviter is the narrow store capability cmdInvite needs — minting an invitation. +// Defined here (not store) so runInvite is testable with a fake, no Postgres. +type inviter interface { + CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) +} + +// cmdInvite mints an invitation for an email and prints the claim URL. Host-side +// only (no Dex session): the operator runs it, copies the link, and sends it. +// Usage: tapir invite . +func cmdInvite(ctx context.Context, args []string) error { + if len(args) < 1 || strings.TrimSpace(args[0]) == "" { + return fmt.Errorf("usage: tapir invite ") + } + email := strings.TrimSpace(args[0]) + + cfg, err := config.Load() + if err != nil { + return err + } + if strings.TrimSpace(cfg.DBDSN) == "" { + return fmt.Errorf("missing required config: TAPIR_DB_DSN") + } + + st, err := store.New(ctx, cfg.DBDSN) + if err != nil { + return err + } + defer st.Close() + + return runInvite(ctx, st, os.Stdout, cfg.PublicURL, email) +} + +// runInvite is the testable core: mint the token and print the absolute claim URL +// to w. Pure of config/store construction so a fake inviter exercises it. +func runInvite(ctx context.Context, inv inviter, w io.Writer, publicURL, email string) error { + token, err := inv.CreateInvitation(ctx, email, inviteTTL) + if err != nil { + return fmt.Errorf("create invitation: %w", err) + } + base := strings.TrimRight(strings.TrimSpace(publicURL), "/") + _, err = fmt.Fprintf(w, "Invite URL (valid 7 days):\n%s/invite/%s\n", base, token) + return err +} diff --git a/cmd/tapir/invite_test.go b/cmd/tapir/invite_test.go new file mode 100644 index 0000000..57a2f6c --- /dev/null +++ b/cmd/tapir/invite_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// fakeInviter records the mint call and returns a canned token. +type fakeInviter struct { + token string + err error + gotEmail string + gotTTL time.Duration + callCount int +} + +func (f *fakeInviter) CreateInvitation(_ context.Context, email string, ttl time.Duration) (string, error) { + f.callCount++ + f.gotEmail, f.gotTTL = email, ttl + return f.token, f.err +} + +func TestRunInvitePrintsURL(t *testing.T) { + inv := &fakeInviter{token: "deadbeefcafe"} + var out strings.Builder + + err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "new@example.com") + require.NoError(t, err) + + require.Equal(t, "new@example.com", inv.gotEmail) + require.Equal(t, inviteTTL, inv.gotTTL) + got := out.String() + require.Contains(t, got, "https://tapir.d-ma.be/invite/deadbeefcafe") + require.Contains(t, got, "valid 7 days") +} + +func TestRunInviteTrimsTrailingSlash(t *testing.T) { + inv := &fakeInviter{token: "tok"} + var out strings.Builder + + err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be/", "x@example.com") + require.NoError(t, err) + require.Contains(t, out.String(), "https://tapir.d-ma.be/invite/tok") + require.NotContains(t, out.String(), "//invite") +} + +func TestRunInvitePropagatesError(t *testing.T) { + inv := &fakeInviter{err: errors.New("db down")} + var out strings.Builder + + err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "x@example.com") + require.Error(t, err) + require.Empty(t, out.String()) +} diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index f368016..c5b78b0 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -53,6 +53,8 @@ func main() { err = cmdRun(ctx, log) case "serve": err = cmdServe(ctx, log) + case "invite": + err = cmdInvite(ctx, os.Args[2:]) default: usage() os.Exit(2) @@ -71,6 +73,7 @@ usage: tapir auth one-time: authorize YouTube and store a refresh token tapir run detect new videos, summarize, deliver to your store tapir serve run the web UI (read summaries, record watch/skip/save) + tapir invite mint an invitation link for a new user (host-side) tapir list [-limit N] list stored summaries, recent first tapir show show one summary in full diff --git a/internal/config/config.go b/internal/config/config.go index 8895bfe..6b132d2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,11 @@ type Config struct { // HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI). HTTPAddr string + // PublicURL is the externally-reachable base URL of the deployed service, + // e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans + // (the `tapir invite` URL). No trailing slash is assumed — callers trim it. + PublicURL string + // Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls // back to the allow-all StubAuth (local dev). When set, serve uses Dex: any // Dex-authenticated subject may sign in, then registers a tapir user (ADR-012). @@ -92,6 +97,7 @@ const ( defaultOAuthRedirectAddr = "localhost:8080" defaultHTTPAddr = ":8080" defaultFetchBackoff = time.Hour + defaultPublicURL = "https://tapir.d-ma.be" ) // Load reads the environment into a Config, applying defaults. It does not @@ -112,6 +118,7 @@ func Load() (Config, error) { SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), + PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL), OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),