From a76551d1676c48c7bfbd510075ad8e924210052c Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Wed, 8 Jul 2026 22:48:05 +0800 Subject: [PATCH] POST addr route --- CLAUDE.md | 9 +++++++++ README.md | 1 + src/fmsgid.go | 55 ++++++++++++++++++++++++++++++++++++++++++++------- src/sql.go | 10 ++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ced83c..63aee59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,10 @@ See AGENTS.md for project instructions. + +## Don't name a specific IdP + +This is a public open-source repo used with many different identity providers. +Never name any specific IdP (product, company, or domain) in code, comments, +commit messages, docs, or README content in this repo. Always refer to it +generically as "the IdP" or "an identity provider", so users integrating their +own IdP aren't confused into thinking a particular one is required or +referenced by this codebase. diff --git a/README.md b/README.md index ca3460b..1e6d9c7 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ All routes are served over HTTPS under the `/fmsgid` path. | Method | Route | Description | |--------|-------|-------------| | `GET` | `/fmsgid/:address` | Lookup an fmsg address and return its details including display name, quotas, and usage. The address must be in fmsg format (`@user@example.com`). Returns `AddressDetail` JSON on success, `400` if the address is invalid, `404` if not found. | +| `POST` | `/fmsgid` | Register a new address with default quotas, idempotently. Accepts a JSON body with `address` and optional `display_name`. Returns `201` if the address was created, `200` if it already existed (never modifies an existing row — use CSV sync to change quotas or `accepting_new` on an existing address), `400` if the address is invalid. | | `POST` | `/fmsgid/send` | Record a send transaction. Accepts an `AddressTx` JSON body with `address`, `ts` (timestamp), and `size`. | | `POST` | `/fmsgid/recv` | Record a receive transaction. Accepts an `AddressTx` JSON body with `address`, `ts` (timestamp), and `size`. | diff --git a/src/fmsgid.go b/src/fmsgid.go index 5d44523..3c31c8b 100644 --- a/src/fmsgid.go +++ b/src/fmsgid.go @@ -36,6 +36,11 @@ type AddressTx struct { Size int `json:"size"` } +type CreateAddressReq struct { + Address string `json:"address"` + DisplayName string `json:"display_name"` +} + type AddressDetail struct { Address string `json:"address"` DisplayName string `json:"displayName"` @@ -66,6 +71,14 @@ func initPool() error { return pool.Ping(context.Background()) } +// isValidFmsgAddr reports whether addr is in fmsg format: @user@example.com +func isValidFmsgAddr(addr string) bool { + if len(addr) < 3 || addr[0] != '@' { + return false + } + return strings.Count(addr, "@") == 2 +} + func getAddressDetail(c *gin.Context) { ctx := c.Request.Context() @@ -76,13 +89,7 @@ func getAddressDetail(c *gin.Context) { return } - // validate address is in fmsg format: @user@example.com - if len(addr) < 3 || addr[0] != '@' { - c.AbortWithStatus(400) - return - } - atCount := strings.Count(addr, "@") - if atCount != 2 { + if !isValidFmsgAddr(addr) { c.AbortWithStatus(400) return } @@ -135,6 +142,39 @@ func getAddressDetail(c *gin.Context) { c.JSON(http.StatusOK, ad) } +// postCreateAddress registers a new address with default quotas, idempotently. +// It never modifies an address that already exists — use CSV sync to change +// quotas or accepting_new on an existing address. +func postCreateAddress(c *gin.Context) { + ctx := c.Request.Context() + + var req CreateAddressReq + if err := c.BindJSON(&req); err != nil { + log.Printf("WARN: Parsing CreateAddressReq: %s\n", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + if !isValidFmsgAddr(req.Address) { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + addrLower := cases.Fold().String(req.Address) + + tag, err := pool.Exec(ctx, sqlInsertAddressIfNotExists, addrLower, req.Address, req.DisplayName) + if err != nil { + c.AbortWithError(500, err) + return + } + + if tag.RowsAffected() == 0 { + c.Status(http.StatusOK) + return + } + c.Status(http.StatusCreated) +} + func postAddressTxSend(c *gin.Context) { postAddressTx(c, TypeSend) } @@ -185,6 +225,7 @@ func main() { } r := gin.Default() r.GET("/fmsgid/:address", getAddressDetail) + r.POST("/fmsgid", postCreateAddress) r.POST("/fmsgid/send", postAddressTxSend) r.POST("/fmsgid/recv", postAddressTxRecv) err = r.Run(":" + port) diff --git a/src/sql.go b/src/sql.go index c277720..18576cd 100644 --- a/src/sql.go +++ b/src/sql.go @@ -40,6 +40,16 @@ on conflict (address_lower) do update set // sqlDisableAbsentAddresses disables addresses not present in the provided parameter array. const sqlDisableAbsentAddresses string = `update address set accepting_new = false where address_lower != ALL($1);` +// sqlInsertAddressIfNotExists registers a new address with default quotas. +// It never modifies an existing row — callers that need to update quotas or +// accepting_new should use sqlUpsertAddress (e.g. the CSV sync path) instead. +const sqlInsertAddressIfNotExists string = `insert into address ( + address_lower, address, display_name, accepting_new, + limit_recv_size_total, limit_recv_size_per_msg, limit_recv_size_per_1d, limit_recv_count_per_1d, + limit_send_size_total, limit_send_size_per_msg, limit_send_size_per_1d, limit_send_count_per_1d +) values ($1, $2, $3, true, 102400000, 10240, 102400, 1000, 102400000, 10240, 102400, 1000) +on conflict (address_lower) do nothing;` + const sqlActuals string = `select coalesce(sum(size) filter (where type = 2), 0) as sent_size_total , coalesce(sum(size) filter (where type = 2 and ts > now() - interval '1 day'), 0) as sent_size_1d