diff --git a/.gitignore b/.gitignore index aaadf73..c87686d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ go.work.sum # env file .env +# Build output +fmsg + # Editor/IDE # .idea/ # .vscode/ diff --git a/README.md b/README.md index f2ed1fc..b517beb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ # fmsg-cli -Command Line Interface to fmsg-webapi fronting a fmsgd instance + +Command-line interface to [fmsg-webapi](https://github.com/markmnl/fmsg-webapi) fronting a fmsgd instance. + +## Requirements + +- Go 1.24 or newer + +## Build + +```sh +go build -o fmsg +``` + +## Usage + +### Authentication + +Before using any other command, log in: + +```sh +fmsg login +``` + +You will be prompted for your FMSG address (e.g. `@user@example.com`). A JWT token is generated locally and stored in `$XDG_CONFIG_HOME/fmsg/auth.json` (typically `~/.config/fmsg/auth.json`) with `0600` permissions. The token is valid for 24 hours. + +### Configuration + +| Variable | Default | Description | +|---------------|--------------------------|---------------------------| +| `FMSG_API_URL` | `http://localhost:4930` | Base URL of the fmsg-webapi | + +### Commands + +| Command | Description | +|---------|-------------| +| `fmsg login` | Authenticate and store a local token | +| `fmsg list [--limit N] [--offset N]` | List messages for the authenticated user | +| `fmsg get ` | Retrieve a message by ID | +| `fmsg send ` | Send a message (file path, text, or `-` for stdin) | +| `fmsg del ` | Delete a message by ID | +| `fmsg attach ` | Upload a file attachment to a message | +| `fmsg get-attach ` | Download an attachment | +| `fmsg rm-attach ` | Remove an attachment from a message | + +### Examples + +```sh +# Login +fmsg login + +# List messages +fmsg list +fmsg list --limit 10 --offset 20 + +# Get a specific message +fmsg get 8f3c2c71 + +# Send a message +fmsg send @recipient@example.com "Hello, world!" +fmsg send @recipient@example.com ./message.txt +echo "Hello via stdin" | fmsg send @recipient@example.com - + +# Delete a message +fmsg del 8f3c2c71 + +# Upload attachment +fmsg attach 8f3c2c71 ./report.pdf + +# Download attachment +fmsg get-attach 8f3c2c71 report.pdf ./downloaded-report.pdf + +# Remove attachment +fmsg rm-attach 8f3c2c71 report.pdf +``` diff --git a/cmd/attach.go b/cmd/attach.go new file mode 100644 index 0000000..06916a3 --- /dev/null +++ b/cmd/attach.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var attachCmd = &cobra.Command{ + Use: "attach ", + Short: "Upload a file as an attachment to a message", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + messageID := args[0] + filePath := args[1] + + client := api.New(config.GetAPIURL(), creds.Token) + if err := client.UploadAttachment(messageID, filePath); err != nil { + return err + } + + fmt.Printf("Attachment uploaded to message %s\n", messageID) + return nil + }, +} + +func init() { + rootCmd.AddCommand(attachCmd) +} diff --git a/cmd/del.go b/cmd/del.go new file mode 100644 index 0000000..24c4098 --- /dev/null +++ b/cmd/del.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var delCmd = &cobra.Command{ + Use: "del ", + Short: "Delete a message by ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + client := api.New(config.GetAPIURL(), creds.Token) + if err := client.DeleteMessage(args[0]); err != nil { + return err + } + + fmt.Printf("Message %s deleted\n", args[0]) + return nil + }, +} + +func init() { + rootCmd.AddCommand(delCmd) +} diff --git a/cmd/get.go b/cmd/get.go new file mode 100644 index 0000000..57b18a7 --- /dev/null +++ b/cmd/get.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Retrieve a message by ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + client := api.New(config.GetAPIURL(), creds.Token) + msg, err := client.GetMessage(args[0]) + if err != nil { + return err + } + + to, _ := json.Marshal(msg.To) + fmt.Printf("ID: %s\n", msg.ID) + fmt.Printf("From: %s\n", msg.From) + fmt.Printf("To: %s\n", string(to)) + if len(msg.Data) > 0 && string(msg.Data) != "null" { + var pretty interface{} + if err := json.Unmarshal(msg.Data, &pretty); err == nil { + b, _ := json.MarshalIndent(pretty, "", " ") + fmt.Printf("Data:\n%s\n", string(b)) + } else { + fmt.Printf("Data: %s\n", string(msg.Data)) + } + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(getCmd) +} diff --git a/cmd/get_attach.go b/cmd/get_attach.go new file mode 100644 index 0000000..02eb1f4 --- /dev/null +++ b/cmd/get_attach.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var getAttachCmd = &cobra.Command{ + Use: "get-attach ", + Short: "Download an attachment from a message", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + messageID := args[0] + filename := args[1] + outputPath := args[2] + + client := api.New(config.GetAPIURL(), creds.Token) + if err := client.DownloadAttachment(messageID, filename, outputPath); err != nil { + return err + } + + fmt.Printf("Attachment saved to %s\n", outputPath) + return nil + }, +} + +func init() { + rootCmd.AddCommand(getAttachCmd) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..f47fedc --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var ( + listLimit int + listOffset int +) + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List messages for the authenticated user", + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + client := api.New(config.GetAPIURL(), creds.Token) + messages, err := client.ListMessages(listLimit, listOffset) + if err != nil { + return err + } + + if len(messages) == 0 { + fmt.Println("No messages.") + return nil + } + + for _, msg := range messages { + to, _ := json.Marshal(msg.To) + fmt.Printf("ID: %s From: %s To: %s\n", msg.ID, msg.From, string(to)) + } + return nil + }, +} + +func init() { + listCmd.Flags().IntVar(&listLimit, "limit", 20, "Max number of messages to return (1-100)") + listCmd.Flags().IntVar(&listOffset, "offset", 0, "Number of messages to skip") + rootCmd.AddCommand(listCmd) +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..6969f0b --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/spf13/cobra" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate and store a local token", + Long: `Prompt for your FMSG address, generate a JWT token, and store it locally. + +The token is stored in $XDG_CONFIG_HOME/fmsg/auth.json (or ~/.config/fmsg/auth.json) +and is valid for 24 hours.`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Print("FMSG address (e.g. @user@example.com): ") + + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + user := strings.TrimSpace(input) + if user == "" { + return fmt.Errorf("FMSG address must not be empty") + } + + token, exp, err := auth.Generate(user) + if err != nil { + return fmt.Errorf("generating token: %w", err) + } + + creds := auth.Credentials{ + Token: token, + ExpiresAt: exp, + User: user, + } + if err := auth.Save(creds); err != nil { + return fmt.Errorf("saving credentials: %w", err) + } + + fmt.Printf("Logged in as %s (token expires %s)\n", user, exp.Format("2006-01-02T15:04:05Z")) + return nil + }, +} + +func init() { + rootCmd.AddCommand(loginCmd) +} diff --git a/cmd/rm_attach.go b/cmd/rm_attach.go new file mode 100644 index 0000000..8b6236b --- /dev/null +++ b/cmd/rm_attach.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var rmAttachCmd = &cobra.Command{ + Use: "rm-attach ", + Short: "Remove an attachment from a message", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + messageID := args[0] + filename := args[1] + + client := api.New(config.GetAPIURL(), creds.Token) + if err := client.DeleteAttachment(messageID, filename); err != nil { + return err + } + + fmt.Printf("Attachment %s removed from message %s\n", filename, messageID) + return nil + }, +} + +func init() { + rootCmd.AddCommand(rmAttachCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..2f2efa1 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,27 @@ +// Package cmd defines all CLI commands for the fmsg application. +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "fmsg", + Short: "fmsg — command-line interface to an fmsg messaging server", + Long: `fmsg is a CLI that communicates with an fmsg-webapi server. + +Before using any command, authenticate with: + + fmsg login`, +} + +// Execute runs the root command and exits on error. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/send.go b/cmd/send.go new file mode 100644 index 0000000..855e912 --- /dev/null +++ b/cmd/send.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/markmnl/fmsg-cli/internal/api" + "github.com/markmnl/fmsg-cli/internal/auth" + "github.com/markmnl/fmsg-cli/internal/config" + "github.com/spf13/cobra" +) + +var sendCmd = &cobra.Command{ + Use: "send ", + Short: "Send a message to a recipient", + Long: `Send a message to a recipient. The second argument can be: + - A path to a file (must exist on disk) + - A text string + - "-" to read from stdin`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := auth.LoadValid() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + recipient := args[0] + content := args[1] + + var data []byte + + switch content { + case "-": + // Read from stdin. + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + default: + // Try as file first; fall back to using content directly as text. + if fileData, ferr := os.ReadFile(content); ferr == nil { + data = fileData + } else { + data = []byte(content) + } + } + + // Build a draft payload. + payload, err := json.Marshal(map[string]interface{}{ + "msg_to": []string{recipient}, + "data": string(data), + }) + if err != nil { + return fmt.Errorf("encoding message: %w", err) + } + + client := api.New(config.GetAPIURL(), creds.Token) + + draft, err := client.CreateMessage(payload) + if err != nil { + return fmt.Errorf("creating draft: %w", err) + } + + if err := client.SendMessage(draft.ID); err != nil { + return fmt.Errorf("sending message: %w", err) + } + + fmt.Println("Message sent successfully") + fmt.Printf("ID: %s\n", draft.ID) + return nil + }, +} + +func init() { + rootCmd.AddCommand(sendCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9666d30 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/markmnl/fmsg-cli + +go 1.24 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5d2a03d --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..8dbc288 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,281 @@ +// Package api provides an HTTP client for the fmsg-webapi service. +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" +) + +// Client is a reusable HTTP client for fmsg-webapi. +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// New creates a Client with the given base URL and JWT token. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: baseURL, + Token: token, + HTTP: &http.Client{}, + } +} + +// apiError represents an error response from the API. +type apiError struct { + StatusCode int + Body string +} + +func (e *apiError) Error() string { + return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Body) +} + +// do performs an HTTP request, attaching the Authorization header. +func (c *Client) do(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+c.Token) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + return resp, nil +} + +// checkStatus reads the response body and returns an error for non-2xx responses. +func checkStatus(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + msg := string(bytes.TrimSpace(body)) + if msg == "" { + msg = http.StatusText(resp.StatusCode) + } + return &apiError{StatusCode: resp.StatusCode, Body: msg} +} + +// Message represents a message returned by the API. +type Message struct { + ID string `json:"id"` + From string `json:"msg_from"` + To []string `json:"msg_to"` + Data json.RawMessage `json:"data,omitempty"` +} + +// ListMessages returns messages for the authenticated user. +func (c *Client) ListMessages(limit, offset int) ([]Message, error) { + u, err := url.Parse(c.BaseURL + "/api/v1/messages") + if err != nil { + return nil, err + } + q := u.Query() + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + if offset > 0 { + q.Set("offset", strconv.Itoa(offset)) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := checkStatus(resp); err != nil { + return nil, err + } + + var messages []Message + if err := json.NewDecoder(resp.Body).Decode(&messages); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return messages, nil +} + +// GetMessage retrieves a single message by ID. +func (c *Client) GetMessage(id string) (*Message, error) { + req, err := http.NewRequest(http.MethodGet, c.BaseURL+"/api/v1/messages/"+id, nil) + if err != nil { + return nil, err + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := checkStatus(resp); err != nil { + return nil, err + } + + var msg Message + if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return &msg, nil +} + +// CreateMessage creates a new draft message. body is optional JSON payload. +func (c *Client) CreateMessage(body []byte) (*Message, error) { + var bodyReader io.Reader + if len(body) > 0 { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/api/v1/messages", bodyReader) + if err != nil { + return nil, err + } + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := checkStatus(resp); err != nil { + return nil, err + } + + var msg Message + if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return &msg, nil +} + +// SendMessage sends a draft message by ID. +func (c *Client) SendMessage(id string) error { + req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/api/v1/messages/"+id+"/send", nil) + if err != nil { + return err + } + + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkStatus(resp) +} + +// DeleteMessage deletes a message by ID. +func (c *Client) DeleteMessage(id string) error { + req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/api/v1/messages/"+id, nil) + if err != nil { + return err + } + + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkStatus(resp) +} + +// UploadAttachment uploads a file as an attachment to a message using multipart. +func (c *Client) UploadAttachment(messageID, filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return fmt.Errorf("creating form file: %w", err) + } + if _, err := io.Copy(fw, f); err != nil { + return fmt.Errorf("reading file: %w", err) + } + if err := mw.Close(); err != nil { + return fmt.Errorf("closing multipart writer: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/api/v1/messages/"+messageID+"/attachments", &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkStatus(resp) +} + +// DownloadAttachment downloads an attachment and writes it to outputPath. +func (c *Client) DownloadAttachment(messageID, filename, outputPath string) error { + req, err := http.NewRequest(http.MethodGet, + c.BaseURL+"/api/v1/messages/"+messageID+"/attachments/"+filename, nil) + if err != nil { + return err + } + + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if err := checkStatus(resp); err != nil { + return err + } + + out, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("writing output file: %w", err) + } + return nil +} + +// DeleteAttachment removes an attachment from a message. +func (c *Client) DeleteAttachment(messageID, filename string) error { + req, err := http.NewRequest(http.MethodDelete, + c.BaseURL+"/api/v1/messages/"+messageID+"/attachments/"+filename, nil) + if err != nil { + return err + } + + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkStatus(resp) +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..85f543e --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,83 @@ +// Package auth handles credential storage on disk. +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Credentials holds the stored authentication state. +type Credentials struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + User string `json:"user"` +} + +// storePath returns the path to auth.json, creating parent directories as needed. +func storePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locating config directory: %w", err) + } + dir := filepath.Join(configDir, "fmsg") + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("creating config directory: %w", err) + } + return filepath.Join(dir, "auth.json"), nil +} + +// Save writes credentials to disk with 0600 permissions. +func Save(creds Credentials) error { + path, err := storePath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return fmt.Errorf("encoding credentials: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("writing credentials: %w", err) + } + return nil +} + +// Load reads stored credentials from disk. +func Load() (Credentials, error) { + path, err := storePath() + if err != nil { + return Credentials{}, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Credentials{}, fmt.Errorf("no stored credentials found") + } + return Credentials{}, fmt.Errorf("reading credentials: %w", err) + } + + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return Credentials{}, fmt.Errorf("decoding credentials: %w", err) + } + return creds, nil +} + +// LoadValid loads stored credentials and returns an error if they are missing +// or expired. The error message instructs the user to run fmsg login. +func LoadValid() (Credentials, error) { + creds, err := Load() + if err != nil { + return Credentials{}, fmt.Errorf("you must login first using: fmsg login") + } + if time.Now().After(creds.ExpiresAt) { + return Credentials{}, fmt.Errorf("token expired — you must login first using: fmsg login") + } + return creds, nil +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..901617d --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,64 @@ +// Package auth handles JWT token generation and validation. +package auth + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// devSecret is a fallback secret used only when FMSG_JWT_SECRET is not set. +// WARNING: This is a development placeholder. Set FMSG_JWT_SECRET in production. +const devSecret = "fmsg-dev-secret-do-not-use-in-production" + +// jwtSecret returns the signing secret from the environment or falls back to devSecret. +func jwtSecret() []byte { + if s := os.Getenv("FMSG_JWT_SECRET"); s != "" { + return []byte(s) + } + return []byte(devSecret) +} + +// TokenDuration is how long a generated token remains valid. +const TokenDuration = 24 * time.Hour + +// Generate creates a signed JWT for the given FMSG address. +// Returns the signed token string and its expiration time. +func Generate(user string) (string, time.Time, error) { + now := time.Now() + exp := now.Add(TokenDuration) + + claims := jwt.MapClaims{ + "sub": user, + "iat": now.Unix(), + "exp": exp.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(jwtSecret()) + if err != nil { + return "", time.Time{}, fmt.Errorf("signing token: %w", err) + } + + return signed, exp, nil +} + +// Validate parses and validates a JWT token string. +// Returns an error if the token is invalid or expired. +func Validate(tokenStr string) error { + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return jwtSecret(), nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil { + return fmt.Errorf("invalid token: %w", err) + } + if !token.Valid { + return fmt.Errorf("token is not valid") + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d6eaae9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,17 @@ +// Package config provides application configuration. +package config + +import "os" + +const ( + DefaultAPIURL = "http://127.0.0.1:8000" + EnvAPIURL = "FMSG_API_URL" +) + +// GetAPIURL returns the API base URL from the environment, or the default. +func GetAPIURL() string { + if url := os.Getenv(EnvAPIURL); url != "" { + return url + } + return DefaultAPIURL +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..dfdc6bb --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/markmnl/fmsg-cli/cmd" + +func main() { + cmd.Execute() +}