aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--Containerfile1
-rw-r--r--app.go411
-rw-r--r--content/editor.html (renamed from content/shapes.html)0
-rw-r--r--content/index.html72
-rw-r--r--content/logger.html45
-rw-r--r--content/static/main.js71
-rw-r--r--exif.go45
-rw-r--r--magick.go16
-rw-r--r--main.go277
10 files changed, 592 insertions, 351 deletions
diff --git a/.gitignore b/.gitignore
index 2aa56f0..e9ad444 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,7 @@
1*.db 1*.db
2belverde-fire 2belverde-fire
3pictures/
4*.png
5*.HEIC
6*.jpg
7*.jpeg
diff --git a/Containerfile b/Containerfile
index 5b51ca6..4c4c5a7 100644
--- a/Containerfile
+++ b/Containerfile
@@ -1,4 +1,5 @@
1FROM debian:bookworm-slim 1FROM debian:bookworm-slim
2RUN apt update && apt install -y sqlite3 imagemagick exiftool && apt-get clean
2WORKDIR /app/data 3WORKDIR /app/data
3COPY belverde-fire /app/belverde-fire 4COPY belverde-fire /app/belverde-fire
4ENTRYPOINT ["/app/belverde-fire"] 5ENTRYPOINT ["/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 @@
1package main
2
3import (
4 "context"
5 "database/sql"
6 "embed"
7 "encoding/json"
8 "fmt"
9 "io"
10 "io/fs"
11 "log"
12 "net/http"
13 "os"
14 "path"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/pkg/errors"
20)
21
22const PicturesFormat string = ".png"
23
24type AppConfig struct {
25 HttpUsername string
26 HttpPassword string
27
28 PicturesDirectory string
29}
30
31type App struct {
32 db *sql.DB
33 contentFs fs.FS
34 config *AppConfig
35
36 // access by multiple goroutines. requires lock
37 pictures []Picture
38 picturesLock sync.Mutex
39
40 // accessed by a single goroutine, no lock required
41 pictureFiles map[string]*PictureFile
42}
43
44type LocationMarker struct {
45 Timestamp float64 `json:"timestamp"`
46 Latitude float64 `json:"latitude"`
47 Longitude float64 `json:"longitude"`
48 Accuracy float64 `json:"accuracy"`
49 Heading float64 `json:"heading"`
50}
51
52type ShapePoint struct {
53 Latitude float64 `json:"latitude"`
54 Longitude float64 `json:"longitude"`
55}
56
57const (
58 ShapeKindUnburned string = "unburned"
59 ShapeKindBurned string = "burned"
60)
61
62type Shape struct {
63 Kind string `json:"kind"`
64 Points []ShapePoint `json:"points"`
65}
66
67type Picture struct {
68 Filename string `json:"filename"`
69 Latitude float64 `json:"latitude"`
70 Longitude float64 `json:"longitude"`
71}
72
73type PictureFile struct {
74 Filename string
75 Latitude float64
76 Longitude float64
77 LastModTime time.Time
78}
79
80//go:embed content/*
81var appContentFs embed.FS
82
83//go:embed schema.sql
84var appDbSchema string
85
86func NewApp(config *AppConfig) (*App, error) {
87 db, err := sql.Open("sqlite3", "file:database.db")
88 if err != nil {
89 return nil, errors.Wrapf(err, "opening sqlite database")
90 }
91
92 if _, err := db.Exec(appDbSchema); err != nil {
93 return nil, errors.Wrapf(err, "executing db schema")
94 }
95
96 contentFs, err := fs.Sub(appContentFs, "content")
97 if err != nil {
98 return nil, err
99 }
100
101 if err := os.MkdirAll(config.PicturesDirectory, 0o755); err != nil {
102 return nil, errors.Wrapf(err, "creating pictures directory")
103 }
104
105 a := &App{
106 db: db,
107 contentFs: contentFs,
108 config: config,
109 pictures: []Picture{},
110 pictureFiles: make(map[string]*PictureFile),
111 }
112
113 go func() {
114 for {
115 a.updatePictures()
116 time.Sleep(time.Minute)
117 }
118 }()
119
120 return a, nil
121}
122
123func (a *App) Close() {
124 a.db.Close()
125}
126
127func (a *App) Mux() *http.ServeMux {
128 mux := http.NewServeMux()
129 mux.HandleFunc("GET /api/location", a.requireBasicAuth(a.handleGetLocationMarkers))
130 mux.HandleFunc("POST /api/location", a.requireBasicAuth(a.handleCreateLocationMarker))
131 mux.HandleFunc("GET /api/shapes", a.handleGetShapes)
132 mux.HandleFunc("POST /api/shapes", a.requireBasicAuth(a.handleUpdateShapes))
133 mux.HandleFunc("GET /api/pictures", a.handleGetPictures)
134 mux.Handle("GET /static/", http.FileServerFS(a.contentFs))
135 mux.HandleFunc("GET /picture/{filename}", a.handleServePicture)
136 mux.HandleFunc("GET /logger", a.requireBasicAuth(a.serveContentFile("logger.html")))
137 mux.HandleFunc("GET /editor", a.requireBasicAuth(a.serveContentFile("editor.html")))
138 mux.HandleFunc("GET /", a.serveContentFile("index.html"))
139 return mux
140}
141
142func (a *App) writeJson(w http.ResponseWriter, v any) {
143 body, err := json.Marshal(v)
144 if err != nil {
145 http.Error(w, "failed to encoded response as json", http.StatusInternalServerError)
146 return
147 }
148 w.Header().Add("Content-Type", "application/json")
149 w.Write(body)
150}
151
152func (a *App) requireBasicAuth(h http.HandlerFunc) http.HandlerFunc {
153 return func(w http.ResponseWriter, r *http.Request) {
154 username, password, ok := r.BasicAuth()
155 required_username := a.config.HttpUsername
156 required_password := a.config.HttpPassword
157 if !ok || username != required_username || password != required_password {
158 w.Header().Add("WWW-Authenticate", "Basic realm=\"User Visible Realm\"")
159 http.Error(w, "invalid authentication", http.StatusUnauthorized)
160 return
161 }
162 h(w, r)
163 }
164}
165
166func (a *App) serveContentFile(path string) http.HandlerFunc {
167 return func(w http.ResponseWriter, r *http.Request) {
168 http.ServeFileFS(w, r, a.contentFs, path)
169 }
170}
171
172func (a *App) loadLocationMarkers(ctx context.Context) ([]LocationMarker, error) {
173 rows, err := a.db.QueryContext(ctx, "SELECT * FROM location")
174 if err != nil {
175 return nil, errors.Wrapf(err, "fetching all locations")
176 }
177
178 locations := []LocationMarker{}
179 for rows.Next() {
180 loc := LocationMarker{}
181 if err := rows.Scan(&loc.Timestamp, &loc.Latitude, &loc.Longitude, &loc.Accuracy, &loc.Heading); err != nil {
182 return nil, errors.Wrapf(err, "failed to scan location row")
183 }
184 locations = append(locations, loc)
185 }
186
187 return locations, nil
188}
189
190func (a *App) appendLocationMarker(ctx context.Context, loc LocationMarker) error {
191 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 {
192 return errors.Wrapf(err, "failed to insert location log")
193 }
194 return nil
195}
196
197func (a *App) updateShapes(ctx context.Context, shapes []Shape) error {
198 tx, err := a.db.Begin()
199 if err != nil {
200 return errors.Wrapf(err, "starting database transaction")
201 }
202 defer tx.Rollback()
203
204 if _, err := tx.ExecContext(ctx, "DELETE FROM shape"); err != nil {
205 return errors.Wrapf(err, "failed to truncate shapes table")
206 }
207 if _, err := tx.ExecContext(ctx, "DELETE FROM shape_point"); err != nil {
208 return errors.Wrapf(err, "faile to truncate shape points tables")
209 }
210
211 for shapeId, shape := range shapes {
212 if _, err := tx.ExecContext(ctx, "INSERT INTO shape(id, kind) VALUES (?, ?)", shapeId, shape.Kind); err != nil {
213 return errors.Wrapf(err, "failed to insert shape")
214 }
215
216 for _, point := range shape.Points {
217 if _, err := tx.ExecContext(ctx, "INSERT INTO shape_point(shape, latitude, longitude) VALUES (?, ?, ?)", shapeId, point.Latitude, point.Longitude); err != nil {
218 return errors.Wrapf(err, "failed to insert shape point")
219 }
220 }
221 }
222
223 return tx.Commit()
224}
225
226func (a *App) loadShapes(ctx context.Context) ([]Shape, error) {
227 shapes := []Shape{}
228 srows, err := a.db.QueryContext(ctx, "SELECT id, kind FROM shape")
229 if err != nil {
230 return nil, errors.Wrapf(err, "failed to fetch shapes")
231 }
232
233 for srows.Next() {
234 var id int
235 var kind string
236 if err := srows.Scan(&id, &kind); err != nil {
237 return nil, errors.Wrapf(err, "failed to scan shape")
238 }
239
240 points := []ShapePoint{}
241 prows, err := a.db.QueryContext(ctx, "SELECT latitude, longitude FROM shape_point WHERE shape = ?", id)
242 if err != nil {
243 return nil, errors.Wrapf(err, "failed to fetch shape points")
244 }
245
246 for prows.Next() {
247 var lat float64
248 var lon float64
249 if err := prows.Scan(&lat, &lon); err != nil {
250 return nil, errors.Wrapf(err, "failed to scan shape point")
251 }
252
253 points = append(points, ShapePoint{
254 Latitude: lat,
255 Longitude: lon,
256 })
257 }
258
259 shapes = append(shapes, Shape{
260 Kind: kind,
261 Points: points,
262 })
263 }
264
265 return shapes, nil
266}
267
268func (a *App) handleCreateLocationMarker(w http.ResponseWriter, r *http.Request) {
269 body, err := io.ReadAll(r.Body)
270 if err != nil {
271 log.Printf("failed to read request body: %v\n", err)
272 http.Error(w, "failed to read request body", http.StatusBadRequest)
273 return
274 }
275
276 l := LocationMarker{}
277 if err := json.Unmarshal(body, &l); err != nil {
278 log.Printf("failed to parse request body: %v\n", err)
279 http.Error(w, "failed to parse request body", http.StatusBadRequest)
280 return
281 }
282
283 log.Printf("storing %v\n", l)
284 if err := a.appendLocationMarker(r.Context(), l); err != nil {
285 log.Printf("%v", err)
286 http.Error(w, "internal error", http.StatusInternalServerError)
287 return
288 }
289}
290
291func (a *App) handleGetLocationMarkers(w http.ResponseWriter, r *http.Request) {
292 locations, err := a.loadLocationMarkers(r.Context())
293 if err != nil {
294 log.Printf("internal error: %v", err)
295 http.Error(w, "internal error", http.StatusInternalServerError)
296 return
297 }
298 a.writeJson(w, locations)
299}
300
301func (a *App) handleGetShapes(w http.ResponseWriter, r *http.Request) {
302 shapes, err := a.loadShapes(r.Context())
303 if err != nil {
304 http.Error(w, fmt.Sprintf("failed to fetch shapes: %v", err), http.StatusInternalServerError)
305 return
306 }
307 a.writeJson(w, shapes)
308}
309
310func (a *App) handleUpdateShapes(w http.ResponseWriter, r *http.Request) {
311 body, err := io.ReadAll(r.Body)
312 if err != nil {
313 log.Printf("failed to read request body: %v\n", err)
314 http.Error(w, "failed to read request body", http.StatusBadRequest)
315 return
316 }
317
318 shapes := []Shape{}
319 if err := json.Unmarshal(body, &shapes); err != nil {
320 http.Error(w, fmt.Sprintf("failed to unmarshal request body: %v", err), http.StatusBadRequest)
321 return
322 }
323
324 if err := a.updateShapes(r.Context(), shapes); err != nil {
325 http.Error(w, fmt.Sprintf("failed to store shapes: %v", err), http.StatusInternalServerError)
326 return
327 }
328}
329
330func (a *App) updatePictures() {
331 entries, err := os.ReadDir(a.config.PicturesDirectory)
332 if err != nil {
333 log.Fatalf("failed to read pictures directory: %v\n", err)
334 }
335
336 for _, entry := range entries {
337 // ignore non files
338 if !entry.Type().IsRegular() {
339 continue
340 }
341
342 // convert if required
343 filePath := path.Join(a.config.PicturesDirectory, entry.Name())
344 fileExt := path.Ext(entry.Name())
345 if fileExt != PicturesFormat {
346 filePathNew := strings.TrimSuffix(filePath, fileExt) + PicturesFormat
347 if _, err := os.Stat(filePathNew); err == nil {
348 continue
349 }
350 if err := magickConvert(filePath, filePathNew); err != nil {
351 log.Printf("failed to convert image: %v", err)
352 }
353 }
354
355 // process file
356 info, err := os.Stat(filePath)
357 if err != nil {
358 log.Printf("failed to stat %v: %v\n", filePath, err)
359 continue
360 }
361 if pfile, ok := a.pictureFiles[filePath]; !ok || info.ModTime().After(pfile.LastModTime) {
362 exifData, err := exiftool(filePath)
363 if err != nil {
364 log.Printf("failed to extract exif data from %v: %v\n", filePath, err)
365 continue
366 }
367
368 pictureFile := &PictureFile{
369 Filename: entry.Name(),
370 Latitude: exifData.Latitude,
371 Longitude: exifData.Longitude,
372 LastModTime: info.ModTime(),
373 }
374 a.pictureFiles[filePath] = pictureFile
375 }
376 }
377
378 a.picturesLock.Lock()
379 defer a.picturesLock.Unlock()
380 a.pictures = make([]Picture, 0, len(a.pictureFiles))
381 for _, pfile := range a.pictureFiles {
382 a.pictures = append(a.pictures, Picture{
383 Filename: pfile.Filename,
384 Latitude: pfile.Latitude,
385 Longitude: pfile.Longitude,
386 })
387 }
388}
389
390func (a *App) handleGetPictures(w http.ResponseWriter, r *http.Request) {
391 a.picturesLock.Lock()
392 response, err := json.Marshal(a.pictures)
393 a.picturesLock.Unlock()
394 if err != nil {
395 http.Error(w, "failed to marshal pictures", http.StatusInternalServerError)
396 return
397 }
398 w.Header().Add("Content-Type", "application/json")
399 w.Write(response)
400}
401
402func (a *App) handleServePicture(w http.ResponseWriter, r *http.Request) {
403 filename := path.Base(r.PathValue("filename"))
404 if !strings.HasSuffix(filename, PicturesFormat) {
405 http.Error(w, "picture not found", http.StatusNotFound)
406 return
407 }
408
409 filepath := path.Join(a.config.PicturesDirectory, filename)
410 http.ServeFile(w, r, filepath)
411}
diff --git a/content/shapes.html b/content/editor.html
index 1bb4588..1bb4588 100644
--- a/content/shapes.html
+++ b/content/editor.html
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 @@
25 height: 100vh; 25 height: 100vh;
26 } 26 }
27 27
28 img { 28 #image-frame {
29 max-height: 100%; 29 display: flex;
30 max-width: 100%; 30 justify-content: center;
31 width: 100vw;
32 height: 100vh;
33 position: fixed;
34 z-index: 400;
35 top: 0;
36 left: 0;
37 background: rgba(0, 0, 0, 0.5);
31 } 38 }
32 39
33 .image-holder { 40 #image-frame>img {
34 height: 300px; 41 display: block;
35 width: 300px; 42 margin: auto;
43 max-height: 90vh;
44 max-width: 90vw;
36 } 45 }
37</style> 46</style>
38 47
39<script> 48<script>
40 // const burned = [
41 // [38.592177702929426, -9.145557060034113],
42 // [38.58385651421202, -9.134116290522673],
43 // [38.587516574932266, -9.134999747627804],
44 // [38.59442184182009, -9.13809184749576],
45 // [38.596734957715675, -9.138621921758839],
46 // ];
47 //
48 // const unburned = [
49 // [38.598388277527036, -9.135874396116632],
50 // [38.589731317901276, -9.149692038446165],
51 // [38.58043902375093, -9.138619879692945],
52 // [38.591568658478, -9.12070962376425],
53 // ];
54 //
55 // const photo = [38.59428333333334, -9.127433333333334];
56 //
57 // function addLine(map, point, bearing) {
58 // const len = 0.0002;
59 // const lat = Math.sin((bearing + 90) * 2 * Math.PI / 360) * len;
60 // const lon = Math.cos((bearing + 90) * 2 * Math.PI / 360) * len;
61 // const coords = [
62 // [point[0], point[1]],
63 // [point[0] + lat, point[1] + lon],
64 // ];
65 // L.polyline(coords, {color: 'blue'}).addTo(map)
66 // }
67 //
68 // function main() {
69 // var map = L.map('map').setView([38.59104623572979, -9.130882470026634], 14);
70 // L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
71 // maxZoom: 19,
72 // attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
73 // }).addTo(map);
74 // var polygon = L.polygon(unburned, {color: 'green'}).addTo(map);
75 // var polygon = L.polygon(burned, {color: 'red'}).addTo(map);
76 // //var polyline = L.polyline(latlngs, {color: 'red'}).addTo(map);
77 // const test = [38.58989148093167, -9.138004507064645];
78 // addLine(map, test, 0);
79 // var marker = L.marker(photo).on('click', () => console.log("clicked the image marker")).addTo(map);
80 // marker.bindPopup("<div class='image-holder'><img src='/img.jpg' /></div>");
81 // //L.circle(test, {radius: 5.9}).on('click', (e) => console.log(`clicked circle, shift = ${e.originalEvent.shiftKey}`)).addTo(map)
82 // L.circle(test, {radius: 5.9}).on('click', (e) => {
83 // if (e.originalEvent.shiftKey) {
84 // console.log("reloaded")
85 // map.remove()
86 // main()
87 // }
88 // }).addTo(map);
89 // }
90
91 window.addEventListener("load", () => page_index__main()); 49 window.addEventListener("load", () => page_index__main());
92</script> 50</script>
93 51
diff --git a/content/logger.html b/content/logger.html
index 1a6967e..4c0700a 100644
--- a/content/logger.html
+++ b/content/logger.html
@@ -28,6 +28,10 @@
28 <td id="value-longitude"></td> 28 <td id="value-longitude"></td>
29 </tr> 29 </tr>
30 <tr> 30 <tr>
31 <td>Accuracy</td>
32 <td id="value-accuracy"></td>
33 </tr>
34 <tr>
31 <td>Heading</td> 35 <td>Heading</td>
32 <td id="value-heading"></td> 36 <td id="value-heading"></td>
33 </tr> 37 </tr>
@@ -46,7 +50,7 @@
46 accuracy: 0, 50 accuracy: 0,
47 timestamp: 0, 51 timestamp: 0,
48 heading: 0, 52 heading: 0,
49 permissions: false, 53 setup: false,
50 inprogress: false, 54 inprogress: false,
51 }; 55 };
52 56
@@ -77,55 +81,52 @@
77 update_ui(); 81 update_ui();
78 } 82 }
79 83
80 function request_permissions() { 84 function logger_setup() {
81 if (DeviceOrientationEvent.requestPermission) { 85 if (DeviceOrientationEvent.requestPermission) {
82 DeviceOrientationEvent.requestPermission().then(() => { 86 DeviceOrientationEvent.requestPermission().then(() => {
83 window.addEventListener("deviceorientation", (event) => { 87 window.addEventListener("deviceorientation", (event) => {
84 logger_state.heading = event.webkitCompassHeading; 88 logger_state.heading = event.webkitCompassHeading;
85 update_ui(); 89 update_ui();
86 }); 90 });
87 logger_state.permissions = true;
88 log(); 91 log();
89 }).catch((err) => { 92 }).catch((err) => {
90 alert(`failed to get device orientation permissions: ${err}`) 93 alert(`failed to get device orientation permissions: ${err}`)
91 }) 94 })
92 } 95 }
93 }
94
95 function log() {
96 if (logger_state.inprogress)
97 return;
98
99 logger_state.latitude = null;
100 logger_state.longitude = null;
101 logger_state.accuracy = null;
102 logger_state.timestamp = null;
103 update_ui();
104
105 if (!logger_state.permissions) {
106 request_permissions();
107 return;
108 }
109 96
110 navigator.geolocation.getCurrentPosition((position) => { 97 navigator.geolocation.watchPosition((position) => {
111 console.log(position); 98 console.log(position);
112 logger_state.latitude = position.coords.latitude; 99 logger_state.latitude = position.coords.latitude;
113 logger_state.longitude = position.coords.longitude; 100 logger_state.longitude = position.coords.longitude;
114 logger_state.accuracy = position.coords.accuracy; 101 logger_state.accuracy = position.coords.accuracy;
115 logger_state.timestamp = position.timestamp; 102 logger_state.timestamp = position.timestamp;
116 update_ui(); 103 update_ui();
117 log_post();
118 }, (err) => { 104 }, (err) => {
119 alert(`failed to get position: ${err}`); 105 alert(`failed to get position: ${err}`);
120 }); 106 }, {enableHighAccuracy: true});
107
108
109 logger_state.setup = true;
110 }
111
112 function log() {
113 if (logger_state.inprogress)
114 return;
115
116 if (!logger_state.setup) {
117 logger_setup();
118 return;
119 }
121 120
122 logger_state.inprogress = true; 121 logger_state.inprogress = true;
122 log_post();
123 update_ui(); 123 update_ui();
124 } 124 }
125 125
126 function update_ui() { 126 function update_ui() {
127 set_table_value("value-latitude", logger_state.latitude); 127 set_table_value("value-latitude", logger_state.latitude);
128 set_table_value("value-longitude", logger_state.longitude); 128 set_table_value("value-longitude", logger_state.longitude);
129 set_table_value("value-accuracy", logger_state.accuracy);
129 set_table_value("value-heading", logger_state.heading); 130 set_table_value("value-heading", logger_state.heading);
130 131
131 const progress_display = logger_state.inprogress ? "block" : "none"; 132 const progress_display = logger_state.inprogress ? "block" : "none";
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";
14 14
15/** 15/**
16 * A location log 16 * A location log
17 * @typedef {Object} LocationLog 17 * @typedef {Object} LocationMarker
18 * @property {number} timestamp 18 * @property {number} timestamp
19 * @property {number} latitude 19 * @property {number} latitude
20 * @property {number} longitude 20 * @property {number} longitude
@@ -37,6 +37,14 @@ const SHAPE_KIND_BURNED = "burned";
37*/ 37*/
38 38
39/** 39/**
40 * A picture descriptor
41 * @typedef {Object} PictureDescriptor
42 * @property {string} filename
43 * @property {number} latitude
44 * @property {number} longitude
45*/
46
47/**
40 * A shape 48 * A shape
41 * @typedef {Object} Shape 49 * @typedef {Object} Shape
42 * @property {string} kind 50 * @property {string} kind
@@ -51,6 +59,7 @@ function lib_setup_handler_onclick(elementId, handler) {
51 59
52function lib_setup_map() { 60function lib_setup_map() {
53 var map = L.map(ELEM_ID_MAP).setView(DEFAULT_COORDINATES, DEFAULT_ZOOM); 61 var map = L.map(ELEM_ID_MAP).setView(DEFAULT_COORDINATES, DEFAULT_ZOOM);
62 L.Icon.Default.imagePath = "/static/";
54 L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 63 L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
55 maxZoom: 19, 64 maxZoom: 19,
56 attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' 65 attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@@ -60,7 +69,7 @@ function lib_setup_map() {
60 69
61/** 70/**
62 * Fetch location logs 71 * Fetch location logs
63 * @return {Promise<LocationLog[]>} 72 * @return {Promise<LocationMarker[]>}
64*/ 73*/
65async function lib_fetch_location_logs() { 74async function lib_fetch_location_logs() {
66 // const burned = [ 75 // const burned = [
@@ -96,7 +105,7 @@ async function lib_fetch_location_logs() {
96} 105}
97 106
98/** 107/**
99 * Fetch location logs 108 * Fetch shape descriptors
100 * @return {Promise<ShapeDescriptor[]>} 109 * @return {Promise<ShapeDescriptor[]>}
101*/ 110*/
102async function lib_fetch_shape_descriptors() { 111async function lib_fetch_shape_descriptors() {
@@ -104,6 +113,20 @@ async function lib_fetch_shape_descriptors() {
104 return response.json(); 113 return response.json();
105} 114}
106 115
116/**
117 * Fetch picture descriptors
118 * @return {Promise<PictureDescriptor[]>}
119*/
120async function lib_fetch_picture_descriptors() {
121 const response = await fetch("/api/pictures");
122 return response.json();
123}
124
125function lib_picture_descriptor_url(picture_descriptor) {
126 const picture_url = `/picture/${picture_descriptor.filename}`
127 return picture_url;
128}
129
107function lib_add_location_logs_to_map(map, locations) { 130function lib_add_location_logs_to_map(map, locations) {
108 for (const location of locations) { 131 for (const location of locations) {
109 const len = 0.0002; 132 const len = 0.0002;
@@ -335,7 +358,7 @@ async function page_shape__main() {
335 } 358 }
336} 359}
337 360
338function page_main__poly_create_from_shape_descriptor(map, shape_descriptor) { 361function page_index__poly_create_from_shape_descriptor(map, shape_descriptor) {
339 const color = lib_shape_color_for_kind(shape_descriptor.kind); 362 const color = lib_shape_color_for_kind(shape_descriptor.kind);
340 const points = [] 363 const points = []
341 for (const point of shape_descriptor.points) { 364 for (const point of shape_descriptor.points) {
@@ -344,10 +367,46 @@ function page_main__poly_create_from_shape_descriptor(map, shape_descriptor) {
344 L.polygon(points, { color: color }).addTo(map); 367 L.polygon(points, { color: color }).addTo(map);
345} 368}
346 369
370function page_index__create_image_popup(picture_descriptor) {
371 const e = document.getElementById("image-frame");
372 if (e != null)
373 e.remove();
374
375 const d = document.createElement("div");
376 d.id = "image-frame";
377 const i = document.createElement("img");
378 i.src = lib_picture_descriptor_url(picture_descriptor);
379
380 d.onclick = () => {
381 d.remove();
382 };
383
384 d.appendChild(i);
385 document.body.appendChild(d);
386}
387
388function page_index__add_picture_descriptor_to_map(map, picture_descriptor) {
389 L.marker([picture_descriptor.latitude, picture_descriptor.longitude])
390 .on('click', () => {
391 page_index__create_image_popup(picture_descriptor);
392 })
393 .addTo(map)
394}
395
347async function page_index__main() { 396async function page_index__main() {
348 const map = lib_setup_map(); 397 const map = lib_setup_map();
349 const shape_descriptors = await lib_fetch_shape_descriptors(); 398 const [shape_descriptors, picture_descriptors] = await Promise.all([
399 lib_fetch_shape_descriptors(),
400 lib_fetch_picture_descriptors(),
401 ]);
350 for (const descriptor of shape_descriptors) { 402 for (const descriptor of shape_descriptors) {
351 page_main__poly_create_from_shape_descriptor(map, descriptor); 403 page_index__poly_create_from_shape_descriptor(map, descriptor);
404 }
405 for (const descriptor of picture_descriptors) {
406 page_index__add_picture_descriptor_to_map(map, descriptor);
352 } 407 }
408
409 setTimeout(() => {
410 console.log("create div");
411 }, 1000);
353} 412}
diff --git a/exif.go b/exif.go
new file mode 100644
index 0000000..1684d28
--- /dev/null
+++ b/exif.go
@@ -0,0 +1,45 @@
1package main
2
3import (
4 "encoding/json"
5 "os/exec"
6 "strconv"
7
8 "github.com/pkg/errors"
9)
10
11type ExifData struct {
12 Latitude float64
13 Longitude float64
14}
15
16func exiftool(path string) (*ExifData, error) {
17 cmd := exec.Command("exiftool", "-c", "%+.24f", "-j", path)
18 output, err := cmd.Output()
19 if err != nil {
20 return nil, errors.Wrapf(err, "running exiftool")
21 }
22
23 type Output struct {
24 GPSLatitude string
25 GPSLongitude string
26 }
27
28 os := []Output{}
29 if err := json.Unmarshal(output, &os); err != nil || len(os) != 1 {
30 return nil, errors.Wrapf(err, "parsing exiftool output")
31 }
32 o := os[0]
33
34 latitude, err := strconv.ParseFloat(o.GPSLatitude, 64)
35 if err != nil {
36 return nil, errors.Wrapf(err, "parsing latitude '%v'", o.GPSLatitude)
37 }
38
39 longitude, err := strconv.ParseFloat(o.GPSLongitude, 64)
40 if err != nil {
41 return nil, errors.Wrapf(err, "parsing longitude '%v'", o.GPSLongitude)
42 }
43
44 return &ExifData{Latitude: latitude, Longitude: longitude}, nil
45}
diff --git a/magick.go b/magick.go
new file mode 100644
index 0000000..9c4182c
--- /dev/null
+++ b/magick.go
@@ -0,0 +1,16 @@
1package main
2
3import (
4 "os/exec"
5
6 "github.com/pkg/errors"
7)
8
9func magickConvert(src string, dst string) error {
10 cmd := exec.Command("convert", src, dst)
11 err := cmd.Run()
12 if err != nil {
13 return errors.Wrapf(err, "running convert '%v' '%v'", src, dst)
14 }
15 return nil
16}
diff --git a/main.go b/main.go
index 2adaa21..c99a8b8 100644
--- a/main.go
+++ b/main.go
@@ -1,22 +1,13 @@
1package main 1package main
2 2
3import ( 3import (
4 "context"
5 "database/sql"
6 "embed"
7 "encoding/json"
8 "fmt"
9 "io"
10 "io/fs"
11 "log" 4 "log"
12 "net/http" 5 "net/http"
13 "os" 6 "os"
14 "strings"
15 7
16 _ "embed" 8 _ "embed"
17 9
18 _ "github.com/mattn/go-sqlite3" 10 _ "github.com/mattn/go-sqlite3"
19 "github.com/pkg/errors"
20) 11)
21 12
22const ( 13const (
@@ -24,270 +15,24 @@ const (
24 ENV_VAR_HTTP_PASSWORD = "HTTP_PASSWORD" 15 ENV_VAR_HTTP_PASSWORD = "HTTP_PASSWORD"
25) 16)
26 17
27type LocationLog struct {
28 Timestamp float64 `json:"timestamp"`
29 Latitude float64 `json:"latitude"`
30 Longitude float64 `json:"longitude"`
31 Accuracy float64 `json:"accuracy"`
32 Heading float64 `json:"heading"`
33}
34
35type ShapePoint struct {
36 Latitude float64 `json:"latitude"`
37 Longitude float64 `json:"longitude"`
38}
39
40const (
41 ShapeKindUnburned string = "unburned"
42 ShapeKindBurned string = "burned"
43)
44
45type Shape struct {
46 Kind string `json:"kind"`
47 Points []ShapePoint `json:"points"`
48}
49
50var db *sql.DB
51
52func dbLoadLocationLogs(ctx context.Context) ([]LocationLog, error) {
53 rows, err := db.QueryContext(ctx, "SELECT * FROM location")
54 if err != nil {
55 return nil, errors.Wrapf(err, "fetching all locations")
56 }
57
58 locations := []LocationLog{}
59 for rows.Next() {
60 loc := LocationLog{}
61 if err := rows.Scan(&loc.Timestamp, &loc.Latitude, &loc.Longitude, &loc.Accuracy, &loc.Heading); err != nil {
62 return nil, errors.Wrapf(err, "failed to scan location row")
63 }
64 locations = append(locations, loc)
65 }
66
67 return locations, nil
68}
69
70func dbAppendLocationLog(ctx context.Context, loc LocationLog) error {
71 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 {
72 return errors.Wrapf(err, "failed to insert location log")
73 }
74 return nil
75}
76
77func dbSetShapes(ctx context.Context, shapes []Shape) error {
78 tx, err := db.Begin()
79 if err != nil {
80 return errors.Wrapf(err, "starting database transaction")
81 }
82 defer tx.Rollback()
83
84 if _, err := tx.ExecContext(ctx, "DELETE FROM shape"); err != nil {
85 return errors.Wrapf(err, "failed to truncate shapes table")
86 }
87 if _, err := tx.ExecContext(ctx, "DELETE FROM shape_point"); err != nil {
88 return errors.Wrapf(err, "faile to truncate shape points tables")
89 }
90
91 for shapeId, shape := range shapes {
92 if _, err := tx.ExecContext(ctx, "INSERT INTO shape(id, kind) VALUES (?, ?)", shapeId, shape.Kind); err != nil {
93 return errors.Wrapf(err, "failed to insert shape")
94 }
95
96 for _, point := range shape.Points {
97 if _, err := tx.ExecContext(ctx, "INSERT INTO shape_point(shape, latitude, longitude) VALUES (?, ?, ?)", shapeId, point.Latitude, point.Longitude); err != nil {
98 return errors.Wrapf(err, "failed to insert shape point")
99 }
100 }
101 }
102
103 return tx.Commit()
104}
105
106func dbGetShapes(ctx context.Context) ([]Shape, error) {
107 shapes := []Shape{}
108 srows, err := db.QueryContext(ctx, "SELECT id, kind FROM shape")
109 if err != nil {
110 return nil, errors.Wrapf(err, "failed to fetch shapes")
111 }
112
113 for srows.Next() {
114 var id int
115 var kind string
116 if err := srows.Scan(&id, &kind); err != nil {
117 return nil, errors.Wrapf(err, "failed to scan shape")
118 }
119
120 points := []ShapePoint{}
121 prows, err := db.QueryContext(ctx, "SELECT latitude, longitude FROM shape_point WHERE shape = ?", id)
122 if err != nil {
123 return nil, errors.Wrapf(err, "failed to fetch shape points")
124 }
125
126 for prows.Next() {
127 var lat float64
128 var lon float64
129 if err := prows.Scan(&lat, &lon); err != nil {
130 return nil, errors.Wrapf(err, "failed to scan shape point")
131 }
132
133 points = append(points, ShapePoint{
134 Latitude: lat,
135 Longitude: lon,
136 })
137 }
138
139 shapes = append(shapes, Shape{
140 Kind: kind,
141 Points: points,
142 })
143 }
144
145 return shapes, nil
146}
147
148func handlePostApiLog(w http.ResponseWriter, r *http.Request) {
149 body, err := io.ReadAll(r.Body)
150 if err != nil {
151 log.Printf("failed to read request body: %v\n", err)
152 http.Error(w, "failed to read request body", http.StatusBadRequest)
153 return
154 }
155
156 l := LocationLog{}
157 if err := json.Unmarshal(body, &l); err != nil {
158 log.Printf("failed to parse request body: %v\n", err)
159 http.Error(w, "failed to parse request body", http.StatusBadRequest)
160 return
161 }
162
163 log.Printf("storing %v\n", l)
164 if err := dbAppendLocationLog(r.Context(), l); err != nil {
165 log.Printf("%v", err)
166 http.Error(w, "internal error", http.StatusInternalServerError)
167 return
168 }
169}
170
171func handleGetApiLog(w http.ResponseWriter, r *http.Request) {
172 locations, err := dbLoadLocationLogs(r.Context())
173 if err != nil {
174 log.Printf("internal error: %v", err)
175 http.Error(w, "internal error", http.StatusInternalServerError)
176 return
177 }
178
179 marshaled, err := json.Marshal(locations)
180 if err != nil {
181 log.Printf("failed to marshal locations: %v\n", err)
182 http.Error(w, "internal error", http.StatusInternalServerError)
183 return
184 }
185
186 w.Write(marshaled)
187}
188
189func handleGetApiShapes(w http.ResponseWriter, r *http.Request) {
190 shapes, err := dbGetShapes(r.Context())
191 if err != nil {
192 http.Error(w, fmt.Sprintf("failed to fetch shapes: %v", err), http.StatusInternalServerError)
193 return
194 }
195
196 marshaled, err := json.Marshal(shapes)
197 if err != nil {
198 http.Error(w, fmt.Sprintf("failed to marshal shapes: %v", err), http.StatusInternalServerError)
199 return
200 }
201
202 w.Write(marshaled)
203}
204
205func handlePostApiShapes(w http.ResponseWriter, r *http.Request) {
206 body, err := io.ReadAll(r.Body)
207 if err != nil {
208 log.Printf("failed to read request body: %v\n", err)
209 http.Error(w, "failed to read request body", http.StatusBadRequest)
210 return
211 }
212
213 shapes := []Shape{}
214 if err := json.Unmarshal(body, &shapes); err != nil {
215 http.Error(w, fmt.Sprintf("failed to unmarshal request body: %v", err), http.StatusBadRequest)
216 return
217 }
218
219 if err := dbSetShapes(r.Context(), shapes); err != nil {
220 http.Error(w, fmt.Sprintf("failed to store shapes: %v", err), http.StatusInternalServerError)
221 return
222 }
223}
224
225func requireBasicAuth(h http.HandlerFunc) http.HandlerFunc {
226 return func(w http.ResponseWriter, r *http.Request) {
227 username, password, ok := r.BasicAuth()
228 required_username := os.Getenv(ENV_VAR_HTTP_USERNAME)
229 required_password := os.Getenv(ENV_VAR_HTTP_PASSWORD)
230 if !ok || username != required_username || password != required_password {
231 w.Header().Add("WWW-Authenticate", "Basic realm=\"User Visible Realm\"")
232 http.Error(w, "invalid authentication", http.StatusUnauthorized)
233 return
234 }
235 h(w, r)
236 }
237}
238
239//go:embed schema.sql
240var schema string
241
242//go:embed content/*
243var contentFs embed.FS
244
245func main() { 18func main() {
246 var err error 19 if os.Getenv(ENV_VAR_HTTP_USERNAME) == "" || os.Getenv(ENV_VAR_HTTP_PASSWORD) == "" {
247 20 log.Fatalf("http username and password cannot be empty")
248 db, err = sql.Open("sqlite3", "file:database.db")
249 if err != nil {
250 log.Fatalf("failed to create sqlite database: %v\n", err)
251 } 21 }
252 defer db.Close()
253 22
254 if _, err := db.Exec(schema); err != nil { 23 config := &AppConfig{
255 log.Fatalf("failed to execute db schema: %v\n", err) 24 HttpUsername: os.Getenv(ENV_VAR_HTTP_USERNAME),
25 HttpPassword: os.Getenv(ENV_VAR_HTTP_PASSWORD),
26 PicturesDirectory: "pictures",
256 } 27 }
257 28 app, err := NewApp(config)
258 fs, err := fs.Sub(contentFs, "content")
259 if err != nil { 29 if err != nil {
260 log.Fatal(err) 30 log.Fatalf("failed to create app: %v\n", err)
261 } 31 }
32 defer app.Close()
262 33
263 if os.Getenv(ENV_VAR_HTTP_USERNAME) == "" || os.Getenv(ENV_VAR_HTTP_PASSWORD) == "" { 34 mux := app.Mux()
264 log.Fatalf("http username and password cannot be empty") 35 if err := http.ListenAndServe("0.0.0.0:8000", mux); err != nil {
265 }
266
267 http.HandleFunc("GET /api/location", handleGetApiLog)
268 http.HandleFunc("POST /api/location", requireBasicAuth(handlePostApiLog))
269 http.HandleFunc("GET /api/shapes", handleGetApiShapes)
270 http.HandleFunc("POST /api/shapes", requireBasicAuth(handlePostApiShapes))
271 http.Handle("GET /static/", http.FileServerFS(fs))
272 http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
273 http.ServeFileFS(w, r, fs, "index.html")
274 })
275 http.HandleFunc("GET /logger", requireBasicAuth(func(w http.ResponseWriter, r *http.Request) {
276 http.ServeFileFS(w, r, fs, "logger.html")
277 }))
278 http.HandleFunc("GET /shapes", requireBasicAuth(func(w http.ResponseWriter, r *http.Request) {
279 http.ServeFileFS(w, r, fs, "shapes.html")
280 }))
281
282 fsHandler := http.FileServerFS(fs)
283 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
284 if r.URL.Path != "/" && !strings.Contains(r.URL.Path, ".") {
285 r.URL.Path += ".html"
286 }
287 fsHandler.ServeHTTP(w, r)
288 })
289
290 if err := http.ListenAndServe("0.0.0.0:8000", nil); err != nil {
291 log.Fatalf("http.ListenAndServe failed: %v", err) 36 log.Fatalf("http.ListenAndServe failed: %v", err)
292 } 37 }
293} 38}