aboutsummaryrefslogtreecommitdiff
path: root/app.go
diff options
context:
space:
mode:
Diffstat (limited to 'app.go')
-rw-r--r--app.go411
1 files changed, 411 insertions, 0 deletions
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}