package main import ( "context" "errors" "fmt" "regexp" "strings" "felis.lolicon.best/internal/platform" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" ) // ---- Storage backends ---- type storageMethod int const ( storageLocal storageMethod = iota storageS3 ) func storageMethodLabel(m storageMethod) string { if m == storageS3 { return "Object storage (S3-compatible)" } return "Local disk (on this node)" } // s3Inputs is the operator-entered coordinates for the S3 backend. Only endpoint, // bucket and region reach felis.toml; the two keys go into a Kubernetes Secret. type s3Inputs struct { endpoint string bucket string region string accessKey string secretKey string } // storageChooserModel presents the two build-context storage backends as peer // choices. "Local" persists uploaded modpacks on a node-local PVC (nothing else to // configure); "S3" points them at an S3-compatible bucket. Both are functional; S3 // suits external object storage. It mirrors connectChooserModel's shape so the two // mid-wizard forks read identically. type storageChooserModel struct { rootDomain string form *huh.Form choice storageMethod prefill s3Inputs // pre-filled non-secret fields when re-entering to change storage width, height int } // newStorageChooserModel opens the storage picker pre-selected on method and, for // S3, carrying prefill's non-secret fields (endpoint/bucket/region) into the detail // form. First-run callers pass (storageLocal, s3Inputs{}); the reconfigure path // passes the backend already in felis.toml so an operator fixing a typo doesn't // retype everything (credentials still must be re-entered — they live only in the // Secret). func newStorageChooserModel(rootDomain string, method storageMethod, prefill s3Inputs) *storageChooserModel { m := &storageChooserModel{rootDomain: rootDomain, choice: method, prefill: prefill} m.form = m.build() return m } func (m *storageChooserModel) build() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( huh.NewSelect[storageMethod](). Title("Where should uploaded modpacks be stored?"). Description("Players submit modpacks for review; approved ones are built from here. You can change this later."). Value(&m.choice). Options( huh.NewOption("Local disk · nothing else to set up", storageLocal), huh.NewOption("Object storage · S3-compatible bucket", storageS3), ), // A dim footnote, subordinate to the picker — the connect-chooser pattern. huh.NewNote().Description( "⚠ Local keeps uploads on this node's disk — simplest, ideal for a single-node install. "+ "Choose S3 for external object storage (AWS S3, MinIO, Cloudflare R2, …)."), ))) } func (m *storageChooserModel) sized(f *huh.Form) *huh.Form { if m.width > 0 { return f.WithWidth(m.width).WithHeight(m.height) } return f } func (m *storageChooserModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { m.form = m.form.WithWidth(w).WithHeight(h) } } func (m *storageChooserModel) Init() tea.Cmd { return m.form.Init() } func (m *storageChooserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "ctrl+c": return m, tea.Quit case "esc": // Skipping is choosing local — the simplest backend, changeable later. return newStorageModel(storageLocal, s3Inputs{}), nil } } form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: return newStorageModel(m.choice, m.prefill), nil case huh.StateAborted: return m, tea.Quit } return m, cmd } func (m *storageChooserModel) View() string { return m.form.View() } // arrowNavOK lets the root repurpose ←/→ to walk the step rail: the picker uses // ↑/↓, so the horizontal arrows are free. func (m *storageChooserModel) arrowNavOK() bool { return true } // ---- Storage apply: local applies immediately, S3 collects then applies ---- type storageStep int const ( ssForm storageStep = iota // S3 only: collect endpoint/bucket/keys ssWorking ssDone ssError ) type storageApplyMsg struct{ err error } // storageResultMsg is the method-agnostic outcome the root advances on, emitted // once the chosen backend is written and the API has rolled. type storageResultMsg struct { method storageMethod detail string } // storageBackMsg returns from the storage sub-screen to the chooser. type storageBackMsg struct{} func goBackStorage() tea.Cmd { return func() tea.Msg { return storageBackMsg{} } } // storageModel drives applying the chosen backend. Local has no form and applies // straight away; S3 first collects its coordinates. Both share the working → done / // error machine, mirroring reverseProxyModel. type storageModel struct { method storageMethod step storageStep form *huh.Form sp spinner.Model err error in s3Inputs width, height int } func newStorageModel(method storageMethod, in s3Inputs) *storageModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel m := &storageModel{method: method, sp: sp, in: in} if method == storageS3 { m.step = ssForm m.form = m.build() } else { m.step = ssWorking } return m } func (m *storageModel) build() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( huh.NewNote(). Title("Object storage (S3-compatible)"). Description("Enter your bucket and credentials. The keys go into a Kubernetes Secret; only the endpoint and bucket are written to felis.toml."), huh.NewInput(). Title("Endpoint"). Description("Your S3 API host, e.g. s3.amazonaws.com or minio.example.com:9000 (prefix http:// for a plaintext dev store)."). Value(&m.in.endpoint). Validate(requiredStorageField("endpoint")), huh.NewInput(). Title("Bucket"). Description("An existing bucket uploads are written to."). Value(&m.in.bucket). Validate(validateBucketName), huh.NewInput(). Title("Region"). Description("Optional — leave blank for MinIO / R2."). Value(&m.in.region), huh.NewInput(). Title("Access key ID"). Value(&m.in.accessKey). Validate(requiredStorageField("access key ID")), huh.NewInput(). Title("Secret access key"). EchoMode(huh.EchoModePassword). Value(&m.in.secretKey). Validate(requiredStorageField("secret access key")), ))) } func (m *storageModel) sized(f *huh.Form) *huh.Form { if m.width > 0 { return f.WithWidth(m.width).WithHeight(m.height) } return f } func (m *storageModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { m.form = m.form.WithWidth(w).WithHeight(h) } } func (m *storageModel) Init() tea.Cmd { if m.method == storageS3 { return m.form.Init() } // Local: the chooser already confirmed the choice, so apply immediately. return tea.Batch(m.sp.Tick, m.apply()) } func (m *storageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case storageApplyMsg: if msg.err != nil { m.step, m.err = ssError, msg.err return m, nil } m.step = ssDone return m, nil case spinner.TickMsg: if m.step == ssWorking { var cmd tea.Cmd m.sp, cmd = m.sp.Update(msg) return m, cmd } return m, nil case tea.KeyMsg: switch m.step { case ssForm: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": return m, goBackStorage() } case ssDone: switch msg.String() { case "ctrl+c", "esc", "enter": return m, m.emit() } return m, nil case ssError: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": if m.method == storageS3 { m.step, m.err = ssForm, nil m.form = m.build() return m, m.form.Init() } return m, goBackStorage() case "enter": m.step, m.err = ssWorking, nil return m, tea.Batch(m.sp.Tick, m.apply()) } return m, nil case ssWorking: if msg.String() == "ctrl+c" { return m, tea.Quit } return m, nil } } if m.step == ssForm && m.form != nil { form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: m.normalizeInputs() m.step = ssWorking return m, tea.Batch(m.sp.Tick, m.apply()) case huh.StateAborted: return m, goBackStorage() } return m, cmd } return m, nil } func (m *storageModel) apply() tea.Cmd { method, in := m.method, m.in return func() tea.Msg { return storageApplyMsg{err: applyStorageConfig(context.Background(), method, in)} } } func (m *storageModel) emit() tea.Cmd { method, detail := m.method, storageDetail(m.method, m.in) return func() tea.Msg { return storageResultMsg{method: method, detail: detail} } } func (m *storageModel) normalizeInputs() { m.in.endpoint = strings.TrimSpace(m.in.endpoint) m.in.bucket = strings.TrimSpace(m.in.bucket) m.in.region = strings.TrimSpace(m.in.region) m.in.accessKey = strings.TrimSpace(m.in.accessKey) m.in.secretKey = strings.TrimSpace(m.in.secretKey) } func (m *storageModel) View() string { switch m.step { case ssWorking: return " " + m.sp.View() + " " + tuiHint.Render("Saving storage settings and rolling the API…") + "\n" case ssDone: var b strings.Builder if m.method == storageS3 { b.WriteString(tuiSuccessBanner("Object storage configured.") + "\n\n") b.WriteString(tuiInfo("Uploads → s3://"+m.in.bucket+" · "+m.in.endpoint) + "\n") } else { b.WriteString(tuiSuccessBanner("Local storage configured.") + "\n\n") b.WriteString(tuiInfo("Uploads → "+platform.UploadsLocalPath+" on this node") + "\n") } b.WriteString("\n" + tuiAction("enter", "continue")) return b.String() case ssError: var b strings.Builder b.WriteString(tuiErrorBanner("Could not save storage settings.") + "\n\n") if m.err != nil { b.WriteString(tuiHint.Render(m.err.Error()) + "\n") } if m.method == storageS3 { b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit")) } else { b.WriteString("\n" + tuiAction("enter", "retry", "esc", "back")) } return b.String() default: if m.form == nil { return "" } return m.form.View() } } // arrowNavOK yields the horizontal arrows to the rail except while the S3 form is // taking text input (where ←/→ move the cursor). func (m *storageModel) arrowNavOK() bool { return m.step != ssForm } // storageDetail is the one-line backend recap shown on the summary and in review. func storageDetail(method storageMethod, in s3Inputs) string { if method == storageS3 { return "s3://" + in.bucket + " · " + in.endpoint } return "local disk · " + platform.UploadsLocalPath } // requiredStorageField rejects a blank value with a field-named message. func requiredStorageField(name string) func(string) error { return func(s string) error { if strings.TrimSpace(s) == "" { return fmt.Errorf("%s is required", name) } return nil } } // bucketNameRE is a pragmatic S3 bucket-name check: 3–63 chars, lowercase // letters/digits/dots/hyphens, starting and ending alphanumeric. It catches typos // without trying to encode every provider's exact rules. var bucketNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$`) func validateBucketName(s string) error { s = strings.TrimSpace(s) if s == "" { return errors.New("bucket is required") } if !bucketNameRE.MatchString(s) { return errors.New("bucket must be 3–63 chars: lowercase letters, digits, dots or hyphens") } return nil }