add api endpoints

This commit is contained in:
Arkadiy Kukarkin
2025-12-23 12:23:32 +01:00
parent 725a673fb0
commit 5661cc3f04
8 changed files with 775 additions and 0 deletions

90
api/adopt.go Normal file
View File

@@ -0,0 +1,90 @@
package api
import (
"fmt"
"net/http"
"strconv"
"github.com/parkan/go-hauk/model"
)
func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sid := r.FormValue("sid")
nickname := r.FormValue("nic")
aid := r.FormValue("aid")
pinStr := r.FormValue("pin")
if sid == "" || nickname == "" || aid == "" || pinStr == "" {
fmt.Fprintln(w, "Missing data!")
return
}
session, err := model.LoadSession(ctx, s.store, sid, s.cfg.MaxCachedPts)
if err != nil {
fmt.Fprintln(w, "Session expired!")
return
}
shareType, err := model.LoadShareType(ctx, s.store, aid)
if err != nil {
fmt.Fprintln(w, "Share not found!")
return
}
if shareType != model.ShareTypeAlone {
fmt.Fprintln(w, "Group shares cannot be adopted!")
return
}
share, err := model.LoadSoloShare(ctx, s.store, aid, s.cfg.PublicURL)
if err != nil {
fmt.Fprintln(w, "Share not found!")
return
}
if !share.Adoptable() {
fmt.Fprintln(w, "Share adoption not allowed!")
return
}
hostSession, err := model.LoadSession(ctx, s.store, share.Host(), s.cfg.MaxCachedPts)
if err != nil {
fmt.Fprintln(w, "Session expired!")
return
}
if hostSession.Encrypted() {
fmt.Fprintln(w, "End-to-end encrypted shares cannot be adopted!")
return
}
pin, _ := strconv.Atoi(pinStr)
target, err := model.LoadGroupShareByPin(ctx, s.store, pin, s.cfg.PublicURL)
if err != nil {
fmt.Fprintln(w, "Session expired!")
return
}
target.AddHost(nickname, share.Host())
if err := target.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hostSession.AddTarget(target.ID())
if err := hostSession.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = session
fmt.Fprintln(w, "OK")
}

211
api/create.go Normal file
View File

@@ -0,0 +1,211 @@
package api
import (
"context"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"github.com/parkan/go-hauk/model"
)
const (
shareModeCreateAlone = 0
shareModeCreateGroup = 1
shareModeJoinGroup = 2
)
var linkIDRe = regexp.MustCompile(`^[\w-]+$`)
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
dur := r.FormValue("dur")
interval := r.FormValue("int")
if dur == "" || interval == "" {
fmt.Fprintln(w, "Missing data!")
return
}
user := r.FormValue("usr")
pass := r.FormValue("pwd")
if err := s.auth.Authenticate(user, pass); err != nil {
fmt.Fprintln(w, "Incorrect password!")
return
}
d, _ := strconv.Atoi(dur)
i, _ := strconv.ParseFloat(interval, 64)
mod, _ := strconv.Atoi(r.FormValue("mod"))
adoptable := r.FormValue("ado") == "1"
encrypted := r.FormValue("e2e") == "1"
salt := r.FormValue("salt")
customLink := r.FormValue("lid")
nickname := r.FormValue("nic")
pin, _ := strconv.Atoi(r.FormValue("pin"))
if d > s.cfg.MaxDuration {
fmt.Fprintln(w, "Share duration exceeds maximum configured!")
return
}
if i > float64(s.cfg.MaxDuration) {
fmt.Fprintln(w, "Interval exceeds maximum configured!")
return
}
if i < s.cfg.MinInterval {
fmt.Fprintln(w, "Interval is too short!")
return
}
if (mod == shareModeCreateGroup || mod == shareModeJoinGroup) && encrypted {
fmt.Fprintln(w, "End-to-end encryption is not supported for group shares.")
return
}
if (mod == shareModeCreateGroup || mod == shareModeJoinGroup) && nickname == "" {
fmt.Fprintln(w, "Missing data!")
return
}
if mod == shareModeJoinGroup && pin == 0 {
fmt.Fprintln(w, "Missing data!")
return
}
if encrypted && salt == "" {
fmt.Fprintln(w, "Missing data!")
return
}
expire := time.Now().Add(time.Duration(d) * time.Second)
session, err := model.NewSession(s.store, s.cfg.MaxCachedPts)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
session.SetExpire(expire)
session.SetInterval(i)
if encrypted {
session.SetEncrypted(true, salt)
}
linkGen := func() (string, error) {
return s.linkgen.Generate(ctx)
}
switch mod {
case shareModeCreateAlone:
share, err := model.NewSoloShare(s.store, s.cfg.PublicURL, linkGen)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if customLink != "" && linkIDRe.MatchString(customLink) {
if err := s.tryCustomLink(ctx, share, customLink); err == nil {
share.SetID(customLink)
}
}
share.SetAdoptable(adoptable)
share.SetHost(session.ID())
share.SetExpire(expire)
if err := share.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
session.AddTarget(share.ID())
if err := session.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "OK")
fmt.Fprintln(w, session.ID())
fmt.Fprintln(w, share.ViewLink())
fmt.Fprintln(w, share.ID())
case shareModeCreateGroup:
share, err := model.NewGroupShare(s.store, s.cfg.PublicURL, linkGen)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if customLink != "" && linkIDRe.MatchString(customLink) {
if err := s.tryCustomLink(ctx, share, customLink); err == nil {
share.SetID(customLink)
}
}
share.AddHost(nickname, session.ID())
share.SetExpire(expire)
if err := share.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
session.AddTarget(share.ID())
if err := session.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "OK")
fmt.Fprintln(w, session.ID())
fmt.Fprintln(w, share.ViewLink())
fmt.Fprintln(w, share.Pin())
fmt.Fprintln(w, share.ID())
case shareModeJoinGroup:
share, err := model.LoadGroupShareByPin(ctx, s.store, pin, s.cfg.PublicURL)
if err != nil {
fmt.Fprintln(w, "Invalid group PIN!")
return
}
share.AddHost(nickname, session.ID())
if err := share.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
session.AddTarget(share.ID())
if err := session.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "OK")
fmt.Fprintln(w, session.ID())
fmt.Fprintln(w, share.ViewLink())
fmt.Fprintln(w, share.ID())
default:
fmt.Fprintln(w, "Unsupported share mode!")
}
}
type customLinkSetter interface {
SetID(string)
}
func (s *Server) tryCustomLink(ctx context.Context, _ customLinkSetter, link string) error {
exists, err := s.store.Exists(ctx, "locdata-"+link)
if err != nil {
return err
}
if exists {
return fmt.Errorf("link already exists")
}
return nil
}

50
api/dynamic.go Normal file
View File

@@ -0,0 +1,50 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/parkan/go-hauk/config"
)
type velocityUnit struct {
MpsMultiplier float64 `json:"mpsMultiplier"`
Unit string `json:"unit"`
}
func (s *Server) handleDynamic(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
var velUnit velocityUnit
switch s.cfg.VelocityUnit {
case config.MilesPerHour:
velUnit = velocityUnit{MpsMultiplier: 3.6 * 0.6213712, Unit: "mph"}
case config.MetersPerSecond:
velUnit = velocityUnit{MpsMultiplier: 1, Unit: "m/s"}
default:
velUnit = velocityUnit{MpsMultiplier: 3.6, Unit: "km/h"}
}
tileURI, _ := json.Marshal(s.cfg.MapTileURI)
attribution, _ := json.Marshal(s.cfg.MapAttribution)
defaultZoom, _ := json.Marshal(s.cfg.DefaultZoom)
maxZoom, _ := json.Marshal(s.cfg.MaxZoom)
maxPoints, _ := json.Marshal(s.cfg.MaxShownPts)
velDelta, _ := json.Marshal(s.cfg.VelocityDataPts)
trailColor, _ := json.Marshal(s.cfg.TrailColor)
velUnitJSON, _ := json.Marshal(velUnit)
offlineTimeout, _ := json.Marshal(s.cfg.OfflineTimeout)
requestTimeout, _ := json.Marshal(s.cfg.RequestTimeout)
fmt.Fprintf(w, "var TILE_URI = %s;\n", tileURI)
fmt.Fprintf(w, "var ATTRIBUTION = %s;\n", attribution)
fmt.Fprintf(w, "var DEFAULT_ZOOM = %s;\n", defaultZoom)
fmt.Fprintf(w, "var MAX_ZOOM = %s;\n", maxZoom)
fmt.Fprintf(w, "var MAX_POINTS = %s;\n", maxPoints)
fmt.Fprintf(w, "var VELOCITY_DELTA_TIME = %s;\n", velDelta)
fmt.Fprintf(w, "var TRAIL_COLOR = %s;\n", trailColor)
fmt.Fprintf(w, "var VELOCITY_UNIT = %s;\n", velUnitJSON)
fmt.Fprintf(w, "var OFFLINE_TIMEOUT = %s;\n", offlineTimeout)
fmt.Fprintf(w, "var REQUEST_TIMEOUT = %s;\n", requestTimeout)
}

108
api/fetch.go Normal file
View File

@@ -0,0 +1,108 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/parkan/go-hauk/model"
)
type soloResponse struct {
Type int `json:"type"`
Expire int64 `json:"expire"`
ServerTime float64 `json:"serverTime"`
Interval float64 `json:"interval"`
Points [][]any `json:"points"`
Encrypted bool `json:"encrypted"`
Salt string `json:"salt"`
}
type groupResponse struct {
Type int `json:"type"`
Expire int64 `json:"expire"`
ServerTime float64 `json:"serverTime"`
Interval float64 `json:"interval"`
Points map[string][][]any `json:"points"`
}
func (s *Server) handleFetch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.URL.Query().Get("id")
if id == "" {
fmt.Fprintln(w, "Invalid session!")
return
}
sinceStr := r.URL.Query().Get("since")
since := float64(0)
if sinceStr != "" {
since, _ = strconv.ParseFloat(sinceStr, 64)
}
shareType, err := model.LoadShareType(ctx, s.store, id)
if err != nil {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, "Invalid session!")
return
}
w.Header().Set("Content-Type", "text/json")
now := float64(time.Now().UnixNano()) / 1e9
switch shareType {
case model.ShareTypeAlone:
share, err := model.LoadSoloShare(ctx, s.store, id, s.cfg.PublicURL)
if err != nil {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, "Invalid session!")
return
}
session, err := model.LoadSession(ctx, s.store, share.Host(), s.cfg.MaxCachedPts)
if err != nil {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, "Invalid session!")
return
}
resp := soloResponse{
Type: share.Type(),
Expire: share.Expire().Unix(),
ServerTime: now,
Interval: session.Interval(),
Points: session.GetPoints(since),
Encrypted: session.Encrypted(),
Salt: session.Salt(),
}
if resp.Points == nil {
resp.Points = [][]any{}
}
json.NewEncoder(w).Encode(resp)
case model.ShareTypeGroup:
share, err := model.LoadGroupShare(ctx, s.store, id, s.cfg.PublicURL)
if err != nil {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, "Invalid session!")
return
}
points, _ := share.GetAllPoints(ctx, since, s.cfg.MaxCachedPts)
if points == nil {
points = make(map[string][][]any)
}
resp := groupResponse{
Type: share.Type(),
Expire: share.Expire().Unix(),
ServerTime: now,
Interval: share.GetAutoInterval(ctx, s.cfg.MaxCachedPts),
Points: points,
}
json.NewEncoder(w).Encode(resp)
}
}

60
api/newlink.go Normal file
View File

@@ -0,0 +1,60 @@
package api
import (
"fmt"
"net/http"
"github.com/parkan/go-hauk/model"
)
func (s *Server) handleNewLink(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sid := r.FormValue("sid")
adoptable := r.FormValue("ado") == "1"
if sid == "" {
fmt.Fprintln(w, "Missing data!")
return
}
session, err := model.LoadSession(ctx, s.store, sid, s.cfg.MaxCachedPts)
if err != nil {
fmt.Fprintln(w, "Session expired!")
return
}
linkGen := func() (string, error) {
return s.linkgen.Generate(ctx)
}
share, err := model.NewSoloShare(s.store, s.cfg.PublicURL, linkGen)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
share.SetAdoptable(adoptable)
share.SetHost(session.ID())
share.SetExpire(session.Expire())
if err := share.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
session.AddTarget(share.ID())
if err := session.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "OK")
fmt.Fprintln(w, share.ViewLink())
fmt.Fprintln(w, share.ID())
}

99
api/post.go Normal file
View File

@@ -0,0 +1,99 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/parkan/go-hauk/model"
)
func (s *Server) handlePost(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sid := r.FormValue("sid")
lat := r.FormValue("lat")
lon := r.FormValue("lon")
ts := r.FormValue("time")
if sid == "" || lat == "" || lon == "" || ts == "" {
fmt.Fprintln(w, "Missing data!")
return
}
session, err := model.LoadSession(ctx, s.store, sid, s.cfg.MaxCachedPts)
if err != nil {
fmt.Fprintln(w, "Session expired!")
return
}
var point []any
if !session.Encrypted() {
latF, _ := strconv.ParseFloat(lat, 64)
lonF, _ := strconv.ParseFloat(lon, 64)
timeF, _ := strconv.ParseFloat(ts, 64)
if latF < -90 || latF > 90 || lonF < -180 || lonF > 180 {
fmt.Fprintln(w, "Invalid location!")
return
}
var speed, acc *float64
if spd := r.FormValue("spd"); spd != "" {
v, _ := strconv.ParseFloat(spd, 64)
speed = &v
}
if a := r.FormValue("acc"); a != "" {
v, _ := strconv.ParseFloat(a, 64)
acc = &v
}
prv := 0
if r.FormValue("prv") == "1" {
prv = 1
}
point = []any{latF, lonF, timeF, prv, acc, speed}
} else {
iv := r.FormValue("iv")
if iv == "" {
fmt.Fprintln(w, "Missing data!")
return
}
var speed, acc, prv any
if spd := r.FormValue("spd"); spd != "" {
speed = spd
}
if a := r.FormValue("acc"); a != "" {
acc = a
}
if p := r.FormValue("prv"); p != "" {
prv = p
}
point = []any{iv, lat, lon, ts, prv, acc, speed}
}
session.AddPoint(point)
if err := session.Save(ctx); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if session.HasExpired() {
fmt.Fprintln(w, "Session expired!")
return
}
fmt.Fprintln(w, "OK")
fmt.Fprintf(w, "%s?%%s\n", s.cfg.PublicURL)
fmt.Fprintln(w, strings.Join(session.Targets(), ","))
}

61
api/server.go Normal file
View File

@@ -0,0 +1,61 @@
package api
import (
"io/fs"
"net/http"
"github.com/parkan/go-hauk/auth"
"github.com/parkan/go-hauk/config"
"github.com/parkan/go-hauk/frontend"
"github.com/parkan/go-hauk/linkgen"
"github.com/parkan/go-hauk/store"
)
const backendVersion = "1.6.2-go"
type Server struct {
mux *http.ServeMux
cfg *config.Config
store store.Store
auth auth.Authenticator
linkgen *linkgen.Generator
}
func NewServer(cfg *config.Config, s store.Store) *Server {
srv := &Server{
mux: http.NewServeMux(),
cfg: cfg,
store: s,
linkgen: linkgen.New(s, cfg.LinkStyle),
}
switch cfg.AuthMethod {
case config.AuthHtpasswd:
srv.auth = auth.NewHtpasswdAuth(cfg.HtpasswdPath)
case config.AuthLDAP:
srv.auth = auth.NewLDAPAuth(
cfg.LDAPUri, cfg.LDAPBaseDN, cfg.LDAPBindDN,
cfg.LDAPBindPass, cfg.LDAPUserFilter, cfg.LDAPStartTLS,
)
default:
srv.auth = auth.NewPasswordAuth(cfg.PasswordHash)
}
srv.mux.HandleFunc("POST /api/create.php", srv.handleCreate)
srv.mux.HandleFunc("POST /api/post.php", srv.handlePost)
srv.mux.HandleFunc("GET /api/fetch.php", srv.handleFetch)
srv.mux.HandleFunc("POST /api/stop.php", srv.handleStop)
srv.mux.HandleFunc("POST /api/adopt.php", srv.handleAdopt)
srv.mux.HandleFunc("POST /api/new-link.php", srv.handleNewLink)
srv.mux.HandleFunc("GET /dynamic.js.php", srv.handleDynamic)
staticFS, _ := fs.Sub(frontend.Files, ".")
srv.mux.Handle("/", http.FileServer(http.FS(staticFS)))
return srv
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Hauk-Version", backendVersion)
s.mux.ServeHTTP(w, r)
}

96
api/stop.go Normal file
View File

@@ -0,0 +1,96 @@
package api
import (
"fmt"
"net/http"
"github.com/parkan/go-hauk/model"
)
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sid := r.FormValue("sid")
if sid == "" {
fmt.Fprintln(w, "OK")
return
}
lid := r.FormValue("lid")
session, err := model.LoadSession(ctx, s.store, sid, s.cfg.MaxCachedPts)
if err != nil {
fmt.Fprintln(w, "OK")
return
}
if lid != "" {
found := false
for _, t := range session.Targets() {
if t == lid {
found = true
break
}
}
if !found {
fmt.Fprintln(w, "OK")
return
}
shareType, err := model.LoadShareType(ctx, s.store, lid)
if err == nil {
switch shareType {
case model.ShareTypeAlone:
share, err := model.LoadSoloShare(ctx, s.store, lid, s.cfg.PublicURL)
if err == nil {
share.Delete(ctx)
}
case model.ShareTypeGroup:
share, err := model.LoadGroupShare(ctx, s.store, lid, s.cfg.PublicURL)
if err == nil {
share.RemoveHost(sid)
if len(share.Hosts()) == 0 {
share.Delete(ctx)
} else {
share.Save(ctx)
}
}
}
}
session.RemoveTarget(lid)
session.Save(ctx)
} else {
for _, t := range session.Targets() {
shareType, err := model.LoadShareType(ctx, s.store, t)
if err != nil {
continue
}
switch shareType {
case model.ShareTypeAlone:
share, err := model.LoadSoloShare(ctx, s.store, t, s.cfg.PublicURL)
if err == nil {
share.Delete(ctx)
}
case model.ShareTypeGroup:
share, err := model.LoadGroupShare(ctx, s.store, t, s.cfg.PublicURL)
if err == nil {
share.RemoveHost(sid)
if len(share.Hosts()) == 0 {
share.Delete(ctx)
} else {
share.Save(ctx)
}
}
}
}
session.Delete(ctx)
}
fmt.Fprintln(w, "OK")
}