From 6fd066773f6dace539f9db6db0d2671294a18b8c Mon Sep 17 00:00:00 2001 From: Swrup Date: Sun, 4 Dec 2022 22:42:55 +0100 Subject: [PATCH] b --- .gitignore | 1 + .ocamlformat | 42 ++ CHANGES.md | 1 + README.md | 40 ++ doc/dune | 3 + doc/index.mld | 17 + dune-project | 31 ++ example/dune | 3 + example/main.ml | 1 + pellest.opam | 32 ++ src/app.ml | 94 ++++ src/content/assets/css/leaflet.css | 640 ++++++++++++++++++++++ src/content/assets/css/style.css | 21 + src/content/assets/img/layers-2x.png | Bin 0 -> 1259 bytes src/content/assets/img/layers.png | Bin 0 -> 696 bytes src/content/assets/img/marker-icon-2x.png | Bin 0 -> 2464 bytes src/content/assets/img/marker-icon.png | Bin 0 -> 1466 bytes src/content/assets/img/marker-shadow.png | Bin 0 -> 618 bytes src/content/assets/js/dune | 8 + src/db.ml | 48 ++ src/dune | 45 ++ src/home.ml | 11 + src/js/client.ml | 0 src/js/dune | 10 + src/js/geo.ml | 97 ++++ src/js/leaflet/leaflet.js | 6 + src/js/utils.ml | 14 + src/login.ml | 16 + src/pellest.ml | 52 ++ src/register.ml | 19 + src/syntax.ml | 12 + src/template.ml | 15 + src/tyx_util.ml | 7 + src/user.ml | 213 +++++++ src/util.ml | 34 ++ test/dune | 3 + test/test.ml | 1 + 37 files changed, 1537 insertions(+) create mode 100644 .gitignore create mode 100644 .ocamlformat create mode 100644 CHANGES.md create mode 100644 README.md create mode 100644 doc/dune create mode 100644 doc/index.mld create mode 100644 dune-project create mode 100644 example/dune create mode 100644 example/main.ml create mode 100644 pellest.opam create mode 100644 src/app.ml create mode 100644 src/content/assets/css/leaflet.css create mode 100644 src/content/assets/css/style.css create mode 100644 src/content/assets/img/layers-2x.png create mode 100644 src/content/assets/img/layers.png create mode 100644 src/content/assets/img/marker-icon-2x.png create mode 100644 src/content/assets/img/marker-icon.png create mode 100644 src/content/assets/img/marker-shadow.png create mode 100644 src/content/assets/js/dune create mode 100644 src/db.ml create mode 100644 src/dune create mode 100644 src/home.ml create mode 100644 src/js/client.ml create mode 100644 src/js/dune create mode 100644 src/js/geo.ml create mode 100644 src/js/leaflet/leaflet.js create mode 100644 src/js/utils.ml create mode 100644 src/login.ml create mode 100644 src/pellest.ml create mode 100644 src/register.ml create mode 100644 src/syntax.ml create mode 100644 src/template.ml create mode 100644 src/tyx_util.ml create mode 100644 src/user.ml create mode 100644 src/util.ml create mode 100644 test/dune create mode 100644 test/test.ml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e35d885 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +_build diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..f81fbf5 --- /dev/null +++ b/.ocamlformat @@ -0,0 +1,42 @@ +version=0.23.0 +assignment-operator=end-line +break-cases=fit +break-fun-decl=wrap +break-fun-sig=wrap +break-infix=wrap +break-infix-before-func=false +break-separators=before +break-sequences=true +cases-exp-indent=2 +cases-matching-exp-indent=normal +doc-comments=before +doc-comments-padding=2 +doc-comments-tag-only=default +dock-collection-brackets=false +exp-grouping=preserve +field-space=loose +if-then-else=compact +indicate-multiline-delimiters=space +indicate-nested-or-patterns=unsafe-no +infix-precedence=indent +leading-nested-match-parens=false +let-and=sparse +let-binding-spacing=compact +let-module=compact +margin=80 +max-indent=68 +module-item-spacing=sparse +ocp-indent-compat=false +parens-ite=false +parens-tuple=always +parse-docstrings=true +sequence-blank-line=preserve-one +sequence-style=terminator +single-case=compact +space-around-arrays=true +space-around-lists=true +space-around-records=true +space-around-variants=true +type-decl=sparse +wrap-comments=false +wrap-fun-args=true diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..d9cd2e7 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1 @@ +## unreleased diff --git a/README.md b/README.md new file mode 100644 index 0000000..01c678e --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# pellest + +[pellest] is an [OCaml] executable/library to TODO. + +## Installation + +`pellest` can be installed with [opam]: + +```sh +opam install pellest +``` + +If you don't have `opam`, you can install it following the [how to install opam] guide. + +If you can't or don't want to use `opam`, consult the [opam file] for build instructions. + +## Quickstart + +```ocaml +let () = Format.printf "TODO@." +``` + +For more, have a look at the [example] folder, at the [documentation] or at the [test suite]. + +## About + +- [LICENSE] +- [CHANGELOG] + +[CHANGELOG]: ./CHANGES.md +[example]: ./example +[LICENSE]: ./LICENSE.md +[opam file]: ./pellest.opam +[test suite]: ./test + +[documentation]: TODO/pellest +[how to install opam]: https://opam.ocaml.org/doc/Install.html +[OCaml]: https://ocaml.org +[opam]: https://opam.ocaml.org/ +[pellest]: TODO/pellest diff --git a/doc/dune b/doc/dune new file mode 100644 index 0000000..83c0c87 --- /dev/null +++ b/doc/dune @@ -0,0 +1,3 @@ +(documentation + (package pellest) + (mld_files index)) diff --git a/doc/index.mld b/doc/index.mld new file mode 100644 index 0000000..85c866f --- /dev/null +++ b/doc/index.mld @@ -0,0 +1,17 @@ +{0 pellest} + +{{:https://TODO/pellest} pellest} is an {{:https://ocaml.org} OCaml} library/executable to TODO. + +{1:api API} + +{!modules: +Pellest +} + +{1:private_api Private API} + +You shouldn't have to use any of these modules, they're used internally only. + +{!modules: +TODO +} diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..1c009d1 --- /dev/null +++ b/dune-project @@ -0,0 +1,31 @@ +(lang dune 2.9) + +(implicit_transitive_deps false) + +(name pellest) + +(authors "swrup") + +(maintainers "swrup@protonmail.com") + +(source + (uri TODO/pellest)) + +(homepage TODO/pellest) + +(bug_reports TODO/pellest) + +(documentation TODO/pellest) + +(generate_opam_files true) + +(package + (name pellest) + (synopsis "OCaml library/executable to TODO") + (description + "pellest is an OCaml library/executable to TODO.") + (tags + (pellest TODO TODO TODO TODO)) + (depends + (ocaml + (>= 4.08)))) diff --git a/example/dune b/example/dune new file mode 100644 index 0000000..e36b142 --- /dev/null +++ b/example/dune @@ -0,0 +1,3 @@ +(executable + (name main) + (modules main)) diff --git a/example/main.ml b/example/main.ml new file mode 100644 index 0000000..16c9b9f --- /dev/null +++ b/example/main.ml @@ -0,0 +1 @@ +let () = Format.printf "TODO@." diff --git a/pellest.opam b/pellest.opam new file mode 100644 index 0000000..72709f3 --- /dev/null +++ b/pellest.opam @@ -0,0 +1,32 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "OCaml library/executable to TODO" +description: "pellest is an OCaml library/executable to TODO." +maintainer: ["swrup@protonmail.com"] +authors: ["swrup"] +tags: ["pellest" "TODO" "TODO" "TODO" "TODO"] +homepage: "TODO/pellest" +doc: "TODO/pellest" +bug-reports: "TODO/pellest" +depends: [ + "dune" {>= "2.9"} + "ocaml" {>= "4.08"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "--promote-install-files=false" + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] + ["dune" "install" "-p" name "--create-install-files" name] +] +dev-repo: "TODO/pellest" diff --git a/src/app.ml b/src/app.ml new file mode 100644 index 0000000..d213823 --- /dev/null +++ b/src/app.ml @@ -0,0 +1,94 @@ +module App_id = struct + let qualifier = "org" + + let organization = "pellest" + + let application = "pellest" +end + +module Project_dirs = Directories.Project_dirs (App_id) + +let data_dir = + match Project_dirs.data_dir with + | None -> failwith "can't compute data directory" + | Some data_dir -> data_dir + +let config_dir = + match Project_dirs.config_dir with + | None -> failwith "can't compute configuration directory" + | Some config_dir -> config_dir + +let config = + let filename = Filename.concat config_dir "config.scfg" in + if not @@ Sys.file_exists filename then + failwith + @@ Format.sprintf "configuration file `%s` does not exist, please create it" + filename; + Dream.log "config file: %s" filename; + match Scfg.Parse.from_file filename with + | Error e -> failwith e + | Ok config -> config + +let open_registration = + match Scfg.Query.get_dir "open_registration" config with + | None -> true + | Some open_registration -> ( + match Scfg.Query.get_param 0 open_registration with + | Error e -> failwith e + | Ok "true" -> true + | Ok "false" -> false + | Ok _unknown -> + failwith "invalid `open_registration` value in configuration file" ) + +let () = Dream.log "open_registration: %b" open_registration + +let port = + match Scfg.Query.get_dir "port" config with + | None -> 8080 + | Some port -> ( + match Scfg.Query.get_param 0 port with + | Error e -> failwith e + | Ok n -> ( + try + let n = int_of_string n in + if n < 0 then raise (Invalid_argument "negative port number"); + n + with Invalid_argument _msg -> + failwith "invalid `port` value in configuration file" ) ) + +let () = Dream.log "port: %d" port + +let hostname = + match Scfg.Query.get_dir "hostname" config with + | None -> Format.sprintf "localhost:%d" port + | Some hostname -> + Result.fold ~error:failwith ~ok:Fun.id (Scfg.Query.get_param 0 hostname) + +let () = Dream.log "hostname: %s" hostname + +let log = + match Scfg.Query.get_dir "log" config with + | None -> true + | Some log -> ( + match Scfg.Query.get_param 0 log with + | Error e -> failwith e + | Ok "true" -> true + | Ok "false" -> false + | Ok _unknown -> failwith "invalid `log` value in configuration file" ) + +let () = Dream.log "log: %b" log + +let get_dirs name = + let dirs = Scfg.Query.get_dirs name config in + List.map + (fun dir -> + Result.fold ~error:failwith ~ok:Fun.id (Scfg.Query.get_param 0 dir) ) + dirs + +let random_state = Random.State.make_self_init () + +let () = Random.set_state random_state + +let about = + (* TODO read from about.txt *) + "This is pellest" diff --git a/src/content/assets/css/leaflet.css b/src/content/assets/css/leaflet.css new file mode 100644 index 0000000..3385b5e --- /dev/null +++ b/src/content/assets/css/leaflet.css @@ -0,0 +1,640 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg, +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + } + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-tile { + will-change: opacity; + } +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +.leaflet-zoom-anim .leaflet-zoom-animated { + will-change: transform; + } +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline: 0; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-container a.leaflet-active { + outline: 2px solid orange; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a, +.leaflet-bar a:hover { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(/assets/img/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(/assets/img/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { + background-image: url(/assets/img/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.7); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover { + text-decoration: underline; + } +.leaflet-container .leaflet-control-attribution, +.leaflet-container .leaflet-control-scale { + font-size: 11px; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + -moz-box-sizing: border-box; + box-sizing: border-box; + + background: #fff; + background: rgba(255, 255, 255, 0.5); + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 19px; + line-height: 1.4; + } +.leaflet-popup-content p { + margin: 18px 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + padding: 4px 4px 0 0; + border: none; + text-align: center; + width: 18px; + height: 14px; + font: 16px/14px Tahoma, Verdana, sans-serif; + color: #c3c3c3; + text-decoration: none; + font-weight: bold; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover { + color: #999; + } +.leaflet-popup-scrolled { + overflow: auto; + border-bottom: 1px solid #ddd; + border-top: 1px solid #ddd; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } +.leaflet-oldie .leaflet-popup-tip-container { + margin-top: -1px; + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-clickable { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } diff --git a/src/content/assets/css/style.css b/src/content/assets/css/style.css new file mode 100644 index 0000000..ac08288 --- /dev/null +++ b/src/content/assets/css/style.css @@ -0,0 +1,21 @@ +html { + height: 100%; +} + +body { + height: 100%; + padding-top: 0rem; + color: #5a5a5a; + background-color: #e8eaf6; + line-height: 1.6; + font-size: 18px; +} + +#page-title { + text-align: center; +} + +main { + height: 100%; + width: 100%; +} diff --git a/src/content/assets/img/layers-2x.png b/src/content/assets/img/layers-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..200c333dca9652ac4cba004d609e5af4eee168c1 GIT binary patch literal 1259 zcmeAS@N?(olHy`uVBq!ia0y~yU@!q;4i*LmhWx_I2@DJ@n><|{Ln;`Pe)y|cT4BSrpMM7AKE|dn|XR+k!t#k(tv`Q zFW(sEd!8~#>$+9`f9l__#fF!KZ}B_rzo8eC{r}xGzO@x)!A}C+%Y(`<1s-2^LiGH- zNefThzU1_3Qe}!q>B&sRiQ8E#H9j`Wp1zeElOM1<_xPqG{7q&i{}sRA|NWDBa`yhi zf4~1}Z!&l?D{`K~v~Df`g0pk31-#L={QUP&^~BTfPt276eJZ4F#OLLkrWm<1w?G}$K(_Zg>&?>%7rSDWy?-oPVGDw+3f6ddKzommMznMtmE1m^mE;|X)^@3r5^M>q%(7WwwkhRUVZzX zFui*B_UL;{ERE{7ySzCa{A5c-^`03^s-Cnw)fD`#!?J7Jk{=GMTBd1ws=RS{H7RMA z+lH2N3m&&zxMt!%-Q)BVRe@_$r#$1#RlV@>f}~i)v%B7(D}^uiKE0`=J9&~rL}git za^vjh=XaOCc>eeO>kuD~_nyZSSrR*HoRZ?^jT=9HS|#-C z#I0=$9oqx1b}%c7@V%LR;e=gTTa&Q*O*Q|E%7{caZdK)_+ZJf zZ?&H!g%0t3zQ`e9eahh1=NZBi3YOTd+htU}IKfpW%e~b`fYbPb(yyJ0eX|!$;Jmtc zpGlI(Uq{=OPOf@MjvXyZfAUxkxkT|?T2R1T7_4W$NpIDXdncV=-Og(5O3XcC)5tQ> zz+Ah^=e$i`pnF#TudS&IcRlWTsKE8W$-*hoo%u=2$;`|LE*k2wcM@{W?B28Hj1!|( z?&`3_D`smL6xn7gD*L#dPAd}*D+qeymA_@1k^7b7fk)o?9(Qdv5?I?{*Qdo6H_a$Z zWx4ILe&t;!1-}-TBu7TxOI@rh@5%8t*~M7);;#tq)bxD@K>`uh2LySGU20__qc2Mz zz5e8&(8Q{b_nT$qIxJK3ZwNANm$G;z+!`QW&+ygqZIXsCQ=*b?Q`wxE@t%9{h$R(y z`}7A0eZ8=B>r+-n=MV*-1+Ej;cKH>13)vbyyUbGSW~KP1V^g_>mQb1#clPCv9d}vs ze&jIq>z1(p^?bgvyl$yv;Vr)mizp0?3uUtD~sA1FSi+%x^t@XE}h@-tYzk9 zqoeJq#fQ#h%zQM#GO;LXnVpKdkC%5qo68}Ws3tQNA+-xadlV-caB*y5$@NebP0l+XkKIZj$K literal 0 HcmV?d00001 diff --git a/src/content/assets/img/layers.png b/src/content/assets/img/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..1a72e5784b2b456eac5d7670738db80697af3377 GIT binary patch literal 696 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 literal 0 HcmV?d00001 diff --git a/src/content/assets/img/marker-shadow.png b/src/content/assets/img/marker-shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..9fd2979532a19a15b824ce763c76e04a8dafadfb GIT binary patch literal 618 zcmeAS@N?(olHy`uVBq!ia0y~yV9*3%4i*Lm2ByptwG0eQhMq2tAr*{oFSYhPb`W5> zz-9dRf4!`+aT{;(+DlW`OuKvh(cD`Fe?o50tvmGJPyYYkI}^-Pj=%i->5t$_?z+Eg z<0t)EHE)aXMy2F{nEjj_T;IYwdK?Rnnaqjf{1)D-;#kNmzt4GE;O+QxK@S+5(^AaU z7N=aDb)V1U?7Vn^%sUpxJr3q=Te918=e*elN$zhlHIzdXC1oeM8Mbg5Nj2o3WOPw+ zpMOwu*&gweJLb*OS~P7Ar@rLmS*ayg8lq+dZ0+?Zm6nX!yo^w|MJ60@P{_ADQggak&v`^0oPSK8g z6Zm$gzJVJ145{rKZ=dn?y=(KrV#&L&JR6VjWuMu>*SMBLWeLMd-$OHax6Mdiqt~NQ zs^Y#(?6|a*=efdFwcBoGHo3%{*nZNbWKCU{!`kM%S>CBD4~8GC3UcaXD%fwav8F%f z=oGOgmCmZ50aHZd{ Dream.log "created %s" db_root + | Ok false -> Dream.log "%s already exists" db_root + | Error (`Msg _) -> + Dream.warning (fun log -> log "error when creating %s" db_root) + +let db = Filename.concat db_root (App.App_id.application ^ ".db") + +let db_uri = Format.sprintf "sqlite3://%s" db + +module Db = + (val Caqti_blocking.connect (Uri.of_string db_uri) |> Caqti_blocking.or_fail) + +let () = + let set_foreign_keys_on = + Caqti_type.(unit ->. unit) "PRAGMA foreign_keys = ON" + in + if Result.is_error (Db.exec set_foreign_keys_on ()) then + Dream.error (fun log -> log "can't set foreign_keys on") + +let () = + let query = + Caqti_type.(unit ->. unit) + "CREATE TABLE IF NOT EXISTS dream_session (id TEXT PRIMARY KEY, label \ + TEXT NOT NULL, expires_at REAL NOT NULL, payload TEXT NOT NULL)" + in + match Db.exec query () with + | Ok () -> () + | Error _e -> + Format.eprintf "db error@\n"; + exit 1 + +let unwrap_err = function + | Error e -> Error (Format.sprintf "db error: %s" (Caqti_error.show e)) + | Ok _ as ok -> ok + +let exec q v = Db.exec q v |> unwrap_err + +let find q v = Db.find q v |> unwrap_err + +let find_opt q v = Db.find_opt q v |> unwrap_err + +let collect_list q v = Db.collect_list q v |> unwrap_err diff --git a/src/dune b/src/dune new file mode 100644 index 0000000..8352fbb --- /dev/null +++ b/src/dune @@ -0,0 +1,45 @@ +(executable + (name pellest) + (modules + app + content + pellest + util + template + home + register + login + user + syntax + db + tyx_util) + (libraries + uuidm + bos + caqti + caqti.blocking + caqti-driver-sqlite3 + directories + dream + emile + fpath + lambdasoup + lwt + safepass + scfg + uri + tyxml + tyxml.functor + yojson) + (preprocess + (pps lwt_ppx))) + +(rule + (target content.ml) + (deps + (source_tree content) + (file content/assets/js/client.js)) + (action + (with-stdout-to + %{null} + (run ocaml-crunch -m plain content -o %{target})))) diff --git a/src/home.ml b/src/home.ml new file mode 100644 index 0000000..2b03752 --- /dev/null +++ b/src/home.ml @@ -0,0 +1,11 @@ +open Tyxml.Html + +let f _request = + let page_title = "Pellest is the best game ever!" in + let about = div [ txt App.about ] in + let link_to_register = + div [ a ~a:[ a_href "/register" ] [ txt "Register" ] ] + in + let link_to_login = div [ a ~a:[ a_href "/login" ] [ txt "Login" ] ] in + let page = div [ about; link_to_login; link_to_register ] in + Template.render ~page_title ~scripts:[] page diff --git a/src/js/client.ml b/src/js/client.ml new file mode 100644 index 0000000..e69de29 diff --git a/src/js/dune b/src/js/dune new file mode 100644 index 0000000..c404ab0 --- /dev/null +++ b/src/js/dune @@ -0,0 +1,10 @@ +(executable + (name client) + (modules client) + (libraries brr utils) + (modes js)) + +(library + (name utils) + (modules utils) + (libraries brr)) diff --git a/src/js/geo.ml b/src/js/geo.ml new file mode 100644 index 0000000..476d102 --- /dev/null +++ b/src/js/geo.ml @@ -0,0 +1,97 @@ +open Utils +open Leaflet + +let map = + let options = Jv.obj [| ("zoomControl", Jv.of_bool false) |] in + Map.create_on ~options "map" + +let () = + let osm_layer = Layer.create_tile_osm None in + Layer.add_to map osm_layer + +let storage = Brr_io.Storage.local Brr.G.window + +let save_view () = + let latlng = Map.get_center map in + let zoom = Map.get_zoom map |> Jstr.of_int in + let lat = Latlng.lat latlng |> Jstr.of_float in + let lng = Latlng.lng latlng |> Jstr.of_float in + match Brr_io.Storage.set_item storage (Jstr.v "lat") lat with + | (exception Jv.Error _) | Error _ -> failwith "can't set latlng storage" + | Ok () -> ( + match Brr_io.Storage.set_item storage (Jstr.v "lng") lng with + | (exception Jv.Error _) | Error _ -> failwith "can't set latlng storage" + | Ok () -> ( + match Brr_io.Storage.set_item storage (Jstr.v "zoom") zoom with + | (exception Jv.Error _) | Error _ -> failwith "can't set zoom storage" + | Ok () -> () ) ) + +(* wrap Leaflet.Map.set_view to save last position to storage *) +let set_view latlng ~zoom = + log "set view wrapper@\n"; + (*we need to wrap coordinates so we don't drift into a parralel universe and lose track of markers :^) *) + (* todo: use `worldCopyJump` option on map creation *) + let wrapped_latlng = Map.wrap_latlng latlng map in + Map.set_view wrapped_latlng ~zoom map; + save_view () + +(* set map's view *) +(* try to set map's view to last position viewed by using web storage *) +let () = + log "setting view@\n"; + let lat = Brr_io.Storage.get_item storage (Jstr.v "lat") in + let lng = Brr_io.Storage.get_item storage (Jstr.v "lng") in + let zoom = Brr_io.Storage.get_item storage (Jstr.v "zoom") in + match (lat, lng, zoom) with + | Some lat, Some lng, Some zoom -> + let lat = Jstr.to_float lat in + let lng = Jstr.to_float lng in + let zoom = + match Jstr.to_int zoom with + | None -> failwith "view storage bug" + | Some zoom -> Some zoom + in + let latlng = Latlng.create lat lng in + set_view latlng ~zoom + | _ -> + let latlng = Latlng.create 51.505 (-0.09) in + set_view latlng ~zoom:(Some 13) + +let () = + log "add on (move/zoom)end event@\n"; + let on_moveend _event = + log "on moveend event@\n"; + save_view () + in + let on_zoomend _event = + log "on zoomend event@\n"; + save_view () + in + Map.on Event.Move_end on_moveend map; + Map.on Event.Zoom_end on_zoomend map + +let watch_geolocation f = + let open Brr_io.Geolocation in + log "geolocalize@\n"; + + let update_location geo = + log "update_location@\n"; + match geo with + | Error e -> + (* todo: popup error message for user *) + log "geolocation failure: %s@\n" @@ Jstr.to_string @@ Error.message e + | Ok geo -> + (* monitors geolocation update with f *) + f geo; + (* set view *) + let lat = Pos.latitude geo in + let lng = Pos.longitude geo in + let latlng = Latlng.create lat lng in + set_view latlng ~zoom:None + (* TODO update/make camel marker on the map *) + in + + (* watch l ~opts f monitors the position of l determined with opts by periodically calling f. Stop watching by calling unwatch with the returned identifier. *) + let l = of_navigator Brr.G.navigator in + let opts = opts ~high_accuracy:true () in + watch l ~opts update_location diff --git a/src/js/leaflet/leaflet.js b/src/js/leaflet/leaflet.js new file mode 100644 index 0000000..21f499c --- /dev/null +++ b/src/js/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.7.1, a JS library for interactive maps. http://leafletjs.com + * (c) 2010-2019 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?i(exports):"function"==typeof define&&define.amd?define(["exports"],i):i(t.L={})}(this,function(t){"use strict";function h(t){for(var i,e,n=1,o=arguments.length;n=this.min.x&&e.x<=this.max.x&&i.y>=this.min.y&&e.y<=this.max.y},intersects:function(t){t=O(t);var i=this.min,e=this.max,n=t.min,o=t.max,s=o.x>=i.x&&n.x<=e.x,r=o.y>=i.y&&n.y<=e.y;return s&&r},overlaps:function(t){t=O(t);var i=this.min,e=this.max,n=t.min,o=t.max,s=o.x>i.x&&n.xi.y&&n.y=n.lat&&e.lat<=o.lat&&i.lng>=n.lng&&e.lng<=o.lng},intersects:function(t){t=N(t);var i=this._southWest,e=this._northEast,n=t.getSouthWest(),o=t.getNorthEast(),s=o.lat>=i.lat&&n.lat<=e.lat,r=o.lng>=i.lng&&n.lng<=e.lng;return s&&r},overlaps:function(t){t=N(t);var i=this._southWest,e=this._northEast,n=t.getSouthWest(),o=t.getNorthEast(),s=o.lat>i.lat&&n.lati.lng&&n.lng';var i=t.firstChild;return i.style.behavior="url(#default#VML)",i&&"object"==typeof i.adj}catch(t){return!1}}();function kt(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var Bt={ie:tt,ielt9:it,edge:et,webkit:nt,android:ot,android23:st,androidStock:at,opera:ht,chrome:ut,gecko:lt,safari:ct,phantom:_t,opera12:dt,win:pt,ie3d:mt,webkit3d:ft,gecko3d:gt,any3d:vt,mobile:yt,mobileWebkit:xt,mobileWebkit3d:wt,msPointer:Pt,pointer:Lt,touch:bt,mobileOpera:Tt,mobileGecko:Mt,retina:zt,passiveEvents:Ct,canvas:St,svg:Zt,vml:Et},At=Pt?"MSPointerDown":"pointerdown",It=Pt?"MSPointerMove":"pointermove",Ot=Pt?"MSPointerUp":"pointerup",Rt=Pt?"MSPointerCancel":"pointercancel",Nt={},Dt=!1;function jt(t,i,e,n){function o(t){Ut(t,r)}var s,r,a,h,u,l,c,_;function d(t){t.pointerType===(t.MSPOINTER_TYPE_MOUSE||"mouse")&&0===t.buttons||Ut(t,h)}return"touchstart"===i?(u=t,l=e,c=n,_=p(function(t){t.MSPOINTER_TYPE_TOUCH&&t.pointerType===t.MSPOINTER_TYPE_TOUCH&&Ri(t),Ut(t,l)}),u["_leaflet_touchstart"+c]=_,u.addEventListener(At,_,!1),Dt||(document.addEventListener(At,Wt,!0),document.addEventListener(It,Ht,!0),document.addEventListener(Ot,Ft,!0),document.addEventListener(Rt,Ft,!0),Dt=!0)):"touchmove"===i?(h=e,(a=t)["_leaflet_touchmove"+n]=d,a.addEventListener(It,d,!1)):"touchend"===i&&(r=e,(s=t)["_leaflet_touchend"+n]=o,s.addEventListener(Ot,o,!1),s.addEventListener(Rt,o,!1)),this}function Wt(t){Nt[t.pointerId]=t}function Ht(t){Nt[t.pointerId]&&(Nt[t.pointerId]=t)}function Ft(t){delete Nt[t.pointerId]}function Ut(t,i){for(var e in t.touches=[],Nt)t.touches.push(Nt[e]);t.changedTouches=[t],i(t)}var Vt=Pt?"MSPointerDown":Lt?"pointerdown":"touchstart",qt=Pt?"MSPointerUp":Lt?"pointerup":"touchend",Gt="_leaflet_";var Kt,Yt,Xt,Jt,$t,Qt,ti=fi(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ii=fi(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),ei="webkitTransition"===ii||"OTransition"===ii?ii+"End":"transitionend";function ni(t){return"string"==typeof t?document.getElementById(t):t}function oi(t,i){var e,n=t.style[i]||t.currentStyle&&t.currentStyle[i];return n&&"auto"!==n||!document.defaultView||(n=(e=document.defaultView.getComputedStyle(t,null))?e[i]:null),"auto"===n?null:n}function si(t,i,e){var n=document.createElement(t);return n.className=i||"",e&&e.appendChild(n),n}function ri(t){var i=t.parentNode;i&&i.removeChild(t)}function ai(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function hi(t){var i=t.parentNode;i&&i.lastChild!==t&&i.appendChild(t)}function ui(t){var i=t.parentNode;i&&i.firstChild!==t&&i.insertBefore(t,i.firstChild)}function li(t,i){if(void 0!==t.classList)return t.classList.contains(i);var e=pi(t);return 0this.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,i){this._enforcingBounds=!0;var e=this.getCenter(),n=this._limitCenter(e,this._zoom,N(t));return e.equals(n)||this.panTo(n,i),this._enforcingBounds=!1,this},panInside:function(t,i){var e,n,o=A((i=i||{}).paddingTopLeft||i.padding||[0,0]),s=A(i.paddingBottomRight||i.padding||[0,0]),r=this.getCenter(),a=this.project(r),h=this.project(t),u=this.getPixelBounds(),l=u.getSize().divideBy(2),c=O([u.min.add(o),u.max.subtract(s)]);return c.contains(h)||(this._enforcingBounds=!0,e=a.subtract(h),n=A(h.x+e.x,h.y+e.y),(h.xc.max.x)&&(n.x=a.x-e.x,0c.max.y)&&(n.y=a.y-e.y,0=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,i){for(var e,n=[],o="mouseout"===i||"mouseover"===i,s=t.target||t.srcElement,r=!1;s;){if((e=this._targets[m(s)])&&("click"===i||"preclick"===i)&&!t._simulated&&this._draggableMoved(e)){r=!0;break}if(e&&e.listens(i,!0)){if(o&&!Vi(s,t))break;if(n.push(e),o)break}if(s===this._container)break;s=s.parentNode}return n.length||r||o||!Vi(s,t)||(n=[this]),n},_handleDOMEvent:function(t){var i;this._loaded&&!Ui(t)&&("mousedown"!==(i=t.type)&&"keypress"!==i&&"keyup"!==i&&"keydown"!==i||Pi(t.target||t.srcElement),this._fireDOMEvent(t,i))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,i,e){var n;if("click"===t.type&&((n=h({},t)).type="preclick",this._fireDOMEvent(n,n.type,e)),!t._stopped&&(e=(e||[]).concat(this._findEventTargets(t,i))).length){var o=e[0];"contextmenu"===i&&o.listens(i,!0)&&Ri(t);var s,r={originalEvent:t};"keypress"!==t.type&&"keydown"!==t.type&&"keyup"!==t.type&&(s=o.getLatLng&&(!o._radius||o._radius<=10),r.containerPoint=s?this.latLngToContainerPoint(o.getLatLng()):this.mouseEventToContainerPoint(t),r.layerPoint=this.containerPointToLayerPoint(r.containerPoint),r.latlng=s?o.getLatLng():this.layerPointToLatLng(r.layerPoint));for(var a=0;athis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(i),o=this._getCenterOffset(t)._divideBy(1-1/n);return!(!0!==e.animate&&!this.getSize().contains(o))&&(M(function(){this._moveStart(!0,!1)._animateZoom(t,i,!0)},this),!0)},_animateZoom:function(t,i,e,n){this._mapPane&&(e&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=i,ci(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:i,noUpdate:n}),setTimeout(p(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&_i(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom),M(function(){this._moveEnd(!0)},this))}});function Yi(t){return new Xi(t)}var Xi=S.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var i=this._map;return i&&i.removeControl(this),this.options.position=t,i&&i.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var i=this._container=this.onAdd(t),e=this.getPosition(),n=t._controlCorners[e];return ci(i,"leaflet-control"),-1!==e.indexOf("bottom")?n.insertBefore(i,n.firstChild):n.appendChild(i),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(ri(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",n=document.createElement("div");return n.innerHTML=e,n.firstChild},_addItem:function(t){var i,e=document.createElement("label"),n=this._map.hasLayer(t.layer);t.overlay?((i=document.createElement("input")).type="checkbox",i.className="leaflet-control-layers-selector",i.defaultChecked=n):i=this._createRadioElement("leaflet-base-layers_"+m(this),n),this._layerControlInputs.push(i),i.layerId=m(t.layer),zi(i,"click",this._onInputClick,this);var o=document.createElement("span");o.innerHTML=" "+t.name;var s=document.createElement("div");return e.appendChild(s),s.appendChild(i),s.appendChild(o),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(e),this._checkDisabledLayers(),e},_onInputClick:function(){var t,i,e=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=e.length-1;0<=s;s--)t=e[s],i=this._getLayer(t.layerId).layer,t.checked?n.push(i):t.checked||o.push(i);for(s=0;si.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expand:function(){return this.expand()},_collapse:function(){return this.collapse()}}),$i=Xi.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"−",zoomOutTitle:"Zoom out"},onAdd:function(t){var i="leaflet-control-zoom",e=si("div",i+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,i+"-in",e,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,i+"-out",e,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),e},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,i,e,n,o){var s=si("a",e,n);return s.innerHTML=t,s.href="#",s.title=i,s.setAttribute("role","button"),s.setAttribute("aria-label",i),Oi(s),zi(s,"click",Ni),zi(s,"click",o,this),zi(s,"click",this._refocusOnMap,this),s},_updateDisabled:function(){var t=this._map,i="leaflet-disabled";_i(this._zoomInButton,i),_i(this._zoomOutButton,i),!this._disabled&&t._zoom!==t.getMinZoom()||ci(this._zoomOutButton,i),!this._disabled&&t._zoom!==t.getMaxZoom()||ci(this._zoomInButton,i)}});Ki.mergeOptions({zoomControl:!0}),Ki.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new $i,this.addControl(this.zoomControl))});var Qi=Xi.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var i="leaflet-control-scale",e=si("div",i),n=this.options;return this._addScales(n,i+"-line",e),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),e},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,i,e){t.metric&&(this._mScale=si("div",i,e)),t.imperial&&(this._iScale=si("div",i,e))},_update:function(){var t=this._map,i=t.getSize().y/2,e=t.distance(t.containerPointToLatLng([0,i]),t.containerPointToLatLng([this.options.maxWidth,i]));this._updateScales(e)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var i=this._getRoundNum(t),e=i<1e3?i+" m":i/1e3+" km";this._updateScale(this._mScale,e,i/t)},_updateImperial:function(t){var i,e,n,o=3.2808399*t;5280Leaflet'},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var i in(t.attributionControl=this)._container=si("div","leaflet-control-attribution"),Oi(this._container),t._layers)t._layers[i].getAttribution&&this.addAttribution(t._layers[i].getAttribution());return this._update(),this._container},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t=[];for(var i in this._attributions)this._attributions[i]&&t.push(i);var e=[];this.options.prefix&&e.push(this.options.prefix),t.length&&e.push(t.join(", ")),this._container.innerHTML=e.join(" | ")}}});Ki.mergeOptions({attributionControl:!0}),Ki.addInitHook(function(){this.options.attributionControl&&(new te).addTo(this)});Xi.Layers=Ji,Xi.Zoom=$i,Xi.Scale=Qi,Xi.Attribution=te,Yi.layers=function(t,i,e){return new Ji(t,i,e)},Yi.zoom=function(t){return new $i(t)},Yi.scale=function(t){return new Qi(t)},Yi.attribution=function(t){return new te(t)};var ie=S.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}});ie.addTo=function(t,i){return t.addHandler(i,this),this};var ee,ne={Events:Z},oe=bt?"touchstart mousedown":"mousedown",se={mousedown:"mouseup",touchstart:"touchend",pointerdown:"touchend",MSPointerDown:"touchend"},re={mousedown:"mousemove",touchstart:"touchmove",pointerdown:"touchmove",MSPointerDown:"touchmove"},ae=E.extend({options:{clickTolerance:3},initialize:function(t,i,e,n){c(this,n),this._element=t,this._dragStartTarget=i||t,this._preventOutline=e},enable:function(){this._enabled||(zi(this._dragStartTarget,oe,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(ae._dragging===this&&this.finishDrag(),Si(this._dragStartTarget,oe,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var i,e;!t._simulated&&this._enabled&&(this._moved=!1,li(this._element,"leaflet-zoom-anim")||ae._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((ae._dragging=this)._preventOutline&&Pi(this._element),xi(),Xt(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=bi(this._element),this._startPoint=new k(i.clientX,i.clientY),this._parentScale=Ti(e),zi(document,re[t.type],this._onMove,this),zi(document,se[t.type],this._onUp,this))))},_onMove:function(t){var i,e;!t._simulated&&this._enabled&&(t.touches&&1i&&(e.push(t[n]),o=n);oi.max.x&&(e|=2),t.yi.max.y&&(e|=8),e}function de(t,i,e,n){var o,s=i.x,r=i.y,a=e.x-s,h=e.y-r,u=a*a+h*h;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-e.x)*(t.y-e.y)/(n.y-e.y)+e.x&&(u=!u);return u||Oe.prototype._containsPoint.call(this,t,!0)}});var Ne=Ce.extend({initialize:function(t,i){c(this,i),this._layers={},t&&this.addData(t)},addData:function(t){var i,e,n,o=g(t)?t:t.features;if(o){for(i=0,e=o.length;iu.x&&(l=s.x+n-u.x+h.x),s.x-l-a.x<0&&(l=s.x-a.x),s.y+e+h.y>u.y&&(c=s.y+e-u.y+h.y),s.y-c-a.y<0&&(c=s.y-a.y),(l||c)&&t.fire("autopanstart").panBy([l,c]))},_onCloseButtonClick:function(t){this._close(),Ni(t)},_getAnchor:function(){return A(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}});Ki.mergeOptions({closePopupOnClick:!0}),Ki.include({openPopup:function(t,i,e){return t instanceof tn||(t=new tn(e).setContent(t)),i&&t.setLatLng(i),this.hasLayer(t)?this:(this._popup&&this._popup.options.autoClose&&this.closePopup(),this._popup=t,this.addLayer(t))},closePopup:function(t){return t&&t!==this._popup||(t=this._popup,this._popup=null),t&&this.removeLayer(t),this}}),Me.include({bindPopup:function(t,i){return t instanceof tn?(c(t,i),(this._popup=t)._source=this):(this._popup&&!i||(this._popup=new tn(i,this)),this._popup.setContent(t)),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t,i){return this._popup&&this._map&&(i=this._popup._prepareOpen(this,t,i),this._map.openPopup(this._popup,i)),this},closePopup:function(){return this._popup&&this._popup._close(),this},togglePopup:function(t){return this._popup&&(this._popup._map?this.closePopup():this.openPopup(t)),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var i=t.layer||t.target;this._popup&&this._map&&(Ni(t),i instanceof Be?this.openPopup(t.layer||t.target,t.latlng):this._map.hasLayer(this._popup)&&this._popup._source===i?this.closePopup():this.openPopup(i,t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}});var en=Qe.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,interactive:!1,opacity:.9},onAdd:function(t){Qe.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&this._source.fire("tooltipopen",{tooltip:this},!0)},onRemove:function(t){Qe.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&this._source.fire("tooltipclose",{tooltip:this},!0)},getEvents:function(){var t=Qe.prototype.getEvents.call(this);return bt&&!this.options.permanent&&(t.preclick=this._close),t},_close:function(){this._map&&this._map.closeTooltip(this)},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=si("div",t)},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var i,e=this._map,n=this._container,o=e.latLngToContainerPoint(e.getCenter()),s=e.layerPointToContainerPoint(t),r=this.options.direction,a=n.offsetWidth,h=n.offsetHeight,u=A(this.options.offset),l=this._getAnchor(),c="top"===r?(i=a/2,h):"bottom"===r?(i=a/2,0):(i="center"===r?a/2:"right"===r?0:"left"===r?a:s.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oe.max.x)||!i.wrapLat&&(t.ye.max.y))return!1}if(!this.options.bounds)return!0;var n=this._tileCoordsToBounds(t);return N(this.options.bounds).overlaps(n)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var i=this._map,e=this.getTileSize(),n=t.scaleBy(e),o=n.add(e);return[i.unproject(n,t.z),i.unproject(o,t.z)]},_tileCoordsToBounds:function(t){var i=this._tileCoordsToNwSe(t),e=new R(i[0],i[1]);return this.options.noWrap||(e=this._map.wrapLatLngBounds(e)),e},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var i=t.split(":"),e=new k(+i[0],+i[1]);return e.z=+i[2],e},_removeTile:function(t){var i=this._tiles[t];i&&(ri(i.el),delete this._tiles[t],this.fire("tileunload",{tile:i.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){ci(t,"leaflet-tile");var i=this.getTileSize();t.style.width=i.x+"px",t.style.height=i.y+"px",t.onselectstart=a,t.onmousemove=a,it&&this.options.opacity<1&&mi(t,this.options.opacity),ot&&!st&&(t.style.WebkitBackfaceVisibility="hidden")},_addTile:function(t,i){var e=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),p(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&M(p(this._tileReady,this,t,null,o)),vi(o,e),this._tiles[n]={el:o,coords:t,current:!0},i.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,i,e){i&&this.fire("tileerror",{error:i,tile:e,coords:t});var n=this._tileCoordsToKey(t);(e=this._tiles[n])&&(e.loaded=+new Date,this._map._fadeAnimated?(mi(e.el,0),z(this._fadeFrame),this._fadeFrame=M(this._updateOpacity,this)):(e.active=!0,this._pruneTiles()),i||(ci(e.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:e.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),it||!this._map._fadeAnimated?M(this._pruneTiles,this):setTimeout(p(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var i=new k(this._wrapX?o(t.x,this._wrapX):t.x,this._wrapY?o(t.y,this._wrapY):t.y);return i.z=t.z,i},_pxBoundsToTileRange:function(t){var i=this.getTileSize();return new I(t.min.unscaleBy(i).floor(),t.max.unscaleBy(i).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var sn=on.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1},initialize:function(t,i){this._url=t,(i=c(this,i)).detectRetina&&zt&&0')}}catch(t){return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}}(),_n={_initContainer:function(){this._container=si("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(hn.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var i=t._container=cn("shape");ci(i,"leaflet-vml-shape "+(this.options.className||"")),i.coordsize="1 1",t._path=cn("path"),i.appendChild(t._path),this._updateStyle(t),this._layers[m(t)]=t},_addPath:function(t){var i=t._container;this._container.appendChild(i),t.options.interactive&&t.addInteractiveTarget(i)},_removePath:function(t){var i=t._container;ri(i),t.removeInteractiveTarget(i),delete this._layers[m(t)]},_updateStyle:function(t){var i=t._stroke,e=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(i=i||(t._stroke=cn("stroke")),o.appendChild(i),i.weight=n.weight+"px",i.color=n.color,i.opacity=n.opacity,n.dashArray?i.dashStyle=g(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):i.dashStyle="",i.endcap=n.lineCap.replace("butt","flat"),i.joinstyle=n.lineJoin):i&&(o.removeChild(i),t._stroke=null),n.fill?(e=e||(t._fill=cn("fill")),o.appendChild(e),e.color=n.fillColor||n.color,e.opacity=n.fillOpacity):e&&(o.removeChild(e),t._fill=null)},_updateCircle:function(t){var i=t._point.round(),e=Math.round(t._radius),n=Math.round(t._radiusY||e);this._setPath(t,t._empty()?"M0 0":"AL "+i.x+","+i.y+" "+e+","+n+" 0,23592600")},_setPath:function(t,i){t._path.v=i},_bringToFront:function(t){hi(t._container)},_bringToBack:function(t){ui(t._container)}},dn=Et?cn:J,pn=hn.extend({getEvents:function(){var t=hn.prototype.getEvents.call(this);return t.zoomstart=this._onZoomStart,t},_initContainer:function(){this._container=dn("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=dn("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){ri(this._container),Si(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_onZoomStart:function(){this._update()},_update:function(){var t,i,e;this._map._animatingZoom&&this._bounds||(hn.prototype._update.call(this),i=(t=this._bounds).getSize(),e=this._container,this._svgSize&&this._svgSize.equals(i)||(this._svgSize=i,e.setAttribute("width",i.x),e.setAttribute("height",i.y)),vi(e,t.min),e.setAttribute("viewBox",[t.min.x,t.min.y,i.x,i.y].join(" ")),this.fire("update"))},_initPath:function(t){var i=t._path=dn("path");t.options.className&&ci(i,t.options.className),t.options.interactive&&ci(i,"leaflet-interactive"),this._updateStyle(t),this._layers[m(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){ri(t._path),t.removeInteractiveTarget(t._path),delete this._layers[m(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var i=t._path,e=t.options;i&&(e.stroke?(i.setAttribute("stroke",e.color),i.setAttribute("stroke-opacity",e.opacity),i.setAttribute("stroke-width",e.weight),i.setAttribute("stroke-linecap",e.lineCap),i.setAttribute("stroke-linejoin",e.lineJoin),e.dashArray?i.setAttribute("stroke-dasharray",e.dashArray):i.removeAttribute("stroke-dasharray"),e.dashOffset?i.setAttribute("stroke-dashoffset",e.dashOffset):i.removeAttribute("stroke-dashoffset")):i.setAttribute("stroke","none"),e.fill?(i.setAttribute("fill",e.fillColor||e.color),i.setAttribute("fill-opacity",e.fillOpacity),i.setAttribute("fill-rule",e.fillRule||"evenodd")):i.setAttribute("fill","none"))},_updatePoly:function(t,i){this._setPath(t,$(t._parts,i))},_updateCircle:function(t){var i=t._point,e=Math.max(Math.round(t._radius),1),n="a"+e+","+(Math.max(Math.round(t._radiusY),1)||e)+" 0 1,0 ",o=t._empty()?"M0 0":"M"+(i.x-e)+","+i.y+n+2*e+",0 "+n+2*-e+",0 ";this._setPath(t,o)},_setPath:function(t,i){t._path.setAttribute("d",i)},_bringToFront:function(t){hi(t._path)},_bringToBack:function(t){ui(t._path)}});function mn(t){return Zt||Et?new pn(t):null}Et&&pn.include(_n),Ki.include({getRenderer:function(t){var i=(i=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(i)||this.addLayer(i),i},_getPaneRenderer:function(t){if("overlayPane"===t||void 0===t)return!1;var i=this._paneRenderers[t];return void 0===i&&(i=this._createRenderer({pane:t}),this._paneRenderers[t]=i),i},_createRenderer:function(t){return this.options.preferCanvas&&ln(t)||mn(t)}});var fn=Re.extend({initialize:function(t,i){Re.prototype.initialize.call(this,this._boundsToLatLngs(t),i)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=N(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});pn.create=dn,pn.pointsToPath=$,Ne.geometryToLayer=De,Ne.coordsToLatLng=We,Ne.coordsToLatLngs=He,Ne.latLngToCoords=Fe,Ne.latLngsToCoords=Ue,Ne.getFeature=Ve,Ne.asFeature=qe,Ki.mergeOptions({boxZoom:!0});var gn=ie.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){zi(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){Si(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){ri(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),Xt(),xi(),this._startPoint=this._map.mouseEventToContainerPoint(t),zi(document,{contextmenu:Ni,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=si("div","leaflet-zoom-box",this._container),ci(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var i=new I(this._point,this._startPoint),e=i.getSize();vi(this._box,i.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(ri(this._box),_i(this._container,"leaflet-crosshair")),Jt(),wi(),Si(document,{contextmenu:Ni,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){var i;1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(p(this._resetState,this),0),i=new R(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(i).fire("boxzoomend",{boxZoomBounds:i})))},_onKeyDown:function(t){27===t.keyCode&&this._finish()}});Ki.addInitHook("addHandler","boxZoom",gn),Ki.mergeOptions({doubleClickZoom:!0});var vn=ie.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var i=this._map,e=i.getZoom(),n=i.options.zoomDelta,o=t.originalEvent.shiftKey?e-n:e+n;"center"===i.options.doubleClickZoom?i.setZoom(o):i.setZoomAround(t.containerPoint,o)}});Ki.addInitHook("addHandler","doubleClickZoom",vn),Ki.mergeOptions({dragging:!0,inertia:!st,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var yn=ie.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new ae(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),ci(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){_i(this._map._container,"leaflet-grab"),_i(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,i=this._map;i._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=N(this._map.options.maxBounds),this._offsetLimit=O(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,i.fire("movestart").fire("dragstart"),i.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var i,e;this._map.options.inertia&&(i=this._lastTime=+new Date,e=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(e),this._times.push(i),this._prunePositions(i)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1i.max.x&&(t.x=this._viscousLimit(t.x,i.max.x)),t.y>i.max.y&&(t.y=this._viscousLimit(t.y,i.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,i=Math.round(t/2),e=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-i+e)%t+i-e,s=(n+i+e)%t-i-e,r=Math.abs(o+e)i.getMaxZoom()&&1 failwith (Format.sprintf "element `%s` not found" id) + | Some el -> el + +let add_event_to_class event name handler = + let el_list = El.find_by_class (Jstr.of_string name) in + List.iter (fun el -> Ev.listen event (handler el) (El.as_target el)) el_list diff --git a/src/login.ml b/src/login.ml new file mode 100644 index 0000000..2d594b1 --- /dev/null +++ b/src/login.ml @@ -0,0 +1,16 @@ +open Tyxml.Html +open Tyx_util + +let f request = + (* todo page titles? *) + let page_title = "Pellest|Login" in + let login = + let submit = button ~a:[ a_id "submit_login" ] [ txt "submit" ] in + let login = make_input_text "login" in + let password = make_input_text "password" in + div + [ make_form request ~action:"/login" ~items:[ login; password; submit ] ] + in + let text = div [ txt "login ~!" ] in + let page = div [ text; login ] in + Template.render ~page_title ~scripts:[] page diff --git a/src/pellest.ml b/src/pellest.ml new file mode 100644 index 0000000..f2ba14a --- /dev/null +++ b/src/pellest.ml @@ -0,0 +1,52 @@ +open Util + +let home_get request = Home.f request |> Dream.html + +let register_get request = Register.f request |> Dream.html + +let login_get request = Login.f request |> Dream.html + +let login_post request = + match%lwt Dream.form request with + | `Ok [ ("login", login); ("password", password) ] -> ( + match User.login ~login ~password request with + | Error e -> render e + | Ok () -> + let url = + match Dream.query request "redirect" with + | None -> "/" + | Some redirect -> Dream.from_percent_encoded redirect + in + Dream.respond ~status:`See_Other + ~headers:[ ("Location", url) ] + "Logged in: Happy geo-posting!" ) + | form -> handle_invalid_form form + +let register_post request = + match%lwt Dream.form request with + | `Ok [ ("email", email); ("nick", nick); ("password", password) ] -> ( + match User.register ~email ~nick ~password with + | Error e -> render e + | Ok () -> + let res = + Result.fold ~error:Fun.id + ~ok:(fun _ -> "User created ! Welcome !") + (User.login ~login:nick ~password request) + in + render res ) + | form -> Util.handle_invalid_form form + +let () = + let logger = if App.log then Dream.logger else Fun.id in + Dream.run ~port:App.port + ~error_handler:(Dream.error_template Util.error_template) + @@ logger @@ Dream.memory_sessions + @@ Dream.router + Dream. + [ get "/assets/**" (Dream.static ~loader:Util.asset_loader "") + ; get "/" home_get + ; get "/login" login_get + ; post "/login" login_post + ; get "/register" register_get + ; post "/register" register_post + ] diff --git a/src/register.ml b/src/register.ml new file mode 100644 index 0000000..f629fc0 --- /dev/null +++ b/src/register.ml @@ -0,0 +1,19 @@ +open Tyxml.Html +open Tyx_util + +let f request = + (* todo page titles? *) + let page_title = "Pellest|Register" in + let register = + let submit = button ~a:[ a_id "submet_reginster" ] [ txt "submit" ] in + let nick = make_input_text "nick" in + let password = make_input_text "password" in + let email = make_input_text "email" in + div + [ make_form request ~action:"/register" + ~items:[ nick; password; email; submit ] + ] + in + let text = div [ txt "register a new pellestian ~!" ] in + let page = div [ text; register ] in + Template.render ~page_title ~scripts:[] page diff --git a/src/syntax.ml b/src/syntax.ml new file mode 100644 index 0000000..62a0617 --- /dev/null +++ b/src/syntax.ml @@ -0,0 +1,12 @@ +(* let bindings for early return when encountering an error *) +(* see https://ocaml.org/releases/4.13/htmlman/bindingops.html *) + +let ( let* ) o f = Result.fold ~ok:f ~error:Result.error o + +let unwrap_list f ids = + let l = List.map f ids in + let res = List.find_opt Result.is_error l in + match res with + | None -> Ok (List.map Result.get_ok l) + | Some (Ok _) -> assert false + | Some (Error _e as error) -> error diff --git a/src/template.ml b/src/template.ml new file mode 100644 index 0000000..5ca3080 --- /dev/null +++ b/src/template.ml @@ -0,0 +1,15 @@ +open Tyxml + +let render ~page_title ~scripts content = + let open Html in + let head = + head + (title (txt page_title)) + ( [ link ~rel:[ `Icon ] ~href:"/assets/img/favicon.png" () + ; link ~rel:[ `Stylesheet ] ~href:"/assets/css/style.css" () + ] + @ scripts ) + in + let body = body [ main [ content ] ] in + let page = html head body in + Format.asprintf "%a@." (pp ~indent:true ()) page diff --git a/src/tyx_util.ml b/src/tyx_util.ml new file mode 100644 index 0000000..e516b4b --- /dev/null +++ b/src/tyx_util.ml @@ -0,0 +1,7 @@ +open Tyxml.Html + +let make_input_text id = input ~a:[ a_id id; a_name id; a_input_type `Text ] () + +let make_form request ~action ~items = + (* TODO labels ...? *) + form ~a:[ a_action action; a_method `Post ] (Util.csrf_tag request :: items) diff --git a/src/user.ml b/src/user.ml new file mode 100644 index 0000000..1944b05 --- /dev/null +++ b/src/user.ml @@ -0,0 +1,213 @@ +open Syntax +open Caqti_request.Infix +open Caqti_type + +type t = + { user_id : string + ; nick : string + ; password : string + ; email : string + } + +let () = + let tables = + [| (unit ->. unit) + "CREATE TABLE IF NOT EXISTS user (user_id TEXT, nick TEXT, password \ + TEXT, email TEXT, PRIMARY KEY(user_id))" + ; (unit ->. unit) + "CREATE TABLE IF NOT EXISTS banished (nick TEXT, email TEXT)" + |] + in + if + Array.exists Result.is_error + (Array.map (fun query -> Db.exec query ()) tables) + then Dream.error (fun log -> log "can't create user tables") + +module Q = struct + let get_user_id_from_email = + Db.find @@ (string ->! string) "SELECT user_id FROM user WHERE email=?" + + let get_password = + Db.find @@ (string ->! string) "SELECT password FROM user WHERE user_id=?" + + let is_already_user = + Db.find + @@ (tup2 string string ->! int) + "SELECT EXISTS(SELECT 1 FROM user WHERE nick=? OR email=?)" + + let upload_user = + Db.exec + @@ (tup4 string string string string ->. unit) + "INSERT INTO user VALUES (?, ?, ?, ?)" + + let list_nicks = Db.collect_list @@ (unit ->* string) "SELECT nick FROM user" + + let get_user = + Db.find + @@ (string ->! tup4 string string string string) + "SELECT * FROM user WHERE user_id=?" + + let update_bio = + Db.exec + @@ (tup2 string string ->. unit) "UPDATE user SET bio=? WHERE user_id=?" + + let update_nick = + Db.exec + @@ (tup2 string string ->. unit) "UPDATE user SET nick=? WHERE user_id=?" + + let update_email = + Db.exec + @@ (tup2 string string ->. unit) "UPDATE user SET email=? WHERE user_id=?" + + let update_password = + Db.exec + @@ (tup2 string string ->. unit) + "UPDATE user SET password=? WHERE user_id=?" + + let get_email = + Db.find @@ (string ->! string) "SELECT email FROM user WHERE user_id=?" + + let delete_user = + Db.exec @@ (string ->. unit) "DELETE FROM user WHERE user_id=?" + + let upload_banished = + Db.exec @@ (tup2 string string ->. unit) "INSERT INTO banished VALUES (?,?)" + + let get_banished = + Db.find + @@ (tup2 string string ->! tup2 string string) + "SELECT * FROM banished WHERE nick=? OR email=?" +end + +let get_nick = + Db.find @@ (string ->! string) "SELECT nick FROM user WHERE user_id=?" + +let get_id_from_nick = + Db.find @@ (string ->! string) "SELECT user_id FROM user WHERE nick=?" + +let exist id = Result.is_ok (Q.get_user id) + +let exist_nick nick = Result.is_ok (get_id_from_nick nick) + +let exist_email email = Result.is_ok (Q.get_user_id_from_email email) + +let get_user user_id = + let* user_id, nick, password, email = Q.get_user user_id in + Ok { user_id; nick; password; email } + +let is_banished login = Result.is_ok (Q.get_banished (login, login)) + +let login ~login ~password request = + let login = String.trim login in + let try_password user_id = + let* good_password = Q.get_password user_id in + if Bcrypt.verify password (Bcrypt.hash_of_string good_password) then + let _unit_lwt = Dream.invalidate_session request in + let _unit_lwt = Dream.put_session "user_id" user_id request in + let* nick = get_nick user_id in + let _unit_lwt = Dream.put_session "nick" nick request in + Ok () + else if is_banished login then Error "YOU ARE BANISHED" + else Error "wrong password" + in + + let id_from_nick = get_id_from_nick login in + let id_from_email = Q.get_user_id_from_email login in + let user_id_list = + List.filter_map Result.to_option [ id_from_nick; id_from_email ] + in + try + List.iter + (fun id -> if Result.is_ok @@ try_password id then raise Exit) + user_id_list; + Error "invalid login" + with Exit -> Ok () + +let valid_nick nick = + String.length nick < 64 + && String.length nick > 0 + && Dream.html_escape nick = nick + +let valid_password password = + String.length password < 128 && String.length password > 0 + +let valid_email email = Result.is_ok @@ Emile.of_string email + +let register ~email ~nick ~password = + let email = String.trim email in + let nick = String.trim nick in + let valid = valid_nick nick && valid_email email && valid_password password in + + let password = Bcrypt.hash password in + let password = Bcrypt.string_of_hash password in + + if not valid then Error "Something is wrong" + else + let* nb = Q.is_already_user (nick, email) in + if nb = 0 then + let user_id = Uuidm.to_string (Uuidm.v4_gen App.random_state ()) in + Q.upload_user (user_id, nick, password, email) + else Error "nick or email already exists" + +let list () = + let* users = Q.list_nicks () in + Ok + (Format.asprintf "
    %a
" + (Format.pp_print_list (fun fmt -> function + | s -> Format.fprintf fmt {|
  • %s
  • |} s s ) + ) + users ) + +let profile request = + match Dream.session "nick" request with + | None -> "not logged in" + | Some nick -> Format.sprintf "Hello %s !" nick + +let banish user_id = + let* nick = get_nick user_id in + let* email = Q.get_email user_id in + let* () = Q.delete_user user_id in + Q.upload_banished (nick, email) + +let delete_user user_id = Q.delete_user user_id + +let update_nick nick user_id = + if valid_nick nick then + if not (exist_nick nick) then Q.update_nick (nick, user_id) + else Error "nick already taken" + else Error "invalid nick" + +let update_email email user_id = + if valid_email email then + if not (exist_email email) then Q.update_email (email, user_id) + else Error "email already taken" + else Error "invalid email" + +let update_password password user_id = + if valid_password password then + let password = Bcrypt.hash password |> Bcrypt.string_of_hash in + Q.update_password (password, user_id) + else Error "invalid password" + +let public_profile user_id = + let* user = get_user user_id in + let user_info = + Format.asprintf + {| +

    %s

    +
    +
    +
    +
    %s
    +
    +
    + Your avatar picture +
    +
    + %a +
    +
    +|} + user.nick user.nick + in + Ok user_info diff --git a/src/util.ml b/src/util.ml new file mode 100644 index 0000000..36c635c --- /dev/null +++ b/src/util.ml @@ -0,0 +1,34 @@ +let handle_invalid_form = function + | `Ok _ -> Dream.respond ~status:`Bad_Request "invalid form" + | `Many_tokens _ | `Missing_token _ | `Invalid_token _ | `Wrong_session _ + | `Expired _ | `Wrong_content_type -> + Dream.empty `Bad_Request + +let asset_loader _root path _request = + match Content.read ("assets/" ^ path) with + | None -> Dream.empty `Not_Found + | Some asset -> + (* TODO cache-control: ~headers:[ ("Cache-Control", "max-age=151200") ] *) + Dream.respond asset + +let error_template _error _debug_info response = + let open Lwt.Syntax in + let status = Dream.status response in + let code = Dream.status_to_int status in + (*TODO improve: can't use template.elm.html because it needs "request" *) + let* body = Dream.body response in + let reason = + if String.equal "" body then Dream.status_to_string status else body + in + Dream.set_body response (Format.sprintf "%d: %s" code reason); + Lwt.return response + +let csrf_tag request = + let open Tyxml.Html in + let token = Dream.csrf_token request in + input ~a:[ a_name "dream.csrf"; a_input_type `Hidden; a_value token ] () + +let render s = + let open Tyxml.Html in + let page = div [ txt s ] in + Dream.html @@ Template.render ~page_title:"blblbl" ~scripts:[] page diff --git a/test/dune b/test/dune new file mode 100644 index 0000000..f929c11 --- /dev/null +++ b/test/dune @@ -0,0 +1,3 @@ +(test + (name test) + (modules test)) diff --git a/test/test.ml b/test/test.ml new file mode 100644 index 0000000..29ef5f9 --- /dev/null +++ b/test/test.ml @@ -0,0 +1 @@ +let () = assert true (* TODO *)