Skip to content

Commit 688ee0e

Browse files
Merge pull request #26 from dmitriimaksimovdevelop/feat/share-api
feat: share-api backend + short-link upload in CLI
2 parents 0446b90 + a34528f commit 688ee0e

13 files changed

Lines changed: 1256 additions & 24 deletions

File tree

cmd/melisai-share-api/main.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// melisai-share-api is the HTTP backend for short share links.
2+
//
3+
// It accepts gzip-compressed melisai report payloads on POST /api/r,
4+
// stores them in a local SQLite database, and serves them back by
5+
// 8-character short code on GET /api/r/{code}. The static viewer page
6+
// at https://melisai.dev/r/{code} fetches the payload and renders it
7+
// client-side.
8+
//
9+
// Designed to run as a sidecar next to the melisai-site nginx pod,
10+
// sharing a PVC for the database file.
11+
package main
12+
13+
import (
14+
"context"
15+
"errors"
16+
"flag"
17+
"fmt"
18+
"log/slog"
19+
"net/http"
20+
"os"
21+
"os/signal"
22+
"path/filepath"
23+
"syscall"
24+
"time"
25+
26+
"github.com/dmitriimaksimovdevelop/melisai/internal/shareapi"
27+
)
28+
29+
func main() {
30+
var (
31+
addr = flag.String("addr", ":8080", "HTTP listen address")
32+
dbPath = flag.String("db", "/var/lib/melisai-share-api/store.db", "SQLite database path")
33+
publicBase = flag.String("public-base", "https://melisai.dev/r", "Viewer base URL embedded in POST responses")
34+
maxBodyBytes = flag.Int64("max-body-bytes", shareapi.DefaultMaxBodyBytes, "Max upload size in bytes")
35+
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, error")
36+
retention = flag.Duration("retention", 0, "Delete reports older than this; 0 disables retention (e.g. 2160h for 90 days)")
37+
cleanupInterval = flag.Duration("cleanup-interval", time.Hour, "How often the retention sweep runs (ignored when --retention=0)")
38+
)
39+
flag.Parse()
40+
41+
logger := newLogger(*logLevel)
42+
43+
if err := shareapi.ValidatePublicBase(*publicBase); err != nil {
44+
logger.Error("invalid --public-base", "err", err)
45+
os.Exit(2)
46+
}
47+
48+
if err := run(*addr, *dbPath, *publicBase, *maxBodyBytes, *retention, *cleanupInterval, logger); err != nil {
49+
logger.Error("server failed", "err", err)
50+
os.Exit(1)
51+
}
52+
}
53+
54+
func run(addr, dbPath, publicBase string, maxBodyBytes int64, retention, cleanupInterval time.Duration, log *slog.Logger) error {
55+
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
56+
return fmt.Errorf("create db dir: %w", err)
57+
}
58+
59+
store, err := shareapi.Open(dbPath)
60+
if err != nil {
61+
return fmt.Errorf("open store: %w", err)
62+
}
63+
defer func() {
64+
if cerr := store.Close(); cerr != nil {
65+
log.Error("store close", "err", cerr)
66+
}
67+
}()
68+
69+
srv, err := shareapi.NewServer(store, publicBase, maxBodyBytes, log)
70+
if err != nil {
71+
return fmt.Errorf("init server: %w", err)
72+
}
73+
74+
httpSrv := &http.Server{
75+
Addr: addr,
76+
Handler: srv.Handler(),
77+
ReadHeaderTimeout: 5 * time.Second,
78+
ReadTimeout: 30 * time.Second,
79+
WriteTimeout: 30 * time.Second,
80+
IdleTimeout: 60 * time.Second,
81+
}
82+
83+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
84+
defer stop()
85+
86+
if retention > 0 {
87+
go runRetention(ctx, store, retention, cleanupInterval, log)
88+
}
89+
90+
// Buffered so the listen goroutine never blocks on send; we drain
91+
// in the shutdown branch below to surface a late ListenAndServe
92+
// error that races with the signal.
93+
serveErr := make(chan error, 1)
94+
go func() {
95+
log.Info("listening", "addr", addr, "db", dbPath, "public_base", publicBase, "retention", retention)
96+
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
97+
serveErr <- err
98+
}
99+
}()
100+
101+
select {
102+
case <-ctx.Done():
103+
log.Info("shutdown signal received")
104+
case err := <-serveErr:
105+
return fmt.Errorf("listen: %w", err)
106+
}
107+
108+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
109+
defer cancel()
110+
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
111+
return fmt.Errorf("shutdown: %w", err)
112+
}
113+
114+
// Drain any error that the listen goroutine produced after we
115+
// already lost the select race.
116+
select {
117+
case err := <-serveErr:
118+
log.Error("late listen error", "err", err)
119+
default:
120+
}
121+
122+
log.Info("server stopped")
123+
return nil
124+
}
125+
126+
// runRetention drops rows older than `age` on the given interval until
127+
// ctx is cancelled. The first sweep fires immediately on startup to
128+
// shrink any backlog from a previous run with longer retention.
129+
func runRetention(ctx context.Context, store *shareapi.Store, age, interval time.Duration, log *slog.Logger) {
130+
sweep := func() {
131+
cutoff := time.Now().Add(-age)
132+
deleted, err := store.DeleteOlderThan(ctx, cutoff)
133+
if err != nil {
134+
log.Error("retention sweep", "err", err)
135+
return
136+
}
137+
if deleted > 0 {
138+
log.Info("retention sweep", "deleted", deleted, "cutoff", cutoff)
139+
}
140+
}
141+
sweep()
142+
143+
t := time.NewTicker(interval)
144+
defer t.Stop()
145+
for {
146+
select {
147+
case <-ctx.Done():
148+
return
149+
case <-t.C:
150+
sweep()
151+
}
152+
}
153+
}
154+
155+
func newLogger(level string) *slog.Logger {
156+
var lvl slog.Level
157+
switch level {
158+
case "debug":
159+
lvl = slog.LevelDebug
160+
case "warn":
161+
lvl = slog.LevelWarn
162+
case "error":
163+
lvl = slog.LevelError
164+
default:
165+
lvl = slog.LevelInfo
166+
}
167+
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
168+
}

cmd/melisai/share.go

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
"time"
89

910
"github.com/spf13/cobra"
1011

@@ -14,48 +15,75 @@ import (
1415

1516
var (
1617
shareBaseURL string
18+
shareAPIURL string
19+
shareOffline bool
20+
shareTimeout time.Duration
1721
)
1822

1923
var shareCmd = &cobra.Command{
2024
Use: "share <report.json>",
2125
Short: "Encode a report summary into a shareable URL",
2226
Long: `Reads a melisai JSON report and prints a URL that embeds the
23-
diagnostic summary (health score, anomalies, USE metrics, recommendations)
24-
inside the URL fragment. No data is uploaded anywhere — the receiving
25-
page at melisai.dev/r decodes and renders the payload entirely
26-
client-side.
27+
diagnostic summary (health score, anomalies, USE metrics, recommendations).
28+
29+
By default the payload is uploaded to the melisai.dev short-link backend
30+
and a URL like https://melisai.dev/r/Xa9bC3kp is printed. If the backend
31+
is unreachable (no network, airgapped server, 5xx, timeout) the command
32+
falls back to a self-contained fragment URL of the form
33+
https://melisai.dev/r#<base64url-gzip> — these are longer but require
34+
no server.
2735
2836
Use "-" to read the report from stdin.
2937
30-
Privacy note: the payload includes hostname, kernel version, and the
31-
recommendation evidence text. Treat the resulting URL with the same
32-
care as the report file itself.
38+
Privacy note: every variant of the URL includes hostname, kernel version,
39+
and recommendation evidence text. Treat the URL with the same care as
40+
the report file itself.
3341
34-
Example:
42+
Examples:
3543
melisai collect --profile quick -o report.json
36-
melisai share report.json
37-
# → https://melisai.dev/r#H4sIAAAAAAAA...`,
44+
melisai share report.json # short link, with fallback
45+
melisai share --offline report.json # force fragment URL
46+
melisai share --api-url=http://lab/api/r - # custom backend, stdin`,
3847
Args: cobra.ExactArgs(1),
3948
RunE: func(cmd *cobra.Command, args []string) error {
4049
report, err := loadReportForShare(args[0])
4150
if err != nil {
4251
return err
4352
}
44-
4553
payload := share.BuildPayload(report)
54+
55+
if !shareOffline {
56+
url, uploadErr := share.Upload(cmd.Context(), payload, shareAPIURL, shareTimeout)
57+
if uploadErr == nil {
58+
fmt.Println(url)
59+
return nil
60+
}
61+
// Fall through to fragment with a warning. The CLI must
62+
// still succeed so users on airgapped boxes can ship the
63+
// long URL via whatever transport they have.
64+
fmt.Fprintf(os.Stderr,
65+
"warning: short-link upload failed (%v); falling back to self-contained URL\n",
66+
uploadErr)
67+
}
68+
4669
url, err := share.BuildURL(payload, shareBaseURL)
4770
if err != nil {
4871
return fmt.Errorf("build url: %w", err)
4972
}
50-
5173
fmt.Println(url)
5274
return nil
5375
},
5476
}
5577

5678
func init() {
5779
shareCmd.Flags().StringVar(&shareBaseURL, "base-url", share.DefaultBaseURL,
58-
"Receiver base URL (must be http or https with a host)")
80+
"Viewer base URL used for fragment fallback (must be http or https with a host)")
81+
shareCmd.Flags().StringVar(&shareAPIURL, "api-url", share.DefaultAPIURL,
82+
"Backend endpoint for short-link upload")
83+
shareCmd.Flags().BoolVar(&shareOffline, "offline", false,
84+
"Skip the backend upload and emit a self-contained fragment URL")
85+
shareCmd.Flags().DurationVar(&shareTimeout, "timeout", share.DefaultUploadTimeout,
86+
"Timeout for the upload attempt before falling back to fragment URL")
5987
}
6088

6189
// loadReportForShare reads a Report from a file path or stdin (when path is "-").

go.mod

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/dmitriimaksimovdevelop/melisai
22

3-
go 1.23.0
3+
go 1.25.0
44

55
require (
66
github.com/cilium/ebpf v0.12.3
@@ -11,16 +11,24 @@ require (
1111
require (
1212
github.com/bahlo/generic-list-go v0.2.0 // indirect
1313
github.com/buger/jsonparser v1.1.1 // indirect
14+
github.com/dustin/go-humanize v1.0.1 // indirect
1415
github.com/google/go-cmp v0.6.0 // indirect
1516
github.com/google/uuid v1.6.0 // indirect
1617
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1718
github.com/invopop/jsonschema v0.13.0 // indirect
1819
github.com/mailru/easyjson v0.7.7 // indirect
20+
github.com/mattn/go-isatty v0.0.20 // indirect
21+
github.com/ncruces/go-strftime v1.0.0 // indirect
22+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
1923
github.com/spf13/cast v1.7.1 // indirect
2024
github.com/spf13/pflag v1.0.5 // indirect
2125
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
2226
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
2327
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
24-
golang.org/x/sys v0.20.0 // indirect
28+
golang.org/x/sys v0.42.0 // indirect
2529
gopkg.in/yaml.v3 v3.0.1 // indirect
30+
modernc.org/libc v1.72.3 // indirect
31+
modernc.org/mathutil v1.7.1 // indirect
32+
modernc.org/memory v1.11.0 // indirect
33+
modernc.org/sqlite v1.50.1 // indirect
2634
)

go.sum

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1
77
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
88
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
99
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
11+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
1012
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
1113
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
1214
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -26,8 +28,14 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
2628
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
2729
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
2830
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
31+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
32+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
33+
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
34+
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
2935
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3036
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
37+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
38+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
3139
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
3240
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
3341
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -45,9 +53,20 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
4553
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
4654
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
4755
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
56+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4857
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
4958
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
59+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
60+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
5061
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
5162
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5263
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
5364
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
65+
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
66+
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
67+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
68+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
69+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
70+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
71+
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
72+
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

0 commit comments

Comments
 (0)