├── core ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── example_command.py ├── migrations │ └── __init__.py ├── tests.py ├── admin.py ├── models.py ├── static │ ├── favicon.ico │ ├── css │ │ ├── style.css │ │ ├── simple.css │ │ └── mapbox-gl.css │ └── js │ │ └── run.js ├── apps.py ├── views.py └── templates │ ├── home.html │ ├── base.html │ ├── privacy.html │ └── run.html ├── up ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── up.py ├── ansible │ └── roles │ │ ├── nginx │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── tasks │ │ │ └── main.yml │ │ └── templates │ │ │ ├── nginx_django.conf.j2 │ │ │ └── nginx_django_ssl.conf.j2 │ │ ├── opensmtpd │ │ └── tasks │ │ │ └── main.yml │ │ ├── django │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── templates │ │ │ ├── env.sh.j2 │ │ │ ├── app.systemd.service.j2 │ │ │ └── app.sh.j2 │ │ └── tasks │ │ │ └── main.yml │ │ ├── ufw │ │ └── tasks │ │ │ └── main.yml │ │ ├── postgres │ │ └── tasks │ │ │ └── main.yml │ │ └── base │ │ └── tasks │ │ └── main.yml ├── LICENSE ├── .gitignore └── README.md ├── nicerun ├── __init__.py ├── asgi.py ├── wsgi.py ├── urls.py ├── middleware.py └── settings.py ├── pyproject.toml ├── Pipfile ├── manage.py ├── requirements.txt ├── README.md ├── .gitignore └── Pipfile.lock /core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /up/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nicerun/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /up/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /up/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/tests.py: -------------------------------------------------------------------------------- 1 | 2 | # Create your tests here. 3 | -------------------------------------------------------------------------------- /core/admin.py: -------------------------------------------------------------------------------- 1 | 2 | # Register your models here. 3 | -------------------------------------------------------------------------------- /core/models.py: -------------------------------------------------------------------------------- 1 | 2 | # Create your models here. 3 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | nginx_timeout: 120 4 | -------------------------------------------------------------------------------- /core/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/nicerun/main/core/static/favicon.ico -------------------------------------------------------------------------------- /up/ansible/roles/opensmtpd/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Install OpenSMTPd 4 | apt: name=opensmtpd state=latest 5 | -------------------------------------------------------------------------------- /up/ansible/roles/django/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | gunicorn_port: 9000 4 | gunicorn_workers: 4 5 | python_version: python3.10 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | 4 | [tool.isort] 5 | line_length = 120 6 | 7 | [tool.ruff] 8 | line-length = 120 -------------------------------------------------------------------------------- /core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "core" 7 | -------------------------------------------------------------------------------- /up/ansible/roles/django/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Restart app 4 | service: 5 | name: "{{ service_name }}" 6 | state: restarted 7 | enabled: yes 8 | -------------------------------------------------------------------------------- /up/ansible/roles/django/templates/env.sh.j2: -------------------------------------------------------------------------------- 1 | {% for variable_name, value in env.items() %} 2 | export {{ variable_name }}="{{ value }}" 3 | {% endfor %} 4 | 5 | . /srv/www/{{ app_path }}/venv/bin/activate 6 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Reload nginx 4 | service: name=nginx state=reloaded 5 | 6 | 7 | - name: Restart nginx 8 | service: name=nginx state=restarted enabled=yes 9 | 10 | -------------------------------------------------------------------------------- /up/ansible/roles/ufw/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Ensure latest UFW 4 | apt: name=ufw state=latest 5 | 6 | - ufw: policy=allow direction=outgoing 7 | - ufw: policy=deny direction=incoming 8 | 9 | - ufw: rule=allow port=22 10 | - ufw: rule=allow port=80 proto=tcp 11 | - ufw: rule=allow port=443 proto=tcp 12 | 13 | - ufw: state=enabled 14 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | pyyaml = "*" 9 | dj-database-url = "*" 10 | fitparse = "*" 11 | python-dateutil = "*" 12 | "srtm.py" = "*" 13 | "activity.py" = "==0.1b2" 14 | thttp = "*" 15 | 16 | [dev-packages] 17 | black = "*" 18 | isort = "*" 19 | 20 | [requires] 21 | python_version = "3.10" 22 | -------------------------------------------------------------------------------- /up/ansible/roles/django/templates/app.systemd.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Runner for {{ app_name }} 3 | After=network.target 4 | 5 | [Service] 6 | User={{ app_name }} 7 | Group={{ app_name }} 8 | WorkingDirectory=/srv/www/{{ app_path }}/ 9 | ExecStart=/srv/www/{{ app_path }}/{{ app_name }}.sh 10 | ExecReload=/bin/kill -s HUP $MAINPID 11 | ExecStop=/bin/kill -s TERM $MAINPID 12 | PrivateTmp=true 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /nicerun/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for nicerun project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nicerun.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /nicerun/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for nicerun project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nicerun.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /core/management/commands/example_command.py: -------------------------------------------------------------------------------- 1 | # Example management command 2 | # https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/ 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "An example management command created by djbs" 9 | 10 | def handle(self, *args, **options): 11 | self.stdout.write("Configure your management commands here...") 12 | raise CommandError("Management command not implemented") 13 | -------------------------------------------------------------------------------- /up/ansible/roles/django/templates/app.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | LOGFILE=/srv/www/{{ app_name }}/logs/{{ app_path }}.log 5 | LOGDIR=$(dirname $LOGFILE) 6 | NUM_WORKERS={{ gunicorn_workers }} 7 | 8 | # user/group to run as 9 | USER={{ app_name }} 10 | GROUP={{ app_name }} 11 | 12 | {% for variable_name, value in env.items() %} 13 | export {{ variable_name }}="{{ value }}" 14 | {% endfor %} 15 | 16 | cd /srv/www/{{ app_path }}/code 17 | source /srv/www/{{ app_path }}/venv/bin/activate 18 | 19 | test -d $LOGDIR || mkdir -p $LOGDIR 20 | 21 | exec gunicorn {{ app_name }}.wsgi:application -w $NUM_WORKERS \ 22 | --timeout=300 --user=$USER --group=$GROUP --log-level=debug \ 23 | -b [::]:{{ gunicorn_port }} --log-file=$LOGFILE 2>> $LOGFILE 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nicerun.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # These requirements were autogenerated by pipenv 3 | # To regenerate from the project's Pipfile, run: 4 | # 5 | # pipenv lock --requirements 6 | # 7 | 8 | -i https://pypi.org/simple 9 | activity.py==0.1b2 10 | asgiref==3.7.2; python_version >= '3.7' 11 | certifi==2023.7.22; python_version >= '3.6' 12 | charset-normalizer==3.2.0; python_version >= '3.7' 13 | defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 14 | dj-database-url==2.1.0 15 | django==4.2.4 16 | fitparse==1.2.0 17 | idna==3.4; python_version >= '3.5' 18 | python-dateutil==2.8.2 19 | pyyaml==6.0.1 20 | requests==2.31.0; python_version >= '3.7' 21 | six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 22 | sqlparse==0.4.4; python_version >= '3.5' 23 | srtm.py==0.3.7 24 | thttp==1.3.0 25 | typing-extensions==4.7.1; python_version >= '3.7' 26 | urllib3==2.0.4; python_version >= '3.7' 27 | -------------------------------------------------------------------------------- /core/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.shortcuts import render 3 | 4 | from activity_py import Activity 5 | 6 | 7 | def home(request): 8 | if request.method == "POST": 9 | name = request.POST.get("name") or "Unnamed Run" 10 | f = request.FILES["file"] 11 | 12 | if f: 13 | if f.name.lower().endswith(".fit"): 14 | activity = Activity.load_fit(f) 15 | elif f.name.lower().endswith(".gpx"): 16 | activity = Activity.load_gpx(f) 17 | else: 18 | return HttpResponse("Unsupported File") 19 | 20 | activity.name = name 21 | 22 | return render( 23 | request, 24 | "run.html", 25 | { 26 | "activity_json": activity.to_json(), 27 | } 28 | ) 29 | 30 | return render(request, "home.html") 31 | 32 | 33 | def run(request): 34 | pass 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nice Run! 2 | 3 | Nice Run! is a simple web app to create screenshot of running activities that can be shared on social media. 4 | 5 | It's a work in progress, but it's close to being _usable_ by others. 6 | 7 | ## Example 8 | 9 | ![](https://media.brntn.me/postie/6775f5ae.png) 10 | 11 | (overall look and feel evolving...) 12 | 13 | ## Usage 14 | 15 | ### Running the initial migrations 16 | 17 | ```bash 18 | pipenv run python manage.py migrate 19 | ``` 20 | 21 | ### Running the development server 22 | 23 | ```bash 24 | pipenv run python manage.py runserver 25 | ``` 26 | 27 | ### Running the tests 28 | 29 | ```bash 30 | pipenv run python manage.py test 31 | ``` 32 | 33 | ### Deploying to a VPS 34 | 35 | Notes: 36 | 37 | - Ansible must be installed on your local machine 38 | - Target should be running Debian 11 39 | 40 | ```bash 41 | pipenv run python manage.py up nice-run.com --email= 42 | ``` 43 | 44 | --- 45 | 46 | Generated with [sesh/djbs](https://github.com/sesh/djbs). 47 | -------------------------------------------------------------------------------- /core/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content%} 3 |
4 |

5 | Nice Run! helps you generate an image for a run (or ride, walk, ski, or really any GPS-based activity) that you can share (see an example). 6 |

7 |

8 | Start by uploading a FIT or GPX from your device. You can export these from Strava or Garmin Connect easily. 9 |

10 |
11 | 12 |
13 |
14 |

15 | 16 | 17 |

18 |

19 | 20 | 21 |

22 | 23 |

24 | 25 |

26 | 27 | {% csrf_token %} 28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /up/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Brenton Cleeland 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /up/ansible/roles/postgres/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - apt: update_cache=yes 4 | 5 | 6 | - name: Install postgresql 7 | apt: 8 | pkg: 9 | - postgresql 10 | - postgresql-client 11 | - python3-psycopg2 12 | 13 | 14 | - name: Ensure postgres is running 15 | service: 16 | name: postgresql 17 | state: started 18 | 19 | 20 | - name: Create our database 21 | postgresql_db: 22 | name: "{{ app_name }}" 23 | encoding: "Unicode" 24 | template: "template0" 25 | become: yes 26 | become_user: postgres 27 | 28 | 29 | - name: Check if there is a previous DB password saved 30 | shell: "cat /srv/www/{{ app_name }}/.dbpass" 31 | ignore_errors: yes 32 | register: dot_dbpass 33 | 34 | 35 | - name: Replace the random DB password with the one from the .dbpass file 36 | set_fact: 37 | db_password: "{{ dot_dbpass.stdout }}" 38 | when: dot_dbpass.stdout != "" 39 | 40 | 41 | - name: Create the database user for this app 42 | postgresql_user: 43 | name: "{{ app_name }}" 44 | db: "{{ app_name }}" 45 | password: "{{ db_password }}" 46 | become: yes 47 | become_user: postgres 48 | 49 | 50 | - name: Save the db password for next time 51 | copy: 52 | content: "{{ db_password }}" 53 | dest: "/srv/www/{{ app_name }}/.dbpass" 54 | -------------------------------------------------------------------------------- /core/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | Nice Run! 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |

Nice Run!

32 |

(This is very much a work in progress and could be broken in weird ways)

33 |
34 | 35 |
36 | {% block content %}{% endblock %} 37 |
38 | 39 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /up/ansible/roles/base/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Exit if target is not Ubuntu 22.04 4 | meta: end_play 5 | when: ansible_distribution_release not in ["jammy"] 6 | 7 | 8 | - name: Add Deadsnakes Nightly APT repository 9 | apt_repository: 10 | repo: ppa:deadsnakes/ppa 11 | 12 | 13 | - apt: update_cache=yes 14 | - apt: upgrade=dist 15 | 16 | 17 | - name: Install base packages 18 | apt: 19 | name: 20 | - build-essential 21 | - "{{ python_version }}" 22 | - "{{ python_version }}-dev" 23 | - "{{ python_version }}-distutils" 24 | - "{{ python_version }}-venv" 25 | - virtualenvwrapper 26 | - libpq-dev 27 | - libjpeg-dev 28 | - zlib1g-dev 29 | state: latest 30 | 31 | 32 | - name: Add app user group 33 | group: 34 | name: "{{ app_name }}" 35 | system: yes 36 | state: present 37 | 38 | 39 | - name: Add app user 40 | user: 41 | name: "{{ app_name }}" 42 | groups: 43 | - "{{ app_name }}" 44 | state: present 45 | append: yes 46 | shell: /bin/bash 47 | 48 | 49 | - name: Install acme.sh 50 | shell: curl https://get.acme.sh | sh -s email={{ certbot_email }} 51 | 52 | 53 | - name: Make directories for application 54 | file: path={{ item }} state=directory owner={{ app_name }} group=staff 55 | with_items: 56 | - /srv/www/{{ app_name }} 57 | - /srv/www/{{ app_name }}/logs 58 | - /srv/www/{{ app_name }}/static 59 | - /srv/www/{{ app_name }}/media 60 | - /srv/www/{{ app_path }} 61 | - /srv/www/{{ app_path }}/code 62 | - /srv/www/{{ app_path }}/logs 63 | -------------------------------------------------------------------------------- /nicerun/urls.py: -------------------------------------------------------------------------------- 1 | """nicerun URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | from django.shortcuts import render 19 | from django.http import HttpResponse 20 | 21 | from core.views import home 22 | 23 | 24 | def robots(request): 25 | return HttpResponse("User-Agent: *", headers={"Content-Type": "text/plain; charset=UTF-8"}) 26 | 27 | 28 | def security(request): 29 | return HttpResponse( 30 | "Contact: \nExpires: 2025-01-01T00:00:00.000Z", 31 | headers={"Content-Type": "text/plain; charset=UTF-8"}, 32 | ) 33 | 34 | 35 | def privacy(request): 36 | return render(request, 'privacy.html') 37 | 38 | 39 | def trigger_error(request): 40 | division_by_zero = 1 / 0 41 | return division_by_zero 42 | 43 | 44 | urlpatterns = [ 45 | path("", home), 46 | path("privacy/", privacy), 47 | 48 | # .well-known 49 | path("robots.txt", robots), 50 | path(".well-known/security.txt", security), 51 | path(".well-known/500", trigger_error), 52 | path("admin/", admin.site.urls), 53 | ] 54 | -------------------------------------------------------------------------------- /core/static/css/style.css: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --page-bg: #f8f9fa; 4 | --run-bg: #ffffff; 5 | --run-border: #adb5bd; 6 | --run-shadow: #ced4da; 7 | --run-text: #222; 8 | --stat-border: #222; 9 | } 10 | 11 | body { 12 | background-color: var(--page-bg); 13 | } 14 | 15 | section { 16 | border: none !important; 17 | } 18 | 19 | section.run-wrapper { 20 | background-color: var(--page-bg); 21 | padding: 1em; 22 | } 23 | .run { 24 | color: var(--run-text); 25 | background-color: var(--run-bg); 26 | border: 2px solid var(--run-border); 27 | box-shadow: 4px 4px 0 var(--run-shadow); 28 | border-radius: 10px; 29 | padding: 0.6em; 30 | } 31 | 32 | .header { 33 | display: flex; 34 | align-items: center; 35 | justify-content: space-between; 36 | margin-bottom: 1em; 37 | } 38 | 39 | .flex { 40 | display: flex; 41 | } 42 | 43 | .header h3 { 44 | font-size: 26px; 45 | margin: 0; 46 | } 47 | 48 | .header div.stacked { 49 | padding: 0.3em 1em; 50 | border-right: 1px solid var(--stat-border); 51 | } 52 | 53 | .header div.stacked:last-child { 54 | border: 0; 55 | } 56 | 57 | .header div.stacked h4 { 58 | font-size: 14px; 59 | margin: 0; 60 | } 61 | 62 | .header div.stacked p { 63 | font-size: 22px; 64 | margin: 0; 65 | } 66 | 67 | #map { 68 | margin-bottom: 1em; 69 | } 70 | 71 | #splits { 72 | height: 100px; 73 | } 74 | 75 | .chart .title, 76 | .chart .subtitle { 77 | text-align: right; 78 | margin: 0; 79 | font-size: 14px; 80 | } 81 | 82 | .chart .title { 83 | font-weight: bold; 84 | } 85 | 86 | .controls { 87 | display: flex; 88 | justify-content: space-between; 89 | } 90 | 91 | .controls p { 92 | margin: 0; 93 | } 94 | 95 | .controls p.space-bottom { 96 | margin-bottom: 1em; 97 | } 98 | 99 | .theme-option-wrapper { 100 | border: 2px solid #222; 101 | } 102 | -------------------------------------------------------------------------------- /core/templates/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

We don't want your data.

6 | 7 |

8 | Data you upload is processed but is not stored 9 |

10 | 11 |

12 | When you upload a file to Nice Run! your file is processed on our server. 13 | The details in the file, including your location data, are read to produce the details needed to generate your image. 14 | We do not store the contents of your files, and we do not log the details of your files. 15 |

16 | 17 |

18 | Due to the way that file uploads are processed by our web framework (Django) large files may be temporarily stored. 19 | These files are regularly cleared by the system. 20 |

21 | 22 |

23 | Cookies are used for sessions only 24 |

25 | 26 |

27 | Cookies are used to store a session id. 28 | We use sessions for rate limiting and for storing temporary data between HTTP requests. 29 | No cookies that are not required for the functionality of Run Randomly are used. 30 |

31 | 32 |

33 | Sentry is used for error reporting and performance monitoring 34 |

35 | 36 |

37 | A third-party service called Sentry is used for error reporting and performance monitoring. 38 | Your IP address and browser information will be shared with Sentry in order for this service to correctly operate. 39 | You can view Sentry's privacy policy for more information. 40 |

41 | 42 |

43 | Mapbox is used to render maps 44 |

45 |

46 | A third-party service called Mapbox is used to render maps. 47 | Your IP address and other browser information is transfered to Mapbox when your browser makes requests for the map data. 48 | You can view Mapbox's privacy policy for more information about what is stored. 49 |

50 | 51 |

52 | If you want more information please reach out to brenton@fastmail.com 53 |

54 |
55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Apt update 4 | apt: update_cache=yes 5 | 6 | 7 | - name: Install nginx 8 | apt: name=nginx state=latest 9 | notify: 10 | - Restart nginx 11 | 12 | 13 | - name: Ensure the challenges directory exists 14 | file: path=/var/www/challenges/ state=directory 15 | 16 | 17 | - name: Ensure the acme.sh/nginx certs directory exists 18 | file: path=/etc/acme.sh/live/{{ domain }} state=directory 19 | 20 | 21 | # Check if there is already a certificate installed for {{ domain }} 22 | - name: Find the latest SSL certificate for this domain 23 | shell: "ls /etc/acme.sh/live/{{ domain }} | tail -n 1" 24 | register: cert_check 25 | 26 | # If no cert: 27 | # Add nginx config without SSL 28 | - name: Add nginx config (No SSL) 29 | template: src=nginx_django.conf.j2 dest=/etc/nginx/sites-available/{{ app_name }}.conf 30 | when: cert_check.stdout == "" 31 | 32 | - name: Link nginx config (No SSL) 33 | file: src=/etc/nginx/sites-available/{{ app_name }}.conf dest=/etc/nginx/sites-enabled/{{ app_name }}.conf state=link 34 | when: cert_check.stdout == "" 35 | 36 | - name: Reload nginx 37 | service: name=nginx state=reloaded 38 | when: cert_check.stdout == "" 39 | 40 | # Use acme.sh to request a certificate 41 | - name: Use acme.sh to request a certificate 42 | shell: /root/.acme.sh/acme.sh --issue {{ certbot_domains }} --server letsencrypt -w /var/www/challenges/ 43 | when: cert_check.stdout == "" 44 | 45 | # Use acme.sh to "install" the certificate 46 | - name: Install the certificates with acme.sh 47 | shell: /root/.acme.sh/acme.sh --install-cert {{ certbot_domains }} \ 48 | --key-file /etc/acme.sh/live/{{ domain }}/key.pem \ 49 | --fullchain-file /etc/acme.sh/live/{{ domain }}/cert.pem \ 50 | --reloadcmd "service nginx force-reload" 51 | when: cert_check.stdout == "" 52 | 53 | 54 | # Check if there is already a certificate installed for {{ domain }} 55 | - name: Find the latest SSL certificate for this domain 56 | shell: "ls /etc/acme.sh/live/ | grep -i ^{{ domain }}$ | tail -n 1" 57 | register: cert_check 58 | 59 | # If cert: 60 | # Just setup the SSL config 61 | 62 | - name: Add nginx config (with SSL) 63 | template: src=nginx_django_ssl.conf.j2 dest=/etc/nginx/sites-available/{{ app_name }}.conf 64 | when: cert_check.stdout != "" 65 | notify: 66 | - Reload nginx 67 | - Restart nginx 68 | 69 | 70 | - name: Link nginx config (with SSL) 71 | file: src=/etc/nginx/sites-available/{{ app_name }}.conf dest=/etc/nginx/sites-enabled/{{ app_name }}.conf state=link 72 | when: cert_check.stdout != "" 73 | notify: 74 | - Reload nginx 75 | - Restart nginx 76 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/templates/nginx_django.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name {{ domain_names }}; 4 | 5 | client_max_body_size 50M; 6 | 7 | # no security problem here, since / is alway passed to upstream 8 | root /srv/www/{{ app_name }}/code/{{ app_name }}; 9 | 10 | # always serve this directory for settings up let's encrypt 11 | location /.well-known/acme-challenge/ { 12 | root /var/www/challenges/; 13 | try_files $uri =404; 14 | } 15 | 16 | # favicon 17 | location /favicon.ico { 18 | log_not_found off; 19 | root /srv/www/{{ app_name }}/static/; 20 | expires 24h; 21 | gzip on; 22 | gzip_types image/x-icon; 23 | } 24 | 25 | # serve directly - analogous for static/staticfiles 26 | location /static/ { 27 | root /srv/www/{{ app_name }}/; 28 | gzip on; 29 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject text/html application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 30 | expires 24h; 31 | } 32 | 33 | location /media/ { 34 | root /srv/www/{{ app_name }}/; 35 | gzip on; 36 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject text/html application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 37 | expires 24h; 38 | } 39 | 40 | location / { 41 | proxy_pass_header Server; 42 | proxy_set_header Host $http_host; 43 | proxy_redirect off; 44 | proxy_set_header X-Real-IP $remote_addr; 45 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 46 | proxy_set_header X-Scheme $scheme; 47 | proxy_connect_timeout {{ nginx_timeout }}; 48 | proxy_read_timeout {{ nginx_timeout }}; 49 | proxy_pass http://localhost:{{ gunicorn_port }}/; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /nicerun/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is free and unencumbered software released into the public domain. 3 | 4 | https://github.com/sesh/django-middleware 5 | """ 6 | 7 | import logging 8 | 9 | logger = logging.getLogger("django") 10 | 11 | 12 | def set_remote_addr(get_response): 13 | def middleware(request): 14 | request.META["REMOTE_ADDR"] = request.META.get("HTTP_X_REAL_IP", request.META["REMOTE_ADDR"]) 15 | response = get_response(request) 16 | return response 17 | 18 | return middleware 19 | 20 | 21 | def permissions_policy(get_response): 22 | def middleware(request): 23 | response = get_response(request) 24 | response.headers["Permissions-Policy"] = "interest-cohort=(),microphone=(),camera=(),autoplay=()" 25 | return response 26 | 27 | return middleware 28 | 29 | 30 | def referrer_policy(get_response): 31 | def middleware(request): 32 | response = get_response(request) 33 | response.headers["Referrer-Policy"] = "same-origin" # using no-referrer breaks CSRF 34 | return response 35 | 36 | return middleware 37 | 38 | 39 | def csp(get_response): 40 | def middleware(request): 41 | response = get_response(request) 42 | response.headers[ 43 | "Content-Security-Policy" 44 | ] = "default-src 'none'; script-src 'self'; style-src 'self';"\ 45 | "img-src 'self'; child-src 'self'; form-action 'self'" 46 | return response 47 | 48 | return middleware 49 | 50 | 51 | def xss_protect(get_response): 52 | def middleware(request): 53 | response = get_response(request) 54 | response.headers["X-XSS-Protection"] = "1; mode=block" 55 | return response 56 | 57 | return middleware 58 | 59 | 60 | def expect_ct(get_response): 61 | def middleware(request): 62 | response = get_response(request) 63 | response.headers["Expect-CT"] = "enforce, max-age=30m" 64 | return response 65 | 66 | return middleware 67 | 68 | 69 | def cache(get_response): 70 | def middleware(request): 71 | response = get_response(request) 72 | if request.method in ["GET", "HEAD"] and "Cache-Control" not in response.headers: 73 | response.headers["Cache-Control"] = "max-age=10" 74 | return response 75 | 76 | return middleware 77 | 78 | 79 | def corp_coop_coep(get_response): 80 | def middleware(request): 81 | response = get_response(request) 82 | response.headers["Cross-Origin-Resource-Policy"] = "same-origin" 83 | response.headers["Cross-Origin-Opener-Policy"] = "same-origin" 84 | response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" 85 | return response 86 | 87 | return middleware 88 | 89 | 90 | def cors(get_response): 91 | def middleware(request): 92 | response = get_response(request) 93 | response.headers["Access-Control-Allow-Origin"] = "*" 94 | return response 95 | 96 | return middleware 97 | 98 | 99 | def dns_prefetch(get_response): 100 | def middleware(request): 101 | response = get_response(request) 102 | response.headers["X-DNS-Prefetch-Control"] = "off" 103 | return response 104 | 105 | return middleware 106 | -------------------------------------------------------------------------------- /up/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .srtm 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/templates/nginx_django_ssl.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | listen [::]:443 ssl http2; 4 | server_name {{ domain_names }}; 5 | 6 | ssl_certificate /etc/acme.sh/live/{{ cert_check.stdout }}/cert.pem; 7 | ssl_certificate_key /etc/acme.sh/live/{{ cert_check.stdout }}/key.pem; 8 | 9 | ssl_session_timeout 1d; 10 | ssl_session_cache shared:MozSSL:10m; # about 40000 sessions 11 | ssl_session_tickets off; 12 | 13 | # modern settings from the Mozilla SSL config generator 14 | # https://mozilla.github.io/server-side-tls/ssl-config-generator/ 15 | # intermediate configuration 16 | ssl_protocols TLSv1.2 TLSv1.3; 17 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; 18 | ssl_prefer_server_ciphers off; 19 | 20 | ssl_stapling on; 21 | ssl_stapling_verify on; 22 | 23 | # enable hsts 24 | add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload"; 25 | 26 | client_max_body_size 50M; 27 | 28 | # no security problem here, since / is alway passed to upstream 29 | root /srv/www/{{ app_name }}/code/{{ app_name }}; 30 | 31 | # favicon 32 | location /favicon.ico { 33 | log_not_found off; 34 | root /srv/www/{{ app_name }}/static/; 35 | expires 24h; 36 | gzip on; 37 | gzip_types image/x-icon; 38 | } 39 | 40 | # serve directly - analogous for static/staticfiles 41 | location /static/ { 42 | root /srv/www/{{ app_name }}/; 43 | gzip on; 44 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 45 | expires 24h; 46 | } 47 | 48 | location /media/ { 49 | root /srv/www/{{ app_name }}/; 50 | gzip on; 51 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 52 | expires 24h; 53 | } 54 | 55 | location / { 56 | proxy_pass_header Server; 57 | proxy_set_header Host $http_host; 58 | proxy_redirect off; 59 | proxy_set_header X-Real-IP $remote_addr; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Scheme $scheme; 62 | proxy_connect_timeout {{ nginx_timeout }}; 63 | proxy_read_timeout {{ nginx_timeout }}; 64 | proxy_pass http://localhost:{{ gunicorn_port }}/; 65 | } 66 | } 67 | 68 | server { 69 | listen 80; 70 | listen [::]:80; 71 | server_name {{ domain_names }}; 72 | 73 | location /.well-known/acme-challenge/ { 74 | root /var/www/challenges/; 75 | try_files $uri =404; 76 | } 77 | 78 | location / { 79 | return 301 https://$server_name$request_uri; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /up/ansible/roles/django/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Check if there is a previous DB password saved 4 | shell: "cat /srv/www/{{ app_name }}/.dbpass" 5 | ignore_errors: yes 6 | register: dot_dbpass 7 | 8 | 9 | - name: Replace the random DB password with the one from the .dbpass file 10 | set_fact: 11 | db_password: "{{ dot_dbpass.stdout }}" 12 | when: dot_dbpass.stdout != "" 13 | 14 | 15 | - name: Merge django_environment and our DATABASE_URL for environment 16 | set_fact: 17 | env: "{{ django_environment|combine({'DATABASE_URL': 'postgres://{{ app_name }}:{{ db_password }}@localhost:5432/{{ app_name }}'}) }}" 18 | 19 | 20 | - name: Copy application files to server 21 | copy: src={{ app_tar }} dest=/tmp/{{ app_path }}.tar 22 | 23 | 24 | - name: Create temporary directory 25 | file: path=/tmp/{{ app_path }}/code state=directory 26 | 27 | 28 | - name: Extract code 29 | unarchive: src=/tmp/{{ app_path }}.tar dest=/tmp/{{ app_path }}/code copy=no owner={{ app_name }} group={{ app_name }} 30 | 31 | 32 | - name: Set Django's static root 33 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="STATIC_ROOT = '/srv/www/{{ app_name }}/static/'" regexp="^STATIC_ROOT" 34 | 35 | 36 | - name: Set Django's media root 37 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="MEDIA_ROOT = '/srv/www/{{ app_name }}/media/'" regexp="^MEDIA_ROOT" 38 | 39 | 40 | - name: Set Django DEBUG=False 41 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="DEBUG = False" regexp="^DEBUG =" 42 | when: django_debug == "no" 43 | 44 | 45 | - name: Set Django DEBUG=True 46 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="DEBUG = True" regexp="^DEBUG =" 47 | when: django_debug == "yes" 48 | 49 | 50 | - name: Add app.sh file 51 | template: src=app.sh.j2 dest=/srv/www/{{ app_path }}/{{ app_name }}.sh owner={{ app_name }} group={{ app_name }} mode=ug+x 52 | 53 | 54 | - name: Add env.sh file 55 | template: src=env.sh.j2 dest=/srv/www/{{ app_path }}/env.sh owner={{ app_name }} group={{ app_name }} mode=ug+x 56 | 57 | 58 | - name: Ensure latest pip 59 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=pip state=latest virtualenv_python={{ python_version }} 60 | 61 | 62 | - name: Ensure latest gunicorn 63 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=gunicorn state=latest virtualenv_python={{ python_version }} 64 | 65 | 66 | - name: Ensure latest psycopg2 67 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=psycopg2-binary state=latest virtualenv_python={{ python_version }} 68 | 69 | 70 | - name: Recreate code directory 71 | file: path=/srv/www/{{ app_path }}/code state=directory 72 | 73 | 74 | - name: Copy code to /srv/ 75 | copy: src=/tmp/{{ app_path }}/code dest=/srv/www/{{ app_path }} remote_src="yes" owner={{ app_name }} group={{ app_name }} 76 | 77 | 78 | - name: Install requirements from requirements.txt 79 | pip: virtualenv=/srv/www/{{ app_path }}/venv requirements=/srv/www/{{ app_path }}/code/requirements.txt virtualenv_python={{ python_version }} 80 | 81 | 82 | - name: Django collect static 83 | django_manage: command=collectstatic app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 84 | environment: 85 | - "{{ env }}" 86 | ignore_errors: yes # this will fail if `staticfiles` is not in installed apps. That's okay. 87 | become: yes 88 | become_user: "{{ app_name }}" 89 | 90 | 91 | - name: Django create cache table 92 | django_manage: command=createcachetable app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 93 | environment: 94 | - "{{ env }}" 95 | ignore_errors: yes # this will fail if `CACHES` doesn't use DB caching 96 | become: yes 97 | become_user: "{{ app_name }}" 98 | 99 | 100 | # stop the service before we run migrate 101 | - name: Stop app 102 | service: name={{ service_name }} state=stopped 103 | ignore_errors: yes # service could be running 104 | 105 | 106 | # TODO: check if there are any migrations to run, don't stop service if there isn't 107 | - name: Django migrate 108 | django_manage: command=migrate app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 109 | environment: 110 | - "{{ env }}" 111 | become: yes 112 | become_user: "{{ app_name }}" 113 | 114 | 115 | # Update the systemd config with our new service 116 | - name: Systemd config 117 | template: src=app.systemd.service.j2 dest=/etc/systemd/system/{{ service_name }}.service 118 | 119 | 120 | - name: Reload service 121 | service: name={{ service_name }} state=reloaded daemon_reload=yes 122 | 123 | 124 | - name: Start app 125 | service: name={{ service_name }} state=started enabled=true 126 | 127 | 128 | - name: Clean up old deployments 129 | shell: find /srv/www/ -type d -name "{{ app_name }}-*" ! -name "{{ app_path }}" -prune -exec rm -r "{}" \; 130 | ignore_errors: yes 131 | -------------------------------------------------------------------------------- /nicerun/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for nicerun project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | import dj_database_url 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "totally-insecure") 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | if not DEBUG and SECRET_KEY == "totally-insecure": 30 | raise Exception("Do not run with the default secret key in production") 31 | 32 | ALLOWED_HOSTS = ["nicerun.xyz", "www.nicerun.xyz", "localhost", ".ngrok.io"] 33 | CSRF_TRUSTED_ORIGINS = ['https://8267-210-1-193-233.ngrok.io'] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.messages", 44 | "django.contrib.staticfiles", 45 | "up", 46 | "core", 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "django.middleware.csrf.CsrfViewMiddleware", 54 | "django.contrib.auth.middleware.AuthenticationMiddleware", 55 | "django.contrib.messages.middleware.MessageMiddleware", 56 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 57 | ] 58 | 59 | ROOT_URLCONF = "nicerun.urls" 60 | 61 | TEMPLATES = [ 62 | { 63 | "BACKEND": "django.template.backends.django.DjangoTemplates", 64 | "DIRS": [], 65 | "APP_DIRS": True, 66 | "OPTIONS": { 67 | "context_processors": [ 68 | "django.template.context_processors.debug", 69 | "django.template.context_processors.request", 70 | "django.contrib.auth.context_processors.auth", 71 | "django.contrib.messages.context_processors.messages", 72 | ], 73 | }, 74 | }, 75 | ] 76 | 77 | WSGI_APPLICATION = "nicerun.wsgi.application" 78 | 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/4.1/ref/settings/ 82 | 83 | DATABASES = {"default": dj_database_url.config(default=f'sqlite:///{BASE_DIR / "db.sqlite3"}')} 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 107 | 108 | LANGUAGE_CODE = "en-us" 109 | 110 | TIME_ZONE = "UTC" 111 | 112 | USE_I18N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 119 | 120 | STATIC_URL = "static/" 121 | if not DEBUG: 122 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 123 | 124 | # Default primary key field type 125 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 126 | 127 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 128 | 129 | # Logging Configuration 130 | if DEBUG is False: 131 | LOGGING = { 132 | "version": 1, 133 | "disable_existing_loggers": False, 134 | "handlers": { 135 | "file": { 136 | "level": "DEBUG", 137 | "class": "logging.FileHandler", 138 | "filename": "/srv/www/nicerun/logs/debug.log", 139 | }, 140 | }, 141 | "loggers": { 142 | "django": { 143 | "handlers": ["file"], 144 | "level": "DEBUG", 145 | "propagate": True, 146 | }, 147 | }, 148 | } 149 | 150 | 151 | # Django Up 152 | # https://github.com/sesh/django-up 153 | 154 | UP_GUNICORN_PORT = 19483 155 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_SCHEME", "https") 156 | 157 | 158 | # Sentry 159 | # https://sentry.io 160 | # Enabled by setting SENTRY_DSN, install sentry_sdk if you plan on using Sentry 161 | 162 | SENTRY_DSN = os.environ.get("SENTRY_DSN", "") 163 | 164 | if SENTRY_DSN and not DEBUG: 165 | import sentry_sdk 166 | from sentry_sdk.integrations.django import DjangoIntegration 167 | 168 | sentry_sdk.init( 169 | dsn=SENTRY_DSN, 170 | integrations=[ 171 | DjangoIntegration(), 172 | ], 173 | # Set traces_sample_rate to 1.0 to capture 100% 174 | # of transactions for performance monitoring. 175 | # We recommend adjusting this value in production. 176 | traces_sample_rate=0.05, 177 | # If you wish to associate users to errors (assuming you are using 178 | # django.contrib.auth) you may enable sending PII data. 179 | send_default_pii=False, 180 | ) 181 | -------------------------------------------------------------------------------- /up/README.md: -------------------------------------------------------------------------------- 1 | # django-up 2 | 3 | `django-up` is a tool to quickly deploy your Django application to a Ubuntu 22.04 server with almost zero configuration. 4 | 5 | ```shell 6 | python manage.py up djangoup.com --email= 7 | ``` 8 | 9 | Running `django-up` will deploy a production ready, SSL-enabled, Django application to a VPS using: 10 | 11 | - Nginx 12 | - Gunicorn 13 | - Postres 14 | - SSL with acme.sh (using Let's Encrypt) 15 | - UFW 16 | - OpenSMTPd 17 | 18 | 19 | ## Supporting this project 20 | 21 | The easiest way to support the development of this project is to use [my Linode referal code][linode] if you need a hosting provider. 22 | By using this link you will receive a $100, 60-day credit once a valid payment method is added. 23 | If you spend $25 I will receive $25 credit in my account. 24 | 25 | `django-up` costs around $7/month to host on Linode, referrals cover that cost, plus help to support my other projects hosted there. I've used various hosting providers over the last few years but Linode is the one that I like the most. 26 | 27 | _This is the only place where referral codes are used. All other links in the documentation will take you to the services without my reference._ 28 | 29 | 30 | ## Quick Start (with Pipenv) 31 | 32 | Create a new VPS with your preferred provider and update your domain's DNS records to point at it. Check that you can SSH to the new server before continuing. 33 | 34 | Ensure that `ansible` is installed on the system your are deploying from. 35 | 36 | Create a directory for your new project and `cd` into it: 37 | 38 | ```shell 39 | mkdir testproj 40 | cd testproj 41 | ``` 42 | 43 | Install Django, PyYAML and dj_database_url: 44 | 45 | ```shell 46 | pipenv install Django pyyaml dj_database_url 47 | ``` 48 | 49 | Start a new Django project: 50 | 51 | ```shell 52 | pipenv run django-admin startproject testproj . 53 | ``` 54 | 55 | Run `git init` to initialise the new project as a git repository: 56 | 57 | ```shell 58 | git init 59 | ``` 60 | 61 | Add `django-up` as a git submodule: 62 | 63 | ```shell 64 | git submodule add git@github.com:sesh/django-up.git up 65 | ``` 66 | 67 | Add `up` to your `INSTALLED_APPS` to enable the management command: 68 | 69 | ```python 70 | INSTALLED_APPS = [ 71 | # ... 72 | 'up', 73 | ] 74 | ``` 75 | 76 | Add your target domain to the `ALLOWED_HOSTS` in your `settings.py`. 77 | 78 | ```python 79 | ALLOWED_HOSTS = [ 80 | 'djup-test.brntn.me', 81 | 'localhost' 82 | ] 83 | ``` 84 | 85 | Set the `SECURE_PROXY_SSL_HEADER` setting in your `settings.py` to ensure the connection is considered secure. 86 | 87 | ```python 88 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_SCHEME', 'https') 89 | ``` 90 | 91 | Set up your database to use `dj_database_url`: 92 | 93 | ```python 94 | import dj_database_url 95 | DATABASES = { 96 | 'default': dj_database_url.config(default=f'sqlite:///{BASE_DIR / "db.sqlite3"}') 97 | } 98 | ``` 99 | 100 | Generate a new secret key (either manually, or with a [trusted tool](https://utils.brntn.me/django-secret/)), and configure your application to pull it out of the environment. 101 | 102 | In `.env`: 103 | 104 | ``` 105 | DJANGO_SECRET_KEY= 106 | ``` 107 | 108 | And in your `settings.py` replace the existing `SECRET_KEY` line with this: 109 | 110 | ``` 111 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 112 | ``` 113 | 114 | Create a requirements file from your environment if one doesn't exist: 115 | 116 | ```shell 117 | pipenv lock -r > requirements.txt 118 | ``` 119 | 120 | Deploy with the `up` management command: 121 | 122 | ```shell 123 | pipenv run python manage.py up yourdomain.example --email= 124 | ``` 125 | 126 | 127 | ## Extra Configuration 128 | 129 | ### Setting environment variables 130 | 131 | Add environment variables to a `.env` file alongside your `manage.py`. These will be exported into the environment before running your server (and management commands during deployment). 132 | 133 | For example, to configure Django to load the SECRET_KEY from your environment, and add a secure secret key to your `.env` file: 134 | 135 | `settings.py`: 136 | 137 | ```python 138 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 139 | ``` 140 | 141 | `.env`: 142 | 143 | ``` 144 | DJANGO_SECRET_KEY="dt(t9)7+&cm$nrq=p(pg--i)#+93dffwt!r05k-isd^8y1y0" 145 | ``` 146 | 147 | 148 | ### Specifying a Python version 149 | 150 | By default, `django-up` uses Python 3.10. 151 | If your application targets a different version you can use the `UP_PYTHON_VERSION` environment variable. 152 | Valid choices are: 153 | 154 | - `python3.8` 155 | - `python3.9` 156 | - `python3.10` (default) 157 | - `python3.11` 158 | 159 | ```python 160 | UP_PYTHON_VERSION = "python3.11" 161 | ``` 162 | 163 | These are the Python version available in the deadsnakes PPA. 164 | Versions older than Python 3.8 require older versions of OpenSSL so are not included in the PPA for Ubuntu 22.04. 165 | 166 | 167 | ### Deploying multiple applications to the same server 168 | 169 | Your application will bind to an internal port on your server. 170 | To deploy multiple applications to the same server you will need to manually specify this port. 171 | 172 | In your `settings.py`, set `GUNICORN_PORT` is set to a unique port for the server that you are deploying to: 173 | 174 | ```python 175 | UP_GUNICORN_PORT = 8556 176 | ``` 177 | 178 | 179 | ### Using manifest file storage 180 | 181 | To minimise downtime, during the deployment `collectstatic` is executed while your previous deployment is still running. 182 | In order make sure that the correct version of static files are used _during the deployment_ you can use the `ManifestStaticFilesStorage` storage backend that Django provides. 183 | 184 | ```python 185 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 186 | ``` 187 | 188 | For most projects using this backend will be a best practice, regardless of whether you are deploying with `django-up`. 189 | 190 | 191 | ### Supporting multiple domains 192 | 193 | As long as all domains that you plan on supporting are pointing to your server, you can include them in your `ALLOWED_HOSTS`. 194 | Certificates will be requested for each domain. 195 | 196 | For example, so support both the apex and `www` subdomain for a project, your could configure your application with: 197 | 198 | ```python 199 | ALLOWED_HOSTS = [ 200 | 'django-up.com', 201 | 'www.django-up.com' 202 | ] 203 | ``` 204 | 205 | 206 | ### Adding `django-up` directly to your project 207 | 208 | If you are likely to customise the Ansible files then it's probably easier to just add the `django-up` files to your own git repository, rather than using a submodule. 209 | 210 | You can use a shell one liner to download the repository from Github and extract it into an "up" directory in your project: 211 | 212 | ```shell 213 | mkdir -p up && curl -L https://github.com/sesh/django-up/tarball/main | tar -xz --strip-components=1 -C up 214 | ``` 215 | 216 | 217 | [django]: https://www.djangoproject.com 218 | [linode]: https://www.linode.com/lp/refer/?r=46340a230dfd33a24e40407c7ea938e31b295dec 219 | -------------------------------------------------------------------------------- /up/management/commands/up.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import shutil 5 | import string 6 | import subprocess 7 | import sys 8 | import tempfile 9 | 10 | import yaml 11 | from django.conf import settings 12 | from django.core.management.base import BaseCommand 13 | from django.core.validators import ValidationError, validate_email 14 | from django.utils.crypto import get_random_string 15 | 16 | """ 17 | Deploying Django applications as quickly as you create them 18 | 19 | Usage: 20 | ./manage.py up [--email=] [--debug] [--verbose] 21 | """ 22 | 23 | 24 | class Command(BaseCommand): 25 | help = "Deploy your Django site to a remote server" 26 | 27 | def add_arguments(self, parser): 28 | parser.add_argument("hostnames", nargs="+", type=str) 29 | parser.add_argument("--email", nargs=1, type=str, dest="email") 30 | parser.add_argument("--domain", nargs=1, type=str, dest="domain") 31 | parser.add_argument("--debug", action="store_true", default=False, dest="debug") 32 | parser.add_argument("--verbose", action="store_true", default=False, dest="verbose") 33 | 34 | def handle(self, *args, **options): 35 | ansible_dir = os.path.join(os.path.dirname(__file__), "..", "..", "ansible") 36 | hostnames = options["hostnames"] 37 | email = options["email"] 38 | 39 | try: 40 | if email: 41 | email = email[0] 42 | else: 43 | email = os.environ.get("UP_EMAIL", None) 44 | validate_email(email) 45 | except (ValidationError, IndexError, TypeError): 46 | sys.exit( 47 | "The --email argument or UP_EMAIL environment variable must be set for the SSL certificate request" 48 | ) 49 | 50 | try: 51 | open("requirements.txt", "r") 52 | except FileNotFoundError: 53 | sys.exit( 54 | "requirements.txt not found in the root directory, use `pip freeze` " \ 55 | "or `pipenv lock --requirements` to generate." 56 | ) 57 | 58 | app_name = settings.WSGI_APPLICATION.split(".")[0] 59 | 60 | up_dir = tempfile.TemporaryDirectory().name + "/django_up" 61 | app_tar = tempfile.NamedTemporaryFile(suffix=".tar") 62 | 63 | # copy our ansible files into our up_dir 64 | shutil.copytree(ansible_dir, up_dir) 65 | 66 | # Build up the django_environment variable from the contents of the .env 67 | # file on the local machine. These environment variables are injected into 68 | # the running environment using the app.sh file that's created. 69 | django_environment = {} 70 | try: 71 | with open(os.path.join(settings.BASE_DIR, ".env")) as env_file: 72 | for line in env_file.readlines(): 73 | if line and "=" in line and line.strip()[0] not in ["#", ";"]: 74 | var, val = line.split("=", 1) 75 | if " " not in var: 76 | django_environment[var] = val.strip() 77 | else: 78 | print("Ignoring environment variable with space: ", line) 79 | print("Loaded environment from .env: ", django_environment.keys()) 80 | except FileNotFoundError: 81 | pass 82 | 83 | # create a tarball of our application code, excluding some common directories 84 | # and files that are unlikely to be wanted on the remote machine 85 | subprocess.call( 86 | [ 87 | "tar", 88 | "--exclude", 89 | "*.pyc", 90 | "--exclude", 91 | ".git", 92 | "--exclude", 93 | "*.sqlite3", 94 | "--exclude", 95 | "__pycache__", 96 | "--exclude", 97 | "*.log", 98 | "--exclude", 99 | "{}.tar".format(app_name), 100 | "--dereference", 101 | "-cf", 102 | app_tar.name, 103 | ".", 104 | ] 105 | ) 106 | 107 | # use allowed_hosts to set up our domain names 108 | domains = [] 109 | 110 | if options["domain"]: 111 | domains = options["domain"] 112 | else: 113 | for host in settings.ALLOWED_HOSTS: 114 | if host.startswith("."): 115 | domains.append("*" + host) 116 | elif "." in host and not host.startswith("127."): 117 | domains.append(host) 118 | 119 | for h in hostnames: 120 | if h not in domains: 121 | sys.exit("{} isn't in allowed domains or DJANGO_ALLOWED_HOSTS".format(h)) 122 | 123 | yam = [ 124 | { 125 | "hosts": app_name, 126 | "remote_user": "root", 127 | "gather_facts": "yes", 128 | "vars": { 129 | # app_name is used for our user, database and to refer to our main application folder 130 | "app_name": app_name, 131 | # app_path is the directory for this specific deployment 132 | "app_path": app_name + "-" + str(get_random_string(6, string.ascii_letters + string.digits)), 133 | # service_name is our systemd service (you cannot have _ or other special characters) 134 | "service_name": app_name.replace("_", ""), 135 | "domain_names": " ".join(domains), 136 | "certbot_domains": "-d " + " -d ".join(domains), 137 | "gunicorn_port": getattr(settings, "UP_GUNICORN_PORT", "9000"), 138 | "app_tar": app_tar.name, 139 | "python_version": getattr(settings, "UP_PYTHON_VERSION", "python3.8"), 140 | # create a random database password to use for the database user, this is 141 | # saved on the remote machine and will be overridden by the ansible run 142 | # if it exists 143 | "db_password": str(get_random_string(12, string.ascii_letters + string.digits)), 144 | "django_debug": "yes" if options["debug"] else "no", 145 | "django_environment": django_environment, 146 | "certbot_email": email, 147 | "domain": domains[0], 148 | }, 149 | "roles": ["base", "ufw", "opensmtpd", "postgres", "nginx", "django"], 150 | } 151 | ] 152 | 153 | app_yml = open(os.path.join(up_dir, "{}.yml".format(app_name)), "w") 154 | yaml.dump(yam, app_yml) 155 | 156 | # create the hosts file for ansible 157 | with open(os.path.join(up_dir, "hosts"), "w") as hosts_file: 158 | hosts_file.write("[{}]\n".format(app_name)) 159 | hosts_file.write("\n".join(hostnames)) 160 | 161 | # add any extra ansible arguments that we need 162 | ansible_args = [] 163 | if options["verbose"]: 164 | ansible_args.append("-vvvv") 165 | 166 | # build the ansible command 167 | command = ["ansible-playbook", "-i", os.path.join(up_dir, "hosts")] 168 | command.extend(ansible_args) 169 | command.extend([os.path.join(up_dir, "{}.yml".format(app_name))]) 170 | 171 | # execute ansible 172 | return_code = subprocess.call(command) 173 | sys.exit(return_code) 174 | -------------------------------------------------------------------------------- /core/templates/run.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% load static %} {% block content %} 2 |
3 |
4 |
5 |

6 |
7 |
8 |
9 |
10 |
11 | 12 |
13 | 14 |

15 |

16 |
17 | 18 |
19 |
20 | 21 |
22 | 23 |

24 |

25 |
26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 |
35 | Customise 36 |
37 |
38 |

39 | 40 | 41 |

42 | 43 |

44 | 48 |

49 |

50 | 54 |

55 |

56 | 60 |

61 |

62 | 66 |

67 |

68 | 72 |

73 |

74 | 78 |

79 |
80 | 81 |
82 |

83 | 84 | 92 |

93 |

94 | 95 | 96 |

97 |

98 | 99 | 100 |

101 |

102 | 103 | 104 |

105 | 106 |

107 | 108 | 109 |

110 |
111 |
112 |
113 |

Colour Scheme

114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 |
136 |
137 | 138 |
139 | 140 |
141 | 144 | 145 | 178 | 179 | 262 | 263 | 293 | 294 | {% endblock %} 295 | -------------------------------------------------------------------------------- /core/static/js/run.js: -------------------------------------------------------------------------------- 1 | /* 2 | Utility Function 3 | */ 4 | 5 | function getWidth() { 6 | return Math.max( 7 | document.body.scrollWidth, 8 | document.documentElement.scrollWidth, 9 | document.body.offsetWidth, 10 | document.documentElement.offsetWidth, 11 | document.documentElement.clientWidth 12 | ); 13 | } 14 | 15 | function zip(arrays) { 16 | return Array.apply(null, Array(arrays[0].length)).map(function (_, i) { 17 | return arrays.map(function (array) { 18 | return array[i]; 19 | }); 20 | }); 21 | } 22 | 23 | function toHHMMSS(x) { 24 | var sec_num = parseInt(x, 10); // don't forget the second param 25 | var hours = Math.floor(sec_num / 3600); 26 | var minutes = Math.floor((sec_num - hours * 3600) / 60); 27 | var seconds = sec_num - hours * 3600 - minutes * 60; 28 | 29 | if (hours < 10) { 30 | hours = "0" + hours; 31 | } 32 | if (minutes < 10) { 33 | minutes = "0" + minutes; 34 | } 35 | if (seconds < 10) { 36 | seconds = "0" + seconds; 37 | } 38 | 39 | if (hours == "00") { 40 | if (minutes[0] == "0") { 41 | minutes = minutes[1]; 42 | } 43 | return minutes + ":" + seconds; 44 | } 45 | 46 | if (hours[0] == "0") { 47 | hours = hours[1]; 48 | } 49 | return hours + ":" + minutes + ":" + seconds; 50 | } 51 | 52 | function utf8_to_b64( str ) { 53 | return window.btoa(unescape(encodeURIComponent( str ))); 54 | } 55 | 56 | function make_url_safe(str) { 57 | return str.replace(/\+/g, "-").replace(/\//g, "_"); 58 | } 59 | 60 | function findParentNode(el, tagName) { 61 | while (true) { 62 | if (el.tagName == undefined) { 63 | return; 64 | } else if (el.tagName.toLowerCase() == tagName.toLowerCase()) { 65 | return el; 66 | } 67 | el = el.parentNode; 68 | } 69 | } 70 | 71 | /* 72 | Mapbox 73 | */ 74 | 75 | let map; 76 | let mapCenter = [run.longitude_values[0], run.latitude_values[0]]; 77 | let mapZoom = 13; 78 | 79 | function initMap() { 80 | console.log(MAP_STYLE); 81 | 82 | /* Map */ 83 | let lon_lat_values = zip([run.longitude_values, run.latitude_values]); 84 | if (MAP_CROP) { 85 | crop = parseInt(MAP_CROP); 86 | lon_lat_values = lon_lat_values.slice(crop, lon_lat_values.length - crop); 87 | } 88 | 89 | const geojson = { 90 | type: "FeatureCollection", 91 | features: [ 92 | { 93 | type: "Feature", 94 | geometry: { 95 | type: "LineString", 96 | properties: {}, 97 | coordinates: lon_lat_values, 98 | }, 99 | }, 100 | ], 101 | }; 102 | 103 | mapboxgl.accessToken = 104 | "pk.eyJ1IjoiYnJudG4iLCJhIjoiY2lvNm9mZzk3MDJoN3ZibHpsYW5sbWw0cCJ9.Pwlwb-SGyANUls0K0R9kjg"; 105 | 106 | map = new mapboxgl.Map({ 107 | container: "map", // container ID 108 | style: MAP_STYLE, // style URL 109 | center: mapCenter, // starting position [lng, lat] 110 | zoom: mapZoom, // starting zoom 111 | }); 112 | 113 | map.on("load", () => { 114 | map.addSource("LineString", { 115 | type: "geojson", 116 | data: geojson, 117 | }); 118 | map.addLayer({ 119 | id: "LineString", 120 | type: "line", 121 | source: "LineString", 122 | layout: { 123 | "line-join": "round", 124 | "line-cap": "round", 125 | }, 126 | paint: { 127 | "line-color": MAP_LINE_COLOUR, 128 | "line-width": 5, 129 | }, 130 | }); 131 | 132 | // Geographic coordinates of the LineString 133 | const coordinates = geojson.features[0].geometry.coordinates; 134 | 135 | // Create a 'LngLatBounds' with both corners at the first coordinate. 136 | const bounds = new mapboxgl.LngLatBounds(coordinates[0], coordinates[0]); 137 | 138 | // Extend the 'LngLatBounds' to include every coordinate in the bounds result. 139 | for (const coord of coordinates) { 140 | bounds.extend(coord); 141 | } 142 | 143 | map.fitBounds(bounds, { 144 | padding: 20, 145 | }); 146 | 147 | // leaving the Mapbox credit, but removing the improve link 148 | // confirm this is okay by the mapbox TOS 149 | document.querySelector(".mapbox-improve-map").remove(); 150 | }); 151 | 152 | map.on("zoomend", () => { 153 | // set these so that when we re-render we don't bounce around 154 | mapZoom = map.getZoom(); 155 | mapCenter = map.getCenter(); 156 | }); 157 | 158 | map.on("moveend", () => { 159 | // set these so that when we re-render we don't bounce around 160 | mapZoom = map.getZoom(); 161 | mapCenter = map.getCenter(); 162 | }); 163 | } 164 | 165 | /* 166 | Stats at the top of the page 167 | */ 168 | 169 | function initStats() { 170 | console.log(SHOW_ELEVATION); 171 | function createStatDiv(name, value) { 172 | var stackedDiv = document.createElement("div"); 173 | stackedDiv.className = "stacked"; 174 | 175 | var statHeading = document.createElement("h4"); 176 | statHeading.innerText = name; 177 | 178 | var statValue = document.createElement("p"); 179 | statValue.innerText = value; 180 | 181 | stackedDiv.appendChild(statHeading); 182 | stackedDiv.appendChild(statValue); 183 | 184 | return stackedDiv; 185 | } 186 | 187 | /* Run fields */ 188 | document.getElementById("name").innerText = run.name; 189 | 190 | let statsEl = document.getElementById("stats"); 191 | statsEl.innerHTML = ""; 192 | if (SHOW_DISTANCE) 193 | statsEl.appendChild( 194 | createStatDiv("Distance", run.distance.toFixed(1) + "km") 195 | ); 196 | if (SHOW_DURATION) 197 | statsEl.appendChild(createStatDiv("Duration", toHHMMSS(run.duration))); 198 | if (SHOW_PACE) 199 | statsEl.appendChild( 200 | createStatDiv("Pace", toHHMMSS(run.duration / run.distance) + "/km") 201 | ); 202 | if (SHOW_ELEVATION) 203 | statsEl.appendChild( 204 | createStatDiv("Elevation", run.uphill.toFixed(0) + "m") 205 | ); 206 | } 207 | 208 | /* 209 | Pace Chart! 210 | */ 211 | 212 | let paceChart; 213 | 214 | function initChart() { 215 | if (paceChart) { 216 | paceChart.destroy(); 217 | } 218 | 219 | let paceChartEl = document.getElementById("pace-chart-wrapper"); 220 | let title = document.getElementById("chart-title"); 221 | let subTitle = document.getElementById("chart-subtitle"); 222 | 223 | if (!SHOW_PACE_CHART) { 224 | paceChartEl.style.display = 'none'; 225 | title.innerHTML = ""; 226 | subTitle.innerHTML = ""; 227 | return; 228 | } else { 229 | paceChartEl.style.display = 'block'; 230 | } 231 | 232 | let clock_distance = zip([run.clock_values, run.distance_values]); 233 | let dist_per_duration = []; 234 | let next = PACE_CHART_SPLIT_DURATION; 235 | let prevDistance = 0; 236 | 237 | for (let value of clock_distance) { 238 | let clock = value[0]; 239 | let distance = value[1]; 240 | 241 | if (clock >= next) { 242 | dist_per_duration.push(distance - prevDistance); 243 | next += PACE_CHART_SPLIT_DURATION; 244 | prevDistance = distance; 245 | } 246 | } 247 | 248 | let chartData = dist_per_duration.map((el) => Math.floor(el * 1000)); 249 | let chartMin = Math.floor(Math.min(...chartData) * 0.8); 250 | 251 | const ctx = document.getElementById("splits"); 252 | ctx.style.height = "100px"; 253 | paceChart = new Chart(ctx, { 254 | type: "bar", 255 | data: { 256 | labels: chartData.map((el, i) => i * PACE_CHART_SPLIT_DURATION), 257 | datasets: [ 258 | { 259 | label: "Distance per 30 Seconds", 260 | data: chartData, 261 | borderWidth: 1, 262 | backgroundColor: PACE_CHART_COLOUR, 263 | }, 264 | ], 265 | }, 266 | options: { 267 | responsive: true, 268 | maintainAspectRatio: false, 269 | scales: { 270 | y: { 271 | beginAtZero: false, 272 | display: false, 273 | suggestedMin: chartMin, 274 | }, 275 | x: { 276 | display: false, 277 | }, 278 | }, 279 | plugins: { 280 | legend: { 281 | display: false, 282 | }, 283 | }, 284 | }, 285 | }); 286 | 287 | title.innerText = `Distance per ${PACE_CHART_SPLIT_DURATION} seconds`; 288 | 289 | let best_split = Math.max(...chartData); 290 | let best_pace = toHHMMSS(PACE_CHART_SPLIT_DURATION * (1000 / best_split)); 291 | 292 | subTitle.innerText = `Best: ${best_pace} min/km`; 293 | } 294 | 295 | 296 | /* 297 | Elevation Chart! 298 | */ 299 | 300 | let elevationChart; 301 | 302 | function initElevationChart() { 303 | if (elevationChart) { 304 | elevationChart.destroy(); 305 | } 306 | 307 | let elevationChartEl = document.getElementById("elevation-chart-wrapper"); 308 | let title = document.getElementById("elevation-chart-title"); 309 | let subTitle = document.getElementById("elevation-chart-subtitle"); 310 | 311 | if (!SHOW_ELEVATION_CHART) { 312 | elevationChartEl.style.display = 'none'; 313 | title.innerHTML = ""; 314 | subTitle.innerHTML = ""; 315 | return; 316 | } else { 317 | elevationChartEl.style.display = 'block'; 318 | } 319 | 320 | let chartData = run.elevation_values; 321 | let chartMin = Math.floor(Math.min(...chartData) * 0.8); 322 | 323 | const ctx = document.getElementById("elevation-chart"); 324 | ctx.style.height = "100px"; 325 | 326 | elevationChart = new Chart(ctx, { 327 | type: 'line', 328 | data: { 329 | labels: chartData, 330 | datasets: [{ 331 | data: chartData, 332 | fill: 'origin', 333 | borderColor: ELEVATION_CHART_COLOUR, 334 | backgroundColor: ELEVATION_CHART_COLOUR, 335 | borderWidth: 1, 336 | }] 337 | }, 338 | options: { 339 | responsive: true, 340 | maintainAspectRatio: false, 341 | scales: { 342 | y: { 343 | beginAtZero: false, 344 | display: false, 345 | min: chartMin, 346 | }, 347 | x: { 348 | display: false, 349 | }, 350 | }, 351 | plugins: { 352 | legend: { 353 | display: false, 354 | }, 355 | }, 356 | elements: { 357 | point:{ 358 | radius: 0 359 | }, 360 | line: { 361 | borderJoinStyle: 'round' 362 | } 363 | } 364 | } 365 | }); 366 | 367 | title.innerText = `Elevation Change`; 368 | } 369 | /* 370 | Download PNG 371 | 372 | "Get the canvas value immediately after rendering the map" 373 | 374 | map.setBearing() triggers the render below... 375 | 376 | Two references here with code snippets that are combined to make this work: 377 | https://github.com/mapbox/mapbox-gl-js/issues/2766#issuecomment-370758650 378 | https://github.com/niklasvh/html2canvas/issues/2707#issuecomment-1003690418 379 | */ 380 | 381 | let downloadEl = document.querySelector("#download"); 382 | let runEl = document.querySelector(".run-wrapper"); 383 | 384 | function takeScreenshot(map) { 385 | return new Promise(function (resolve, reject) { 386 | map.once("render", function () { 387 | html2canvas(runEl, { 388 | scale: 2, 389 | useCORS: true, 390 | allowTaint: true, 391 | proxy: '/proxy/' 392 | }).then((canvas) => { 393 | url = canvas.toDataURL('image/png'); 394 | resolve(url); 395 | }); 396 | }); 397 | 398 | /* trigger render */ 399 | map.setBearing(map.getBearing()); 400 | }); 401 | } 402 | 403 | /* 404 | Init! 405 | */ 406 | 407 | function initAll() { 408 | initStats(); 409 | initMap(); 410 | initChart(); 411 | initElevationChart(); 412 | 413 | downloadEl.onclick = () => { 414 | takeScreenshot(map).then(function (data) { 415 | let downloadLink = document.createElement('a'); 416 | downloadLink.setAttribute('download', run.name + '.png'); 417 | 418 | let url = data.replace(/^data:image\/png/,'data:application/octet-stream'); 419 | downloadLink.setAttribute('href',url); 420 | downloadLink.click(); 421 | }); 422 | }; 423 | } 424 | 425 | initAll(); 426 | -------------------------------------------------------------------------------- /core/static/css/simple.css: -------------------------------------------------------------------------------- 1 | /* Global variables. */ 2 | :root { 3 | /* Set sans-serif & mono fonts */ 4 | --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, 5 | "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica, 6 | "Helvetica Neue", sans-serif; 7 | --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace; 8 | 9 | /* Default (light) theme */ 10 | --bg: #fff; 11 | --accent-bg: #f5f7ff; 12 | --text: #212121; 13 | --text-light: #585858; 14 | --border: #898EA4; 15 | --accent: #0d47a1; 16 | --code: #d81b60; 17 | --preformatted: #444; 18 | --marked: #ffdd33; 19 | --disabled: #efefef; 20 | } 21 | 22 | /* Dark theme 23 | 24 | @media (prefers-color-scheme: dark) { 25 | :root { 26 | color-scheme: dark; 27 | --bg: #212121; 28 | --accent-bg: #2b2b2b; 29 | --text: #dcdcdc; 30 | --text-light: #ababab; 31 | --accent: #ffb300; 32 | --code: #f06292; 33 | --preformatted: #ccc; 34 | --disabled: #111; 35 | } 36 | // Add a bit of transparency so light media isn't so glaring in dark mode 37 | img, 38 | video { 39 | opacity: 0.8; 40 | } 41 | } */ 42 | 43 | /* Reset box-sizing */ 44 | *, *::before, *::after { 45 | box-sizing: border-box; 46 | } 47 | 48 | /* Reset default appearance */ 49 | textarea, 50 | select, 51 | input, 52 | progress { 53 | appearance: none; 54 | -webkit-appearance: none; 55 | -moz-appearance: none; 56 | } 57 | 58 | html { 59 | /* Set the font globally */ 60 | font-family: var(--sans-font); 61 | scroll-behavior: smooth; 62 | } 63 | 64 | /* Make the body a nice central block */ 65 | body { 66 | color: var(--text); 67 | background-color: var(--bg); 68 | font-size: 1.15rem; 69 | line-height: 1.5; 70 | display: grid; 71 | grid-template-columns: 1fr min(45rem, 90%) 1fr; 72 | margin: 0; 73 | } 74 | body > * { 75 | grid-column: 2; 76 | } 77 | 78 | /* Make the header bg full width, but the content inline with body */ 79 | body > header { 80 | background-color: var(--accent-bg); 81 | border-bottom: 1px solid var(--border); 82 | text-align: center; 83 | padding: 0 0.5rem 2rem 0.5rem; 84 | grid-column: 1 / -1; 85 | } 86 | 87 | body > header h1 { 88 | max-width: 1200px; 89 | margin: 1rem auto; 90 | } 91 | 92 | body > header p { 93 | max-width: 40rem; 94 | margin: 1rem auto; 95 | } 96 | 97 | /* Add a little padding to ensure spacing is correct between content and header > nav */ 98 | main { 99 | padding-top: 1.5rem; 100 | } 101 | 102 | body > footer { 103 | margin-top: 4rem; 104 | padding: 2rem 1rem 1.5rem 1rem; 105 | color: var(--text-light); 106 | font-size: 0.9rem; 107 | text-align: center; 108 | border-top: 1px solid var(--border); 109 | } 110 | 111 | /* Format headers */ 112 | h1 { 113 | font-size: 3rem; 114 | } 115 | 116 | h2 { 117 | font-size: 2.6rem; 118 | margin-top: 3rem; 119 | } 120 | 121 | h3 { 122 | font-size: 2rem; 123 | margin-top: 3rem; 124 | } 125 | 126 | h4 { 127 | font-size: 1.44rem; 128 | } 129 | 130 | h5 { 131 | font-size: 1.15rem; 132 | } 133 | 134 | h6 { 135 | font-size: 0.96rem; 136 | } 137 | 138 | /* Prevent long strings from overflowing container */ 139 | p, h1, h2, h3, h4, h5, h6 { 140 | overflow-wrap: break-word; 141 | } 142 | 143 | /* Fix line height when title wraps */ 144 | h1, 145 | h2, 146 | h3 { 147 | line-height: 1.1; 148 | } 149 | 150 | /* Reduce header size on mobile */ 151 | @media only screen and (max-width: 720px) { 152 | h1 { 153 | font-size: 2.5rem; 154 | } 155 | 156 | h2 { 157 | font-size: 2.1rem; 158 | } 159 | 160 | h3 { 161 | font-size: 1.75rem; 162 | } 163 | 164 | h4 { 165 | font-size: 1.25rem; 166 | } 167 | } 168 | 169 | /* Format links & buttons */ 170 | a, 171 | a:visited { 172 | color: var(--accent); 173 | } 174 | 175 | a:hover { 176 | text-decoration: none; 177 | } 178 | 179 | button, 180 | [role="button"], 181 | input[type="submit"], 182 | input[type="reset"], 183 | input[type="button"], 184 | label[type="button"] { 185 | border: none; 186 | border-radius: 5px; 187 | background-color: var(--accent); 188 | font-size: 1rem; 189 | color: var(--bg); 190 | padding: 0.7rem 0.9rem; 191 | margin: 0.5rem 0; 192 | } 193 | 194 | button[disabled], 195 | [role="button"][aria-disabled="true"], 196 | input[type="submit"][disabled], 197 | input[type="reset"][disabled], 198 | input[type="button"][disabled], 199 | input[type="checkbox"][disabled], 200 | input[type="radio"][disabled], 201 | select[disabled] { 202 | cursor: not-allowed; 203 | } 204 | 205 | input:disabled, 206 | textarea:disabled, 207 | select:disabled, 208 | button[disabled] { 209 | cursor: not-allowed; 210 | background-color: var(--disabled); 211 | color: var(--text-light) 212 | } 213 | 214 | input[type="range"] { 215 | padding: 0; 216 | } 217 | 218 | /* Set the cursor to '?' on an abbreviation and style the abbreviation to show that there is more information underneath */ 219 | abbr[title] { 220 | cursor: help; 221 | text-decoration-line: underline; 222 | text-decoration-style: dotted; 223 | } 224 | 225 | button:enabled:hover, 226 | [role="button"]:not([aria-disabled="true"]):hover, 227 | input[type="submit"]:enabled:hover, 228 | input[type="reset"]:enabled:hover, 229 | input[type="button"]:enabled:hover, 230 | label[type="button"]:hover { 231 | filter: brightness(1.4); 232 | cursor: pointer; 233 | } 234 | 235 | button:focus-visible:where(:enabled, [role="button"]:not([aria-disabled="true"])), 236 | input:enabled:focus-visible:where([type="submit"], 237 | [type="reset"], 238 | [type="button"], 239 | ) { 240 | outline: 2px solid var(--accent); 241 | outline-offset: 1px; 242 | } 243 | 244 | /* Format navigation */ 245 | header > nav { 246 | font-size: 1rem; 247 | line-height: 2; 248 | padding: 1rem 0 0 0; 249 | } 250 | 251 | /* Use flexbox to allow items to wrap, as needed */ 252 | header > nav ul, 253 | header > nav ol { 254 | align-content: space-around; 255 | align-items: center; 256 | display: flex; 257 | flex-direction: row; 258 | flex-wrap: wrap; 259 | justify-content: center; 260 | list-style-type: none; 261 | margin: 0; 262 | padding: 0; 263 | } 264 | 265 | /* List items are inline elements, make them behave more like blocks */ 266 | header > nav ul li, 267 | header > nav ol li { 268 | display: inline-block; 269 | } 270 | 271 | header > nav a, 272 | header > nav a:visited { 273 | margin: 0 0.5rem 1rem 0.5rem; 274 | border: 1px solid var(--border); 275 | border-radius: 5px; 276 | color: var(--text); 277 | display: inline-block; 278 | padding: 0.1rem 1rem; 279 | text-decoration: none; 280 | } 281 | 282 | header > nav a:hover { 283 | border-color: var(--accent); 284 | color: var(--accent); 285 | cursor: pointer; 286 | } 287 | 288 | /* Reduce nav side on mobile */ 289 | @media only screen and (max-width: 720px) { 290 | header > nav a { 291 | border: none; 292 | padding: 0; 293 | text-decoration: underline; 294 | line-height: 1; 295 | } 296 | } 297 | 298 | /* Consolidate box styling */ 299 | aside, details, pre, progress { 300 | background-color: var(--accent-bg); 301 | border: 1px solid var(--border); 302 | border-radius: 5px; 303 | margin-bottom: 1rem; 304 | } 305 | 306 | aside { 307 | font-size: 1rem; 308 | width: 30%; 309 | padding: 0 15px; 310 | margin-left: 15px; 311 | float: right; 312 | } 313 | 314 | /* Make aside full-width on mobile */ 315 | @media only screen and (max-width: 720px) { 316 | aside { 317 | width: 100%; 318 | float: none; 319 | margin-left: 0; 320 | } 321 | } 322 | 323 | article, fieldset { 324 | border: 1px solid var(--border); 325 | padding: 1rem; 326 | border-radius: 5px; 327 | margin-bottom: 1rem; 328 | } 329 | 330 | article h2:first-child, 331 | section h2:first-child { 332 | margin-top: 1rem; 333 | } 334 | 335 | section { 336 | border-top: 1px solid var(--border); 337 | border-bottom: 1px solid var(--border); 338 | padding: 2rem 1rem; 339 | margin: 3rem 0; 340 | } 341 | 342 | /* Don't double separators when chaining sections */ 343 | section + section, 344 | section:first-child { 345 | border-top: 0; 346 | padding-top: 0; 347 | } 348 | 349 | section:last-child { 350 | border-bottom: 0; 351 | padding-bottom: 0; 352 | } 353 | 354 | details { 355 | padding: 0.7rem 1rem; 356 | } 357 | 358 | summary { 359 | cursor: pointer; 360 | font-weight: bold; 361 | padding: 0.7rem 1rem; 362 | margin: -0.7rem -1rem; 363 | word-break: break-all; 364 | } 365 | 366 | details[open] > summary + * { 367 | margin-top: 0; 368 | } 369 | 370 | details[open] > summary { 371 | margin-bottom: 0.5rem; 372 | } 373 | 374 | details[open] > :last-child { 375 | margin-bottom: 0; 376 | } 377 | 378 | /* Format tables */ 379 | table { 380 | border-collapse: collapse; 381 | display: block; 382 | margin: 1.5rem 0; 383 | overflow: auto; 384 | width: 100%; 385 | } 386 | 387 | td, 388 | th { 389 | border: 1px solid var(--border); 390 | text-align: left; 391 | padding: 0.5rem; 392 | } 393 | 394 | th { 395 | background-color: var(--accent-bg); 396 | font-weight: bold; 397 | } 398 | 399 | tr:nth-child(even) { 400 | /* Set every other cell slightly darker. Improves readability. */ 401 | background-color: var(--accent-bg); 402 | } 403 | 404 | table caption { 405 | font-weight: bold; 406 | margin-bottom: 0.5rem; 407 | } 408 | 409 | /* Format forms */ 410 | textarea, 411 | select, 412 | input { 413 | font-size: inherit; 414 | font-family: inherit; 415 | padding: 0.5rem; 416 | margin-bottom: 0.5rem; 417 | color: var(--text); 418 | background-color: var(--bg); 419 | border: 1px solid var(--border); 420 | border-radius: 5px; 421 | box-shadow: none; 422 | max-width: 100%; 423 | display: inline-block; 424 | } 425 | label { 426 | display: block; 427 | } 428 | textarea:not([cols]) { 429 | width: 100%; 430 | } 431 | 432 | /* Add arrow to drop-down */ 433 | select:not([multiple]) { 434 | background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%), 435 | linear-gradient(135deg, var(--text) 51%, transparent 49%); 436 | background-position: calc(100% - 15px), calc(100% - 10px); 437 | background-size: 5px 5px, 5px 5px; 438 | background-repeat: no-repeat; 439 | padding-right: 25px; 440 | } 441 | 442 | /* checkbox and radio button style */ 443 | input[type="checkbox"], 444 | input[type="radio"] { 445 | vertical-align: middle; 446 | position: relative; 447 | width: min-content; 448 | } 449 | 450 | input[type="checkbox"] + label, 451 | input[type="radio"] + label { 452 | display: inline-block; 453 | } 454 | 455 | input[type="radio"] { 456 | border-radius: 100%; 457 | } 458 | 459 | input[type="checkbox"]:checked, 460 | input[type="radio"]:checked { 461 | background-color: var(--accent); 462 | } 463 | 464 | input[type="checkbox"]:checked::after { 465 | /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */ 466 | content: " "; 467 | width: 0.18em; 468 | height: 0.32em; 469 | border-radius: 0; 470 | position: absolute; 471 | top: 0.05em; 472 | left: 0.17em; 473 | background-color: transparent; 474 | border-right: solid var(--bg) 0.08em; 475 | border-bottom: solid var(--bg) 0.08em; 476 | font-size: 1.8em; 477 | transform: rotate(45deg); 478 | } 479 | input[type="radio"]:checked::after { 480 | /* creates a colored circle for the checked radio button */ 481 | content: " "; 482 | width: 0.25em; 483 | height: 0.25em; 484 | border-radius: 100%; 485 | position: absolute; 486 | top: 0.125em; 487 | background-color: var(--bg); 488 | left: 0.125em; 489 | font-size: 32px; 490 | } 491 | 492 | /* Makes input fields wider on smaller screens */ 493 | @media only screen and (max-width: 720px) { 494 | textarea, 495 | select, 496 | input { 497 | width: 100%; 498 | } 499 | } 500 | 501 | /* Set a height for color input */ 502 | input[type="color"] { 503 | height: 2.5rem; 504 | padding: 0.2rem; 505 | } 506 | 507 | /* do not show border around file selector button */ 508 | input[type="file"] { 509 | border: 0; 510 | } 511 | 512 | /* Misc body elements */ 513 | hr { 514 | border: none; 515 | height: 1px; 516 | background: var(--border); 517 | margin: 1rem auto; 518 | } 519 | 520 | mark { 521 | padding: 2px 5px; 522 | border-radius: 4px; 523 | background-color: var(--marked); 524 | } 525 | 526 | img, 527 | video { 528 | max-width: 100%; 529 | height: auto; 530 | border-radius: 5px; 531 | } 532 | 533 | figure { 534 | margin: 0; 535 | text-align: center; 536 | } 537 | 538 | figcaption { 539 | font-size: 0.9rem; 540 | color: var(--text-light); 541 | margin-bottom: 1rem; 542 | } 543 | 544 | blockquote { 545 | margin: 2rem 0 2rem 2rem; 546 | padding: 0.4rem 0.8rem; 547 | border-left: 0.35rem solid var(--accent); 548 | color: var(--text-light); 549 | font-style: italic; 550 | } 551 | 552 | cite { 553 | font-size: 0.9rem; 554 | color: var(--text-light); 555 | font-style: normal; 556 | } 557 | 558 | dt { 559 | color: var(--text-light); 560 | } 561 | 562 | /* Use mono font for code elements */ 563 | code, 564 | pre, 565 | pre span, 566 | kbd, 567 | samp { 568 | font-family: var(--mono-font); 569 | color: var(--code); 570 | } 571 | 572 | kbd { 573 | color: var(--preformatted); 574 | border: 1px solid var(--preformatted); 575 | border-bottom: 3px solid var(--preformatted); 576 | border-radius: 5px; 577 | padding: 0.1rem 0.4rem; 578 | } 579 | 580 | pre { 581 | padding: 1rem 1.4rem; 582 | max-width: 100%; 583 | overflow: auto; 584 | color: var(--preformatted); 585 | } 586 | 587 | /* Fix embedded code within pre */ 588 | pre code { 589 | color: var(--preformatted); 590 | background: none; 591 | margin: 0; 592 | padding: 0; 593 | } 594 | 595 | /* Progress bars */ 596 | /* Declarations are repeated because you */ 597 | /* cannot combine vendor-specific selectors */ 598 | progress { 599 | width: 100%; 600 | } 601 | 602 | progress:indeterminate { 603 | background-color: var(--accent-bg); 604 | } 605 | 606 | progress::-webkit-progress-bar { 607 | border-radius: 5px; 608 | background-color: var(--accent-bg); 609 | } 610 | 611 | progress::-webkit-progress-value { 612 | border-radius: 5px; 613 | background-color: var(--accent); 614 | } 615 | 616 | progress::-moz-progress-bar { 617 | border-radius: 5px; 618 | background-color: var(--accent); 619 | transition-property: width; 620 | transition-duration: 0.3s; 621 | } 622 | 623 | progress:indeterminate::-moz-progress-bar { 624 | background-color: var(--accent-bg); 625 | } 626 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "38fc3043f8bf17722f4fda261cfd3e5ff191dbcb5029d9645f4ac72bbbdef782" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "activity.py": { 20 | "hashes": [ 21 | "sha256:7421b3217df94e95732b95f07f69b21162d3619280ab0b5bc5a64e606b65ed1d", 22 | "sha256:ac8dc62e7eeadc7ca49e89a408e02c182ab7594cb3945b9b89f5b2987921a273" 23 | ], 24 | "index": "pypi", 25 | "version": "==0.1b2" 26 | }, 27 | "asgiref": { 28 | "hashes": [ 29 | "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", 30 | "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" 31 | ], 32 | "markers": "python_version >= '3.7'", 33 | "version": "==3.7.2" 34 | }, 35 | "certifi": { 36 | "hashes": [ 37 | "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", 38 | "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" 39 | ], 40 | "markers": "python_version >= '3.6'", 41 | "version": "==2023.7.22" 42 | }, 43 | "charset-normalizer": { 44 | "hashes": [ 45 | "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", 46 | "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", 47 | "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", 48 | "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", 49 | "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", 50 | "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", 51 | "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", 52 | "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", 53 | "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", 54 | "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", 55 | "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", 56 | "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", 57 | "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", 58 | "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", 59 | "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", 60 | "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", 61 | "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", 62 | "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", 63 | "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", 64 | "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", 65 | "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", 66 | "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", 67 | "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", 68 | "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", 69 | "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", 70 | "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", 71 | "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", 72 | "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", 73 | "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", 74 | "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", 75 | "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", 76 | "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", 77 | "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", 78 | "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", 79 | "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", 80 | "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", 81 | "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", 82 | "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", 83 | "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", 84 | "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", 85 | "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", 86 | "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", 87 | "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", 88 | "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", 89 | "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", 90 | "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", 91 | "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", 92 | "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", 93 | "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", 94 | "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", 95 | "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", 96 | "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", 97 | "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", 98 | "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", 99 | "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", 100 | "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", 101 | "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", 102 | "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", 103 | "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", 104 | "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", 105 | "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", 106 | "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", 107 | "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", 108 | "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", 109 | "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", 110 | "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", 111 | "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", 112 | "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", 113 | "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", 114 | "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", 115 | "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", 116 | "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", 117 | "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", 118 | "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", 119 | "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" 120 | ], 121 | "markers": "python_version >= '3.7'", 122 | "version": "==3.2.0" 123 | }, 124 | "defusedxml": { 125 | "hashes": [ 126 | "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", 127 | "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" 128 | ], 129 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 130 | "version": "==0.7.1" 131 | }, 132 | "dj-database-url": { 133 | "hashes": [ 134 | "sha256:04bc34b248d4c21aaa13e4ab419ae6575ef5f10f3df735ce7da97722caa356e0", 135 | "sha256:f2042cefe1086e539c9da39fad5ad7f61173bf79665e69bf7e4de55fa88b135f" 136 | ], 137 | "index": "pypi", 138 | "version": "==2.1.0" 139 | }, 140 | "django": { 141 | "hashes": [ 142 | "sha256:7e4225ec065e0f354ccf7349a22d209de09cc1c074832be9eb84c51c1799c432", 143 | "sha256:860ae6a138a238fc4f22c99b52f3ead982bb4b1aad8c0122bcd8c8a3a02e409d" 144 | ], 145 | "index": "pypi", 146 | "version": "==4.2.4" 147 | }, 148 | "fitparse": { 149 | "hashes": [ 150 | "sha256:2d691022452dea6dabad13cc6e017ca467fe8a3a895cd3ac67a50a7bb716b4a9" 151 | ], 152 | "index": "pypi", 153 | "version": "==1.2.0" 154 | }, 155 | "idna": { 156 | "hashes": [ 157 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 158 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 159 | ], 160 | "markers": "python_version >= '3.5'", 161 | "version": "==3.4" 162 | }, 163 | "python-dateutil": { 164 | "hashes": [ 165 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 166 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 167 | ], 168 | "index": "pypi", 169 | "version": "==2.8.2" 170 | }, 171 | "pyyaml": { 172 | "hashes": [ 173 | "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", 174 | "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", 175 | "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", 176 | "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", 177 | "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", 178 | "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", 179 | "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", 180 | "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", 181 | "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", 182 | "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", 183 | "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", 184 | "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", 185 | "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", 186 | "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", 187 | "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", 188 | "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", 189 | "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", 190 | "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", 191 | "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", 192 | "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", 193 | "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", 194 | "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", 195 | "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", 196 | "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", 197 | "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", 198 | "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", 199 | "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", 200 | "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", 201 | "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", 202 | "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", 203 | "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", 204 | "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", 205 | "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", 206 | "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", 207 | "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", 208 | "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", 209 | "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", 210 | "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", 211 | "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", 212 | "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" 213 | ], 214 | "index": "pypi", 215 | "version": "==6.0.1" 216 | }, 217 | "requests": { 218 | "hashes": [ 219 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 220 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 221 | ], 222 | "markers": "python_version >= '3.7'", 223 | "version": "==2.31.0" 224 | }, 225 | "six": { 226 | "hashes": [ 227 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 228 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 229 | ], 230 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 231 | "version": "==1.16.0" 232 | }, 233 | "sqlparse": { 234 | "hashes": [ 235 | "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", 236 | "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" 237 | ], 238 | "markers": "python_version >= '3.5'", 239 | "version": "==0.4.4" 240 | }, 241 | "srtm.py": { 242 | "hashes": [ 243 | "sha256:38c8ba9900751cdc9e7f6d91af1a339cc4a8e2fcb7482c6d613fd3dc6c5d8c4c" 244 | ], 245 | "index": "pypi", 246 | "version": "==0.3.7" 247 | }, 248 | "thttp": { 249 | "hashes": [ 250 | "sha256:36f18932385e840ffb18821598cd5e7d112a3f2a79f068f86cf9ddfc044e71ff", 251 | "sha256:fceff289adc386121e275a35b3860759d2346b73e073fc64c3a3677c7f3d18a2" 252 | ], 253 | "index": "pypi", 254 | "version": "==1.3.0" 255 | }, 256 | "typing-extensions": { 257 | "hashes": [ 258 | "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", 259 | "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" 260 | ], 261 | "markers": "python_version >= '3.7'", 262 | "version": "==4.7.1" 263 | }, 264 | "urllib3": { 265 | "hashes": [ 266 | "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", 267 | "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4" 268 | ], 269 | "markers": "python_version >= '3.7'", 270 | "version": "==2.0.4" 271 | } 272 | }, 273 | "develop": { 274 | "black": { 275 | "hashes": [ 276 | "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3", 277 | "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb", 278 | "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087", 279 | "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320", 280 | "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6", 281 | "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3", 282 | "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc", 283 | "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f", 284 | "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587", 285 | "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91", 286 | "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a", 287 | "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad", 288 | "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926", 289 | "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9", 290 | "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be", 291 | "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd", 292 | "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96", 293 | "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491", 294 | "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2", 295 | "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a", 296 | "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f", 297 | "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995" 298 | ], 299 | "index": "pypi", 300 | "version": "==23.7.0" 301 | }, 302 | "click": { 303 | "hashes": [ 304 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 305 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 306 | ], 307 | "markers": "python_version >= '3.7'", 308 | "version": "==8.1.7" 309 | }, 310 | "isort": { 311 | "hashes": [ 312 | "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", 313 | "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" 314 | ], 315 | "index": "pypi", 316 | "version": "==5.12.0" 317 | }, 318 | "mypy-extensions": { 319 | "hashes": [ 320 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 321 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 322 | ], 323 | "markers": "python_version >= '3.5'", 324 | "version": "==1.0.0" 325 | }, 326 | "packaging": { 327 | "hashes": [ 328 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 329 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 330 | ], 331 | "markers": "python_version >= '3.7'", 332 | "version": "==23.1" 333 | }, 334 | "pathspec": { 335 | "hashes": [ 336 | "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", 337 | "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" 338 | ], 339 | "markers": "python_version >= '3.7'", 340 | "version": "==0.11.2" 341 | }, 342 | "platformdirs": { 343 | "hashes": [ 344 | "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", 345 | "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" 346 | ], 347 | "markers": "python_version >= '3.7'", 348 | "version": "==3.10.0" 349 | }, 350 | "tomli": { 351 | "hashes": [ 352 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 353 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 354 | ], 355 | "markers": "python_version < '3.11'", 356 | "version": "==2.0.1" 357 | } 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /core/static/css/mapbox-gl.css: -------------------------------------------------------------------------------- 1 | .mapboxgl-map{-webkit-tap-highlight-color:rgb(0 0 0/0);font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative}.mapboxgl-canvas{left:0;position:absolute;top:0}.mapboxgl-map:-webkit-full-screen{height:100%;width:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.mapboxgl-ctrl-top-left{left:0;top:0}.mapboxgl-ctrl-top-right{right:0;top:0}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-bottom-right{bottom:0;right:0}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{float:left;margin:10px 0 0 10px}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{float:right;margin:10px 10px 0 0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl{float:left;margin:0 0 10px 10px}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl{float:right;margin:0 10px 10px 0}.mapboxgl-ctrl-group{background:#fff;border-radius:4px}.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.mapboxgl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;overflow:hidden;padding:0;width:29px}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:transparent}.mapboxgl-ctrl-group button+button{border-top:1px solid ButtonText}}.mapboxgl-ctrl-attrib-button:focus,.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl-group button:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:only-child{border-radius:inherit}.mapboxgl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{animation:mapboxgl-spin 2s linear infinite}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}}@keyframes mapboxgl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{background-color:#fff;border-radius:12px;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.mapboxgl-ctrl-attrib.mapboxgl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button{left:0}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0;top:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0;top:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{font-weight:700;margin-left:2px}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.mapboxgl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{flex-direction:column-reverse}.mapboxgl-popup-anchor-left{flex-direction:row}.mapboxgl-popup-anchor-right{flex-direction:row-reverse}.mapboxgl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.mapboxgl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.mapboxgl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.mapboxgl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:10px 10px 15px;pointer-events:auto;position:relative}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{left:0;opacity:1;position:absolute;top:0;transition:opacity .2s;will-change:transform}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.mapboxgl-user-location-dot:before{animation:mapboxgl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.mapboxgl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading{height:0;width:0}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after,.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-bottom:7.5px solid #4aa1eb;content:"";position:absolute}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-left:7.5px solid transparent;transform:translateY(-28px) skewY(-20deg)}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after{border-right:7.5px solid transparent;transform:translate(7.5px,-28px) skewY(20deg)}@keyframes mapboxgl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}@media print{.mapbox-improve-map{display:none}}.mapboxgl-scroll-zoom-blocker,.mapboxgl-touch-pan-blocker{align-items:center;background:rgba(0,0,0,.7);color:#fff;display:flex;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;height:100%;justify-content:center;left:0;opacity:0;pointer-events:none;position:absolute;text-align:center;top:0;transition:opacity .75s ease-in-out;transition-delay:1s;width:100%}.mapboxgl-scroll-zoom-blocker-show,.mapboxgl-touch-pan-blocker-show{opacity:1;transition:opacity .1s ease-in-out}.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page,.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas{touch-action:pan-x pan-y} 2 | --------------------------------------------------------------------------------