From 99c3b1e3ac15f534fd7448acc5b9e8974aa5876e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:44:08 +0000 Subject: [PATCH 1/2] Initial plan From 775d80b542cce3b5f0c3da2a75ee03c97b72b340 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:50:07 +0000 Subject: [PATCH 2/2] Implement fmsg-webapi: Go HTTP API with JWT auth, message CRUD, and attachment handling Co-authored-by: markmnl <2630321+markmnl@users.noreply.github.com> --- README.md | 61 ++++- src/db/db.go | 33 +++ src/go.mod | 48 ++++ src/go.sum | 116 ++++++++++ src/handlers/attachments.go | 283 +++++++++++++++++++++++ src/handlers/messages.go | 443 ++++++++++++++++++++++++++++++++++++ src/main.go | 86 +++++++ src/middleware/jwt.go | 138 +++++++++++ src/middleware/util.go | 10 + src/models/models.go | 22 ++ 10 files changed, 1239 insertions(+), 1 deletion(-) create mode 100644 src/db/db.go create mode 100644 src/go.mod create mode 100644 src/go.sum create mode 100644 src/handlers/attachments.go create mode 100644 src/handlers/messages.go create mode 100644 src/main.go create mode 100644 src/middleware/jwt.go create mode 100644 src/middleware/util.go create mode 100644 src/models/models.go diff --git a/README.md b/README.md index a723408..0641b54 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ # fmsg-webapi -HTTP API providing user/client message handling for fmsgd host + +HTTP API providing user/client message handling for an fmsg host. Exposes CRUD +operations for a messaging datastore backed by PostgreSQL. Authentication is +delegated to an external system — this service validates JWT tokens and enforces +fine-grained authorisation rules based on the user identity they contain. + +## Environment Variables + +| Variable | Default | Description | +| ------------------- | ------------------------ | ------------------------------------------------------- | +| `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/opt/fmsg/data` | +| `FMSG_API_JWT_SECRET` | *(required)* | HMAC secret used to validate JWT tokens | +| `FMSG_API_PORT` | `8000` | TCP port the HTTP server listens on | +| `FMSG_ID_URL` | `http://127.0.0.1:8080` | Base URL of the fmsgid identity service | + +Standard PostgreSQL environment variables (`PGHOST`, `PGPORT`, `PGUSER`, +`PGPASSWORD`, `PGDATABASE`) are used for database connectivity. + +A `.env` file placed in the working directory is loaded automatically at startup +(values in the environment take precedence). + +## Building + +Requires **Go 1.25** or newer. + +```bash +cd src +go build ./... +``` + +## Running + +```bash +export FMSG_DATA_DIR=/opt/fmsg/data +export FMSG_API_JWT_SECRET=changeme +export PGHOST=localhost +export PGUSER=fmsg +export PGPASSWORD=secret +export PGDATABASE=fmsg + +cd src +go run . +``` + +The server starts on port `8000` by default. Override with `FMSG_API_PORT`. + +## API Routes + +All routes are prefixed with `/api/v1` and require a valid `Authorization: Bearer ` header. + +| Method | Path | Description | +| -------- | ------------------------------------------- | ------------------------ | +| `POST` | `/api/v1/messages` | Create a draft message | +| `GET` | `/api/v1/messages/:id` | Retrieve a message | +| `PUT` | `/api/v1/messages/:id` | Update a draft message | +| `DELETE` | `/api/v1/messages/:id` | Delete a draft message | +| `POST` | `/api/v1/messages/:id/send` | Send a message | +| `POST` | `/api/v1/messages/:id/attachments` | Upload an attachment | +| `GET` | `/api/v1/messages/:id/attachments/:filename`| Download an attachment | +| `DELETE` | `/api/v1/messages/:id/attachments/:filename`| Delete an attachment | diff --git a/src/db/db.go b/src/db/db.go new file mode 100644 index 0000000..56ad92a --- /dev/null +++ b/src/db/db.go @@ -0,0 +1,33 @@ +// Package db provides PostgreSQL database connectivity and helper methods. +package db + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB wraps a pgxpool.Pool providing helper methods for the application. +type DB struct { + Pool *pgxpool.Pool +} + +// New opens a connection pool to PostgreSQL using the standard PG environment +// variables (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, etc.). +// An empty dsn string causes pgxpool to read from the environment. +func New(ctx context.Context, dsn string) (*DB, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("db: failed to create pool: %w", err) + } + if err = pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("db: failed to ping: %w", err) + } + return &DB{Pool: pool}, nil +} + +// Close releases all connections held by the pool. +func (d *DB) Close() { + d.Pool.Close() +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..c3cbb6b --- /dev/null +++ b/src/go.mod @@ -0,0 +1,48 @@ +module github.com/markmnl/fmsg-webapi + +go 1.25.0 + +require ( + github.com/appleboy/gin-jwt/v2 v2.10.3 + github.com/gin-gonic/gin v1.12.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..476e95c --- /dev/null +++ b/src/go.sum @@ -0,0 +1,116 @@ +github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc= +github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8= +github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= +github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/handlers/attachments.go b/src/handlers/attachments.go new file mode 100644 index 0000000..1c552f5 --- /dev/null +++ b/src/handlers/attachments.go @@ -0,0 +1,283 @@ +package handlers + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + + "github.com/markmnl/fmsg-webapi/db" + "github.com/markmnl/fmsg-webapi/middleware" +) + +// AttachmentHandler holds dependencies for attachment routes. +type AttachmentHandler struct { + DB *db.DB + DataDir string +} + +// NewAttachmentHandler creates an AttachmentHandler. +func NewAttachmentHandler(database *db.DB, dataDir string) *AttachmentHandler { + return &AttachmentHandler{DB: database, DataDir: dataDir} +} + +// Upload handles POST /api/v1/messages/:id/attachments. +func (h *AttachmentHandler) Upload(c *gin.Context) { + identity := middleware.GetIdentity(c) + msgID, ok := parseID(c) + if !ok { + return + } + + ctx := c.Request.Context() + + // Load the message to check ownership and draft status. + var fromAddr string + var timeSent *float64 + err := h.DB.Pool.QueryRow(ctx, + "SELECT from_addr, time_sent FROM msg WHERE id = $1", msgID, + ).Scan(&fromAddr, &timeSent) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + log.Printf("upload attachment: fetch msg %d: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) + } + return + } + + if fromAddr != identity { + c.JSON(http.StatusForbidden, gin.H{"error": "only the owner may upload attachments"}) + return + } + if timeSent != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "attachments cannot be added to a sent message"}) + return + } + + // Expect multipart file upload with field "file". + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file field required"}) + return + } + defer file.Close() + + // Sanitize the intended filename (no path components). + intendedFilename := filepath.Base(header.Filename) + if intendedFilename == "." || intendedFilename == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + return + } + + // Determine directory for this message. + dir := msgDataDir(h.DataDir, fromAddr, msgID) + if err = os.MkdirAll(dir, 0750); err != nil { + log.Printf("upload attachment: mkdir: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare storage"}) + return + } + + // Resolve collision-safe filepath. + finalPath := resolveFilePath(dir, intendedFilename) + + // Write file to disk. + dst, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0640) + if err != nil { + log.Printf("upload attachment: open file: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save attachment"}) + return + } + written, err := io.Copy(dst, file) + closeErr := dst.Close() + if err != nil || closeErr != nil { + _ = os.Remove(finalPath) + log.Printf("upload attachment: write: %v / %v", err, closeErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write attachment"}) + return + } + + // Persist to DB. + _, err = h.DB.Pool.Exec(ctx, + `INSERT INTO msg_attachment (msg_id, filename, filesize, filepath) + VALUES ($1, $2, $3, $4) + ON CONFLICT (msg_id, filename) DO UPDATE SET filesize=$3, filepath=$4`, + msgID, intendedFilename, written, finalPath, + ) + if err != nil { + _ = os.Remove(finalPath) + log.Printf("upload attachment: insert: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"filename": intendedFilename, "size": written}) +} + +// Download handles GET /api/v1/messages/:id/attachments/:filename. +func (h *AttachmentHandler) Download(c *gin.Context) { + identity := middleware.GetIdentity(c) + msgID, ok := parseID(c) + if !ok { + return + } + + // Validate and sanitize filename parameter. + filename := filepath.Base(c.Param("filename")) + if filename == "." || filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + return + } + + ctx := c.Request.Context() + + // Check ownership or recipient access. + var fromAddr string + err := h.DB.Pool.QueryRow(ctx, "SELECT from_addr FROM msg WHERE id = $1", msgID).Scan(&fromAddr) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + log.Printf("download attachment: fetch msg %d: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) + } + return + } + + // Check recipients if not owner. + if fromAddr != identity { + var recipientCount int + if err = h.DB.Pool.QueryRow(ctx, + "SELECT COUNT(*) FROM msg_to WHERE msg_id = $1 AND addr = $2", msgID, identity, + ).Scan(&recipientCount); err != nil || recipientCount == 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + } + + // Look up the attachment filepath. + var storedPath string + err = h.DB.Pool.QueryRow(ctx, + "SELECT filepath FROM msg_attachment WHERE msg_id = $1 AND filename = $2", + msgID, filename, + ).Scan(&storedPath) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"}) + } else { + log.Printf("download attachment: fetch path: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve attachment"}) + } + return + } + + // Path traversal protection: ensure the stored path is within DataDir. + cleanPath := filepath.Clean(storedPath) + cleanDataDir := filepath.Clean(h.DataDir) + if !strings.HasPrefix(cleanPath, cleanDataDir+string(filepath.Separator)) { + log.Printf("download attachment: path traversal attempt: %s", storedPath) + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + c.FileAttachment(cleanPath, filename) +} + +// DeleteAttachment handles DELETE /api/v1/messages/:id/attachments/:filename. +func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) { + identity := middleware.GetIdentity(c) + msgID, ok := parseID(c) + if !ok { + return + } + + filename := filepath.Base(c.Param("filename")) + if filename == "." || filename == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + return + } + + ctx := c.Request.Context() + + var fromAddr string + var timeSent *float64 + err := h.DB.Pool.QueryRow(ctx, + "SELECT from_addr, time_sent FROM msg WHERE id = $1", msgID, + ).Scan(&fromAddr, &timeSent) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + log.Printf("delete attachment: fetch msg %d: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) + } + return + } + + if fromAddr != identity { + c.JSON(http.StatusForbidden, gin.H{"error": "only the owner may delete attachments"}) + return + } + if timeSent != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "attachments of a sent message cannot be deleted"}) + return + } + + // Get filepath before deleting. + var storedPath string + err = h.DB.Pool.QueryRow(ctx, + "SELECT filepath FROM msg_attachment WHERE msg_id = $1 AND filename = $2", + msgID, filename, + ).Scan(&storedPath) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"}) + } else { + log.Printf("delete attachment: fetch path: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve attachment"}) + } + return + } + + if _, err = h.DB.Pool.Exec(ctx, + "DELETE FROM msg_attachment WHERE msg_id = $1 AND filename = $2", msgID, filename, + ); err != nil { + log.Printf("delete attachment: db: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"}) + return + } + + // Remove file from disk. + cleanPath := filepath.Clean(storedPath) + cleanDataDir := filepath.Clean(h.DataDir) + if strings.HasPrefix(cleanPath, cleanDataDir+string(filepath.Separator)) { + _ = os.Remove(cleanPath) + } + + c.Status(http.StatusNoContent) +} + +// resolveFilePath returns a path under dir for filename, incrementing a suffix +// until no collision is found. +func resolveFilePath(dir, filename string) string { + candidate := filepath.Join(dir, filename) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + ext := filepath.Ext(filename) + base := strings.TrimSuffix(filename, ext) + for i := 1; ; i++ { + candidate = filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, i, ext)) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } +} diff --git a/src/handlers/messages.go b/src/handlers/messages.go new file mode 100644 index 0000000..576c5a0 --- /dev/null +++ b/src/handlers/messages.go @@ -0,0 +1,443 @@ +// Package handlers implements HTTP handlers for the fmsg web API. +package handlers + +import ( +"context" +"crypto/sha256" +"errors" +"fmt" +"log" +"mime" +"net/http" +"os" +"path/filepath" +"strconv" +"strings" +"time" + +"github.com/gin-gonic/gin" +"github.com/jackc/pgx/v5" + +"github.com/markmnl/fmsg-webapi/db" +"github.com/markmnl/fmsg-webapi/middleware" +"github.com/markmnl/fmsg-webapi/models" +) + +// MessageHandler holds dependencies for message routes. +type MessageHandler struct { +DB *db.DB +DataDir string +} + +// NewMessageHandler creates a MessageHandler. +func NewMessageHandler(database *db.DB, dataDir string) *MessageHandler { +return &MessageHandler{DB: database, DataDir: dataDir} +} + +// Create handles POST /api/v1/messages — creates a draft message. +func (h *MessageHandler) Create(c *gin.Context) { +identity := middleware.GetIdentity(c) +if identity == "" { +c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) +return +} + +var msg models.Message +if err := c.ShouldBindJSON(&msg); err != nil { +c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +return +} + +// Enforce ownership: from must match the JWT identity. +if msg.From != identity { +c.JSON(http.StatusForbidden, gin.H{"error": "from address must match authenticated user"}) +return +} + +if len(msg.To) == 0 { +c.JSON(http.StatusBadRequest, gin.H{"error": "to list must not be empty"}) +return +} + +// Compute SHA-256 of the data payload. +hash := sha256.Sum256([]byte(msg.Data)) + +// Parse extension from MIME type. +ext := mimeToExt(msg.Type) + +ctx := c.Request.Context() + +// Insert message row with empty filepath; update after we know the ID. +var msgID int64 +err := h.DB.Pool.QueryRow(ctx, +`INSERT INTO msg (version, pid, flags, from_addr, topic, type, sha256, size, filepath) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '') + RETURNING id`, +msg.Version, msg.PID, msg.Flags, msg.From, msg.Topic, msg.Type, hash[:], msg.Size, +).Scan(&msgID) +if err != nil { +log.Printf("create message: insert: %v", err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) +return +} + +// Build filesystem path and save data. +dataPath, err := h.saveMessageData(msg.From, msgID, ext, msg.Data) +if err != nil { +log.Printf("create message: save data: %v", err) +// Attempt rollback. +_, _ = h.DB.Pool.Exec(ctx, "DELETE FROM msg WHERE id = $1", msgID) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save message data"}) +return +} + +// Update filepath in the database. +if _, err = h.DB.Pool.Exec(ctx, "UPDATE msg SET filepath = $1 WHERE id = $2", dataPath, msgID); err != nil { +log.Printf("create message: update filepath: %v", err) +} + +// Insert recipients. +for _, addr := range msg.To { +if _, err = h.DB.Pool.Exec(ctx, +"INSERT INTO msg_to (msg_id, addr) VALUES ($1, $2) ON CONFLICT DO NOTHING", +msgID, addr, +); err != nil { +log.Printf("create message: insert recipient %s: %v", addr, err) +} +} + +c.JSON(http.StatusCreated, gin.H{"id": msgID}) +} + +// Get handles GET /api/v1/messages/:id — retrieves a message. +func (h *MessageHandler) Get(c *gin.Context) { +identity := middleware.GetIdentity(c) +msgID, ok := parseID(c) +if !ok { +return +} + +ctx := c.Request.Context() +msg, err := h.fetchMessage(ctx, msgID) +if err != nil { +if errors.Is(err, pgx.ErrNoRows) { +c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) +} else { +log.Printf("get message %d: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) +} +return +} + +// Authorization: owner or recipient. +if msg.From != identity && !isRecipient(msg.To, identity) { +c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) +return +} + +c.JSON(http.StatusOK, msg) +} + +// Update handles PUT /api/v1/messages/:id — updates a draft message. +func (h *MessageHandler) Update(c *gin.Context) { +identity := middleware.GetIdentity(c) +msgID, ok := parseID(c) +if !ok { +return +} + +ctx := c.Request.Context() +existing, err := h.fetchMessage(ctx, msgID) +if err != nil { +if errors.Is(err, pgx.ErrNoRows) { +c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) +} else { +log.Printf("update message %d fetch: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) +} +return +} + +if existing.From != identity { +c.JSON(http.StatusForbidden, gin.H{"error": "only the owner may update a message"}) +return +} +if existing.Time != nil { +c.JSON(http.StatusForbidden, gin.H{"error": "sent messages are immutable"}) +return +} + +var msg models.Message +if err := c.ShouldBindJSON(&msg); err != nil { +c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +return +} +if msg.From != identity { +c.JSON(http.StatusForbidden, gin.H{"error": "from address must match authenticated user"}) +return +} + +hash := sha256.Sum256([]byte(msg.Data)) +ext := mimeToExt(msg.Type) + +dataPath, err := h.saveMessageData(msg.From, msgID, ext, msg.Data) +if err != nil { +log.Printf("update message %d save: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save message data"}) +return +} + +_, err = h.DB.Pool.Exec(ctx, +`UPDATE msg SET version=$1, pid=$2, flags=$3, topic=$4, type=$5, sha256=$6, size=$7, filepath=$8 WHERE id=$9`, +msg.Version, msg.PID, msg.Flags, msg.Topic, msg.Type, hash[:], msg.Size, dataPath, msgID, +) +if err != nil { +log.Printf("update message %d: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update message"}) +return +} + +// Replace recipients. +if _, err = h.DB.Pool.Exec(ctx, "DELETE FROM msg_to WHERE msg_id = $1", msgID); err != nil { +log.Printf("update message %d delete recipients: %v", msgID, err) +} +for _, addr := range msg.To { +if _, err = h.DB.Pool.Exec(ctx, +"INSERT INTO msg_to (msg_id, addr) VALUES ($1, $2) ON CONFLICT DO NOTHING", +msgID, addr, +); err != nil { +log.Printf("update message %d insert recipient %s: %v", msgID, addr, err) +} +} + +c.JSON(http.StatusOK, gin.H{"id": msgID}) +} + +// Delete handles DELETE /api/v1/messages/:id — deletes a draft message. +func (h *MessageHandler) Delete(c *gin.Context) { +identity := middleware.GetIdentity(c) +msgID, ok := parseID(c) +if !ok { +return +} + +ctx := c.Request.Context() +existing, err := h.fetchMessage(ctx, msgID) +if err != nil { +if errors.Is(err, pgx.ErrNoRows) { +c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) +} else { +log.Printf("delete message %d fetch: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) +} +return +} + +if existing.From != identity { +c.JSON(http.StatusForbidden, gin.H{"error": "only the owner may delete a message"}) +return +} +if existing.Time != nil { +c.JSON(http.StatusForbidden, gin.H{"error": "sent messages cannot be deleted"}) +return +} + +// Remove attachment files from disk. +rows, err := h.DB.Pool.Query(ctx, "SELECT filepath FROM msg_attachment WHERE msg_id = $1", msgID) +if err == nil { +var paths []string +for rows.Next() { +var p string +if scanErr := rows.Scan(&p); scanErr == nil { +paths = append(paths, p) +} +} +rows.Close() +for _, p := range paths { +_ = os.Remove(p) +} +} + +if _, err = h.DB.Pool.Exec(ctx, "DELETE FROM msg_attachment WHERE msg_id = $1", msgID); err != nil { +log.Printf("delete message %d: delete attachments: %v", msgID, err) +} +if _, err = h.DB.Pool.Exec(ctx, "DELETE FROM msg_to WHERE msg_id = $1", msgID); err != nil { +log.Printf("delete message %d: delete recipients: %v", msgID, err) +} + +// Get data filepath before deleting. +var dataPath string +_ = h.DB.Pool.QueryRow(ctx, "SELECT filepath FROM msg WHERE id = $1", msgID).Scan(&dataPath) + +if _, err = h.DB.Pool.Exec(ctx, "DELETE FROM msg WHERE id = $1", msgID); err != nil { +log.Printf("delete message %d: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"}) +return +} + +if dataPath != "" { +_ = os.Remove(dataPath) +} + +c.Status(http.StatusNoContent) +} + +// Send handles POST /api/v1/messages/:id/send — marks a message as sent. +func (h *MessageHandler) Send(c *gin.Context) { +identity := middleware.GetIdentity(c) +msgID, ok := parseID(c) +if !ok { +return +} + +ctx := c.Request.Context() +existing, err := h.fetchMessage(ctx, msgID) +if err != nil { +if errors.Is(err, pgx.ErrNoRows) { +c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) +} else { +log.Printf("send message %d fetch: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) +} +return +} + +if existing.From != identity { +c.JSON(http.StatusForbidden, gin.H{"error": "only the owner may send a message"}) +return +} +if existing.Time != nil { +c.JSON(http.StatusConflict, gin.H{"error": "message already sent"}) +return +} + +now := float64(time.Now().UnixMicro()) / 1e6 +if _, err = h.DB.Pool.Exec(ctx, "UPDATE msg SET time_sent = $1 WHERE id = $2", now, msgID); err != nil { +log.Printf("send message %d: %v", msgID, err) +c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"}) +return +} + +c.JSON(http.StatusOK, gin.H{"id": msgID, "time": now}) +} + +// fetchMessage loads a message with its recipients and attachments from the DB. +func (h *MessageHandler) fetchMessage(ctx context.Context, msgID int64) (*models.Message, error) { +row := h.DB.Pool.QueryRow(ctx, +`SELECT version, pid, flags, time_sent, from_addr, topic, type, size FROM msg WHERE id = $1`, +msgID, +) + +msg := &models.Message{} +var pid *int64 +var timeSent *float64 +if err := row.Scan(&msg.Version, &pid, &msg.Flags, &timeSent, &msg.From, &msg.Topic, &msg.Type, &msg.Size); err != nil { +return nil, err +} +msg.PID = pid +msg.Time = timeSent + +// Load recipients. +rows, err := h.DB.Pool.Query(ctx, "SELECT addr FROM msg_to WHERE msg_id = $1", msgID) +if err == nil { +for rows.Next() { +var addr string +if scanErr := rows.Scan(&addr); scanErr == nil { +msg.To = append(msg.To, addr) +} +} +rows.Close() +} + +// Load attachments. +attRows, err := h.DB.Pool.Query(ctx, "SELECT filename, filesize FROM msg_attachment WHERE msg_id = $1", msgID) +if err == nil { +for attRows.Next() { +var a models.Attachment +if scanErr := attRows.Scan(&a.Filename, &a.Size); scanErr == nil { +msg.Attachments = append(msg.Attachments, a) +} +} +attRows.Close() +} + +return msg, nil +} + +// saveMessageData writes data to the filesystem and returns the absolute path. +func (h *MessageHandler) saveMessageData(fromAddr string, msgID int64, ext, data string) (string, error) { +dir := msgDataDir(h.DataDir, fromAddr, msgID) +if err := os.MkdirAll(dir, 0750); err != nil { +return "", fmt.Errorf("mkdir: %w", err) +} +filename := "data" + ext +path := filepath.Join(dir, filename) +if err := os.WriteFile(path, []byte(data), 0640); err != nil { +return "", fmt.Errorf("write: %w", err) +} +return path, nil +} + +// msgDataDir returns ///out/. +func msgDataDir(dataDir, fromAddr string, msgID int64) string { +user, domain := parseAddr(fromAddr) +return filepath.Join(dataDir, domain, user, "out", strconv.FormatInt(msgID, 10)) +} + +// parseAddr extracts user and domain from "@user@domain". +func parseAddr(addr string) (user, domain string) { +if len(addr) < 3 { +return addr, "" +} +rest := addr[1:] // "user@domain" +idx := strings.LastIndex(rest, "@") +if idx < 0 { +return rest, "" +} +return rest[:idx], rest[idx+1:] +} + +// mimeToExt converts a MIME type to a file extension. +func mimeToExt(mimeType string) string { +mediaType, _, err := mime.ParseMediaType(mimeType) +if err != nil { +return ".bin" +} +exts, err := mime.ExtensionsByType(mediaType) +if err != nil || len(exts) == 0 { +switch mediaType { +case "text/plain": +return ".txt" +case "text/html": +return ".html" +case "application/json": +return ".json" +case "application/pdf": +return ".pdf" +default: +return ".bin" +} +} +return exts[0] +} + +// parseID extracts and validates the :id path parameter. +func parseID(c *gin.Context) (int64, bool) { +idStr := c.Param("id") +id, err := strconv.ParseInt(idStr, 10, 64) +if err != nil || id <= 0 { +c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"}) +return 0, false +} +return id, true +} + +// isRecipient checks whether addr appears in the to list. +func isRecipient(to []string, addr string) bool { +for _, a := range to { +if a == addr { +return true +} +} +return false +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..663be65 --- /dev/null +++ b/src/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + + "github.com/markmnl/fmsg-webapi/db" + "github.com/markmnl/fmsg-webapi/handlers" + "github.com/markmnl/fmsg-webapi/middleware" +) + +func main() { + // Load .env file if present (ignore error when absent). + _ = godotenv.Load() + + // Required configuration. + dataDir := mustEnv("FMSG_DATA_DIR") + jwtSecret := mustEnv("FMSG_API_JWT_SECRET") + + // Optional configuration with defaults. + port := envOrDefault("FMSG_API_PORT", "8000") + idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080") + + // Connect to PostgreSQL (uses standard PG* environment variables). + ctx := context.Background() + database, err := db.New(ctx, "") + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer database.Close() + log.Println("connected to PostgreSQL") + + // Initialise JWT middleware. + jwtMiddleware, err := middleware.SetupJWT(jwtSecret, idURL) + if err != nil { + log.Fatalf("failed to initialise JWT middleware: %v", err) + } + + // Create Gin router. + router := gin.Default() + + // Instantiate handlers. + msgHandler := handlers.NewMessageHandler(database, dataDir) + attHandler := handlers.NewAttachmentHandler(database, dataDir) + + // Register routes under /api/v1, all protected by JWT. + v1 := router.Group("/api/v1") + v1.Use(jwtMiddleware.MiddlewareFunc()) + { + v1.POST("/messages", msgHandler.Create) + v1.GET("/messages/:id", msgHandler.Get) + v1.PUT("/messages/:id", msgHandler.Update) + v1.DELETE("/messages/:id", msgHandler.Delete) + v1.POST("/messages/:id/send", msgHandler.Send) + + v1.POST("/messages/:id/attachments", attHandler.Upload) + v1.GET("/messages/:id/attachments/:filename", attHandler.Download) + v1.DELETE("/messages/:id/attachments/:filename", attHandler.DeleteAttachment) + } + + log.Printf("fmsg-webapi starting on :%s", port) + if err = router.Run(":" + port); err != nil { + log.Fatalf("server error: %v", err) + } +} + +// mustEnv returns the value of an environment variable or exits if it is unset. +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("required environment variable %s is not set", key) + } + return v +} + +// envOrDefault returns the environment variable value or defaultValue when unset. +func envOrDefault(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} diff --git a/src/middleware/jwt.go b/src/middleware/jwt.go new file mode 100644 index 0000000..9390aec --- /dev/null +++ b/src/middleware/jwt.go @@ -0,0 +1,138 @@ +// Package middleware configures the JWT authentication middleware. +package middleware + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" +) + +const IdentityKey = "addr" + +// identityClaims is the payload stored in the JWT. +type identityClaims struct { + Addr string +} + +// SetupJWT creates and returns a configured GinJWTMiddleware. +// secret is the HMAC secret used to validate tokens. +// idURL is the base URL of the fmsgid service used to validate user addresses. +func SetupJWT(secret string, idURL string) (*jwt.GinJWTMiddleware, error) { + mw, err := jwt.New(&jwt.GinJWTMiddleware{ + Realm: "fmsg", + Key: []byte(secret), + Timeout: 24 * time.Hour, + MaxRefresh: 24 * time.Hour, + IdentityKey: IdentityKey, + + // PayloadFunc stores the user address from the login credentials into JWT claims. + PayloadFunc: func(data interface{}) jwt.MapClaims { + if v, ok := data.(*identityClaims); ok { + return jwt.MapClaims{IdentityKey: v.Addr} + } + return jwt.MapClaims{} + }, + + // IdentityHandler extracts the address from JWT claims and puts it in Gin context. + IdentityHandler: func(c *gin.Context) interface{} { + claims := jwt.ExtractClaims(c) + addr, _ := claims[IdentityKey].(string) + return &identityClaims{Addr: addr} + }, + + // Authorizator validates the extracted identity and checks with fmsgid. + Authorizator: func(data interface{}, c *gin.Context) bool { + v, ok := data.(*identityClaims) + if !ok || v == nil { + return false + } + addr := v.Addr + if !isValidAddr(addr) { + return false + } + // Store the validated identity in context for downstream handlers. + c.Set(IdentityKey, addr) + + // Validate with fmsgid service. + code, accepting, err := checkFmsgID(idURL, addr) + if err != nil { + log.Printf("fmsgid check error for %s: %v", addr, err) + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "identity service unavailable"}) + return false + } + if code == http.StatusNotFound { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("User %s not found", addr)}) + return false + } + if code == http.StatusOK && !accepting { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("User %s not authorised to send new messages", addr)}) + return false + } + return true + }, + + // Unauthorized responds with 401 when JWT validation fails. + Unauthorized: func(c *gin.Context, code int, message string) { + c.JSON(code, gin.H{"error": message}) + }, + + TokenLookup: "header: Authorization", + TokenHeadName: "Bearer", + TimeFunc: time.Now, + }) + return mw, err +} + +// GetIdentity retrieves the authenticated user address from the Gin context. +func GetIdentity(c *gin.Context) string { + v, exists := c.Get(IdentityKey) + if !exists { + return "" + } + addr, _ := v.(string) + return addr +} + +// isValidAddr checks that the address has the form "@user@domain". +func isValidAddr(addr string) bool { + if len(addr) < 3 { + return false + } + if addr[0] != '@' { + return false + } + rest := addr[1:] + return strings.Contains(rest, "@") +} + +// checkFmsgID queries the fmsgid service for a user address. +// Returns (statusCode, acceptingNew, error). +func checkFmsgID(idURL, addr string) (int, bool, error) { + url := strings.TrimRight(idURL, "/") + "/addr/" + addr + resp, err := http.Get(url) //nolint:gosec // URL constructed from trusted config + validated addr + if err != nil { + return 0, false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return http.StatusNotFound, false, nil + } + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, false, nil + } + + // Parse acceptingNew from the JSON response. + var result struct { + AcceptingNew bool `json:"acceptingNew"` + } + if err := decodeJSON(resp.Body, &result); err != nil { + return http.StatusOK, true, nil // assume accepting if parse fails + } + return http.StatusOK, result.AcceptingNew, nil +} diff --git a/src/middleware/util.go b/src/middleware/util.go new file mode 100644 index 0000000..160cf5e --- /dev/null +++ b/src/middleware/util.go @@ -0,0 +1,10 @@ +package middleware + +import ( + "encoding/json" + "io" +) + +func decodeJSON(r io.Reader, v interface{}) error { + return json.NewDecoder(r).Decode(v) +} diff --git a/src/models/models.go b/src/models/models.go new file mode 100644 index 0000000..0150d69 --- /dev/null +++ b/src/models/models.go @@ -0,0 +1,22 @@ +package models + +// Attachment represents a file attachment associated with a message. +type Attachment struct { + Size int `json:"size"` + Filename string `json:"filename"` +} + +// Message represents a fmsg message as exchanged over the HTTP API. +type Message struct { + Version int `json:"version"` + Flags int `json:"flags"` + PID *int64 `json:"pid"` + From string `json:"from"` + To []string `json:"to"` + Time *float64 `json:"time"` + Topic string `json:"topic"` + Type string `json:"type"` + Size int `json:"size"` + Data string `json:"data"` + Attachments []Attachment `json:"attachments"` +}