diff options
| author | diogo464 <[email protected]> | 2024-10-04 18:49:11 +0100 |
|---|---|---|
| committer | diogo464 <[email protected]> | 2024-10-04 18:56:46 +0100 |
| commit | 888834093351a6182b0b3dd57b6bce15a6fb0e92 (patch) | |
| tree | 48c09bb1c9c0bf0e5daeb4dca732fd08b7389973 | |
| parent | 987f6fbbe4ffabd5dca60bdb7a828049603ae0db (diff) | |
rework and added pictures
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | Containerfile | 1 | ||||
| -rw-r--r-- | app.go | 411 | ||||
| -rw-r--r-- | content/editor.html (renamed from content/shapes.html) | 0 | ||||
| -rw-r--r-- | content/index.html | 72 | ||||
| -rw-r--r-- | content/logger.html | 45 | ||||
| -rw-r--r-- | content/static/main.js | 71 | ||||
| -rw-r--r-- | exif.go | 45 | ||||
| -rw-r--r-- | magick.go | 16 | ||||
| -rw-r--r-- | main.go | 277 |
10 files changed, 592 insertions, 351 deletions
| @@ -1,2 +1,7 @@ | |||
| 1 | *.db | 1 | *.db |
| 2 | belverde-fire | 2 | belverde-fire |
| 3 | pictures/ | ||
| 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 @@ | |||
| 1 | FROM debian:bookworm-slim | 1 | FROM debian:bookworm-slim |
| 2 | RUN apt update && apt install -y sqlite3 imagemagick exiftool && apt-get clean | ||
| 2 | WORKDIR /app/data | 3 | WORKDIR /app/data |
| 3 | COPY belverde-fire /app/belverde-fire | 4 | COPY belverde-fire /app/belverde-fire |
| 4 | ENTRYPOINT ["/app/belverde-fire"] | 5 | ENTRYPOINT ["/app/belverde-fire"] |
| @@ -0,0 +1,411 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 22 | const PicturesFormat string = ".png" | ||
| 23 | |||
| 24 | type AppConfig struct { | ||
| 25 | HttpUsername string | ||
| 26 | HttpPassword string | ||
| 27 | |||
| 28 | PicturesDirectory string | ||
| 29 | } | ||
| 30 | |||
| 31 | type 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 | |||
| 44 | type 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 | |||
| 52 | type ShapePoint struct { | ||
| 53 | Latitude float64 `json:"latitude"` | ||
| 54 | Longitude float64 `json:"longitude"` | ||
| 55 | } | ||
| 56 | |||
| 57 | const ( | ||
| 58 | ShapeKindUnburned string = "unburned" | ||
| 59 | ShapeKindBurned string = "burned" | ||
| 60 | ) | ||
| 61 | |||
| 62 | type Shape struct { | ||
| 63 | Kind string `json:"kind"` | ||
| 64 | Points []ShapePoint `json:"points"` | ||
| 65 | } | ||
| 66 | |||
| 67 | type Picture struct { | ||
| 68 | Filename string `json:"filename"` | ||
| 69 | Latitude float64 `json:"latitude"` | ||
| 70 | Longitude float64 `json:"longitude"` | ||
| 71 | } | ||
| 72 | |||
| 73 | type PictureFile struct { | ||
| 74 | Filename string | ||
| 75 | Latitude float64 | ||
| 76 | Longitude float64 | ||
| 77 | LastModTime time.Time | ||
| 78 | } | ||
| 79 | |||
| 80 | //go:embed content/* | ||
| 81 | var appContentFs embed.FS | ||
| 82 | |||
| 83 | //go:embed schema.sql | ||
| 84 | var appDbSchema string | ||
| 85 | |||
| 86 | func 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 | |||
| 123 | func (a *App) Close() { | ||
| 124 | a.db.Close() | ||
| 125 | } | ||
| 126 | |||
| 127 | func (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 | |||
| 142 | func (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 | |||
| 152 | func (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 | |||
| 166 | func (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 | |||
| 172 | func (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 | |||
| 190 | func (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 | |||
| 197 | func (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 | |||
| 226 | func (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 | |||
| 268 | func (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 | |||
| 291 | func (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 | |||
| 301 | func (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 | |||
| 310 | func (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 | |||
| 330 | func (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 | |||
| 390 | func (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 | |||
| 402 | func (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: '© <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 | ||
| 52 | function lib_setup_map() { | 60 | function 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: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' | 65 | attribution: '© <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 | */ |
| 65 | async function lib_fetch_location_logs() { | 74 | async 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 | */ |
| 102 | async function lib_fetch_shape_descriptors() { | 111 | async 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 | */ | ||
| 120 | async function lib_fetch_picture_descriptors() { | ||
| 121 | const response = await fetch("/api/pictures"); | ||
| 122 | return response.json(); | ||
| 123 | } | ||
| 124 | |||
| 125 | function lib_picture_descriptor_url(picture_descriptor) { | ||
| 126 | const picture_url = `/picture/${picture_descriptor.filename}` | ||
| 127 | return picture_url; | ||
| 128 | } | ||
| 129 | |||
| 107 | function lib_add_location_logs_to_map(map, locations) { | 130 | function 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 | ||
| 338 | function page_main__poly_create_from_shape_descriptor(map, shape_descriptor) { | 361 | function 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 | ||
| 370 | function 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 | |||
| 388 | function 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 | |||
| 347 | async function page_index__main() { | 396 | async 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 | } |
| @@ -0,0 +1,45 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "os/exec" | ||
| 6 | "strconv" | ||
| 7 | |||
| 8 | "github.com/pkg/errors" | ||
| 9 | ) | ||
| 10 | |||
| 11 | type ExifData struct { | ||
| 12 | Latitude float64 | ||
| 13 | Longitude float64 | ||
| 14 | } | ||
| 15 | |||
| 16 | func 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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os/exec" | ||
| 5 | |||
| 6 | "github.com/pkg/errors" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func 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 | } | ||
| @@ -1,22 +1,13 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 22 | const ( | 13 | const ( |
| @@ -24,270 +15,24 @@ const ( | |||
| 24 | ENV_VAR_HTTP_PASSWORD = "HTTP_PASSWORD" | 15 | ENV_VAR_HTTP_PASSWORD = "HTTP_PASSWORD" |
| 25 | ) | 16 | ) |
| 26 | 17 | ||
| 27 | type 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 | |||
| 35 | type ShapePoint struct { | ||
| 36 | Latitude float64 `json:"latitude"` | ||
| 37 | Longitude float64 `json:"longitude"` | ||
| 38 | } | ||
| 39 | |||
| 40 | const ( | ||
| 41 | ShapeKindUnburned string = "unburned" | ||
| 42 | ShapeKindBurned string = "burned" | ||
| 43 | ) | ||
| 44 | |||
| 45 | type Shape struct { | ||
| 46 | Kind string `json:"kind"` | ||
| 47 | Points []ShapePoint `json:"points"` | ||
| 48 | } | ||
| 49 | |||
| 50 | var db *sql.DB | ||
| 51 | |||
| 52 | func 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 | |||
| 70 | func 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 | |||
| 77 | func 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 | |||
| 106 | func 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 | |||
| 148 | func 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 | |||
| 171 | func 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 | |||
| 189 | func 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 | |||
| 205 | func 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 | |||
| 225 | func 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 | ||
| 240 | var schema string | ||
| 241 | |||
| 242 | //go:embed content/* | ||
| 243 | var contentFs embed.FS | ||
| 244 | |||
| 245 | func main() { | 18 | func 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 | } |
