Skip to content

Commit 857b518

Browse files
committed
Release:--: v0.0.6_2026-04-28_12:06:26_UTC
1 parent e84e766 commit 857b518

11 files changed

Lines changed: 737 additions & 495 deletions

File tree

ac_config/ac_config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import (
1515
)
1616

1717
const (
18-
ProjectName = "api_show"
19-
ProjectVersion = "v0.0.5"
18+
ProjectName = "api_show"
19+
ProjectVersion = "v0.0.6"
20+
ProjectBundleID = "com.0xYeah.api_show"
2021

2122
ListenPort = 12110
2223
DefaultDataDir = "./data"

ac_config/app.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package ac_config
2+
3+
import (
4+
"github.com/george012/gtbox"
5+
"github.com/george012/gtbox/gtbox_app"
6+
)
7+
8+
var (
9+
CurrentApp *ExtendApp
10+
)
11+
12+
type ExtendApp struct {
13+
*gtbox_app.App
14+
APIPort int
15+
}
16+
17+
func NewApp(appName, bundleID, description string, runMode gtbox.RunMode) *ExtendApp {
18+
return &ExtendApp{
19+
App: gtbox_app.NewApp(appName, ProjectVersion, bundleID, description, runMode),
20+
}
21+
}

api/api.go

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
// Package api hosts the HTTP routing layer for api_show. main only sets up
2+
// config + signal handling and calls StartAPIServices.
3+
package api
4+
5+
import (
6+
"embed"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"io/fs"
12+
"net"
13+
"net/http"
14+
"os"
15+
"path/filepath"
16+
"regexp"
17+
"sort"
18+
"strconv"
19+
"strings"
20+
"time"
21+
22+
"github.com/0xYeah/api_show/auth"
23+
"github.com/george012/gtbox/gtbox_log"
24+
)
25+
26+
var (
27+
identRe = regexp.MustCompile(`^[a-z0-9_-]+$`)
28+
versionRe = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
29+
specPathRe = regexp.MustCompile(`^/data/([a-z0-9_-]+)/(v\d+\.\d+\.\d+)/openapi\.yaml$`)
30+
maxUploadSize = int64(5 << 20)
31+
)
32+
33+
// Options carries the runtime knobs main has resolved from config.
34+
type Options struct {
35+
Deps auth.HandlerDeps
36+
DataDir string
37+
ListenPort int
38+
UploadToken string
39+
OnlyLocal bool
40+
WebrootFS embed.FS
41+
}
42+
43+
var current *Options
44+
45+
func StartAPIServices(opts *Options) {
46+
current = opts
47+
48+
mux := buildMux(opts)
49+
addr := fmt.Sprintf(":%d", opts.ListenPort)
50+
gtbox_log.LogInfof("[api_show] listening on %s (data=%s)", addr, opts.DataDir)
51+
52+
srv := &http.Server{
53+
Addr: addr,
54+
Handler: logMiddleware(mux),
55+
ReadHeaderTimeout: 5 * time.Second,
56+
WriteTimeout: 30 * time.Second,
57+
IdleTimeout: 120 * time.Second,
58+
MaxHeaderBytes: 1 << 16,
59+
}
60+
go func() {
61+
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
62+
gtbox_log.LogErrorf("[api_show] server error: %v", err)
63+
}
64+
}()
65+
}
66+
67+
func buildMux(opts *Options) *http.ServeMux {
68+
mux := http.NewServeMux()
69+
70+
sub, err := fs.Sub(opts.WebrootFS, "webroot")
71+
if err != nil {
72+
gtbox_log.LogErrorf("[api_show] embed sub failed: %v", err)
73+
return mux
74+
}
75+
publicAssets := http.FileServer(http.FS(sub))
76+
mux.HandleFunc("/style.css", func(w http.ResponseWriter, r *http.Request) { publicAssets.ServeHTTP(w, r) })
77+
mux.HandleFunc("/scalar.js", func(w http.ResponseWriter, r *http.Request) { publicAssets.ServeHTTP(w, r) })
78+
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { publicAssets.ServeHTTP(w, r) })
79+
80+
mux.HandleFunc("/login", auth.LoginHandler(opts.Deps))
81+
mux.HandleFunc("/logout", auth.LogoutHandler())
82+
mux.HandleFunc("/bind-totp", auth.BindTOTPHandler(opts.Deps))
83+
mux.HandleFunc("/bind-totp/qr", auth.BindTOTPQRHandler(opts.Deps))
84+
85+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
86+
_, _ = w.Write([]byte("ok"))
87+
})
88+
mux.HandleFunc("/upload", uploadHandler)
89+
90+
mux.Handle("/api/sources.json", auth.RequireAuth(opts.Deps.Secret, http.HandlerFunc(sourcesHandler)))
91+
mux.Handle("/data/", auth.RequireAuth(opts.Deps.Secret, http.HandlerFunc(specYamlHandler)))
92+
93+
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94+
if r.URL.Path != "/" {
95+
http.NotFound(w, r)
96+
return
97+
}
98+
publicAssets.ServeHTTP(w, r)
99+
})
100+
mux.Handle("/", auth.RequireAuth(opts.Deps.Secret, rootHandler))
101+
102+
return mux
103+
}
104+
105+
type version struct {
106+
Version string `json:"version"`
107+
URL string `json:"url"`
108+
Released string `json:"released"`
109+
}
110+
111+
type project struct {
112+
ID string `json:"id"`
113+
Name string `json:"name"`
114+
Latest string `json:"latest"`
115+
Versions []version `json:"versions"`
116+
}
117+
118+
type latestMeta struct {
119+
Version string `json:"version"`
120+
Released string `json:"released"`
121+
}
122+
123+
func sourcesHandler(w http.ResponseWriter, r *http.Request) {
124+
user := auth.UserFromCtx(r.Context())
125+
w.Header().Set("Content-Type", "application/json")
126+
w.Header().Set("Cache-Control", "no-store")
127+
128+
entries, err := os.ReadDir(current.DataDir)
129+
if err != nil {
130+
writeJSON(w, map[string]any{"projects": []any{}})
131+
return
132+
}
133+
134+
out := []project{}
135+
for _, e := range entries {
136+
if !e.IsDir() || !identRe.MatchString(e.Name()) {
137+
continue
138+
}
139+
if !auth.CanSeeProject(user, e.Name()) {
140+
continue
141+
}
142+
pdir := filepath.Join(current.DataDir, e.Name())
143+
versions := scanVersions(pdir, e.Name())
144+
if len(versions) == 0 {
145+
continue
146+
}
147+
latest := readLatest(pdir)
148+
if latest == "" {
149+
latest = versions[0].Version
150+
}
151+
out = append(out, project{
152+
ID: e.Name(),
153+
Name: e.Name(),
154+
Latest: latest,
155+
Versions: versions,
156+
})
157+
}
158+
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
159+
writeJSON(w, map[string]any{"projects": out, "user": user})
160+
}
161+
162+
func scanVersions(projDir, projID string) []version {
163+
subs, err := os.ReadDir(projDir)
164+
if err != nil {
165+
return nil
166+
}
167+
out := []version{}
168+
for _, s := range subs {
169+
if !s.IsDir() || !versionRe.MatchString(s.Name()) {
170+
continue
171+
}
172+
spec := filepath.Join(projDir, s.Name(), "openapi.yaml")
173+
info, err := os.Stat(spec)
174+
if err != nil {
175+
continue
176+
}
177+
out = append(out, version{
178+
Version: s.Name(),
179+
URL: fmt.Sprintf("/data/%s/%s/openapi.yaml", projID, s.Name()),
180+
Released: info.ModTime().UTC().Format("2006-01-02"),
181+
})
182+
}
183+
sort.Slice(out, func(i, j int) bool { return semverLess(out[j].Version, out[i].Version) })
184+
return out
185+
}
186+
187+
func readLatest(projDir string) string {
188+
b, err := os.ReadFile(filepath.Join(projDir, "latest.json"))
189+
if err != nil {
190+
return ""
191+
}
192+
var m latestMeta
193+
if json.Unmarshal(b, &m) != nil {
194+
return ""
195+
}
196+
return m.Version
197+
}
198+
199+
func semverLess(a, b string) bool {
200+
pa := parseSemver(a)
201+
pb := parseSemver(b)
202+
for i := range 3 {
203+
if pa[i] != pb[i] {
204+
return pa[i] < pb[i]
205+
}
206+
}
207+
return false
208+
}
209+
210+
func parseSemver(v string) [3]int {
211+
out := [3]int{}
212+
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
213+
for i := 0; i < 3 && i < len(parts); i++ {
214+
n, _ := strconv.Atoi(parts[i])
215+
out[i] = n
216+
}
217+
return out
218+
}
219+
220+
func specYamlHandler(w http.ResponseWriter, r *http.Request) {
221+
m := specPathRe.FindStringSubmatch(r.URL.Path)
222+
if m == nil {
223+
http.NotFound(w, r)
224+
return
225+
}
226+
proj, ver := m[1], m[2]
227+
user := auth.UserFromCtx(r.Context())
228+
229+
if !auth.CanSeeProject(user, proj) {
230+
http.Error(w, "forbidden", http.StatusForbidden)
231+
return
232+
}
233+
234+
specPath := filepath.Join(current.DataDir, proj, ver, "openapi.yaml")
235+
info, err := os.Stat(specPath)
236+
if err != nil {
237+
http.NotFound(w, r)
238+
return
239+
}
240+
mtimeUnix := info.ModTime().Unix()
241+
242+
if cached, ok := auth.GetCachedFiltered(user, proj, ver, mtimeUnix); ok {
243+
writeYAML(w, cached)
244+
return
245+
}
246+
247+
raw, err := os.ReadFile(specPath)
248+
if err != nil {
249+
http.Error(w, "read spec: "+err.Error(), http.StatusInternalServerError)
250+
return
251+
}
252+
filtered, err := auth.FilterSpec(raw, user, proj)
253+
if err != nil {
254+
http.Error(w, "filter spec: "+err.Error(), http.StatusForbidden)
255+
return
256+
}
257+
auth.PutCachedFiltered(user, proj, ver, mtimeUnix, filtered)
258+
writeYAML(w, filtered)
259+
}
260+
261+
func writeYAML(w http.ResponseWriter, b []byte) {
262+
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
263+
w.Header().Set("Cache-Control", "no-store")
264+
_, _ = w.Write(b)
265+
}
266+
267+
func uploadHandler(w http.ResponseWriter, r *http.Request) {
268+
if r.Method != http.MethodPost {
269+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
270+
return
271+
}
272+
if current.OnlyLocal && !remoteIsLocal(r) {
273+
gtbox_log.LogWarnf("[api_show] /upload rejected: only_local=true, remote=%s", r.RemoteAddr)
274+
http.Error(w, "forbidden (LAN-only mode)", http.StatusForbidden)
275+
return
276+
}
277+
if current.UploadToken == "" {
278+
http.Error(w, "uploads disabled (upload_token unset)", http.StatusServiceUnavailable)
279+
return
280+
}
281+
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
282+
if tok == "" || tok != current.UploadToken {
283+
http.Error(w, "unauthorized", http.StatusUnauthorized)
284+
return
285+
}
286+
proj := r.URL.Query().Get("project")
287+
ver := r.URL.Query().Get("version")
288+
setLatest := r.URL.Query().Get("latest") == "true"
289+
if !identRe.MatchString(proj) {
290+
http.Error(w, "bad project (need [a-z0-9_-]+)", http.StatusBadRequest)
291+
return
292+
}
293+
if !versionRe.MatchString(ver) {
294+
http.Error(w, "bad version (need vX.Y.Z)", http.StatusBadRequest)
295+
return
296+
}
297+
298+
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxUploadSize))
299+
if err != nil {
300+
http.Error(w, "body too large or unreadable", http.StatusRequestEntityTooLarge)
301+
return
302+
}
303+
if len(body) == 0 {
304+
http.Error(w, "empty body", http.StatusBadRequest)
305+
return
306+
}
307+
308+
targetDir := filepath.Join(current.DataDir, proj, ver)
309+
if err := os.MkdirAll(targetDir, 0o755); err != nil {
310+
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
311+
return
312+
}
313+
finalPath := filepath.Join(targetDir, "openapi.yaml")
314+
tmpPath := finalPath + ".tmp"
315+
if err := os.WriteFile(tmpPath, body, 0o644); err != nil {
316+
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
317+
return
318+
}
319+
if err := os.Rename(tmpPath, finalPath); err != nil {
320+
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
321+
return
322+
}
323+
324+
if setLatest {
325+
meta := latestMeta{Version: ver, Released: time.Now().UTC().Format("2006-01-02")}
326+
mb, _ := json.Marshal(meta)
327+
latestPath := filepath.Join(current.DataDir, proj, "latest.json")
328+
_ = os.WriteFile(latestPath+".tmp", mb, 0o644)
329+
_ = os.Rename(latestPath+".tmp", latestPath)
330+
}
331+
332+
gtbox_log.LogInfof("[api_show] upload ok project=%s version=%s latest=%v bytes=%d", proj, ver, setLatest, len(body))
333+
writeJSON(w, map[string]any{
334+
"ok": true,
335+
"project": proj,
336+
"version": ver,
337+
"latest": setLatest,
338+
"bytes": len(body),
339+
})
340+
}
341+
342+
func writeJSON(w http.ResponseWriter, v any) {
343+
w.Header().Set("Content-Type", "application/json")
344+
_ = json.NewEncoder(w).Encode(v)
345+
}
346+
347+
func logMiddleware(next http.Handler) http.Handler {
348+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
349+
start := time.Now()
350+
next.ServeHTTP(w, r)
351+
gtbox_log.LogInfof("[api_show] %s %s %s %s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
352+
})
353+
}
354+
355+
func remoteIsLocal(r *http.Request) bool {
356+
host, _, err := net.SplitHostPort(r.RemoteAddr)
357+
if err != nil {
358+
host = r.RemoteAddr
359+
}
360+
ip := net.ParseIP(host)
361+
if ip == nil {
362+
return false
363+
}
364+
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
365+
}

0 commit comments

Comments
 (0)