From 888834093351a6182b0b3dd57b6bce15a6fb0e92 Mon Sep 17 00:00:00 2001 From: diogo464 Date: Fri, 4 Oct 2024 18:49:11 +0100 Subject: rework and added pictures --- .gitignore | 5 + Containerfile | 1 + app.go | 411 +++++++++++++++++++++++++++++++++++++++++++++++++ content/editor.html | 72 +++++++++ content/index.html | 72 ++------- content/logger.html | 45 +++--- content/shapes.html | 72 --------- content/static/main.js | 71 ++++++++- exif.go | 45 ++++++ magick.go | 16 ++ main.go | 277 ++------------------------------- 11 files changed, 664 insertions(+), 423 deletions(-) create mode 100644 app.go create mode 100644 content/editor.html delete mode 100644 content/shapes.html create mode 100644 exif.go create mode 100644 magick.go diff --git a/.gitignore b/.gitignore index 2aa56f0..e9ad444 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ *.db belverde-fire +pictures/ +*.png +*.HEIC +*.jpg +*.jpeg diff --git a/Containerfile b/Containerfile index 5b51ca6..4c4c5a7 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,5 @@ FROM debian:bookworm-slim +RUN apt update && apt install -y sqlite3 imagemagick exiftool && apt-get clean WORKDIR /app/data COPY belverde-fire /app/belverde-fire ENTRYPOINT ["/app/belverde-fire"] diff --git a/app.go b/app.go new file mode 100644 index 0000000..ab28fbb --- /dev/null +++ b/app.go @@ -0,0 +1,411 @@ +package main + +import ( + "context" + "database/sql" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "path" + "strings" + "sync" + "time" + + "github.com/pkg/errors" +) + +const PicturesFormat string = ".png" + +type AppConfig struct { + HttpUsername string + HttpPassword string + + PicturesDirectory string +} + +type App struct { + db *sql.DB + contentFs fs.FS + config *AppConfig + + // access by multiple goroutines. requires lock + pictures []Picture + picturesLock sync.Mutex + + // accessed by a single goroutine, no lock required + pictureFiles map[string]*PictureFile +} + +type LocationMarker struct { + Timestamp float64 `json:"timestamp"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Accuracy float64 `json:"accuracy"` + Heading float64 `json:"heading"` +} + +type ShapePoint struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +const ( + ShapeKindUnburned string = "unburned" + ShapeKindBurned string = "burned" +) + +type Shape struct { + Kind string `json:"kind"` + Points []ShapePoint `json:"points"` +} + +type Picture struct { + Filename string `json:"filename"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +type PictureFile struct { + Filename string + Latitude float64 + Longitude float64 + LastModTime time.Time +} + +//go:embed content/* +var appContentFs embed.FS + +//go:embed schema.sql +var appDbSchema string + +func NewApp(config *AppConfig) (*App, error) { + db, err := sql.Open("sqlite3", "file:database.db") + if err != nil { + return nil, errors.Wrapf(err, "opening sqlite database") + } + + if _, err := db.Exec(appDbSchema); err != nil { + return nil, errors.Wrapf(err, "executing db schema") + } + + contentFs, err := fs.Sub(appContentFs, "content") + if err != nil { + return nil, err + } + + if err := os.MkdirAll(config.PicturesDirectory, 0o755); err != nil { + return nil, errors.Wrapf(err, "creating pictures directory") + } + + a := &App{ + db: db, + contentFs: contentFs, + config: config, + pictures: []Picture{}, + pictureFiles: make(map[string]*PictureFile), + } + + go func() { + for { + a.updatePictures() + time.Sleep(time.Minute) + } + }() + + return a, nil +} + +func (a *App) Close() { + a.db.Close() +} + +func (a *App) Mux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/location", a.requireBasicAuth(a.handleGetLocationMarkers)) + mux.HandleFunc("POST /api/location", a.requireBasicAuth(a.handleCreateLocationMarker)) + mux.HandleFunc("GET /api/shapes", a.handleGetShapes) + mux.HandleFunc("POST /api/shapes", a.requireBasicAuth(a.handleUpdateShapes)) + mux.HandleFunc("GET /api/pictures", a.handleGetPictures) + mux.Handle("GET /static/", http.FileServerFS(a.contentFs)) + mux.HandleFunc("GET /picture/{filename}", a.handleServePicture) + mux.HandleFunc("GET /logger", a.requireBasicAuth(a.serveContentFile("logger.html"))) + mux.HandleFunc("GET /editor", a.requireBasicAuth(a.serveContentFile("editor.html"))) + mux.HandleFunc("GET /", a.serveContentFile("index.html")) + return mux +} + +func (a *App) writeJson(w http.ResponseWriter, v any) { + body, err := json.Marshal(v) + if err != nil { + http.Error(w, "failed to encoded response as json", http.StatusInternalServerError) + return + } + w.Header().Add("Content-Type", "application/json") + w.Write(body) +} + +func (a *App) requireBasicAuth(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + required_username := a.config.HttpUsername + required_password := a.config.HttpPassword + if !ok || username != required_username || password != required_password { + w.Header().Add("WWW-Authenticate", "Basic realm=\"User Visible Realm\"") + http.Error(w, "invalid authentication", http.StatusUnauthorized) + return + } + h(w, r) + } +} + +func (a *App) serveContentFile(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, a.contentFs, path) + } +} + +func (a *App) loadLocationMarkers(ctx context.Context) ([]LocationMarker, error) { + rows, err := a.db.QueryContext(ctx, "SELECT * FROM location") + if err != nil { + return nil, errors.Wrapf(err, "fetching all locations") + } + + locations := []LocationMarker{} + for rows.Next() { + loc := LocationMarker{} + if err := rows.Scan(&loc.Timestamp, &loc.Latitude, &loc.Longitude, &loc.Accuracy, &loc.Heading); err != nil { + return nil, errors.Wrapf(err, "failed to scan location row") + } + locations = append(locations, loc) + } + + return locations, nil +} + +func (a *App) appendLocationMarker(ctx context.Context, loc LocationMarker) error { + if _, err := a.db.ExecContext(ctx, "INSERT INTO location(timestamp, latitude, longitude, accuracy, heading) VALUES (?, ?, ?, ?, ?)", loc.Timestamp, loc.Latitude, loc.Longitude, loc.Accuracy, loc.Heading); err != nil { + return errors.Wrapf(err, "failed to insert location log") + } + return nil +} + +func (a *App) updateShapes(ctx context.Context, shapes []Shape) error { + tx, err := a.db.Begin() + if err != nil { + return errors.Wrapf(err, "starting database transaction") + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, "DELETE FROM shape"); err != nil { + return errors.Wrapf(err, "failed to truncate shapes table") + } + if _, err := tx.ExecContext(ctx, "DELETE FROM shape_point"); err != nil { + return errors.Wrapf(err, "faile to truncate shape points tables") + } + + for shapeId, shape := range shapes { + if _, err := tx.ExecContext(ctx, "INSERT INTO shape(id, kind) VALUES (?, ?)", shapeId, shape.Kind); err != nil { + return errors.Wrapf(err, "failed to insert shape") + } + + for _, point := range shape.Points { + if _, err := tx.ExecContext(ctx, "INSERT INTO shape_point(shape, latitude, longitude) VALUES (?, ?, ?)", shapeId, point.Latitude, point.Longitude); err != nil { + return errors.Wrapf(err, "failed to insert shape point") + } + } + } + + return tx.Commit() +} + +func (a *App) loadShapes(ctx context.Context) ([]Shape, error) { + shapes := []Shape{} + srows, err := a.db.QueryContext(ctx, "SELECT id, kind FROM shape") + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch shapes") + } + + for srows.Next() { + var id int + var kind string + if err := srows.Scan(&id, &kind); err != nil { + return nil, errors.Wrapf(err, "failed to scan shape") + } + + points := []ShapePoint{} + prows, err := a.db.QueryContext(ctx, "SELECT latitude, longitude FROM shape_point WHERE shape = ?", id) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch shape points") + } + + for prows.Next() { + var lat float64 + var lon float64 + if err := prows.Scan(&lat, &lon); err != nil { + return nil, errors.Wrapf(err, "failed to scan shape point") + } + + points = append(points, ShapePoint{ + Latitude: lat, + Longitude: lon, + }) + } + + shapes = append(shapes, Shape{ + Kind: kind, + Points: points, + }) + } + + return shapes, nil +} + +func (a *App) handleCreateLocationMarker(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("failed to read request body: %v\n", err) + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + l := LocationMarker{} + if err := json.Unmarshal(body, &l); err != nil { + log.Printf("failed to parse request body: %v\n", err) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + log.Printf("storing %v\n", l) + if err := a.appendLocationMarker(r.Context(), l); err != nil { + log.Printf("%v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } +} + +func (a *App) handleGetLocationMarkers(w http.ResponseWriter, r *http.Request) { + locations, err := a.loadLocationMarkers(r.Context()) + if err != nil { + log.Printf("internal error: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + a.writeJson(w, locations) +} + +func (a *App) handleGetShapes(w http.ResponseWriter, r *http.Request) { + shapes, err := a.loadShapes(r.Context()) + if err != nil { + http.Error(w, fmt.Sprintf("failed to fetch shapes: %v", err), http.StatusInternalServerError) + return + } + a.writeJson(w, shapes) +} + +func (a *App) handleUpdateShapes(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("failed to read request body: %v\n", err) + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + shapes := []Shape{} + if err := json.Unmarshal(body, &shapes); err != nil { + http.Error(w, fmt.Sprintf("failed to unmarshal request body: %v", err), http.StatusBadRequest) + return + } + + if err := a.updateShapes(r.Context(), shapes); err != nil { + http.Error(w, fmt.Sprintf("failed to store shapes: %v", err), http.StatusInternalServerError) + return + } +} + +func (a *App) updatePictures() { + entries, err := os.ReadDir(a.config.PicturesDirectory) + if err != nil { + log.Fatalf("failed to read pictures directory: %v\n", err) + } + + for _, entry := range entries { + // ignore non files + if !entry.Type().IsRegular() { + continue + } + + // convert if required + filePath := path.Join(a.config.PicturesDirectory, entry.Name()) + fileExt := path.Ext(entry.Name()) + if fileExt != PicturesFormat { + filePathNew := strings.TrimSuffix(filePath, fileExt) + PicturesFormat + if _, err := os.Stat(filePathNew); err == nil { + continue + } + if err := magickConvert(filePath, filePathNew); err != nil { + log.Printf("failed to convert image: %v", err) + } + } + + // process file + info, err := os.Stat(filePath) + if err != nil { + log.Printf("failed to stat %v: %v\n", filePath, err) + continue + } + if pfile, ok := a.pictureFiles[filePath]; !ok || info.ModTime().After(pfile.LastModTime) { + exifData, err := exiftool(filePath) + if err != nil { + log.Printf("failed to extract exif data from %v: %v\n", filePath, err) + continue + } + + pictureFile := &PictureFile{ + Filename: entry.Name(), + Latitude: exifData.Latitude, + Longitude: exifData.Longitude, + LastModTime: info.ModTime(), + } + a.pictureFiles[filePath] = pictureFile + } + } + + a.picturesLock.Lock() + defer a.picturesLock.Unlock() + a.pictures = make([]Picture, 0, len(a.pictureFiles)) + for _, pfile := range a.pictureFiles { + a.pictures = append(a.pictures, Picture{ + Filename: pfile.Filename, + Latitude: pfile.Latitude, + Longitude: pfile.Longitude, + }) + } +} + +func (a *App) handleGetPictures(w http.ResponseWriter, r *http.Request) { + a.picturesLock.Lock() + response, err := json.Marshal(a.pictures) + a.picturesLock.Unlock() + if err != nil { + http.Error(w, "failed to marshal pictures", http.StatusInternalServerError) + return + } + w.Header().Add("Content-Type", "application/json") + w.Write(response) +} + +func (a *App) handleServePicture(w http.ResponseWriter, r *http.Request) { + filename := path.Base(r.PathValue("filename")) + if !strings.HasSuffix(filename, PicturesFormat) { + http.Error(w, "picture not found", http.StatusNotFound) + return + } + + filepath := path.Join(a.config.PicturesDirectory, filename) + http.ServeFile(w, r, filepath) +} diff --git a/content/editor.html b/content/editor.html new file mode 100644 index 0000000..1bb4588 --- /dev/null +++ b/content/editor.html @@ -0,0 +1,72 @@ + + + + + + + Edit points + + + + + + +
+
+ + + + + + +
+
+
+ + + + + + + diff --git a/content/index.html b/content/index.html index 26ce206..9913dbb 100644 --- a/content/index.html +++ b/content/index.html @@ -25,69 +25,27 @@ height: 100vh; } - img { - max-height: 100%; - max-width: 100%; + #image-frame { + display: flex; + justify-content: center; + width: 100vw; + height: 100vh; + position: fixed; + z-index: 400; + top: 0; + left: 0; + background: rgba(0, 0, 0, 0.5); } - .image-holder { - height: 300px; - width: 300px; + #image-frame>img { + display: block; + margin: auto; + max-height: 90vh; + max-width: 90vw; } diff --git a/content/logger.html b/content/logger.html index 1a6967e..4c0700a 100644 --- a/content/logger.html +++ b/content/logger.html @@ -27,6 +27,10 @@ Longitude + + Accuracy + + Heading @@ -46,7 +50,7 @@ accuracy: 0, timestamp: 0, heading: 0, - permissions: false, + setup: false, inprogress: false, }; @@ -77,55 +81,52 @@ update_ui(); } - function request_permissions() { + function logger_setup() { if (DeviceOrientationEvent.requestPermission) { DeviceOrientationEvent.requestPermission().then(() => { window.addEventListener("deviceorientation", (event) => { logger_state.heading = event.webkitCompassHeading; update_ui(); }); - logger_state.permissions = true; log(); }).catch((err) => { alert(`failed to get device orientation permissions: ${err}`) }) } - } - - function log() { - if (logger_state.inprogress) - return; - - logger_state.latitude = null; - logger_state.longitude = null; - logger_state.accuracy = null; - logger_state.timestamp = null; - update_ui(); - - if (!logger_state.permissions) { - request_permissions(); - return; - } - navigator.geolocation.getCurrentPosition((position) => { + navigator.geolocation.watchPosition((position) => { console.log(position); logger_state.latitude = position.coords.latitude; logger_state.longitude = position.coords.longitude; logger_state.accuracy = position.coords.accuracy; logger_state.timestamp = position.timestamp; update_ui(); - log_post(); }, (err) => { alert(`failed to get position: ${err}`); - }); + }, {enableHighAccuracy: true}); + + + logger_state.setup = true; + } + + function log() { + if (logger_state.inprogress) + return; + + if (!logger_state.setup) { + logger_setup(); + return; + } logger_state.inprogress = true; + log_post(); update_ui(); } function update_ui() { set_table_value("value-latitude", logger_state.latitude); set_table_value("value-longitude", logger_state.longitude); + set_table_value("value-accuracy", logger_state.accuracy); set_table_value("value-heading", logger_state.heading); const progress_display = logger_state.inprogress ? "block" : "none"; diff --git a/content/shapes.html b/content/shapes.html deleted file mode 100644 index 1bb4588..0000000 --- a/content/shapes.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - Edit points - - - - - - -
-
- - - - - - -
-
-
- - - - - - - diff --git a/content/static/main.js b/content/static/main.js index aa8759b..d8debae 100644 --- a/content/static/main.js +++ b/content/static/main.js @@ -14,7 +14,7 @@ const SHAPE_KIND_BURNED = "burned"; /** * A location log - * @typedef {Object} LocationLog + * @typedef {Object} LocationMarker * @property {number} timestamp * @property {number} latitude * @property {number} longitude @@ -36,6 +36,14 @@ const SHAPE_KIND_BURNED = "burned"; * @property {[]ShapePoint} points */ +/** + * A picture descriptor + * @typedef {Object} PictureDescriptor + * @property {string} filename + * @property {number} latitude + * @property {number} longitude +*/ + /** * A shape * @typedef {Object} Shape @@ -51,6 +59,7 @@ function lib_setup_handler_onclick(elementId, handler) { function lib_setup_map() { var map = L.map(ELEM_ID_MAP).setView(DEFAULT_COORDINATES, DEFAULT_ZOOM); + L.Icon.Default.imagePath = "/static/"; L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' @@ -60,7 +69,7 @@ function lib_setup_map() { /** * Fetch location logs - * @return {Promise} + * @return {Promise} */ async function lib_fetch_location_logs() { // const burned = [ @@ -96,7 +105,7 @@ async function lib_fetch_location_logs() { } /** - * Fetch location logs + * Fetch shape descriptors * @return {Promise} */ async function lib_fetch_shape_descriptors() { @@ -104,6 +113,20 @@ async function lib_fetch_shape_descriptors() { return response.json(); } +/** + * Fetch picture descriptors + * @return {Promise} +*/ +async function lib_fetch_picture_descriptors() { + const response = await fetch("/api/pictures"); + return response.json(); +} + +function lib_picture_descriptor_url(picture_descriptor) { + const picture_url = `/picture/${picture_descriptor.filename}` + return picture_url; +} + function lib_add_location_logs_to_map(map, locations) { for (const location of locations) { const len = 0.0002; @@ -335,7 +358,7 @@ async function page_shape__main() { } } -function page_main__poly_create_from_shape_descriptor(map, shape_descriptor) { +function page_index__poly_create_from_shape_descriptor(map, shape_descriptor) { const color = lib_shape_color_for_kind(shape_descriptor.kind); const points = [] for (const point of shape_descriptor.points) { @@ -344,10 +367,46 @@ function page_main__poly_create_from_shape_descriptor(map, shape_descriptor) { L.polygon(points, { color: color }).addTo(map); } +function page_index__create_image_popup(picture_descriptor) { + const e = document.getElementById("image-frame"); + if (e != null) + e.remove(); + + const d = document.createElement("div"); + d.id = "image-frame"; + const i = document.createElement("img"); + i.src = lib_picture_descriptor_url(picture_descriptor); + + d.onclick = () => { + d.remove(); + }; + + d.appendChild(i); + document.body.appendChild(d); +} + +function page_index__add_picture_descriptor_to_map(map, picture_descriptor) { + L.marker([picture_descriptor.latitude, picture_descriptor.longitude]) + .on('click', () => { + page_index__create_image_popup(picture_descriptor); + }) + .addTo(map) +} + async function page_index__main() { const map = lib_setup_map(); - const shape_descriptors = await lib_fetch_shape_descriptors(); + const [shape_descriptors, picture_descriptors] = await Promise.all([ + lib_fetch_shape_descriptors(), + lib_fetch_picture_descriptors(), + ]); for (const descriptor of shape_descriptors) { - page_main__poly_create_from_shape_descriptor(map, descriptor); + page_index__poly_create_from_shape_descriptor(map, descriptor); + } + for (const descriptor of picture_descriptors) { + page_index__add_picture_descriptor_to_map(map, descriptor); } + + setTimeout(() => { + console.log("create div"); + }, 1000); } diff --git a/exif.go b/exif.go new file mode 100644 index 0000000..1684d28 --- /dev/null +++ b/exif.go @@ -0,0 +1,45 @@ +package main + +import ( + "encoding/json" + "os/exec" + "strconv" + + "github.com/pkg/errors" +) + +type ExifData struct { + Latitude float64 + Longitude float64 +} + +func exiftool(path string) (*ExifData, error) { + cmd := exec.Command("exiftool", "-c", "%+.24f", "-j", path) + output, err := cmd.Output() + if err != nil { + return nil, errors.Wrapf(err, "running exiftool") + } + + type Output struct { + GPSLatitude string + GPSLongitude string + } + + os := []Output{} + if err := json.Unmarshal(output, &os); err != nil || len(os) != 1 { + return nil, errors.Wrapf(err, "parsing exiftool output") + } + o := os[0] + + latitude, err := strconv.ParseFloat(o.GPSLatitude, 64) + if err != nil { + return nil, errors.Wrapf(err, "parsing latitude '%v'", o.GPSLatitude) + } + + longitude, err := strconv.ParseFloat(o.GPSLongitude, 64) + if err != nil { + return nil, errors.Wrapf(err, "parsing longitude '%v'", o.GPSLongitude) + } + + return &ExifData{Latitude: latitude, Longitude: longitude}, nil +} diff --git a/magick.go b/magick.go new file mode 100644 index 0000000..9c4182c --- /dev/null +++ b/magick.go @@ -0,0 +1,16 @@ +package main + +import ( + "os/exec" + + "github.com/pkg/errors" +) + +func magickConvert(src string, dst string) error { + cmd := exec.Command("convert", src, dst) + err := cmd.Run() + if err != nil { + return errors.Wrapf(err, "running convert '%v' '%v'", src, dst) + } + return nil +} diff --git a/main.go b/main.go index 2adaa21..c99a8b8 100644 --- a/main.go +++ b/main.go @@ -1,22 +1,13 @@ package main import ( - "context" - "database/sql" - "embed" - "encoding/json" - "fmt" - "io" - "io/fs" "log" "net/http" "os" - "strings" _ "embed" _ "github.com/mattn/go-sqlite3" - "github.com/pkg/errors" ) const ( @@ -24,270 +15,24 @@ const ( ENV_VAR_HTTP_PASSWORD = "HTTP_PASSWORD" ) -type LocationLog struct { - Timestamp float64 `json:"timestamp"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Accuracy float64 `json:"accuracy"` - Heading float64 `json:"heading"` -} - -type ShapePoint struct { - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` -} - -const ( - ShapeKindUnburned string = "unburned" - ShapeKindBurned string = "burned" -) - -type Shape struct { - Kind string `json:"kind"` - Points []ShapePoint `json:"points"` -} - -var db *sql.DB - -func dbLoadLocationLogs(ctx context.Context) ([]LocationLog, error) { - rows, err := db.QueryContext(ctx, "SELECT * FROM location") - if err != nil { - return nil, errors.Wrapf(err, "fetching all locations") - } - - locations := []LocationLog{} - for rows.Next() { - loc := LocationLog{} - if err := rows.Scan(&loc.Timestamp, &loc.Latitude, &loc.Longitude, &loc.Accuracy, &loc.Heading); err != nil { - return nil, errors.Wrapf(err, "failed to scan location row") - } - locations = append(locations, loc) - } - - return locations, nil -} - -func dbAppendLocationLog(ctx context.Context, loc LocationLog) error { - if _, err := db.ExecContext(ctx, "INSERT INTO location(timestamp, latitude, longitude, accuracy, heading) VALUES (?, ?, ?, ?, ?)", loc.Timestamp, loc.Latitude, loc.Longitude, loc.Accuracy, loc.Heading); err != nil { - return errors.Wrapf(err, "failed to insert location log") - } - return nil -} - -func dbSetShapes(ctx context.Context, shapes []Shape) error { - tx, err := db.Begin() - if err != nil { - return errors.Wrapf(err, "starting database transaction") - } - defer tx.Rollback() - - if _, err := tx.ExecContext(ctx, "DELETE FROM shape"); err != nil { - return errors.Wrapf(err, "failed to truncate shapes table") - } - if _, err := tx.ExecContext(ctx, "DELETE FROM shape_point"); err != nil { - return errors.Wrapf(err, "faile to truncate shape points tables") - } - - for shapeId, shape := range shapes { - if _, err := tx.ExecContext(ctx, "INSERT INTO shape(id, kind) VALUES (?, ?)", shapeId, shape.Kind); err != nil { - return errors.Wrapf(err, "failed to insert shape") - } - - for _, point := range shape.Points { - if _, err := tx.ExecContext(ctx, "INSERT INTO shape_point(shape, latitude, longitude) VALUES (?, ?, ?)", shapeId, point.Latitude, point.Longitude); err != nil { - return errors.Wrapf(err, "failed to insert shape point") - } - } - } - - return tx.Commit() -} - -func dbGetShapes(ctx context.Context) ([]Shape, error) { - shapes := []Shape{} - srows, err := db.QueryContext(ctx, "SELECT id, kind FROM shape") - if err != nil { - return nil, errors.Wrapf(err, "failed to fetch shapes") - } - - for srows.Next() { - var id int - var kind string - if err := srows.Scan(&id, &kind); err != nil { - return nil, errors.Wrapf(err, "failed to scan shape") - } - - points := []ShapePoint{} - prows, err := db.QueryContext(ctx, "SELECT latitude, longitude FROM shape_point WHERE shape = ?", id) - if err != nil { - return nil, errors.Wrapf(err, "failed to fetch shape points") - } - - for prows.Next() { - var lat float64 - var lon float64 - if err := prows.Scan(&lat, &lon); err != nil { - return nil, errors.Wrapf(err, "failed to scan shape point") - } - - points = append(points, ShapePoint{ - Latitude: lat, - Longitude: lon, - }) - } - - shapes = append(shapes, Shape{ - Kind: kind, - Points: points, - }) - } - - return shapes, nil -} - -func handlePostApiLog(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - log.Printf("failed to read request body: %v\n", err) - http.Error(w, "failed to read request body", http.StatusBadRequest) - return - } - - l := LocationLog{} - if err := json.Unmarshal(body, &l); err != nil { - log.Printf("failed to parse request body: %v\n", err) - http.Error(w, "failed to parse request body", http.StatusBadRequest) - return - } - - log.Printf("storing %v\n", l) - if err := dbAppendLocationLog(r.Context(), l); err != nil { - log.Printf("%v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } -} - -func handleGetApiLog(w http.ResponseWriter, r *http.Request) { - locations, err := dbLoadLocationLogs(r.Context()) - if err != nil { - log.Printf("internal error: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - marshaled, err := json.Marshal(locations) - if err != nil { - log.Printf("failed to marshal locations: %v\n", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - w.Write(marshaled) -} - -func handleGetApiShapes(w http.ResponseWriter, r *http.Request) { - shapes, err := dbGetShapes(r.Context()) - if err != nil { - http.Error(w, fmt.Sprintf("failed to fetch shapes: %v", err), http.StatusInternalServerError) - return - } - - marshaled, err := json.Marshal(shapes) - if err != nil { - http.Error(w, fmt.Sprintf("failed to marshal shapes: %v", err), http.StatusInternalServerError) - return - } - - w.Write(marshaled) -} - -func handlePostApiShapes(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - log.Printf("failed to read request body: %v\n", err) - http.Error(w, "failed to read request body", http.StatusBadRequest) - return - } - - shapes := []Shape{} - if err := json.Unmarshal(body, &shapes); err != nil { - http.Error(w, fmt.Sprintf("failed to unmarshal request body: %v", err), http.StatusBadRequest) - return - } - - if err := dbSetShapes(r.Context(), shapes); err != nil { - http.Error(w, fmt.Sprintf("failed to store shapes: %v", err), http.StatusInternalServerError) - return - } -} - -func requireBasicAuth(h http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - required_username := os.Getenv(ENV_VAR_HTTP_USERNAME) - required_password := os.Getenv(ENV_VAR_HTTP_PASSWORD) - if !ok || username != required_username || password != required_password { - w.Header().Add("WWW-Authenticate", "Basic realm=\"User Visible Realm\"") - http.Error(w, "invalid authentication", http.StatusUnauthorized) - return - } - h(w, r) - } -} - -//go:embed schema.sql -var schema string - -//go:embed content/* -var contentFs embed.FS - func main() { - var err error - - db, err = sql.Open("sqlite3", "file:database.db") - if err != nil { - log.Fatalf("failed to create sqlite database: %v\n", err) + if os.Getenv(ENV_VAR_HTTP_USERNAME) == "" || os.Getenv(ENV_VAR_HTTP_PASSWORD) == "" { + log.Fatalf("http username and password cannot be empty") } - defer db.Close() - if _, err := db.Exec(schema); err != nil { - log.Fatalf("failed to execute db schema: %v\n", err) + config := &AppConfig{ + HttpUsername: os.Getenv(ENV_VAR_HTTP_USERNAME), + HttpPassword: os.Getenv(ENV_VAR_HTTP_PASSWORD), + PicturesDirectory: "pictures", } - - fs, err := fs.Sub(contentFs, "content") + app, err := NewApp(config) if err != nil { - log.Fatal(err) + log.Fatalf("failed to create app: %v\n", err) } + defer app.Close() - if os.Getenv(ENV_VAR_HTTP_USERNAME) == "" || os.Getenv(ENV_VAR_HTTP_PASSWORD) == "" { - log.Fatalf("http username and password cannot be empty") - } - - http.HandleFunc("GET /api/location", handleGetApiLog) - http.HandleFunc("POST /api/location", requireBasicAuth(handlePostApiLog)) - http.HandleFunc("GET /api/shapes", handleGetApiShapes) - http.HandleFunc("POST /api/shapes", requireBasicAuth(handlePostApiShapes)) - http.Handle("GET /static/", http.FileServerFS(fs)) - http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { - http.ServeFileFS(w, r, fs, "index.html") - }) - http.HandleFunc("GET /logger", requireBasicAuth(func(w http.ResponseWriter, r *http.Request) { - http.ServeFileFS(w, r, fs, "logger.html") - })) - http.HandleFunc("GET /shapes", requireBasicAuth(func(w http.ResponseWriter, r *http.Request) { - http.ServeFileFS(w, r, fs, "shapes.html") - })) - - fsHandler := http.FileServerFS(fs) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" && !strings.Contains(r.URL.Path, ".") { - r.URL.Path += ".html" - } - fsHandler.ServeHTTP(w, r) - }) - - if err := http.ListenAndServe("0.0.0.0:8000", nil); err != nil { + mux := app.Mux() + if err := http.ListenAndServe("0.0.0.0:8000", mux); err != nil { log.Fatalf("http.ListenAndServe failed: %v", err) } } -- cgit