Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down
55 changes: 48 additions & 7 deletions src/fmsgid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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()

Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading