diff --git a/api/adopt.go b/api/adopt.go new file mode 100644 index 0000000..9b97373 --- /dev/null +++ b/api/adopt.go @@ -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") +} diff --git a/api/create.go b/api/create.go new file mode 100644 index 0000000..8864043 --- /dev/null +++ b/api/create.go @@ -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 +} diff --git a/api/dynamic.go b/api/dynamic.go new file mode 100644 index 0000000..1525f30 --- /dev/null +++ b/api/dynamic.go @@ -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) +} diff --git a/api/fetch.go b/api/fetch.go new file mode 100644 index 0000000..4d210ce --- /dev/null +++ b/api/fetch.go @@ -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) + } +} diff --git a/api/newlink.go b/api/newlink.go new file mode 100644 index 0000000..7904b21 --- /dev/null +++ b/api/newlink.go @@ -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()) +} diff --git a/api/post.go b/api/post.go new file mode 100644 index 0000000..9338308 --- /dev/null +++ b/api/post.go @@ -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(), ",")) +} diff --git a/api/server.go b/api/server.go new file mode 100644 index 0000000..818a433 --- /dev/null +++ b/api/server.go @@ -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) +} diff --git a/api/stop.go b/api/stop.go new file mode 100644 index 0000000..903bbc9 --- /dev/null +++ b/api/stop.go @@ -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") +}