├── .dockerignore ├── .gitignore ├── Dockerfile ├── Dockerfile-arm64 ├── Makefile ├── README.md ├── docker-compose.yml.sample ├── docs ├── README.md └── assets │ ├── architecture.jpg │ └── system-architecture.drawio ├── entrypoint.sh ├── env.build ├── env.sample ├── gunicorn_settings.py ├── requirements.txt └── src ├── config ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── firefox-extensions └── i_dont_care_about_cookies-3.1.3-an+fx.xpi ├── manage.py ├── media └── cache │ └── .gitignore ├── shots ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── cleanup_7day_old.py │ │ ├── screenshot_worker.py │ │ └── screenshot_worker_ff.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_screenshot_is_fullpage.py │ ├── 0003_auto_20200127_2029.py │ ├── 0004_screenshot_keywords.py │ ├── 0005_screenshot_raw_html.py │ ├── 0006_auto_20200204_1043.py │ ├── 0007_screenshot_duration.py │ ├── 0008_auto_20200207_1203.py │ ├── 0009_auto_20200209_1605.py │ ├── 0010_auto_20200209_2152.py │ ├── 0011_remove_screenshot_raw_html.py │ ├── 0012_remove_screenshot_image.py │ ├── 0013_screenshot_image_binary.py │ ├── 0014_auto_20200211_1242.py │ ├── 0015_auto_20200213_1418.py │ ├── 0016_auto_20200228_1053.py │ ├── 0017_screenshot_sleep_seconds.py │ ├── 0018_screenshot_dpi.py │ ├── 0019_auto_20200315_1818.py │ ├── 0020_auto_20200315_2020.py │ ├── 0021_screenshot_file.py │ ├── 0022_remove_screenshot_image_binary.py │ └── __init__.py ├── models.py ├── templates │ ├── about.html │ ├── base.html │ ├── index.html │ ├── screenshot_get.html │ └── static │ │ ├── normalize.css │ │ └── picnic.css ├── templatetags │ ├── __init__.py │ └── thumbnail_url.py ├── tests.py ├── validators.py └── views.py └── static ├── css ├── home.css ├── site.css └── tacit.css └── js ├── intercooler-1.2.3.min.js ├── jquery-3.4.1.min.js └── site.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | src/staticfiles/ 3 | src/api/staticfiles/ 4 | data/ 5 | __pycache__/ 6 | venv/ 7 | *.7z 8 | *.zip 9 | playground/* 10 | *.log 11 | *.sqlite3 12 | src/data/* 13 | 14 | .hypothesis/ 15 | .mypy_cache/ 16 | 17 | *.sql 18 | 19 | _images/ 20 | docs/ 21 | 22 | src/media/*/**.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | **/.sass-cache/ 4 | src/staticfiles/ 5 | src/locks 6 | src/media/*/**.jpg 7 | 8 | *.sqlite3 9 | *.log 10 | 11 | src/api/staticfiles/ 12 | data/ 13 | 14 | env 15 | env.local 16 | env.bash 17 | env.docker 18 | 19 | __pycache__/ 20 | venv/ 21 | 22 | .mypy_cache/ 23 | 24 | .idea/ 25 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #FROM python:3.6-slim 2 | FROM ubuntu:18.04 3 | 4 | RUN apt-get update && \ 5 | apt-get install -y git python3-pip firefox-geckodriver && \ 6 | apt-get autoremove 7 | 8 | COPY requirements.txt / 9 | RUN pip3 install --no-cache-dir --upgrade pip 10 | RUN pip3 install --no-cache-dir -r /requirements.txt 11 | RUN mkdir /app 12 | COPY src/ app/ 13 | 14 | RUN ln -s /usr/bin/python3 /usr/bin/python 15 | 16 | ARG RELEASE 17 | ENV RELEASE ${RELEASE} 18 | 19 | ENV PYTHONUNBUFFERED 1 20 | 21 | WORKDIR /app 22 | VOLUME ["/images"] 23 | 24 | COPY env.build /env.build 25 | RUN ( set -a; . /env.build; set +a; python manage.py collectstatic --noinput) 26 | RUN rm /env.build 27 | 28 | COPY gunicorn_settings.py /gunicorn_settings.py 29 | 30 | COPY entrypoint.sh /entrypoint.sh 31 | RUN chmod +x /entrypoint.sh 32 | ENTRYPOINT ["/entrypoint.sh"] 33 | 34 | EXPOSE 8000 35 | 36 | CMD ["gunicorn", "-c", "/gunicorn_settings.py", "wsgi:application"] 37 | -------------------------------------------------------------------------------- /Dockerfile-arm64: -------------------------------------------------------------------------------- 1 | #FROM python:3.6-slim 2 | FROM arm64v8/ubuntu:18.04 3 | 4 | RUN apt-get update && \ 5 | apt-get install -y git python3-pip firefox-geckodriver libpq-dev \ 6 | postgresql-common && \ 7 | apt-get autoremove 8 | 9 | COPY requirements.txt / 10 | RUN pip3 install --no-cache-dir --upgrade pip 11 | RUN pip3 install --no-cache-dir -r /requirements.txt 12 | RUN mkdir /app 13 | COPY src/ app/ 14 | 15 | RUN ln -s /usr/bin/python3 /usr/bin/python 16 | 17 | ARG RELEASE 18 | ENV RELEASE ${RELEASE} 19 | 20 | ENV PYTHONUNBUFFERED 1 21 | 22 | WORKDIR /app 23 | VOLUME ["/images"] 24 | 25 | COPY env.build /env.build 26 | RUN ( set -a; . /env.build; set +a; python manage.py collectstatic --noinput) 27 | RUN rm /env.build 28 | 29 | COPY gunicorn_settings.py /gunicorn_settings.py 30 | 31 | COPY entrypoint.sh /entrypoint.sh 32 | RUN chmod +x /entrypoint.sh 33 | ENTRYPOINT ["/entrypoint.sh"] 34 | 35 | EXPOSE 8000 36 | 37 | CMD ["gunicorn", "-c", "/gunicorn_settings.py", "wsgi:application"] 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG:=$(shell date "+%Y%m%d%H%M") 2 | 3 | ############################################################################### 4 | # HELP / DEFAULT COMMAND 5 | ############################################################################### 6 | .PHONY: help 7 | help: 8 | @awk 'BEGIN {FS = ":.*?## "} /^[0-9a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 9 | 10 | 11 | .PHONY: build 12 | build: ## Build the screenshots services 13 | docker build -t screenshots -t screenshots:$(TAG) . 14 | 15 | .PHONY: build-prod 16 | build-prod: ## Build the screenshots services remotely 17 | docker -H screenshots build -t screenshots -t screenshots:$(TAG) . 18 | 19 | .PHONY: prod-deploy 20 | prod-deploy: ## deploy to production 21 | ssh screenshots "cd deployment/screenshots && docker-compose up -d" 22 | 23 | .PHONY: prod-migrate 24 | prod-migrate: ## run production migrations 25 | ssh root@screenshots "cd /root/deployment/screenshots && docker-compose exec web ./manage.py migrate" 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Purpose 2 | The purpose of this project is to explore and experiment with what it takes to 3 | make a website screen-shotting tool. At first it may seem like an easy task, 4 | but it becomes complex once you try. 5 | 6 | **NOTE**: If you just want a tool that "just works" then I suggest you try any of the 7 | capable services linked below. 8 | 9 | # Common problems 10 | 11 | * Javascript heavy pages (almost all these days); many sites use JavaScript to 12 | load content after the page has downloaded into the browser. Therefore you 13 | need to have a modern javascript engine to parse and execute those extra 14 | instructions to get the content as it was intented to be seen by humans. 15 | * Geography-restricted content; some sites in the US have blocked visitors 16 | from Europe because of GDPR. Do you accept this, or is there a way to work 17 | around it? 18 | * Bot and automation detection schemes; some sites use services to protect against 19 | automated processes from collecting content. This includes taking screenshots 20 | * Improperly configured domain names, SSL/TLS encryption certificates, and other 21 | network-related issues 22 | * Nefarious website owners and hacked sites that attempt to exploit the web browser 23 | to mine crypto-currencies. This puts an added load on your resources and can 24 | significantly slow your render-times. 25 | * Taking too many screenshots at a time may overload the server and cause timeouts or 26 | failure to load pages. 27 | * Temporary network or website failure; If the problem is on the site's end, then how 28 | will we know that and schedule another attempt later? 29 | * People using the service as a defacto proxy (eg- pranksters downloading porn at their 30 | schools or in public places) 31 | 32 | ## Requirements 33 | 34 | My development evironment is on MacOS, so HomeBrew and PyCharm are my friends here. 35 | 36 | * python 3.x stable in Virtual Environment (this is the only version I'm working with) 37 | * Selenium/geckodriver/chrome-driver installed via homebrew `brew install geckodriver` 38 | * Docker 39 | * Postgres installed via Homebrew. 40 | 41 | I don't use Docker on my development machine because I have not figured out how to get PyCharm's awesome debugger 42 | working well inside docker containers. IF you can, ping me. 43 | 44 | ## Getting started 45 | 46 | 1. Check out the repo 47 | 1. Install a local virtual environment `python -m venv venv/` 48 | 1. Jump into venv/ with `source venv/bin/activate` 49 | 1. Install requirements `pip install -r requirements.txt` 50 | 1. Create the postgres database for the project `CREATE DATABASE screenshots` 51 | 1. copy the `env.sample` to `env` in the root source folder 52 | 1. Check / update values in the `env` folder if needed 53 | 1. Install Selenium geckodriver for your platform `brew install geckodriver` 54 | 1. Migrate the database `cd src && ./manage.py migrate` 55 | 1. Create the cache table `cd src && ./manage.py createcachetable` 56 | 1. Create the superuser `cd src && ./manage.py createsuperuser` 57 | 1. Start the worker `cd src && ./manage.py screenshot_worker_ff` 58 | 1. Finally, start the webserver `cd src && ./manage.py runserver 0.0.0.0:8000` 59 | 60 | Open a browser onto http://localhost:8000 and see the screenshot app in all its glory. 61 | 62 | ## System Architecture 63 | 64 | ![system architecture][systemarch] 65 | 66 | ### Web process 67 | Django runs as usual in either development mode or inside gunicorn (for production). 68 | 69 | ### Worker Processes 70 | There is a worker (or a number of workers) that run as parallel, independent processes to the webserver process. 71 | They connect to the database and poll for new work on an interval. This pattern obviates the need for Celery, Redis, 72 | RabbitMQ, or other complicated moving parts in the system. 73 | 74 | The worker processes work like this: 75 | 76 | 1. poll database for new screenshots to make 77 | 1. find a screenshot, mark it as pending 78 | 1. launch slenium and take screenshot of resulting page (up to 60 seconds time limit) 79 | 1. save screenshot to database 80 | 1. shutdown selenium browser 81 | 1. sleep 82 | 1. repeat 83 | 84 | ### But where are images stored? 85 | In the database! Now, before you lose it -- I know what many of you will say about storing images in the database. I 86 | have linked to the StackOverflow here: 87 | 88 | * https://stackoverflow.com/questions/3748/storing-images-in-db-yea-or-nay 89 | * https://stackoverflow.com/questions/54500/storing-images-in-postgresql 90 | 91 | My rationale is this: 92 | 93 | * All content lives in the database, so there is no syncing issues with regards to the data (screenshots) and the 94 | metadata (database). 95 | * Images will be smallish because they are compressed screenshots not more than a 1mb (often far less). But we will 96 | need to run many iterations and save as much metadata about the screens to really know. 97 | * Thumbnails will be stored in cache (also a database table), but get purged after 30 days. 98 | * Todays compute, network, and storage capacities are so big that 1TB is no longer considered unreasonable. This means 99 | that if we build up a screenshot datbase of 1TB, then that is a good problem to have and we can re-architect from there. 100 | 101 | Note: This is a hypothesis, and I am willing to change my mind if this does not work out. 102 | 103 | ## Recommended reading on the subject 104 | 105 | * https://medium.com/@eknkc/how-to-run-headless-chrome-in-scale-7a3c7c83b28f 106 | * https://medium.com/@timotheejeannin/i-built-a-screenshot-api-and-some-guy-was-mining-cryptocurrencies-with-it-cd188dfae773 107 | 108 | ## Alternative Services 109 | 110 | * http://url2png.com 111 | * https://apiflash.com/ 112 | 113 | ## Thank-yous 114 | 115 | * Philip Walton - [Simple sticky footers using flexbox](https://philipwalton.github.io/solved-by-flexbox/demos/sticky-footer/) 116 | 117 | ## Contributing 118 | 119 | Please fork and submit pull requests if you are inspired to do so. Issues are open as well. 120 | 121 | 122 | [systemarch]: https://raw.githubusercontent.com/undernewmanagement/screenshots/master/docs/assets/architecture.jpg "Diagram of system architecture" 123 | 124 | -------------------------------------------------------------------------------- /docker-compose.yml.sample: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | web: 6 | image: screenshots 7 | restart: always 8 | env_file: env 9 | networks: 10 | - web 11 | labels: 12 | - traefik.enable=true 13 | - traefik.http.routers.screenshot.rule=Host(`yourdomain.com`) 14 | - traefik.http.routers.screenshot.tls=true 15 | - traefik.http.routers.screenshot.tls.certresolver=le 16 | - traefik.http.services.screenshot.loadbalancer.server.port=8000 17 | 18 | - traefik.http.middlewares.screenshot.compress=true 19 | 20 | - traefik.http.middlewares. 21 | - traefik.http.routers.screenshot.middlewares=screenshot@docker 22 | 23 | worker_ff: 24 | image: screenshots 25 | command: ./manage.py screenshot_worker_ff 26 | restart: always 27 | env_file: env 28 | volumes: 29 | - /dev/shm:/dev/shm 30 | - ./geckodriver.log:/app/geckodriver.log 31 | networks: 32 | - web 33 | 34 | networks: 35 | web: 36 | external: true 37 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/docs/README.md -------------------------------------------------------------------------------- /docs/assets/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/docs/assets/architecture.jpg -------------------------------------------------------------------------------- /docs/assets/system-architecture.drawio: -------------------------------------------------------------------------------- 1 | 1VrbctowEP0aHsv4CuSxgSS0k0zTIdMkfRO2YisRliuLW7++EpZ8k4NpgBheEmm1K0tHe3bXMh17OFvdUBCHd8SHuGMZ/qpjjzqWZdq2wf8JyTqVDEwnFQQU+VIpF0zQXyiF0i6YIx8mJUVGCGYoLgs9EkXQYyUZoJQsy2ovBJefGoMAaoKJB7AufUQ+C+UuXCOXjyEKQvVk05AjM6CUpSAJgU+WBZF91bGHlBCWtmarIcQCPIVLanf9zmi2MAojtovB03c4Hq/vjGHkEPDn98us/zT+Yl2k0ywAnssdy9WytYKAknnkQzGL0bEvlyFicBIDT4wu+aFzWchmmPdM3vRBEm50RUdfolz1AlIGVwWRXPINJDPI6JqryFGrL+GT/qOAX+aH4SpZWDgI90IKgXSAIJs6x4g3JEz/AZnyzJOFzDbLkJm2jplt1WDmuMfCzOppEEGf80x2CWUhCUgE8FUuvSyDmOvcEhJLtF4hY2sZNMCckTKwcIXYkzDv9l3ZfS4MjVZy6k1nrToR33Bq5apuZiU6udmmp+wSBij7KiIOF3gYJAnylPgaYdx0vgmZUw9uw1BGP0ADyLboyfgq8N3qLRRiwNCiHOcOf/I1ZOlhJtweLXgzEM17krCAwsnPWzXIn1UY15yngVEgidNc8IJWwoEOwSqnEogcR2fVoIZUg2NxytGQnTBCRTqrosUzTyya3hojzijaHJKmKfVup5kAeG/BhpA/5ozPAg8XrnqDZmB7nwnsoDm+Cyji3feelSdgqmYwtmJSdbYspBcwySqNIiiZ8OCouM2o7M/KrefR7FFtUbF3Btio0Upt0HIQ62vIPRL6BmlysgBaZQCtlgE0TQ1BlUOnKn2OiMchLaTW6buJtd3C1TK6hmG5ZQ+tQfjC6ro6xtzaORLKNYT+xPpVtZ/zqrS5fM3q1YJVm+WrenlvKl8l0ocrXzemfGdgXVCICYpYUpj5XggKRUklTqr3zusd9Z2BUXG6dAW5C2Zb2YP7GvVHryAKyBFi50fKZZ3LPN1woWMN3L65+eu0Gzwz523ptbT4VmrsRmvjxGitonMjr81D83q/k9cvcR4ogC/o7RTIYzs15Emz4wmRxz73nNhvnT39tpLiXidv6+TRas5fd2dRb5aT9u6V5rGQdc6OU6eWkOydC03jtEj1/otc4SZ0/PBw/94daa3yJGMmrWo23LG2Ss3q/VfNJwyz7hNG72jMbDXbcV6VasXuRQM7eeceUsT3Lq57t37YMFpnrLUjY+3TqiHVuguM/RZxvCO+Cc1XMEZxInwiu4zHZO63kPOq9387EusDZSTv5l+303fe/DcC9tU/ -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cmd="$@" 6 | 7 | DO_MIGRATION=${DO_MIGRATION:-n} 8 | DO_STATIC=${DO_STATIC:-n} 9 | 10 | if [[ $DO_MIGRATION = y ]]; then 11 | python /app/manage.py migrate 12 | fi 13 | 14 | if [[ $DO_STATIC = y ]]; then 15 | python /app/manage.py collectstatic 16 | fi 17 | 18 | echo "[ * ] Starting app" 19 | exec $cmd 20 | -------------------------------------------------------------------------------- /env.build: -------------------------------------------------------------------------------- 1 | SECRET_KEY=lllllllllllllllllllllllllllllllllllllllllllllllllll 2 | DATABASE_URL=postgres://postgres@localhost/screenshot 3 | EMAIL_URL=smtp://localhost:1025/ 4 | SENTRY_DSN= 5 | DEV_ENV=build 6 | -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | SECRET_KEY=your-crazy-long-secret-key-should-go-here-no-questions 2 | SENTRY_DSN= 3 | DATABASE_URL=postgres://postgres@127.0.0.1/screenshots 4 | SMTP_URL=smtp://mailhog:1025 5 | DEV_ENV=dev 6 | DEBUG=y 7 | 8 | SOCKS5_PROXY_ENABLED=n 9 | SOCKS5_PROXY_HOSTNAME= 10 | SOCKS5_PROXY_PORT= 11 | 12 | S3_BUCKET_PREFIX= 13 | S3_REGION_NAME= 14 | S3_ENDPOINT_URL= 15 | AWS_ACCESS_KEY_ID= 16 | AWS_SECRET_ACCESS_KEY= -------------------------------------------------------------------------------- /gunicorn_settings.py: -------------------------------------------------------------------------------- 1 | bind = "0.0.0.0:8000" 2 | workers = 2 3 | pythonpath = '/app/config' 4 | forwarded_allow_ips = '*' 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.2.3 2 | boto3==1.12.25 3 | botocore==1.15.25 4 | certifi==2019.11.28 5 | chardet==3.0.4 6 | dj-database-url==0.5.0 7 | dj-email-url==0.2.0 8 | Django==3.0.3 9 | django-csp==3.6 10 | django-storages==1.9.1 11 | docutils==0.15.2 12 | gunicorn==20.0.4 13 | idna==2.9 14 | jmespath==0.9.5 15 | Pillow==7.0.0 16 | psycopg2-binary==2.8.4 17 | python-dateutil==2.8.1 18 | python-dotenv==0.10.5 19 | python-magic==0.4.15 20 | pytz==2019.3 21 | requests==2.23.0 22 | s3transfer==0.3.3 23 | selenium==3.141.0 24 | sentry-sdk==0.14.2 25 | six==1.14.0 26 | sorl-thumbnail==12.6.3 27 | sqlparse==0.3.0 28 | urllib3==1.25.8 29 | whitenoise==5.0.1 30 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/config/__init__.py -------------------------------------------------------------------------------- /src/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for config 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/3.0/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', 'config.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sentry_sdk 3 | from sentry_sdk.integrations.django import DjangoIntegration 4 | import dj_database_url 5 | import dj_email_url 6 | from dotenv import load_dotenv, find_dotenv 7 | 8 | 9 | load_dotenv(find_dotenv(os.getenv('ENV_FILE', 'env'))) 10 | 11 | SECRET_KEY = os.environ['SECRET_KEY'] 12 | 13 | DEBUG = os.getenv('DEBUG') in ['true', 'y', 't'] 14 | 15 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 16 | 17 | """ 18 | SOCKS5 Configuration 19 | - used by the screenshot worker, generally to get around the error code 451 20 | GDPR bullshit 21 | """ 22 | SOCKS5_PROXY_ENABLED = os.getenv('SOCKS5_PROXY_ENABLED') in ['true', 'y', 't'] 23 | 24 | if SOCKS5_PROXY_ENABLED: 25 | SOCKS5_PROXY_HOSTNAME = os.getenv('SOCKS5_PROXY_HOSTNAME') 26 | SOCKS5_PROXY_PORT = int(os.getenv('SOCKS5_PROXY_PORT')) 27 | 28 | if os.environ['DEV_ENV'] in ['production', 'staging']: 29 | sentry_sdk.init( 30 | dsn=os.getenv('SENTRY_DSN'), 31 | integrations=[DjangoIntegration()], 32 | 33 | # If you wish to associate users to errors (assuming you are using 34 | # django.contrib.auth) you may enable sending PII data. 35 | send_default_pii=True 36 | ) 37 | 38 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 39 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 40 | 41 | ALLOWED_HOSTS = ['*'] 42 | 43 | 44 | # Application definition 45 | 46 | INSTALLED_APPS = [ 47 | 'django.contrib.admin', 48 | 'django.contrib.auth', 49 | 'django.contrib.contenttypes', 50 | 'django.contrib.sessions', 51 | 'django.contrib.messages', 52 | 'django.contrib.staticfiles', 53 | 'sorl.thumbnail', 54 | 'shots', 55 | ] 56 | 57 | MIDDLEWARE = [ 58 | 'django.middleware.security.SecurityMiddleware', 59 | 'whitenoise.middleware.WhiteNoiseMiddleware', 60 | 'django.contrib.sessions.middleware.SessionMiddleware', 61 | 'django.middleware.common.CommonMiddleware', 62 | 'django.middleware.csrf.CsrfViewMiddleware', 63 | 'csp.middleware.CSPMiddleware', 64 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 65 | 'django.contrib.messages.middleware.MessageMiddleware', 66 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 67 | ] 68 | 69 | ROOT_URLCONF = 'config.urls' 70 | 71 | TEMPLATES = [ 72 | { 73 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 74 | 'DIRS': [], 75 | 'APP_DIRS': True, 76 | 'OPTIONS': { 77 | 'context_processors': [ 78 | 'django.template.context_processors.debug', 79 | 'django.template.context_processors.request', 80 | 'django.contrib.auth.context_processors.auth', 81 | 'django.contrib.messages.context_processors.messages', 82 | ], 83 | }, 84 | }, 85 | ] 86 | 87 | WSGI_APPLICATION = 'config.wsgi.application' 88 | 89 | 90 | """ 91 | DATABASES = { 92 | 'default': { 93 | 'ENGINE': 'django.db.backends.sqlite3', 94 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 95 | } 96 | } 97 | """ 98 | 99 | if os.getenv('DEV_ENV') not in 'build': 100 | DATABASES = { 101 | 'default': dj_database_url.parse(os.environ['DATABASE_URL'], conn_max_age=600) 102 | } 103 | 104 | CACHES = { 105 | 'default': { 106 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 107 | 'LOCATION': 'cache', 108 | }, 109 | 'page': { 110 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 111 | 'LOCATION': 'unique-snowflake', 112 | } 113 | } 114 | 115 | 116 | 117 | # Password validation 118 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 119 | 120 | AUTH_PASSWORD_VALIDATORS = [ 121 | { 122 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 123 | }, 124 | { 125 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 126 | }, 127 | { 128 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 129 | }, 130 | { 131 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 132 | }, 133 | ] 134 | 135 | 136 | # Internationalization 137 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 138 | 139 | LANGUAGE_CODE = 'en-us' 140 | 141 | TIME_ZONE = 'UTC' 142 | 143 | USE_I18N = True 144 | 145 | USE_L10N = True 146 | 147 | USE_TZ = True 148 | 149 | """ 150 | EMAIL SETTINGS 151 | """ 152 | DEFAULT_FROM_EMAIL = "no-reply@screenshot.m3b.net" 153 | 154 | if os.environ['DEV_ENV'] in ['test', 'build']: 155 | EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' 156 | else: 157 | email_config = dj_email_url.parse(os.environ["SMTP_URL"]) 158 | 159 | EMAIL_HOST = email_config['EMAIL_HOST'] 160 | EMAIL_HOST_USER = email_config['EMAIL_HOST_USER'] 161 | EMAIL_HOST_PASSWORD = email_config['EMAIL_HOST_PASSWORD'] 162 | EMAIL_PORT = email_config['EMAIL_PORT'] 163 | EMAIL_USE_TLS = email_config['EMAIL_USE_TLS'] 164 | 165 | """ 166 | STATIC ASSET HANDLING 167 | - WhiteNoise configuration for forever-cacheable files and compression support 168 | """ 169 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 170 | 171 | STATIC_URL = '/static/' 172 | STATICFILES_DIRS = [ 173 | os.path.join(BASE_DIR, "static"), 174 | ] 175 | 176 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 177 | MEDIA_URL = '/media/' 178 | 179 | """ 180 | COOKIES & CSRF COOKIE POLICIES 181 | 182 | TODO: These are only enabled in production because people the admin/ won't 183 | work without HTTPS enabled. And I'm too lazy to futz with HTTPS on localhost 184 | right now. 185 | """ 186 | if not DEBUG: 187 | CSRF_COOKIE_HTTPONLY = True 188 | CSRF_COOKIE_SAMESITE = 'Strict' 189 | CSRF_COOKIE_SECURE = True 190 | CSRF_COOKIE_NAME = '__Host-csrftoken' 191 | SESSION_COOKIE_SAMESITE = 'Strict' 192 | SESSION_COOKIE_SECURE = True 193 | 194 | """ 195 | S3 Settings 196 | """ 197 | S3_BUCKET_PREFIX = os.getenv('S3_BUCKET_PREFIX') 198 | S3_REGION_NAME = os.getenv('S3_REGION_NAME') 199 | S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL') 200 | AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') 201 | AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') 202 | 203 | """ 204 | sorl thumbnails 205 | """ 206 | THUMBNAIL_DUMMY = True 207 | 208 | """ 209 | CONTENT-SECURITY-POLICY 210 | - Refer to Mozilla Observatory when crafting your CSP: https://observatory.mozilla.org 211 | """ 212 | CSP_DEFAULT_SRC = ("'none'",) 213 | CSP_SCRIPT_SRC = ("'self'",'https://www.googletagmanager.com','https://www.google-analytics.com',) 214 | CSP_STYLE_SRC = ("'self'",) 215 | CSP_INCLUDE_NONCE_IN = ['script-src', 'style-src'] 216 | CSP_IMG_SRC = ("'self'","data:",'https://www.google-analytics.com', S3_ENDPOINT_URL, "http://dummyimage.com") 217 | CSP_FRAME_ANCESTORS = ("'none'",) 218 | CSP_BASE_URI = ("'none'",) 219 | CSP_FORM_ACTION = ("'self'",) 220 | 221 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 222 | 223 | AWS_STORAGE_BUCKET_NAME = S3_BUCKET_PREFIX 224 | AWS_DEFAULT_ACL = 'public-read' 225 | # AWS_S3_OBJECT_PARAMETERS = {} 226 | # AWS_LOCATION = '' 227 | AWS_S3_REGION_NAME = S3_REGION_NAME 228 | AWS_S3_ENDPOINT_URL = S3_ENDPOINT_URL -------------------------------------------------------------------------------- /src/config/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | from shots import views 4 | 5 | 6 | urlpatterns = [ 7 | path('admin/', admin.site.urls), 8 | path('', views.index, name='home'), 9 | path('about', views.about, name='about'), 10 | path('screenshot/create', views.screenshot_create, name='screenshot_create'), 11 | path('screenshot/', views.screenshot_get, name='screenshot_get'), 12 | 13 | path('api/screenshot', views.api_screenshot, name='api-screenshot'), 14 | path('health-check', views.health_check, name='health-check'), 15 | ] 16 | -------------------------------------------------------------------------------- /src/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config 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/3.0/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', 'config.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/firefox-extensions/i_dont_care_about_cookies-3.1.3-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/firefox-extensions/i_dont_care_about_cookies-3.1.3-an+fx.xpi -------------------------------------------------------------------------------- /src/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 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /src/media/cache/.gitignore: -------------------------------------------------------------------------------- 1 | *.jpg -------------------------------------------------------------------------------- /src/shots/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/shots/__init__.py -------------------------------------------------------------------------------- /src/shots/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import ScreenShot 3 | 4 | 5 | def reset_status(modeladmin, request, queryset): 6 | queryset.update(status=ScreenShot.NEW) 7 | 8 | def reset_status_to_failed(modeladmin, request, queryset): 9 | queryset.update(status=ScreenShot.FAILURE) 10 | 11 | reset_status.short_description = "Reset status to NEW (Refresh Images)" 12 | reset_status_to_failed.short_description = "Mark all as failed" 13 | 14 | class ScreenShotAdmin(admin.ModelAdmin): 15 | list_display = ('url', 'status', 'format', 'keywords', 'created_at') 16 | list_filter = ('status', 'format', ) 17 | search_fields = ('url', 'keywords',) 18 | readonly_fields = ('width', 'height', 'duration', 'format', ) 19 | 20 | actions = [reset_status, reset_status_to_failed] 21 | 22 | 23 | admin.site.register(ScreenShot, ScreenShotAdmin) 24 | -------------------------------------------------------------------------------- /src/shots/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ShotsConfig(AppConfig): 5 | name = 'shots' 6 | -------------------------------------------------------------------------------- /src/shots/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/shots/management/__init__.py -------------------------------------------------------------------------------- /src/shots/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/shots/management/commands/__init__.py -------------------------------------------------------------------------------- /src/shots/management/commands/cleanup_7day_old.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from time import sleep 3 | from django.core.management.base import BaseCommand, CommandError 4 | from django.utils import timezone 5 | from shots.models import ScreenShot 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Remove screenshots mor than 7 days old' 10 | 11 | def handle(self, *args, **options): 12 | 13 | while True: 14 | target_day = timezone.now() - timedelta(days=7) 15 | count, result = ScreenShot.objects.filter(created_at__lt=target_day).delete() 16 | self.stdout.write(self.style.SUCCESS(f'Deleted {count} screenshots')) 17 | 18 | sleep(86400) -------------------------------------------------------------------------------- /src/shots/management/commands/screenshot_worker.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from selenium import webdriver 3 | from time import sleep 4 | from shots.models import ScreenShot 5 | from django.conf import settings 6 | 7 | class Command(BaseCommand): 8 | help = 'Run the screenshot worker' 9 | 10 | def handle(self, *args, **options): 11 | 12 | while True: 13 | sleep(3) 14 | shots = ScreenShot.objects.filter(status=ScreenShot.NEW) 15 | 16 | if shots.count() > 0: 17 | shot = shots.all()[0] 18 | self.stdout.write(self.style.SUCCESS(f'Screenshot started: {shot.url}')) 19 | 20 | shot.status = ScreenShot.PENDING 21 | shot.save() 22 | self.get_screenshot(shot) 23 | shot.status = ScreenShot.SUCCESS 24 | shot.save() 25 | 26 | self.stdout.write(self.style.SUCCESS(f'Screenshot saved: {shot.url}')) 27 | 28 | def get_screenshot(self, shot): 29 | options = webdriver.ChromeOptions() 30 | options.add_argument('--no-sandbox') 31 | options.add_argument('--headless') 32 | options.add_argument('--disable-gpu') 33 | 34 | driver = webdriver.Chrome(options=options) 35 | driver.set_window_size(1280,960) 36 | driver.get(shot.url) 37 | 38 | height = driver.execute_script("return document.body.scrollHeight") 39 | driver.set_window_size(1280,height+100) 40 | sleep(10) 41 | driver.save_screenshot(f"{settings.IMAGE_DIR}/{shot.id}.png") 42 | 43 | driver.quit() 44 | -------------------------------------------------------------------------------- /src/shots/management/commands/screenshot_worker_ff.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | import requests 4 | from django.core.files import File 5 | from django.core.management.base import BaseCommand, CommandError 6 | from django.utils import timezone 7 | from selenium import webdriver 8 | from selenium.webdriver.firefox.options import Options 9 | from selenium.common.exceptions import NoSuchElementException, WebDriverException, TimeoutException 10 | from django.conf import settings 11 | from time import sleep 12 | from shots.models import ScreenShot 13 | from datetime import datetime 14 | import random 15 | from PIL import Image 16 | import io 17 | from django.core.cache import cache 18 | from django.core.cache import caches 19 | from sentry_sdk import capture_exception 20 | import boto3 21 | 22 | 23 | class ScreenShotException(Exception): 24 | pass 25 | 26 | 27 | class SeleniumScreenShot(object): 28 | def __init__(self, height, title, description, file): 29 | self.height = height 30 | self.title = title 31 | self.description = description 32 | self.file = file 33 | 34 | """ 35 | def upload_to_s3(file_bytes, s3_object_name): 36 | 37 | # required because we are on scaleway at the moment 38 | s3 = boto3.client('s3', 39 | region_name=settings.S3_REGION_NAME, 40 | endpoint_url=settings.S3_ENDPOINT_URL, 41 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 42 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY 43 | ) 44 | 45 | s3.put_object( 46 | Body=file_bytes, 47 | Bucket=f"{settings.S3_BUCKET_PREFIX}", 48 | Key=s3_object_name, 49 | ACL='public-read', 50 | CacheControl='max-age=31556926' # 1 year 51 | ) 52 | """ 53 | 54 | def convert_png_to_jpg(binary_data) -> bytes: 55 | 56 | with io.BytesIO(binary_data) as png_file: 57 | with Image.open(png_file).convert('RGB') as i: 58 | with io.BytesIO() as output: 59 | i.save(output, format="JPEG") 60 | image_binary = output.getvalue() 61 | 62 | return image_binary 63 | 64 | class Command(BaseCommand): 65 | help = 'Run the screenshot worker' 66 | 67 | def handle(self, *args, **options): 68 | 69 | self.stdout.write(self.style.SUCCESS(f'Starting Screenshot Firefox Worker.')) 70 | while True: 71 | # do this to try to prevent race conditions when multiple workers 72 | # are present. 73 | sleep(random.randrange(1, 10)) 74 | 75 | start = timezone.now().strftime('%s') 76 | shots = ScreenShot.objects.filter(status=ScreenShot.NEW) 77 | 78 | if shots.count() > 0: 79 | shot = shots.all()[0] 80 | self.stdout.write(self.style.SUCCESS(f'Screenshot started: {shot.url}')) 81 | 82 | cache.delete(shot.id.hex) 83 | caches['page'].clear() 84 | 85 | shot.status = ScreenShot.PENDING 86 | shot.save() 87 | 88 | try: 89 | results = self.get_screenshot(shot) 90 | shot.status = ScreenShot.SUCCESS 91 | shot.height = results.height 92 | shot.duration = int(timezone.now().strftime('%s')) - int(start) 93 | 94 | with tempfile.TemporaryFile(mode="w+b") as f: 95 | f.write(results.file) 96 | shot.file.save(f"{shot.id.hex}.jpg", File(f)) 97 | 98 | # JSON Fields 99 | shot.meta = { 100 | 'title': results.title, 101 | 'description': results.description 102 | } 103 | 104 | shot.save() 105 | 106 | self.stdout.write(self.style.SUCCESS(f'Screenshot saved: {shot.url} {shot.duration} seconds')) 107 | self.do_webhook(shot) 108 | 109 | except ScreenShotException as e: 110 | shot.status = ScreenShot.FAILURE 111 | shot.save() 112 | self.stdout.write(self.style.ERROR(f'Error: {e}')) 113 | 114 | def do_webhook(self, shot): 115 | 116 | if not shot.callback_url: 117 | return 118 | 119 | payload = { 120 | 'id': shot.id.hex, 121 | 'url': shot.url, 122 | 'callback_url': shot.callback_url, 123 | 'created_at': shot.created_at.strftime("%Y-%m-%dT%H:%M:%S%z"), 124 | 'image_url': shot.s3_url, 125 | 'title': shot.meta['title'], 126 | 'description': shot.meta['description'] 127 | } 128 | 129 | headers = { 130 | 'content-type': 'application/json' 131 | } 132 | 133 | requests.post(shot.callback_url, json=payload, headers=headers) 134 | 135 | self.stdout.write(self.style.SUCCESS(f'Fired Webhook: {shot.url} to {shot.callback_url}')) 136 | 137 | def get_screenshot(self, shot) -> SeleniumScreenShot: 138 | 139 | profile = webdriver.FirefoxProfile() 140 | profile.set_preference("layout.css.devPixelsPerPx", str(shot.dpi)) 141 | 142 | if settings.SOCKS5_PROXY_ENABLED: 143 | self.stdout.write(self.style.SUCCESS(f'Proxy enabled: {settings.SOCKS5_PROXY_HOSTNAME}:{settings.SOCKS5_PROXY_PORT}')) 144 | profile.set_preference('network.proxy.type', 1) 145 | profile.set_preference("network.proxy.socks_version", 5) 146 | profile.set_preference('network.proxy.socks', settings.SOCKS5_PROXY_HOSTNAME) 147 | 148 | # explicit casting to int because otherwise it is ignored and fails silently. 149 | profile.set_preference('network.proxy.socks_port', int(settings.SOCKS5_PROXY_PORT)) 150 | profile.set_preference("network.proxy.socks_remote_dns", True) 151 | 152 | profile.set_preference("dom.webnotifications.enabled", False) 153 | profile.set_preference("dom.push.enabled", False) 154 | 155 | options = Options() 156 | options.headless = True 157 | 158 | driver = webdriver.Firefox(options=options, firefox_profile=profile) 159 | driver.install_addon(f'{settings.BASE_DIR}/firefox-extensions/i_dont_care_about_cookies-3.1.3-an+fx.xpi') 160 | driver.set_page_load_timeout(60) 161 | driver.set_window_size(shot.width, shot.height) 162 | 163 | try: 164 | driver.get(shot.url) 165 | except WebDriverException as e: 166 | driver.quit() 167 | capture_exception(e) 168 | raise ScreenShotException 169 | 170 | for i in range(10): 171 | doc_element_height = driver.execute_script("return document.documentElement.scrollHeight") 172 | doc_body_height = driver.execute_script("return document.body.scrollHeight") 173 | height = doc_element_height if doc_element_height > doc_body_height else doc_body_height 174 | driver.execute_script("window.scrollTo(0,document.body.scrollHeight)") 175 | sleep(1) 176 | 177 | # some sites like pandora and statesman.com have error/GDPR pages that are shorter than 178 | # a normal screen. 179 | if height > shot.height: 180 | driver.set_window_size(shot.width, height+100) 181 | 182 | sleep(shot.sleep_seconds) # this might not be necessary, but needs testing 183 | 184 | image_binary = convert_png_to_jpg(driver.get_screenshot_as_png()) 185 | 186 | # with io.BytesIO(driver.get_screenshot_as_png()) as png_file: 187 | # 188 | # with Image.open(png_file).convert('RGB') as i: 189 | # 190 | # with io.BytesIO() as output: 191 | # i.save(output, format="JPEG") 192 | # image_binary = output.getvalue() 193 | 194 | title = driver.title 195 | try: 196 | description = driver.find_element_by_xpath("//meta[@name='description']").get_attribute("content") 197 | except NoSuchElementException: 198 | description = title 199 | 200 | driver.quit() 201 | return SeleniumScreenShot(height=height, title=title, description=description, file=image_binary) -------------------------------------------------------------------------------- /src/shots/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-01-27 14:07 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ScreenShot', 17 | fields=[ 18 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 19 | ('url', models.TextField()), 20 | ('status', models.CharField(choices=[('N', 'New'), ('P', 'Pending'), ('S', 'Success'), ('F', 'Failed'), ('R1', 'Retry #1'), ('R2', 'Retry #2'), ('R3', 'Retry #3')], default='N', max_length=2)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/shots/migrations/0002_screenshot_is_fullpage.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-01-27 15:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='is_fullpage', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0003_auto_20200127_2029.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-01-27 20:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0002_screenshot_is_fullpage'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='height', 16 | field=models.IntegerField(default=768), 17 | ), 18 | migrations.AddField( 19 | model_name='screenshot', 20 | name='width', 21 | field=models.IntegerField(default=1366), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/shots/migrations/0004_screenshot_keywords.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-01-27 21:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0003_auto_20200127_2029'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='keywords', 16 | field=models.CharField(blank=True, max_length=250, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0005_screenshot_raw_html.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-01-31 09:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0004_screenshot_keywords'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='raw_html', 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0006_auto_20200204_1043.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-04 10:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0005_screenshot_raw_html'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='screenshot', 15 | name='is_fullpage', 16 | ), 17 | migrations.AlterField( 18 | model_name='screenshot', 19 | name='url', 20 | field=models.CharField(max_length=500), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/shots/migrations/0007_screenshot_duration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-07 00:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0006_auto_20200204_1043'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='duration', 16 | field=models.IntegerField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0008_auto_20200207_1203.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-07 12:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0007_screenshot_duration'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='screenshot', 15 | name='url', 16 | field=models.URLField(max_length=500), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0009_auto_20200209_1605.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-09 16:05 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import shots.models 6 | import shots.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('shots', '0008_auto_20200207_1203'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='screenshot', 18 | name='url', 19 | field=models.URLField(max_length=500, validators=[django.core.validators.URLValidator(), shots.validators.validate_hostname_dns]), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/shots/migrations/0010_auto_20200209_2152.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-09 21:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0009_auto_20200209_1605'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='base64_full', 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='screenshot', 20 | name='base64_thumb', 21 | field=models.TextField(blank=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/shots/migrations/0011_remove_screenshot_raw_html.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-09 22:19 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0010_auto_20200209_2152'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='screenshot', 15 | name='raw_html', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/shots/migrations/0012_remove_screenshot_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-10 00:16 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0011_remove_screenshot_raw_html'), 10 | ] 11 | 12 | operations = [ 13 | ] 14 | -------------------------------------------------------------------------------- /src/shots/migrations/0013_screenshot_image_binary.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-10 08:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0012_remove_screenshot_image'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='image_binary', 16 | field=models.BinaryField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0014_auto_20200211_1242.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-11 12:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0013_screenshot_image_binary'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='screenshot', 15 | name='base64_full', 16 | ), 17 | migrations.RemoveField( 18 | model_name='screenshot', 19 | name='base64_thumb', 20 | ), 21 | migrations.AddField( 22 | model_name='screenshot', 23 | name='format', 24 | field=models.CharField(choices=[('D', 'Desktop'), ('M', 'Mobile')], default='D', max_length=1), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /src/shots/migrations/0015_auto_20200213_1418.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-13 14:18 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('shots', '0014_auto_20200211_1242'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='screenshot', 16 | name='created_at', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /src/shots/migrations/0016_auto_20200228_1053.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-28 10:53 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import shots.validators 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('shots', '0015_auto_20200213_1418'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='screenshot', 17 | name='callback_url', 18 | field=models.URLField(blank=True, max_length=500, null=True, validators=[django.core.validators.URLValidator(), shots.validators.validate_hostname_dns]), 19 | ), 20 | migrations.AddField( 21 | model_name='screenshot', 22 | name='created_with', 23 | field=models.CharField(choices=[('A', 'API'), ('B', 'Browser')], default='B', max_length=1), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/shots/migrations/0017_screenshot_sleep_seconds.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-07 23:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0016_auto_20200228_1053'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='sleep_seconds', 16 | field=models.IntegerField(default=5), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0018_screenshot_dpi.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-08 02:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0017_screenshot_sleep_seconds'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='dpi', 16 | field=models.DecimalField(decimal_places=1, default=1.0, max_digits=2), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0019_auto_20200315_1818.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-15 18:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0018_screenshot_dpi'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='description', 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='screenshot', 20 | name='title', 21 | field=models.TextField(blank=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/shots/migrations/0020_auto_20200315_2020.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-15 20:20 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('shots', '0019_auto_20200315_1818'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='screenshot', 16 | name='description', 17 | ), 18 | migrations.RemoveField( 19 | model_name='screenshot', 20 | name='title', 21 | ), 22 | migrations.AddField( 23 | model_name='screenshot', 24 | name='meta', 25 | field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/shots/migrations/0021_screenshot_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-21 09:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0020_auto_20200315_2020'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='screenshot', 15 | name='file', 16 | field=models.FileField(blank=True, null=True, upload_to=''), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/shots/migrations/0022_remove_screenshot_image_binary.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-21 18:24 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('shots', '0021_screenshot_file'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='screenshot', 15 | name='image_binary', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/shots/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/shots/migrations/__init__.py -------------------------------------------------------------------------------- /src/shots/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import JSONField 2 | from django.db import models 3 | import uuid 4 | from django.conf import settings 5 | from django.core import validators 6 | from shots.validators import validate_hostname_dns 7 | from django.shortcuts import reverse 8 | 9 | 10 | class ScreenShot(models.Model): 11 | 12 | NEW = 'N' 13 | PENDING = 'P' 14 | SUCCESS = 'S' 15 | FAILURE = 'F' 16 | RETRY_1 = 'R1' 17 | RETRY_2 = 'R2' 18 | RETRY_3 = 'R3' 19 | STATUS_CHOICES = ( 20 | (NEW, 'New'), 21 | (PENDING, 'Pending'), 22 | (SUCCESS, 'Success'), 23 | (FAILURE, 'Failed'), 24 | (RETRY_1, 'Retry #1'), 25 | (RETRY_2, 'Retry #2'), 26 | (RETRY_3, 'Retry #3'), 27 | ) 28 | 29 | DESKTOP = 'D' 30 | MOBILE = 'M' 31 | FORMAT_CHOICES = ( 32 | (DESKTOP, 'Desktop'), 33 | (MOBILE, 'Mobile'), 34 | ) 35 | 36 | BROWSER = 'B' 37 | API = 'A' 38 | CREATED_WITH_CHOICES = ( 39 | (API, 'API'), 40 | (BROWSER, 'Browser') 41 | ) 42 | 43 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 44 | url = models.URLField(max_length=500, validators=[validators.URLValidator(), validate_hostname_dns ]) 45 | status = models.CharField(max_length=2, choices=STATUS_CHOICES, default=NEW) 46 | width = models.IntegerField(default=1366) 47 | height = models.IntegerField(default=768) 48 | keywords = models.CharField(blank=True, null=True, max_length=250) 49 | duration = models.IntegerField(null=True, blank=True) 50 | format = models.CharField(max_length=1, choices=FORMAT_CHOICES, default=DESKTOP) 51 | created_at = models.DateTimeField(auto_now_add=True) 52 | created_with = models.CharField(max_length=1, choices=CREATED_WITH_CHOICES, default=BROWSER) 53 | callback_url = models.URLField(null=True, blank=True, max_length=500, 54 | validators=[validators.URLValidator(), validate_hostname_dns ]) 55 | sleep_seconds = models.IntegerField(default=5) 56 | dpi = models.DecimalField(default=1.0, decimal_places=1, max_digits=2) 57 | meta = JSONField(null=True, blank=True) 58 | file = models.FileField(null=True, blank=True) 59 | 60 | @property 61 | def resolution(self): 62 | return f"{self.width}x{self.height}" 63 | 64 | def get_absolute_url(self): 65 | return reverse("screenshot_get", kwargs={"id": self.id}) 66 | 67 | @property 68 | def s3_url(self): 69 | return f"{settings.S3_ENDPOINT_URL}/{settings.S3_BUCKET_PREFIX}/{self.id.hex}.jpg" 70 | -------------------------------------------------------------------------------- /src/shots/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{% endblock %} 4 | {% block meta_description %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |

About Screenshots

9 | 10 |

11 | Hello there! Thanks for checking out ScreenShots. It is an experiment 12 | where I explore what it takes to build a robust screenshot tool 13 | that can handles the complexity of today's websites. Please feel free 14 | to try it out and make your own. 15 |

16 | 17 |

18 | Feel free to submit bug reports on this 19 | GitHub page. 21 |

22 | 23 | 24 |
25 | 26 |

Technical deatails of this project

27 | People are often curious about the technical details of the project such as tech stack and 28 | hosting. 29 | 30 |

Tech Stack

31 | 41 | 42 |

Hosting

43 |

Screenshots is hosted at Scaleway, 44 | a cloud services provider based in France. They offer budget-hosting solutions like 45 | the one I use here.

46 | 47 |

The server on which I run this service, the 48 | DEV1-M 49 | is a 3-core VM with 4Gigs or RAM and 20G local storage.

50 | 51 |

Open Source Used in this project

52 | 58 | 59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /src/shots/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}{% endblock %} 8 | 9 | 10 | 11 | {% block meta_extra %}{% endblock %} 12 | 13 | 14 | 15 | {% block style %}{% endblock %} 16 | 17 | 18 | 19 |
20 | Screenshots is an experiment from SimpleCTO. 21 | Please enjoy! 22 |
23 | 34 | 35 |
36 | {% block content %}{% endblock %} 37 |
38 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/shots/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load thumbnail_url thumbnail static %} 3 | 4 | {% block title %}Screenshot as a Service{% endblock %} 5 | {% block meta_description %}Screenshot as a Service{% endblock %} 6 | 7 | {% block style %} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 |

Make a website screenshot

13 | 14 |
15 | {% csrf_token %} 16 | {{ form.non_field_errors }} 17 | {% for hidden_field in form.hidden_fields %} 18 | {{ hidden_field.errors }} 19 | {{ hidden_field }} 20 | {% endfor %} 21 | 22 | 26 | {% if form.url.errors %} 27 |
28 | {% for e in form.url.errors %} 29 | {{e}} 30 | {% endfor %} 31 |
32 | {% endif %} 33 | 34 |
35 | 38 | 39 | 42 | 43 |
44 | 45 |
46 | 47 | This is a simple utility to get a website screenshot. Just 48 | plugin in the URL, wait a few seconds, and then get a snapshot 49 | of the desired website 50 | 51 | 52 |
53 | {% for s in shots %} 54 |
55 | {% thumbnail s.s3_url "300x198" crop="top" as im %} 56 | {{ s.url }} screenshot taken on {{ s.created_at }} 57 | {% endthumbnail %} 58 | 59 | {# #} 60 | 61 | {{s.url|truncatechars:30}} 62 | 63 |
64 | {% endfor %} 65 |
66 | {% endblock %} 67 | -------------------------------------------------------------------------------- /src/shots/templates/screenshot_get.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{% endblock %} 4 | {% block meta_description %}{% endblock %} 5 | 6 | {% block style %} 7 | 35 | {% endblock %} 36 | 37 | {% block content %} 38 | 39 |

40 | This is a screenshot of 41 | {{shot.url}} 42 |

43 | 44 | {% if shot.status == 'S' %} 45 |
46 | Full-page screenshot of {{shot.url}} 47 |
48 | {% endif %} 49 | 50 | {% if shot.status == 'P' or shot.status == 'N' %} 51 |
52 | Your screenshot is still loading. Please wait. This page will reload automatically... 53 |
54 | 55 | 56 | {% endif %} 57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /src/shots/templates/static/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /src/shots/templates/static/picnic.css: -------------------------------------------------------------------------------- 1 | /* Picnic CSS v6.5.0 http://picnicss.com/ */ 2 | html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:0;padding:0}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}*{box-sizing:inherit}html,body{font-family:Arial, Helvetica, sans-serif;box-sizing:border-box;height:100%}body{color:#111;font-size:1.1em;line-height:1.5;background:#fff}main{display:block}h1,h2,h3,h4,h5,h6{margin:0;padding:.6em 0}li{margin:0 0 .3em}a{color:#0074d9;text-decoration:none;box-shadow:none;transition:all 0.3s}code{padding:.3em .6em;font-size:.8em;background:#f5f5f5}pre{text-align:left;padding:.3em .6em;background:#f5f5f5;border-radius:.2em}pre code{padding:0}blockquote{padding:0 0 0 1em;margin:0 0 0 .1em;box-shadow:inset 5px 0 rgba(17,17,17,0.3)}label{cursor:pointer}[class^="icon-"]:before,[class*=" icon-"]:before{margin:0 .6em 0 0}i[class^="icon-"]:before,i[class*=" icon-"]:before{margin:0}.label,[data-tooltip]:after,button,.button,[type=submit],.dropimage{display:inline-block;text-align:center;letter-spacing:inherit;margin:0;padding:.3em .9em;vertical-align:middle;background:#0074d9;color:#fff;border:0;border-radius:.2em;width:auto;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.success.label,.success[data-tooltip]:after,button.success,.success.button,.success[type=submit],.success.dropimage{background:#2ecc40}.warning.label,.warning[data-tooltip]:after,button.warning,.warning.button,.warning[type=submit],.warning.dropimage{background:#ff851b}.error.label,.error[data-tooltip]:after,button.error,.error.button,.error[type=submit],.error.dropimage{background:#ff4136}.pseudo.label,.pseudo[data-tooltip]:after,button.pseudo,.pseudo.button,.pseudo[type=submit],.pseudo.dropimage{background-color:transparent;color:inherit}.label,[data-tooltip]:after{font-size:.6em;padding:.4em .6em;margin-left:1em;line-height:1}button,.button,[type=submit],.dropimage{margin:.3em 0;cursor:pointer;transition:all 0.3s;border-radius:.2em;height:auto;vertical-align:baseline;box-shadow:0 0 rgba(0,0,0,0) inset}button:hover,.button:hover,:hover[type=submit],.dropimage:hover,button:focus,.button:focus,:focus[type=submit],.dropimage:focus{box-shadow:inset 0 0 0 99em rgba(255,255,255,0.2);border:0}button.pseudo:hover,.pseudo.button:hover,.pseudo:hover[type=submit],.pseudo.dropimage:hover,button.pseudo:focus,.pseudo.button:focus,.pseudo:focus[type=submit],.pseudo.dropimage:focus{box-shadow:inset 0 0 0 99em rgba(17,17,17,0.1)}button.active,.active.button,.active[type=submit],.active.dropimage,button:active,.button:active,:active[type=submit],.dropimage:active,button.pseudo:active,.pseudo.button:active,.pseudo:active[type=submit],.pseudo.dropimage:active{box-shadow:inset 0 0 0 99em rgba(17,17,17,0.2)}button[disabled],.button[disabled],[disabled][type=submit],.dropimage[disabled]{cursor:default;box-shadow:none;background:#bbb}:checked+.toggle,:checked+.toggle:hover{box-shadow:inset 0 0 0 99em rgba(17,17,17,0.2)}[type]+.toggle{padding:.3em .9em;margin-right:0}[type]+.toggle:after,[type]+.toggle:before{display:none}input,textarea,.select select{line-height:1.5;margin:0;height:2.1em;padding:.3em .6em;border:1px solid #ccc;background-color:#fff;border-radius:.2em;transition:all 0.3s;width:100%}input:focus,textarea:focus,.select select:focus{border:1px solid #0074d9;outline:0}textarea{height:auto}[type=file],[type=color]{cursor:pointer}[type=file]{height:auto}select{background:#fff url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyIiBoZWlnaHQ9IjMiPjxwYXRoIGQ9Im0gMCwxIDEsMiAxLC0yIHoiLz48L3N2Zz4=) no-repeat scroll 95% center/10px 15px;background-position:calc(100% - 15px) center;border:1px solid #ccc;border-radius:.2em;cursor:pointer;width:100%;height:2.2em;box-sizing:border-box;padding:.3em .45em;transition:all 0.3s;-moz-appearance:none;-webkit-appearance:none;appearance:none}select::-ms-expand{display:none}select:focus,select:active{border:1px solid #0074d9;transition:outline 0s}select:-moz-focusring{color:transparent;text-shadow:0 0 0 #111}select option{font-size:inherit;padding:.45em}select[multiple]{height:auto;background:none;padding:0}[type=radio],[type=checkbox]{opacity:0;width:0;position:absolute;display:inline-block}[type=radio]+.checkable:hover:before,[type=checkbox]+.checkable:hover:before,:focus[type=radio]+.checkable:before,:focus[type=checkbox]+.checkable:before{border:1px solid #0074d9}[type=radio]+.checkable,[type=checkbox]+.checkable{position:relative;cursor:pointer;padding-left:1.5em;margin-right:.6em}[type=radio]+.checkable:before,[type=checkbox]+.checkable:before,[type=radio]+.checkable:after,[type=checkbox]+.checkable:after{content:'';position:absolute;display:inline-block;left:0;top:50%;transform:translateY(-50%);font-size:1em;line-height:1em;color:transparent;font-family:sans;text-align:center;box-sizing:border-box;width:1em;height:1em;border-radius:50%;transition:all 0.3s}[type=radio]+.checkable:before,[type=checkbox]+.checkable:before{border:1px solid #aaa}:checked[type=radio]+.checkable:after,:checked[type=checkbox]+.checkable:after{background:#555;transform:scale(0.5) translateY(-100%)}[type=checkbox]+.checkable:before{border-radius:.2em}[type=checkbox]+.checkable:after{content:"✔";background:none;transform:scale(2) translateY(-25%);visibility:hidden;opacity:0}:checked[type=checkbox]+.checkable:after{color:#111;background:none;transform:translateY(-50%);transition:all 0.3s;visibility:visible;opacity:1}table{text-align:left}td,th{padding:.3em 2.4em .3em .6em}th{text-align:left;font-weight:900;color:#fff;background-color:#0074d9}.success th{background-color:#2ecc40}.warning th{background-color:#ff851b}.error th{background-color:#ff4136}.dull th{background-color:#aaa}tr:nth-child(even){background:rgba(0,0,0,0.05)}.flex{display:-ms-flexbox;display:flex;margin-left:-.6em;width:calc(100% + .6em);flex-wrap:wrap;transition:all .3s ease}.flex>*{box-sizing:border-box;flex:1 1 auto;padding-left:.6em;padding-bottom:.6em}.flex[class*="one"]>*,.flex[class*="two"]>*,.flex[class*="three"]>*,.flex[class*="four"]>*,.flex[class*="five"]>*,.flex[class*="six"]>*,.flex[class*="seven"]>*,.flex[class*="eight"]>*,.flex[class*="nine"]>*,.flex[class*="ten"]>*,.flex[class*="eleven"]>*,.flex[class*="twelve"]>*{flex-grow:0}.flex.grow>*{flex-grow:1}.center{justify-content:center}.one>*{width:100%}.two>*{width:50%}.three>*{width:33.33333%}.four>*{width:25%}.five>*{width:20%}.six>*{width:16.66666%}.seven>*{width:14.28571%}.eight>*{width:12.5%}.nine>*{width:11.11111%}.ten>*{width:10%}.eleven>*{width:9.09091%}.twelve>*{width:8.33333%}@media all and (min-width: 500px){.one-500>*{width:100%}.two-500>*{width:50%}.three-500>*{width:33.33333%}.four-500>*{width:25%}.five-500>*{width:20%}.six-500>*{width:16.66666%}.seven-500>*{width:14.28571%}.eight-500>*{width:12.5%}.nine-500>*{width:11.11111%}.ten-500>*{width:10%}.eleven-500>*{width:9.09091%}.twelve-500>*{width:8.33333%}}@media all and (min-width: 600px){.one-600>*{width:100%}.two-600>*{width:50%}.three-600>*{width:33.33333%}.four-600>*{width:25%}.five-600>*{width:20%}.six-600>*{width:16.66666%}.seven-600>*{width:14.28571%}.eight-600>*{width:12.5%}.nine-600>*{width:11.11111%}.ten-600>*{width:10%}.eleven-600>*{width:9.09091%}.twelve-600>*{width:8.33333%}}@media all and (min-width: 700px){.one-700>*{width:100%}.two-700>*{width:50%}.three-700>*{width:33.33333%}.four-700>*{width:25%}.five-700>*{width:20%}.six-700>*{width:16.66666%}.seven-700>*{width:14.28571%}.eight-700>*{width:12.5%}.nine-700>*{width:11.11111%}.ten-700>*{width:10%}.eleven-700>*{width:9.09091%}.twelve-700>*{width:8.33333%}}@media all and (min-width: 800px){.one-800>*{width:100%}.two-800>*{width:50%}.three-800>*{width:33.33333%}.four-800>*{width:25%}.five-800>*{width:20%}.six-800>*{width:16.66666%}.seven-800>*{width:14.28571%}.eight-800>*{width:12.5%}.nine-800>*{width:11.11111%}.ten-800>*{width:10%}.eleven-800>*{width:9.09091%}.twelve-800>*{width:8.33333%}}@media all and (min-width: 900px){.one-900>*{width:100%}.two-900>*{width:50%}.three-900>*{width:33.33333%}.four-900>*{width:25%}.five-900>*{width:20%}.six-900>*{width:16.66666%}.seven-900>*{width:14.28571%}.eight-900>*{width:12.5%}.nine-900>*{width:11.11111%}.ten-900>*{width:10%}.eleven-900>*{width:9.09091%}.twelve-900>*{width:8.33333%}}@media all and (min-width: 1000px){.one-1000>*{width:100%}.two-1000>*{width:50%}.three-1000>*{width:33.33333%}.four-1000>*{width:25%}.five-1000>*{width:20%}.six-1000>*{width:16.66666%}.seven-1000>*{width:14.28571%}.eight-1000>*{width:12.5%}.nine-1000>*{width:11.11111%}.ten-1000>*{width:10%}.eleven-1000>*{width:9.09091%}.twelve-1000>*{width:8.33333%}}@media all and (min-width: 1100px){.one-1100>*{width:100%}.two-1100>*{width:50%}.three-1100>*{width:33.33333%}.four-1100>*{width:25%}.five-1100>*{width:20%}.six-1100>*{width:16.66666%}.seven-1100>*{width:14.28571%}.eight-1100>*{width:12.5%}.nine-1100>*{width:11.11111%}.ten-1100>*{width:10%}.eleven-1100>*{width:9.09091%}.twelve-1100>*{width:8.33333%}}@media all and (min-width: 1200px){.one-1200>*{width:100%}.two-1200>*{width:50%}.three-1200>*{width:33.33333%}.four-1200>*{width:25%}.five-1200>*{width:20%}.six-1200>*{width:16.66666%}.seven-1200>*{width:14.28571%}.eight-1200>*{width:12.5%}.nine-1200>*{width:11.11111%}.ten-1200>*{width:10%}.eleven-1200>*{width:9.09091%}.twelve-1200>*{width:8.33333%}}@media all and (min-width: 1300px){.one-1300>*{width:100%}.two-1300>*{width:50%}.three-1300>*{width:33.33333%}.four-1300>*{width:25%}.five-1300>*{width:20%}.six-1300>*{width:16.66666%}.seven-1300>*{width:14.28571%}.eight-1300>*{width:12.5%}.nine-1300>*{width:11.11111%}.ten-1300>*{width:10%}.eleven-1300>*{width:9.09091%}.twelve-1300>*{width:8.33333%}}@media all and (min-width: 1400px){.one-1400>*{width:100%}.two-1400>*{width:50%}.three-1400>*{width:33.33333%}.four-1400>*{width:25%}.five-1400>*{width:20%}.six-1400>*{width:16.66666%}.seven-1400>*{width:14.28571%}.eight-1400>*{width:12.5%}.nine-1400>*{width:11.11111%}.ten-1400>*{width:10%}.eleven-1400>*{width:9.09091%}.twelve-1400>*{width:8.33333%}}@media all and (min-width: 1500px){.one-1500>*{width:100%}.two-1500>*{width:50%}.three-1500>*{width:33.33333%}.four-1500>*{width:25%}.five-1500>*{width:20%}.six-1500>*{width:16.66666%}.seven-1500>*{width:14.28571%}.eight-1500>*{width:12.5%}.nine-1500>*{width:11.11111%}.ten-1500>*{width:10%}.eleven-1500>*{width:9.09091%}.twelve-1500>*{width:8.33333%}}@media all and (min-width: 1600px){.one-1600>*{width:100%}.two-1600>*{width:50%}.three-1600>*{width:33.33333%}.four-1600>*{width:25%}.five-1600>*{width:20%}.six-1600>*{width:16.66666%}.seven-1600>*{width:14.28571%}.eight-1600>*{width:12.5%}.nine-1600>*{width:11.11111%}.ten-1600>*{width:10%}.eleven-1600>*{width:9.09091%}.twelve-1600>*{width:8.33333%}}@media all and (min-width: 1700px){.one-1700>*{width:100%}.two-1700>*{width:50%}.three-1700>*{width:33.33333%}.four-1700>*{width:25%}.five-1700>*{width:20%}.six-1700>*{width:16.66666%}.seven-1700>*{width:14.28571%}.eight-1700>*{width:12.5%}.nine-1700>*{width:11.11111%}.ten-1700>*{width:10%}.eleven-1700>*{width:9.09091%}.twelve-1700>*{width:8.33333%}}@media all and (min-width: 1800px){.one-1800>*{width:100%}.two-1800>*{width:50%}.three-1800>*{width:33.33333%}.four-1800>*{width:25%}.five-1800>*{width:20%}.six-1800>*{width:16.66666%}.seven-1800>*{width:14.28571%}.eight-1800>*{width:12.5%}.nine-1800>*{width:11.11111%}.ten-1800>*{width:10%}.eleven-1800>*{width:9.09091%}.twelve-1800>*{width:8.33333%}}@media all and (min-width: 1900px){.one-1900>*{width:100%}.two-1900>*{width:50%}.three-1900>*{width:33.33333%}.four-1900>*{width:25%}.five-1900>*{width:20%}.six-1900>*{width:16.66666%}.seven-1900>*{width:14.28571%}.eight-1900>*{width:12.5%}.nine-1900>*{width:11.11111%}.ten-1900>*{width:10%}.eleven-1900>*{width:9.09091%}.twelve-1900>*{width:8.33333%}}@media all and (min-width: 2000px){.one-2000>*{width:100%}.two-2000>*{width:50%}.three-2000>*{width:33.33333%}.four-2000>*{width:25%}.five-2000>*{width:20%}.six-2000>*{width:16.66666%}.seven-2000>*{width:14.28571%}.eight-2000>*{width:12.5%}.nine-2000>*{width:11.11111%}.ten-2000>*{width:10%}.eleven-2000>*{width:9.09091%}.twelve-2000>*{width:8.33333%}}.full{width:100%}.half{width:50%}.third{width:33.33333%}.two-third{width:66.66666%}.fourth{width:25%}.three-fourth{width:75%}.fifth{width:20%}.two-fifth{width:40%}.three-fifth{width:60%}.four-fifth{width:80%}.sixth{width:16.66666%}.none{display:none}@media all and (min-width: 500px){.full-500{width:100%;display:block}.half-500{width:50%;display:block}.third-500{width:33.33333%;display:block}.two-third-500{width:66.66666%;display:block}.fourth-500{width:25%;display:block}.three-fourth-500{width:75%;display:block}.fifth-500{width:20%;display:block}.two-fifth-500{width:40%;display:block}.three-fifth-500{width:60%;display:block}.four-fifth-500{width:80%;display:block}.sixth-500{width:16.66666%;display:block}}@media all and (min-width: 600px){.full-600{width:100%;display:block}.half-600{width:50%;display:block}.third-600{width:33.33333%;display:block}.two-third-600{width:66.66666%;display:block}.fourth-600{width:25%;display:block}.three-fourth-600{width:75%;display:block}.fifth-600{width:20%;display:block}.two-fifth-600{width:40%;display:block}.three-fifth-600{width:60%;display:block}.four-fifth-600{width:80%;display:block}.sixth-600{width:16.66666%;display:block}}@media all and (min-width: 700px){.full-700{width:100%;display:block}.half-700{width:50%;display:block}.third-700{width:33.33333%;display:block}.two-third-700{width:66.66666%;display:block}.fourth-700{width:25%;display:block}.three-fourth-700{width:75%;display:block}.fifth-700{width:20%;display:block}.two-fifth-700{width:40%;display:block}.three-fifth-700{width:60%;display:block}.four-fifth-700{width:80%;display:block}.sixth-700{width:16.66666%;display:block}}@media all and (min-width: 800px){.full-800{width:100%;display:block}.half-800{width:50%;display:block}.third-800{width:33.33333%;display:block}.two-third-800{width:66.66666%;display:block}.fourth-800{width:25%;display:block}.three-fourth-800{width:75%;display:block}.fifth-800{width:20%;display:block}.two-fifth-800{width:40%;display:block}.three-fifth-800{width:60%;display:block}.four-fifth-800{width:80%;display:block}.sixth-800{width:16.66666%;display:block}}@media all and (min-width: 900px){.full-900{width:100%;display:block}.half-900{width:50%;display:block}.third-900{width:33.33333%;display:block}.two-third-900{width:66.66666%;display:block}.fourth-900{width:25%;display:block}.three-fourth-900{width:75%;display:block}.fifth-900{width:20%;display:block}.two-fifth-900{width:40%;display:block}.three-fifth-900{width:60%;display:block}.four-fifth-900{width:80%;display:block}.sixth-900{width:16.66666%;display:block}}@media all and (min-width: 1000px){.full-1000{width:100%;display:block}.half-1000{width:50%;display:block}.third-1000{width:33.33333%;display:block}.two-third-1000{width:66.66666%;display:block}.fourth-1000{width:25%;display:block}.three-fourth-1000{width:75%;display:block}.fifth-1000{width:20%;display:block}.two-fifth-1000{width:40%;display:block}.three-fifth-1000{width:60%;display:block}.four-fifth-1000{width:80%;display:block}.sixth-1000{width:16.66666%;display:block}}@media all and (min-width: 1100px){.full-1100{width:100%;display:block}.half-1100{width:50%;display:block}.third-1100{width:33.33333%;display:block}.two-third-1100{width:66.66666%;display:block}.fourth-1100{width:25%;display:block}.three-fourth-1100{width:75%;display:block}.fifth-1100{width:20%;display:block}.two-fifth-1100{width:40%;display:block}.three-fifth-1100{width:60%;display:block}.four-fifth-1100{width:80%;display:block}.sixth-1100{width:16.66666%;display:block}}@media all and (min-width: 1200px){.full-1200{width:100%;display:block}.half-1200{width:50%;display:block}.third-1200{width:33.33333%;display:block}.two-third-1200{width:66.66666%;display:block}.fourth-1200{width:25%;display:block}.three-fourth-1200{width:75%;display:block}.fifth-1200{width:20%;display:block}.two-fifth-1200{width:40%;display:block}.three-fifth-1200{width:60%;display:block}.four-fifth-1200{width:80%;display:block}.sixth-1200{width:16.66666%;display:block}}@media all and (min-width: 1300px){.full-1300{width:100%;display:block}.half-1300{width:50%;display:block}.third-1300{width:33.33333%;display:block}.two-third-1300{width:66.66666%;display:block}.fourth-1300{width:25%;display:block}.three-fourth-1300{width:75%;display:block}.fifth-1300{width:20%;display:block}.two-fifth-1300{width:40%;display:block}.three-fifth-1300{width:60%;display:block}.four-fifth-1300{width:80%;display:block}.sixth-1300{width:16.66666%;display:block}}@media all and (min-width: 1400px){.full-1400{width:100%;display:block}.half-1400{width:50%;display:block}.third-1400{width:33.33333%;display:block}.two-third-1400{width:66.66666%;display:block}.fourth-1400{width:25%;display:block}.three-fourth-1400{width:75%;display:block}.fifth-1400{width:20%;display:block}.two-fifth-1400{width:40%;display:block}.three-fifth-1400{width:60%;display:block}.four-fifth-1400{width:80%;display:block}.sixth-1400{width:16.66666%;display:block}}@media all and (min-width: 1500px){.full-1500{width:100%;display:block}.half-1500{width:50%;display:block}.third-1500{width:33.33333%;display:block}.two-third-1500{width:66.66666%;display:block}.fourth-1500{width:25%;display:block}.three-fourth-1500{width:75%;display:block}.fifth-1500{width:20%;display:block}.two-fifth-1500{width:40%;display:block}.three-fifth-1500{width:60%;display:block}.four-fifth-1500{width:80%;display:block}.sixth-1500{width:16.66666%;display:block}}@media all and (min-width: 1600px){.full-1600{width:100%;display:block}.half-1600{width:50%;display:block}.third-1600{width:33.33333%;display:block}.two-third-1600{width:66.66666%;display:block}.fourth-1600{width:25%;display:block}.three-fourth-1600{width:75%;display:block}.fifth-1600{width:20%;display:block}.two-fifth-1600{width:40%;display:block}.three-fifth-1600{width:60%;display:block}.four-fifth-1600{width:80%;display:block}.sixth-1600{width:16.66666%;display:block}}@media all and (min-width: 1700px){.full-1700{width:100%;display:block}.half-1700{width:50%;display:block}.third-1700{width:33.33333%;display:block}.two-third-1700{width:66.66666%;display:block}.fourth-1700{width:25%;display:block}.three-fourth-1700{width:75%;display:block}.fifth-1700{width:20%;display:block}.two-fifth-1700{width:40%;display:block}.three-fifth-1700{width:60%;display:block}.four-fifth-1700{width:80%;display:block}.sixth-1700{width:16.66666%;display:block}}@media all and (min-width: 1800px){.full-1800{width:100%;display:block}.half-1800{width:50%;display:block}.third-1800{width:33.33333%;display:block}.two-third-1800{width:66.66666%;display:block}.fourth-1800{width:25%;display:block}.three-fourth-1800{width:75%;display:block}.fifth-1800{width:20%;display:block}.two-fifth-1800{width:40%;display:block}.three-fifth-1800{width:60%;display:block}.four-fifth-1800{width:80%;display:block}.sixth-1800{width:16.66666%;display:block}}@media all and (min-width: 1900px){.full-1900{width:100%;display:block}.half-1900{width:50%;display:block}.third-1900{width:33.33333%;display:block}.two-third-1900{width:66.66666%;display:block}.fourth-1900{width:25%;display:block}.three-fourth-1900{width:75%;display:block}.fifth-1900{width:20%;display:block}.two-fifth-1900{width:40%;display:block}.three-fifth-1900{width:60%;display:block}.four-fifth-1900{width:80%;display:block}.sixth-1900{width:16.66666%;display:block}}@media all and (min-width: 2000px){.full-2000{width:100%;display:block}.half-2000{width:50%;display:block}.third-2000{width:33.33333%;display:block}.two-third-2000{width:66.66666%;display:block}.fourth-2000{width:25%;display:block}.three-fourth-2000{width:75%;display:block}.fifth-2000{width:20%;display:block}.two-fifth-2000{width:40%;display:block}.three-fifth-2000{width:60%;display:block}.four-fifth-2000{width:80%;display:block}.sixth-2000{width:16.66666%;display:block}}@media all and (min-width: 500px){.none-500{display:none}}@media all and (min-width: 600px){.none-600{display:none}}@media all and (min-width: 700px){.none-700{display:none}}@media all and (min-width: 800px){.none-800{display:none}}@media all and (min-width: 900px){.none-900{display:none}}@media all and (min-width: 1000px){.none-1000{display:none}}@media all and (min-width: 1100px){.none-1100{display:none}}@media all and (min-width: 1200px){.none-1200{display:none}}@media all and (min-width: 1300px){.none-1300{display:none}}@media all and (min-width: 1400px){.none-1400{display:none}}@media all and (min-width: 1500px){.none-1500{display:none}}@media all and (min-width: 1600px){.none-1600{display:none}}@media all and (min-width: 1700px){.none-1700{display:none}}@media all and (min-width: 1800px){.none-1800{display:none}}@media all and (min-width: 1900px){.none-1900{display:none}}@media all and (min-width: 2000px){.none-2000{display:none}}.off-none{margin-left:0}.off-half{margin-left:50%}.off-third{margin-left:33.33333%}.off-two-third{margin-left:66.66666%}.off-fourth{margin-left:25%}.off-three-fourth{margin-left:75%}.off-fifth{margin-left:20%}.off-two-fifth{margin-left:40%}.off-three-fifth{margin-left:60%}.off-four-fifth{margin-left:80%}.off-sixth{margin-left:16.66666%}@media all and (min-width: 500px){.off-none-500{margin-left:0}.off-half-500{margin-left:50%}.off-third-500{margin-left:33.33333%}.off-two-third-500{margin-left:66.66666%}.off-fourth-500{margin-left:25%}.off-three-fourth-500{margin-left:75%}.off-fifth-500{margin-left:20%}.off-two-fifth-500{margin-left:40%}.off-three-fifth-500{margin-left:60%}.off-four-fifth-500{margin-left:80%}.off-sixth-500{margin-left:16.66666%}}@media all and (min-width: 600px){.off-none-600{margin-left:0}.off-half-600{margin-left:50%}.off-third-600{margin-left:33.33333%}.off-two-third-600{margin-left:66.66666%}.off-fourth-600{margin-left:25%}.off-three-fourth-600{margin-left:75%}.off-fifth-600{margin-left:20%}.off-two-fifth-600{margin-left:40%}.off-three-fifth-600{margin-left:60%}.off-four-fifth-600{margin-left:80%}.off-sixth-600{margin-left:16.66666%}}@media all and (min-width: 700px){.off-none-700{margin-left:0}.off-half-700{margin-left:50%}.off-third-700{margin-left:33.33333%}.off-two-third-700{margin-left:66.66666%}.off-fourth-700{margin-left:25%}.off-three-fourth-700{margin-left:75%}.off-fifth-700{margin-left:20%}.off-two-fifth-700{margin-left:40%}.off-three-fifth-700{margin-left:60%}.off-four-fifth-700{margin-left:80%}.off-sixth-700{margin-left:16.66666%}}@media all and (min-width: 800px){.off-none-800{margin-left:0}.off-half-800{margin-left:50%}.off-third-800{margin-left:33.33333%}.off-two-third-800{margin-left:66.66666%}.off-fourth-800{margin-left:25%}.off-three-fourth-800{margin-left:75%}.off-fifth-800{margin-left:20%}.off-two-fifth-800{margin-left:40%}.off-three-fifth-800{margin-left:60%}.off-four-fifth-800{margin-left:80%}.off-sixth-800{margin-left:16.66666%}}@media all and (min-width: 900px){.off-none-900{margin-left:0}.off-half-900{margin-left:50%}.off-third-900{margin-left:33.33333%}.off-two-third-900{margin-left:66.66666%}.off-fourth-900{margin-left:25%}.off-three-fourth-900{margin-left:75%}.off-fifth-900{margin-left:20%}.off-two-fifth-900{margin-left:40%}.off-three-fifth-900{margin-left:60%}.off-four-fifth-900{margin-left:80%}.off-sixth-900{margin-left:16.66666%}}@media all and (min-width: 1000px){.off-none-1000{margin-left:0}.off-half-1000{margin-left:50%}.off-third-1000{margin-left:33.33333%}.off-two-third-1000{margin-left:66.66666%}.off-fourth-1000{margin-left:25%}.off-three-fourth-1000{margin-left:75%}.off-fifth-1000{margin-left:20%}.off-two-fifth-1000{margin-left:40%}.off-three-fifth-1000{margin-left:60%}.off-four-fifth-1000{margin-left:80%}.off-sixth-1000{margin-left:16.66666%}}@media all and (min-width: 1100px){.off-none-1100{margin-left:0}.off-half-1100{margin-left:50%}.off-third-1100{margin-left:33.33333%}.off-two-third-1100{margin-left:66.66666%}.off-fourth-1100{margin-left:25%}.off-three-fourth-1100{margin-left:75%}.off-fifth-1100{margin-left:20%}.off-two-fifth-1100{margin-left:40%}.off-three-fifth-1100{margin-left:60%}.off-four-fifth-1100{margin-left:80%}.off-sixth-1100{margin-left:16.66666%}}@media all and (min-width: 1200px){.off-none-1200{margin-left:0}.off-half-1200{margin-left:50%}.off-third-1200{margin-left:33.33333%}.off-two-third-1200{margin-left:66.66666%}.off-fourth-1200{margin-left:25%}.off-three-fourth-1200{margin-left:75%}.off-fifth-1200{margin-left:20%}.off-two-fifth-1200{margin-left:40%}.off-three-fifth-1200{margin-left:60%}.off-four-fifth-1200{margin-left:80%}.off-sixth-1200{margin-left:16.66666%}}@media all and (min-width: 1300px){.off-none-1300{margin-left:0}.off-half-1300{margin-left:50%}.off-third-1300{margin-left:33.33333%}.off-two-third-1300{margin-left:66.66666%}.off-fourth-1300{margin-left:25%}.off-three-fourth-1300{margin-left:75%}.off-fifth-1300{margin-left:20%}.off-two-fifth-1300{margin-left:40%}.off-three-fifth-1300{margin-left:60%}.off-four-fifth-1300{margin-left:80%}.off-sixth-1300{margin-left:16.66666%}}@media all and (min-width: 1400px){.off-none-1400{margin-left:0}.off-half-1400{margin-left:50%}.off-third-1400{margin-left:33.33333%}.off-two-third-1400{margin-left:66.66666%}.off-fourth-1400{margin-left:25%}.off-three-fourth-1400{margin-left:75%}.off-fifth-1400{margin-left:20%}.off-two-fifth-1400{margin-left:40%}.off-three-fifth-1400{margin-left:60%}.off-four-fifth-1400{margin-left:80%}.off-sixth-1400{margin-left:16.66666%}}@media all and (min-width: 1500px){.off-none-1500{margin-left:0}.off-half-1500{margin-left:50%}.off-third-1500{margin-left:33.33333%}.off-two-third-1500{margin-left:66.66666%}.off-fourth-1500{margin-left:25%}.off-three-fourth-1500{margin-left:75%}.off-fifth-1500{margin-left:20%}.off-two-fifth-1500{margin-left:40%}.off-three-fifth-1500{margin-left:60%}.off-four-fifth-1500{margin-left:80%}.off-sixth-1500{margin-left:16.66666%}}@media all and (min-width: 1600px){.off-none-1600{margin-left:0}.off-half-1600{margin-left:50%}.off-third-1600{margin-left:33.33333%}.off-two-third-1600{margin-left:66.66666%}.off-fourth-1600{margin-left:25%}.off-three-fourth-1600{margin-left:75%}.off-fifth-1600{margin-left:20%}.off-two-fifth-1600{margin-left:40%}.off-three-fifth-1600{margin-left:60%}.off-four-fifth-1600{margin-left:80%}.off-sixth-1600{margin-left:16.66666%}}@media all and (min-width: 1700px){.off-none-1700{margin-left:0}.off-half-1700{margin-left:50%}.off-third-1700{margin-left:33.33333%}.off-two-third-1700{margin-left:66.66666%}.off-fourth-1700{margin-left:25%}.off-three-fourth-1700{margin-left:75%}.off-fifth-1700{margin-left:20%}.off-two-fifth-1700{margin-left:40%}.off-three-fifth-1700{margin-left:60%}.off-four-fifth-1700{margin-left:80%}.off-sixth-1700{margin-left:16.66666%}}@media all and (min-width: 1800px){.off-none-1800{margin-left:0}.off-half-1800{margin-left:50%}.off-third-1800{margin-left:33.33333%}.off-two-third-1800{margin-left:66.66666%}.off-fourth-1800{margin-left:25%}.off-three-fourth-1800{margin-left:75%}.off-fifth-1800{margin-left:20%}.off-two-fifth-1800{margin-left:40%}.off-three-fifth-1800{margin-left:60%}.off-four-fifth-1800{margin-left:80%}.off-sixth-1800{margin-left:16.66666%}}@media all and (min-width: 1900px){.off-none-1900{margin-left:0}.off-half-1900{margin-left:50%}.off-third-1900{margin-left:33.33333%}.off-two-third-1900{margin-left:66.66666%}.off-fourth-1900{margin-left:25%}.off-three-fourth-1900{margin-left:75%}.off-fifth-1900{margin-left:20%}.off-two-fifth-1900{margin-left:40%}.off-three-fifth-1900{margin-left:60%}.off-four-fifth-1900{margin-left:80%}.off-sixth-1900{margin-left:16.66666%}}@media all and (min-width: 2000px){.off-none-2000{margin-left:0}.off-half-2000{margin-left:50%}.off-third-2000{margin-left:33.33333%}.off-two-third-2000{margin-left:66.66666%}.off-fourth-2000{margin-left:25%}.off-three-fourth-2000{margin-left:75%}.off-fifth-2000{margin-left:20%}.off-two-fifth-2000{margin-left:40%}.off-three-fifth-2000{margin-left:60%}.off-four-fifth-2000{margin-left:80%}.off-sixth-2000{margin-left:16.66666%}}nav{position:fixed;top:0;left:0;right:0;height:3em;padding:0 .6em;background:#fff;box-shadow:0 0 0.2em rgba(17,17,17,0.2);z-index:10000;transition:all .3s;transform-style:preserve-3d}nav .brand,nav .menu,nav .burger{float:right;position:relative;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%)}nav .brand{font-weight:700;float:left;padding:0 .6em;max-width:50%;white-space:nowrap;color:inherit}nav .brand *{vertical-align:middle}nav .logo{height:2em;margin-right:.3em}nav .select::after{height:calc(100% - 1px);padding:0;line-height:2.4em}nav .menu>*{margin-right:.6em}nav .burger{display:none}@media all and (max-width: 60em){nav .burger{display:inline-block;cursor:pointer;bottom:-1000em;margin:0}nav .burger ~ .menu,nav .show:checked ~ .burger{position:fixed;min-height:100%;top:0;right:0;bottom:-1000em;margin:0;background:#fff;transition:all .5s ease;transform:none}nav .burger ~ .menu{z-index:11}nav .show:checked ~ .burger{color:transparent;width:100%;border-radius:0;background:rgba(0,0,0,0.2);transition:all .5s ease}nav .show ~ .menu{width:70%;max-width:300px;transform-origin:center right;transition:all .25s ease;transform:scaleX(0)}nav .show ~ .menu>*{transform:translateX(100%);transition:all 0s ease .5s}nav .show:checked ~ .menu>*:nth-child(1){transition:all .5s cubic-bezier(0.645, 0.045, 0.355, 1) 0s}nav .show:checked ~ .menu>*:nth-child(2){transition:all .5s cubic-bezier(0.645, 0.045, 0.355, 1) .1s}nav .show:checked ~ .menu>*:nth-child(3){transition:all .5s cubic-bezier(0.645, 0.045, 0.355, 1) .2s}nav .show:checked ~ .menu>*:nth-child(4){transition:all .5s cubic-bezier(0.645, 0.045, 0.355, 1) .3s}nav .show:checked ~ .menu>*:nth-child(5){transition:all .5s cubic-bezier(0.645, 0.045, 0.355, 1) .4s}nav .show:checked ~ .menu>*:nth-child(6){transition:all .5s cubic-bezier(0.645, 0.045, 0.355, 1) .5s}nav .show:checked ~ .menu{transform:scaleX(1)}nav .show:checked ~ .menu>*{transform:translateX(0);transition:all .5s ease-in-out .6s}nav .burger ~ .menu>*{display:block;margin:.3em;text-align:left;max-width:calc(100% - .6em)}nav .burger ~ .menu>a{padding:.3em .9em}}.stack,.stack .toggle{margin-top:0;margin-bottom:0;display:block;width:100%;text-align:left;border-radius:0}.stack:first-child,.stack:first-child .toggle{border-top-left-radius:.2em;border-top-right-radius:.2em}.stack:last-child,.stack:last-child .toggle{border-bottom-left-radius:.2em;border-bottom-right-radius:.2em}input.stack,textarea.stack,select.stack{transition:border-bottom 0 ease 0;border-bottom-width:0}input.stack:last-child,textarea.stack:last-child,select.stack:last-child{border-bottom-width:1px}input.stack:focus+input,input.stack:focus+textarea,input.stack:focus+select,textarea.stack:focus+input,textarea.stack:focus+textarea,textarea.stack:focus+select,select.stack:focus+input,select.stack:focus+textarea,select.stack:focus+select{border-top-color:#0074d9}.card,.modal .overlay ~ *{position:relative;box-shadow:0;border-radius:.2em;border:1px solid #ccc;overflow:hidden;text-align:left;background:#fff;margin-bottom:.6em;padding:0;transition:all .3s ease}.hidden.card,.modal .overlay ~ .hidden,:checked+.card,.modal .overlay ~ :checked+*,.modal .overlay:checked+*{font-size:0;padding:0;margin:0;border:0}.card>*,.modal .overlay ~ *>*{max-width:100%;display:block}.card>*:last-child,.modal .overlay ~ *>*:last-child{margin-bottom:0}.card header,.modal .overlay ~ * header,.card section,.modal .overlay ~ * section,.card>p,.modal .overlay ~ *>p{padding:.6em .8em}.card section,.modal .overlay ~ * section{padding:.6em .8em 0}.card hr,.modal .overlay ~ * hr{border:none;height:1px;background-color:#eee}.card header,.modal .overlay ~ * header{font-weight:bold;position:relative;border-bottom:1px solid #eee}.card header h1,.modal .overlay ~ * header h1,.card header h2,.modal .overlay ~ * header h2,.card header h3,.modal .overlay ~ * header h3,.card header h4,.modal .overlay ~ * header h4,.card header h5,.modal .overlay ~ * header h5,.card header h6,.modal .overlay ~ * header h6{padding:0;margin:0 2em 0 0;line-height:1;display:inline-block;vertical-align:text-bottom}.card header:last-child,.modal .overlay ~ * header:last-child{border-bottom:0}.card footer,.modal .overlay ~ * footer{padding:.8em}.card p,.modal .overlay ~ * p{margin:.3em 0}.card p:first-child,.modal .overlay ~ * p:first-child{margin-top:0}.card p:last-child,.modal .overlay ~ * p:last-child{margin-bottom:0}.card>p,.modal .overlay ~ *>p{margin:0;padding-right:2.5em}.card .close,.modal .overlay ~ * .close{position:absolute;top:.4em;right:.3em;font-size:1.2em;padding:0 .5em;cursor:pointer;width:auto}.card .close:hover,.modal .overlay ~ * .close:hover{color:#ff4136}.card h1+.close,.modal .overlay ~ * h1+.close{margin:.2em}.card h2+.close,.modal .overlay ~ * h2+.close{margin:.1em}.card .dangerous,.modal .overlay ~ * .dangerous{background:#ff4136;float:right}.modal{text-align:center}.modal>input{display:none}.modal>input ~ *{opacity:0;max-height:0;overflow:hidden}.modal .overlay{top:0;left:0;bottom:0;right:0;position:fixed;margin:0;border-radius:0;background:rgba(17,17,17,0.6);transition:all 0.3s;z-index:100000}.modal .overlay:before,.modal .overlay:after{display:none}.modal .overlay ~ *{border:0;position:fixed;top:50%;left:50%;transform:translateX(-50%) translateY(-50%) scale(0.2, 0.2);z-index:1000000;transition:all 0.3s}.modal>input:checked ~ *{display:block;opacity:1;max-height:10000px;transition:all 0.3s}.modal>input:checked ~ .overlay ~ *{max-height:90%;overflow:auto;-webkit-transform:translateX(-50%) translateY(-50%) scale(1, 1);transform:translateX(-50%) translateY(-50%) scale(1, 1)}@media (max-width: 60em){.modal .overlay ~ *{min-width:90%}}.dropimage{position:relative;display:block;padding:0;padding-bottom:56.25%;overflow:hidden;cursor:pointer;border:0;margin:.3em 0;border-radius:.2em;background-color:#ddd;background-size:cover;background-position:center center;background-image:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NDAiIGhlaWdodD0iNjQwIiB2ZXJzaW9uPSIxLjEiPjxnIHN0eWxlPSJmaWxsOiMzMzMiPjxwYXRoIGQ9Ik0gMTg3IDIzMCBDIDE3NSAyMzAgMTY1IDI0MCAxNjUgMjUyIEwgMTY1IDMwMCBMIDE2NSA0MDggQyAxNjUgNDIwIDE3NSA0MzAgMTg3IDQzMCBMIDQ2MyA0MzAgQyA0NzUgNDMwIDQ4NSA0MjAgNDg1IDQwOCBMIDQ4NSAzMDAgTCA0ODUgMjUyIEMgNDg1IDI0MCA0NzUgMjMwIDQ2MyAyMzAgTCAxODcgMjMwIHogTSAzNjAgMjU2IEEgNzAgNzIgMCAwIDEgNDMwIDMyOCBBIDcwIDcyIDAgMCAxIDM2MCA0MDAgQSA3MCA3MiAwIDAgMSAyOTAgMzI4IEEgNzAgNzIgMCAwIDEgMzYwIDI1NiB6Ii8+PGNpcmNsZSBjeD0iMzYwIiBjeT0iMzMwIiByPSI0MSIvPjxwYXRoIGQ9Im0yMDUgMjI1IDUtMTAgMjAgMCA1IDEwLTMwIDAiLz48cGF0aCBkPSJNMjg1IDIwMEwyNzAgMjI1IDM3NiAyMjUgMzYxIDIwMCAyODUgMjAwek0zMTAgMjA1TDMzNyAyMDUgMzM3IDIxOCAzMTAgMjE4IDMxMCAyMDV6Ii8+PHBhdGggZD0ibTQwNSAyMjUgNS0xMCAyMCAwIDUgMTAtMzAgMCIvPjwvZz48L3N2Zz4=)}.dropimage input{left:0;width:100%;height:100%;border:0;margin:0;padding:0;opacity:0;cursor:pointer;position:absolute}.tabs{position:relative;overflow:hidden}.tabs>label img{float:left;margin-left:.6em}.tabs>.row{width:calc(100% + 2 * .6em);display:table;table-layout:fixed;position:relative;padding-left:0;transition:all .3s;border-spacing:0;margin:0}.tabs>.row:before,.tabs>.row:after{display:none}.tabs>.row>*,.tabs>.row img{display:table-cell;vertical-align:top;margin:0;width:100%}.tabs>input{display:none}.tabs>input+*{width:100%}.tabs>input+label{width:auto}.two.tabs>.row{width:200%;left:-100%}.two.tabs>input:nth-of-type(1):checked ~ .row{margin-left:100%}.two.tabs>label img{width:48%;margin:4% 0 4% 4%}.three.tabs>.row{width:300%;left:-200%}.three.tabs>input:nth-of-type(1):checked ~ .row{margin-left:200%}.three.tabs>input:nth-of-type(2):checked ~ .row{margin-left:100%}.three.tabs>label img{width:30%;margin:5% 0 5% 5%}.four.tabs>.row{width:400%;left:-300%}.four.tabs>input:nth-of-type(1):checked ~ .row{margin-left:300%}.four.tabs>input:nth-of-type(2):checked ~ .row{margin-left:200%}.four.tabs>input:nth-of-type(3):checked ~ .row{margin-left:100%}.four.tabs>label img{width:22%;margin:4% 0 4% 4%}.tabs>label:first-of-type img{margin-left:0}[data-tooltip]{position:relative}[data-tooltip]:after,[data-tooltip]:before{position:absolute;z-index:10;opacity:0;border-width:0;height:0;padding:0;overflow:hidden;transition:opacity .6s ease, height 0s ease .6s;top:calc(100% - 6px);left:0;margin-top:12px}[data-tooltip]:after{margin-left:0;font-size:.8em;background:#111;content:attr(data-tooltip);white-space:nowrap}[data-tooltip]:before{content:'';width:0;height:0;border-width:0;border-style:solid;border-color:transparent transparent #111;margin-top:0;left:10px}[data-tooltip]:hover:after,[data-tooltip]:focus:after,[data-tooltip]:hover:before,[data-tooltip]:focus:before{opacity:1;border-width:6px;height:auto}[data-tooltip]:hover:after,[data-tooltip]:focus:after{padding:.45em .9em}.tooltip-top:after,.tooltip-top:before{top:auto;bottom:calc(100% - 6px);left:0;margin-bottom:12px}.tooltip-top:before{border-color:#111 transparent transparent;margin-bottom:0;left:10px}.tooltip-right:after,.tooltip-right:before{left:100%;margin-left:6px;margin-top:0;top:0}.tooltip-right:before{border-color:transparent #111 transparent transparent;margin-left:-6px;left:100%;top:7px}.tooltip-left:after,.tooltip-left:before{right:100%;margin-right:6px;left:auto;margin-top:0;top:0}.tooltip-left:before{border-color:transparent transparent transparent #111;margin-right:-6px;right:100%;top:7px} 3 | 4 | -------------------------------------------------------------------------------- /src/shots/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glovebx/screenshots/6d328fdbbfe9076a70dedd3117005d1a5f1d9438/src/shots/templatetags/__init__.py -------------------------------------------------------------------------------- /src/shots/templatetags/thumbnail_url.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | from PIL import ImageOps, Image 4 | from django import template 5 | from django.core.cache import cache 6 | 7 | register = template.Library() 8 | 9 | @register.simple_tag 10 | def thumbnail(shot, width, height): 11 | thumb = cache.get(shot.id.hex) 12 | if not thumb: 13 | with io.BytesIO(shot.image_binary.tobytes()) as buf: 14 | with Image.open(buf) as image: 15 | with io.BytesIO() as tbuffer: 16 | thumb = ImageOps.fit(image, (width, height), Image.ANTIALIAS, centering=(1.0, 0.0)) 17 | thumb.save(tbuffer, "JPEG") 18 | b64 = base64.b64encode(tbuffer.getvalue()).decode('ascii') 19 | thumb = f'data:image/jpg;base64,{b64}' 20 | cache.set(shot.id.hex, thumb, 30 * 86400) # 30 days 21 | 22 | return thumb -------------------------------------------------------------------------------- /src/shots/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | import json 3 | from django.urls import reverse 4 | from shots.models import ScreenShot 5 | from django.http import HttpResponse, JsonResponse 6 | 7 | 8 | class TestScreenShotAPI(TestCase): 9 | 10 | def setUp(self) -> None: 11 | self.api_url = reverse('api-screenshot') 12 | 13 | def do_post(self, data) -> JsonResponse: 14 | return self.client.post( 15 | self.api_url, 16 | data=json.dumps(data), 17 | content_type='application/json' 18 | ) 19 | 20 | def test_url_missing(self): 21 | data = {} 22 | response = self.do_post(data) 23 | self.assertEqual(response.status_code, 400) 24 | 25 | def test_url_domain_does_not_exist(self): 26 | data = {'url': 'sdf234dfg345fdg-doesnotexist.com'} 27 | response = self.do_post(data) 28 | self.assertEqual(response.status_code, 400) 29 | 30 | def test_url_malformatted(self): 31 | data = {'url': 'htp:google.com'} 32 | response = self.do_post(data) 33 | self.assertEqual(response.status_code, 400) 34 | 35 | def test_url_ok(self): 36 | data = {'url': 'http://google.com'} 37 | response = self.do_post(data) 38 | self.assertEqual(response.status_code, 201) 39 | 40 | def test_callback_url_domain_does_not_exist(self): 41 | data = { 42 | 'url': 'https://www.simplecto.com', 43 | 'callback_url': 'sdf234dfg345fdg-doesnotexist.com' 44 | } 45 | response = self.do_post(data) 46 | self.assertEqual(response.status_code, 400) 47 | 48 | def test_callback_url_malformatted(self): 49 | data = { 50 | 'url': 'https://www.simplecto.com', 51 | 'callback_url': 'htp:sdf234dfg345fdg-doesnotexist.com' 52 | } 53 | response = self.do_post(data) 54 | self.assertEqual(response.status_code, 400) 55 | -------------------------------------------------------------------------------- /src/shots/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | import socket 3 | from urllib.parse import urlparse 4 | 5 | 6 | def validate_hostname_dns(value): 7 | 8 | domain = urlparse(value).netloc.split(':')[0] 9 | 10 | if len(domain) == 0: 11 | raise ValidationError(f"Domain name is required") 12 | 13 | try: 14 | socket.gethostbyname(domain) 15 | except socket.gaierror: 16 | raise ValidationError(f"{value} does not resolve with public DNS.") 17 | -------------------------------------------------------------------------------- /src/shots/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django import forms 3 | from django.http import HttpResponse, JsonResponse 4 | from django.shortcuts import render, redirect, get_object_or_404 5 | from shots.models import ScreenShot 6 | from django.forms import ModelForm 7 | from django.views.decorators.http import require_http_methods 8 | from django.views.decorators.http import condition 9 | from django.core.exceptions import ValidationError 10 | from django.views.decorators.csrf import csrf_exempt 11 | 12 | 13 | class ScreenShotForm(ModelForm): 14 | class Meta: 15 | model = ScreenShot 16 | fields = ('url', 'format', ) 17 | widgets = { 18 | 'format' : forms.RadioSelect() 19 | } 20 | 21 | def latest_entry(request): 22 | return ScreenShot.objects.filter(status=ScreenShot.SUCCESS)\ 23 | .latest("created_at").created_at 24 | 25 | def index(request): 26 | form = ScreenShotForm() 27 | 28 | try: 29 | shots = ScreenShot.objects\ 30 | .filter(status=ScreenShot.SUCCESS)\ 31 | .order_by('-created_at').all()[:30] 32 | except ScreenShot.DoesNotExist: 33 | shots = None 34 | 35 | data = { 36 | 'form': form, 37 | 'shots': shots 38 | } 39 | return render(request, "index.html", data) 40 | 41 | 42 | def about(request): 43 | return render(request, "about.html") 44 | 45 | 46 | @require_http_methods(["POST"]) 47 | def screenshot_create(request): 48 | form = ScreenShotForm(request.POST) 49 | 50 | if form.is_valid(): 51 | shot = form.save() 52 | return redirect(shot) 53 | 54 | data = { 55 | 'form': form 56 | } 57 | 58 | return render(request, "index.html", data) 59 | 60 | 61 | def screenshot_get(request, id): 62 | shot = get_object_or_404(ScreenShot, id=id) 63 | data = { 64 | 'shot': shot 65 | } 66 | return render(request, "screenshot_get.html", data) 67 | 68 | 69 | def health_check(request): 70 | return HttpResponse('OK') 71 | 72 | @csrf_exempt 73 | def api_screenshot(request): 74 | body_unicode = request.body.decode('utf-8') 75 | 76 | try: 77 | body = json.loads(body_unicode) 78 | except json.decoder.JSONDecodeError as e: 79 | data = { 80 | 'message': 'Invalid JSON' 81 | } 82 | return JsonResponse(data=data, status=400) 83 | 84 | url = body['url'] if 'url' in body else None 85 | callback_url = body['callback_url'] if 'callback_url' in body else None 86 | keywords = body['keywords'] if 'keywords' in body else None 87 | sleep_seconds = body['sleep_seconds'] if 'sleep_seconds' in body else 5 88 | dpi = body['dpi'] if 'dpi' in body else 1.0 89 | 90 | if not url: 91 | data = { 92 | 'message': 'url is a required parameter' 93 | } 94 | return JsonResponse(data=data, status=400) 95 | 96 | s = ScreenShot(url=url, 97 | callback_url=callback_url, 98 | keywords=keywords, 99 | sleep_seconds=sleep_seconds, 100 | dpi=dpi) 101 | 102 | try: 103 | s.full_clean() 104 | except ValidationError as e: 105 | data = { 106 | 'errors': ','.join(e.messages) 107 | } 108 | return JsonResponse(data=data, status=400) 109 | 110 | s.save() 111 | data = { 112 | 'id': s.id, 113 | 'url': s.url 114 | } 115 | 116 | if callback_url: 117 | data['callback_url'] = s.callback_url 118 | 119 | return JsonResponse(data=data, status=201) 120 | -------------------------------------------------------------------------------- /src/static/css/home.css: -------------------------------------------------------------------------------- 1 | #images { 2 | display: flex; 3 | flex-flow: row wrap; 4 | justify-content: center; 5 | padding-top: 5rem; 6 | } 7 | 8 | #images > div { 9 | margin: 1rem; 10 | } 11 | 12 | #images > div a { 13 | font-size: 0.75rem; 14 | width: 100%; 15 | margin: 0.5rem; 16 | text-align: center; 17 | display: block; 18 | } 19 | 20 | #images > div img { 21 | display: block; 22 | border: 1px solid #afafaf 23 | } 24 | 25 | h1 { 26 | font-family: Georgia; 27 | text-align: center; 28 | } 29 | 30 | @media only screen and (min-width: 320px){ 31 | h1 { 32 | font-size: 2rem; 33 | margin: 4rem 0 2rem 0; 34 | } 35 | 36 | } 37 | 38 | @media only screen and (min-width: 800px){ 39 | h1 { 40 | font-size: 3rem; 41 | margin: 6rem 0 1.5rem 0 42 | } 43 | 44 | } 45 | 46 | 47 | #search { 48 | display: flex; 49 | justify-content: center; 50 | } 51 | 52 | #search button { 53 | text-align: center; 54 | } 55 | 56 | #form_errors { 57 | color: #FF1744; 58 | 59 | margin: 0 auto; 60 | text-align: center; 61 | font-size: 0.75rem; 62 | } 63 | 64 | input[type="text"] { 65 | border-radius: 0; 66 | width: 80%; 67 | border-bottom: 1px solid gray; 68 | margin: 0; 69 | } 70 | 71 | button { 72 | border-radius: 0; 73 | margin: 0; 74 | } 75 | 76 | em { 77 | display: block; 78 | text-align: center; 79 | } 80 | 81 | input[type="radio"] { 82 | margin: 0; 83 | } 84 | 85 | #shot-format-container { 86 | display: flex; 87 | justify-content: center; 88 | margin-top: 1rem; 89 | } 90 | label { 91 | margin-right: 1rem 92 | } 93 | -------------------------------------------------------------------------------- /src/static/css/site.css: -------------------------------------------------------------------------------- 1 | /** 2 | These styles apply site-wide. 3 | */ 4 | 5 | html, body { 6 | margin: 0 auto; 7 | padding: 0; 8 | background-color: #f7f7f7; 9 | display: flex; 10 | min-height: 100vh; 11 | flex-direction: column; 12 | } 13 | 14 | #top { 15 | padding: 0.5rem; 16 | min-height: 50px; 17 | width: 100%; 18 | background: #353A61; 19 | font-size: 0.75rem; 20 | color: #faeeaf; 21 | text-align: center; 22 | } 23 | 24 | #top a { 25 | color: #faeeaf; 26 | border-bottom: 1px dotted #faeeaf; 27 | } 28 | 29 | #top a[target="_blank"]:after { 30 | content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAEumlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjEwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMTAiCiAgIGV4aWY6Q29sb3JTcGFjZT0iMSIKICAgdGlmZjpJbWFnZVdpZHRoPSIxMCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMTAiCiAgIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiCiAgIHRpZmY6WFJlc29sdXRpb249IjcyLjAiCiAgIHRpZmY6WVJlc29sdXRpb249IjcyLjAiCiAgIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiCiAgIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjAtMDItMTFUMjA6MDc6MDcrMDE6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjAtMDItMTFUMjA6MDc6MDcrMDE6MDAiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJwcm9kdWNlZCIKICAgICAgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWZmaW5pdHkgRGVzaWduZXIgKEF1ZyAxMiAyMDE5KSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMC0wMi0xMVQyMDowNzowNyswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+k9juIwAAAYNpQ0NQc1JHQiBJRUM2MTk2Ni0yLjEAACiRdZHPK0RRFMc/xo8R40exsLCYNGzMiFETG2Uk1CSNUQabmTe/1Px4vTeTJltlqyix8WvBX8BWWStFpGRlYU1smJ7zZqZGMud27vnc773ndO+5YAkklZReNwipdFbzT3nti8Elu/UFC1Za6achpOjq+Nycj6r2eU+NGW9dZq3q5/615khUV6CmUXhMUbWs8LSwby2rmrwj3KkkQhHhM2GnJhcUvjP1cIlfTY6X+NtkLeCfAEu7sD3+i8O/WEloKWF5OY5UMqeU72O+xBZNL8xL7BHvRsfPFF7szDDJBB6GGJXZgws3A7KiSv5gMX+WjOQqMqvk0VglToIsTlFzUj0qMSZ6VEaSvNn/v33VY8PuUnWbF+qfDeO9F6zbUNgyjK8jwygcQ+0TXKYr+ZlDGPkQfauiOQ6gbQPOrypaeBcuNqHrUQ1poaJUK26JxeDtFFqC0HEDTculnpX3OXmAwLp81TXs7UOfnG9b+QERwGe/EOgPAgAAAAlwSFlzAAALEwAACxMBAJqcGAAAALhJREFUGJV1zrFKA0EQh/HfhnMFiwgLFlHfQV8hjf3WeQkbWxs738JGkBRXSB4hvZ1gkcLO5rhOONTY7MGx6HT/mW++meCfGrp2gfMS+zB07RwrnE24V/zgseS3BvfYYDsBe3zgBZcIDRYx5efq7CHWSLjFavbHbyN0gWVM+Q5Xswo6mEI4hZjye23cY1dMO9yMg6aYQkx5H1P+wnXpNfiegms8DV3bVfYTPIwhlO0jHFdgH1P+HMMv1vM2ao7Gl8EAAAAASUVORK5CYII=); 31 | margin: 0 0 0 2px; 32 | height: 1px; 33 | width: 10px; 34 | color: #faeeaf; 35 | } 36 | 37 | 38 | 39 | #top a:hover { 40 | text-decoration: none; 41 | } 42 | 43 | nav a:visited { 44 | color: #353A61; 45 | } 46 | 47 | nav div a { 48 | display: inline-block; 49 | margin: 0 2rem; 50 | font-size: 0.75rem; 51 | color: #353A61; 52 | } 53 | 54 | nav div a:hover { 55 | text-decoration: none; 56 | color: #353A61; 57 | } 58 | 59 | #brand { 60 | margin-bottom: 1rem; 61 | font-size: 1.5rem; 62 | text-transform: uppercase; 63 | 64 | } 65 | 66 | main { 67 | margin: 0 auto; 68 | padding: 0 1rem; 69 | max-width: 60rem; 70 | flex: 1; 71 | } 72 | 73 | footer { 74 | background-color: rgb(41, 41, 41); 75 | margin: 0 auto; 76 | font-size: 0.75em; 77 | color: gray; 78 | text-align: center; 79 | width: 100%; 80 | } 81 | 82 | p { 83 | max-width: 40rem; 84 | } -------------------------------------------------------------------------------- /src/static/css/tacit.css: -------------------------------------------------------------------------------- 1 | input,textarea,select,button,option,html,body{font-family:system-ui,"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:18px;font-stretch:normal;font-style:normal;font-weight:400;line-height:29.7px}input,textarea,select,button,option,html,body{font-family:system-ui,"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:18px;font-stretch:normal;font-style:normal;font-weight:400;line-height:29.7px}th{font-weight:600}td,th{border-bottom:1.08px solid #595959;padding:14.85px 18px;text-align:left;vertical-align:top}thead th{border-bottom-width:2.16px;padding-bottom:6.3px}table{display:table;width:100%}@media all and (max-width: 1024px){table{display:none}}@media all and (max-width: 1024px){table thead{display:none}}table tr{border-bottom-width:2.16px}table tr th{border-bottom-width:2.16px}table tr td,table tr th{overflow:hidden;padding:5.4px 3.6px}@media all and (max-width: 1024px){table tr td,table tr th{border:0;display:inline-block}}@media all and (max-width: 1024px){table tr{display:inline-block;margin:10.8px 0}}@media all and (max-width: 1024px){table{display:inline-block}}input,textarea,select,button,option,html,body{font-family:system-ui,"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:18px;font-stretch:normal;font-style:normal;font-weight:400;line-height:29.7px}fieldset{display:flex;flex-direction:row;flex-wrap:wrap}fieldset legend{margin:18px 0}input,textarea,select,button{border-radius:3.6px;display:inline-block;padding:9.9px}input+label,input+input[type="checkbox"],input+input[type="radio"],textarea+label,textarea+input[type="checkbox"],textarea+input[type="radio"],select+label,select+input[type="checkbox"],select+input[type="radio"],button+label,button+input[type="checkbox"],button+input[type="radio"]{page-break-before:always}input,select,label{margin-right:3.6px}textarea{min-height:90px;min-width:360px}label{display:inline-block;margin-bottom:12.6px}label+*{page-break-before:always}label>input{margin-bottom:0}input[type="submit"],input[type="reset"],button{background:#f2f2f2;color:#191919;cursor:pointer;display:inline;margin-bottom:18px;margin-right:7.2px;padding:6.525px 23.4px;text-align:center}input[type="submit"]:hover,input[type="reset"]:hover,button:hover{background:#d9d9d9;color:#000}input[type="submit"][disabled],input[type="reset"][disabled],button[disabled]{background:#e6e5e5;color:#403f3f;cursor:not-allowed}input[type="submit"],button[type="submit"]{background:#275a90;color:#fff}input[type="submit"]:hover,button[type="submit"]:hover{background:#173454;color:#bfbfbf}input,select,textarea{margin-bottom:18px}input[type="text"],input[type="password"],input[type="email"],input[type="url"],input[type="phone"],input[type="tel"],input[type="number"],input[type="datetime"],input[type="date"],input[type="month"],input[type="week"],input[type="color"],input[type="time"],input[type="search"],input[type="range"],input[type="file"],input[type="datetime-local"],select,textarea{border:1px solid #595959;padding:5.4px 6.3px}input[type="checkbox"],input[type="radio"]{flex-grow:0;height:29.7px;margin-left:0;margin-right:9px;vertical-align:middle}input[type="checkbox"]+label,input[type="radio"]+label{page-break-before:avoid}select[multiple]{min-width:270px}input,textarea,select,button,option,html,body{font-family:system-ui,"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:18px;font-stretch:normal;font-style:normal;font-weight:400;line-height:29.7px}pre,code,kbd,samp,var,output{font-family:Menlo,Monaco,Consolas,"Courier New",monospace;font-size:14.4px}pre{border-left:1.8px solid #59c072;line-height:25.2px;overflow:auto;padding-left:18px}pre code{background:none;border:0;line-height:29.7px;padding:0}code,kbd{background:#daf1e0;border-radius:3.6px;color:#2a6f3b;display:inline-block;line-height:18px;padding:3.6px 6.3px 2.7px}kbd{background:#2a6f3b;color:#fff}mark{background:#ffc;padding:0 3.6px}input,textarea,select,button,option,html,body{font-family:system-ui,"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:18px;font-stretch:normal;font-style:normal;font-weight:400;line-height:29.7px}h1,h2,h3,h4,h5,h6{color:#000;margin-bottom:18px}h1{font-size:36px;font-weight:500;line-height:43.2px;margin-top:72px}h2{font-size:25.2px;font-weight:400;line-height:34.2px;margin-top:54px}h3{font-size:21.6px;line-height:27px;margin-top:36px}h4{font-size:18px;line-height:23.4px;margin-top:18px}h5{font-size:14.4px;font-weight:bold;line-height:21.6px;text-transform:uppercase}h6{color:#595959;font-size:14.4px;font-weight:bold;line-height:18px;text-transform:uppercase}input,textarea,select,button,option,html,body{font-family:system-ui,"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:18px;font-stretch:normal;font-style:normal;font-weight:400;line-height:29.7px}a{color:#275a90;text-decoration:none}a:hover{text-decoration:underline}hr{border-bottom:1px solid #595959}figcaption,small{font-size:15.3px}figcaption{color:#595959}var,em,i{font-style:italic}dt,strong,b{font-weight:600}del,s{text-decoration:line-through}ins,u{text-decoration:underline}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}*{border:0;border-collapse:separate;border-spacing:0;box-sizing:border-box;margin:0;max-width:100%;padding:0;vertical-align:baseline}html,body{width:100%}html{height:100%}body{background:#fff;color:#1a1919;padding:36px}p,ul,ol,dl,blockquote,hr,pre,table,form,fieldset,figure,address{margin-bottom:29.7px}section{margin-left:auto;margin-right:auto;width:900px}aside{float:right;width:285px}article,header,footer{padding:43.2px}article{background:#fff;border:1px solid #d9d9d9}nav{text-align:center}nav ul{list-style:none;margin-left:0;text-align:center}nav ul li{display:inline-block;margin-left:9px;margin-right:9px;vertical-align:middle}nav ul li:first-child{margin-left:0}nav ul li:last-child{margin-right:0}ol,ul{margin-left:31.5px}li dl,li ol,li ul{margin-bottom:0}dl{display:inline-block}dt{padding:0 18px}dd{padding:0 18px 4.5px}dd:last-of-type{border-bottom:1.08px solid #595959}dd+dt{border-top:1.08px solid #595959;padding-top:9px}blockquote{border-left:2.16px solid #595959;padding:4.5px 18px 4.5px 15.84px}blockquote footer{color:#595959;font-size:13.5px;margin:0}blockquote p{margin-bottom:0}img{height:auto;margin:0 auto}figure img{display:block}@media (max-width: 767px){body{padding:18px 0}article{border:0;padding:18px}header,footer{padding:18px}textarea,input,select{min-width:0}fieldset{min-width:0}fieldset *{flex-grow:1;page-break-before:auto}section{width:auto}x:-moz-any-link{display:table-cell}} 2 | -------------------------------------------------------------------------------- /src/static/js/intercooler-1.2.3.min.js: -------------------------------------------------------------------------------- 1 | /*! intercooler 1.2.3 2019-11-05 */ 2 | 3 | !function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(c){return a.Intercooler=b(c)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):a.Intercooler=b(a.jQuery)}(this,function($){var Intercooler=Intercooler||function(){"use strict";function remove(a){a.remove()}function showIndicator(a){a.closest(".ic-use-transition").length>0?(a.data("ic-use-transition",!0),a.removeClass("ic-use-transition")):a.show()}function hideIndicator(a){a.data("ic-use-transition")||a.data("ic-indicator-cleared")?(a.data("ic-use-transition",null),a.addClass("ic-use-transition"),a.data("ic-indicator-cleared",!0)):a.hide()}function fixICAttributeName(a){return USE_DATA?"data-"+a:a}function getICAttribute(a,b){return a.attr(fixICAttributeName(b))}function setICAttribute(a,b,c){a.attr(fixICAttributeName(b),c)}function prepend(a,b){try{a.prepend(b)}catch(b){log(a,formatError(b),"ERROR")}if(getICAttribute(a,"ic-limit-children")){var c=parseInt(getICAttribute(a,"ic-limit-children"));a.children().length>c&&a.children().slice(c,a.children().length).remove()}}function append(a,b){try{a.append(b)}catch(b){log(a,formatError(b),"ERROR")}if(getICAttribute(a,"ic-limit-children")){var c=parseInt(getICAttribute(a,"ic-limit-children"));a.children().length>c&&a.children().slice(0,a.children().length-c).remove()}}function triggerEvent(a,b,c){$.zepto&&(b=b.split(".").reverse().join(":")),a.trigger(b,c)}function log(a,b,c){if(null==a&&(a=$("body")),triggerEvent(a,"log.ic",[b,c,a]),"ERROR"==c){window.console&&window.console.log("Intercooler Error : "+b);var d=closestAttrValue($("body"),"ic-post-errors-to");d&&$.post(d,{error:b})}}function uuid(){return _UUID++}function icSelectorFor(a){return getICAttributeSelector("ic-id='"+getIntercoolerId(a)+"'")}function parseInterval(a){return log(null,"POLL: Parsing interval string "+a,"DEBUG"),"null"==a||"false"==a||""==a?null:a.lastIndexOf("ms")==a.length-2?parseFloat(a.substr(0,a.length-2)):a.lastIndexOf("s")==a.length-1?1e3*parseFloat(a.substr(0,a.length-1)):1e3}function getICAttributeSelector(a){return"["+fixICAttributeName(a)+"]"}function initScrollHandler(){null==_scrollHandler&&(_scrollHandler=function(){$(getICAttributeSelector("ic-trigger-on='scrolled-into-view'")).each(function(){var a=$(this);isScrolledIntoView(getTriggeredElement(a))&&1!=a.data("ic-scrolled-into-view-loaded")&&(a.data("ic-scrolled-into-view-loaded",!0),fireICRequest(a))})},$(window).scroll(_scrollHandler))}function currentUrl(){return window.location.pathname+window.location.search+window.location.hash}function createDocument(a){var b=null;return/<(html|body)/i.test(a)?(b=document.documentElement.cloneNode(),b.innerHTML=a):(b=document.documentElement.cloneNode(!0),b.querySelector("body").innerHTML=a),$(b)}function getTarget(a){return getTargetImpl(a,"ic-target")}function getTargetImpl(a,b){var c=$(a).closest(getICAttributeSelector(b)),d=getICAttribute(c,b);return"this"==d?c:d&&0!=d.indexOf("this.")?0==d.indexOf("closest ")?a.closest(d.substr(8)):0==d.indexOf("find ")?a.find(d.substr(5)):$(d):a}function processHeaders(a,b){a=$(a),triggerEvent(a,"beforeHeaders.ic",[a,b]),log(a,"response headers: "+b.getAllResponseHeaders(),"DEBUG");var c=null;if(b.getResponseHeader("X-IC-Title")&&(document.title=b.getResponseHeader("X-IC-Title")),b.getResponseHeader("X-IC-Title-Encoded")){var d=decodeURIComponent(b.getResponseHeader("X-IC-Title-Encoded").replace(/\+/g,"%20"));document.title=d}if(b.getResponseHeader("X-IC-Refresh")){var e=b.getResponseHeader("X-IC-Refresh").split(",");log(a,"X-IC-Refresh: refreshing "+e,"DEBUG"),$.each(e,function(b,c){refreshDependencies(c.replace(/ /g,""),a)})}if(b.getResponseHeader("X-IC-Script")&&(log(a,"X-IC-Script: evaling "+b.getResponseHeader("X-IC-Script"),"DEBUG"),globalEval(b.getResponseHeader("X-IC-Script"),[["elt",a]])),b.getResponseHeader("X-IC-Redirect")&&(log(a,"X-IC-Redirect: redirecting to "+b.getResponseHeader("X-IC-Redirect"),"DEBUG"),window.location=b.getResponseHeader("X-IC-Redirect")),"true"==b.getResponseHeader("X-IC-CancelPolling")&&cancelPolling(a.closest(getICAttributeSelector("ic-poll"))),"true"==b.getResponseHeader("X-IC-ResumePolling")){var f=a.closest(getICAttributeSelector("ic-poll"));setICAttribute(f,"ic-pause-polling",null),startPolling(f)}if(b.getResponseHeader("X-IC-SetPollInterval")){var f=a.closest(getICAttributeSelector("ic-poll"));cancelPolling(f),setICAttribute(f,"ic-poll",b.getResponseHeader("X-IC-SetPollInterval")),startPolling(f)}b.getResponseHeader("X-IC-Open")&&(log(a,"X-IC-Open: opening "+b.getResponseHeader("X-IC-Open"),"DEBUG"),window.open(b.getResponseHeader("X-IC-Open")));var g=b.getResponseHeader("X-IC-Trigger");if(g)if(log(a,"X-IC-Trigger: found trigger "+g,"DEBUG"),c=getTarget(a),b.getResponseHeader("X-IC-Trigger-Data")){var h=$.parseJSON(b.getResponseHeader("X-IC-Trigger-Data"));triggerEvent(c,g,h)}else g.indexOf("{")>=0?$.each($.parseJSON(g),function(a,b){triggerEvent(c,a,b)}):triggerEvent(c,g,[]);var i=b.getResponseHeader("X-IC-Set-Local-Vars");if(i&&$.each($.parseJSON(i),function(a,b){localStorage.setItem(a,b)}),b.getResponseHeader("X-IC-Remove")&&a){var j=b.getResponseHeader("X-IC-Remove");j+="";var k=parseInterval(j);log(a,"X-IC-Remove header found.","DEBUG"),c=getTarget(a),"true"==j||null==k?remove(c):(c.addClass("ic-removing"),setTimeout(function(){remove(c)},k))}return triggerEvent(a,"afterHeaders.ic",[a,b]),!0}function beforeRequest(a){a.addClass("disabled"),a.addClass("ic-request-in-flight"),a.data("ic-request-in-flight",!0)}function requestCleanup(a,b,c){a.length>0&&hideIndicator(a),b.length>0&&hideIndicator(b),c.removeClass("disabled"),c.removeClass("ic-request-in-flight"),c.data("ic-request-in-flight",!1),c.data("ic-next-request")&&(c.data("ic-next-request").req(),c.data("ic-next-request",null))}function replaceOrAddMethod(a,b){if("string"===$.type(a)){var c=/(&|^)_method=[^&]*/,d="&_method="+b;return c.test(a)?a.replace(c,d):a+d}return a.append("_method",b),a}function isIdentifier(a){return/^[$A-Z_][0-9A-Z_$]*$/i.test(a)}function globalEval(a,b){var c=[],d=[];if(b)for(var e=0;e0?getICAttribute(c,b):null}function formatError(a){var b=a.toString()+"\n";try{b+=a.stack}catch(a){}return b}function getLocalURL(a,b,c){if(b){a+="?";var d={};c.replace(/([^=&]+)=([^&]*)/gi,function(a,b,c){d[b]=c}),$(b.split(",")).each(function(b){var c=$.trim(this),e=d[c]||"";a+=0==b?"":"&",a+=c+"="+e})}return a}function handleRemoteRequest(a,b,c,d,e){beforeRequest(a),d=replaceOrAddMethod(d,b);var f=findGlobalIndicator(a);f&&f.length>0&&showIndicator(f);var g=findIndicator(a);g.length>0&&showIndicator(g);var h,i=uuid(),j=new Date;h=USE_ACTUAL_HTTP_METHOD?b:"GET"==b?"GET":"POST";var k={type:h,url:c,data:d,dataType:"text",headers:{Accept:"text/html-partial, */*; q=0.9","X-IC-Request":!0,"X-HTTP-Method-Override":b},beforeSend:function(e,f){triggerEvent(a,"beforeSend.ic",[a,d,f,e,i]),log(a,"before AJAX request "+i+": "+b+" to "+c,"DEBUG");var g=closestAttrValue(a,"ic-on-beforeSend");g&&globalEval(g,[["elt",a],["data",d],["settings",f],["xhr",e]]),maybeInvokeLocalAction(a,"-beforeSend")},success:function(b,c,h){triggerEvent(a,"success.ic",[a,b,c,h,i]),log(a,"AJAX request "+i+" was successful.","DEBUG");var j=closestAttrValue(a,"ic-on-success");if(!j||0!=globalEval(j,[["elt",a],["data",b],["textStatus",c],["xhr",h]])){var k=new Date,l=document.title;try{if(processHeaders(a,h)){log(a,"Processed headers for request "+i+" in "+(new Date-k)+"ms","DEBUG");var m=new Date;if(h.getResponseHeader("X-IC-PushURL")||"true"==closestAttrValue(a,"ic-push-url"))try{requestCleanup(g,f,a);var n=closestAttrValue(a,"ic-src"),o=closestAttrValue(a,"ic-push-params"),p=h.getResponseHeader("X-IC-PushURL")||getLocalURL(n,o,d);if(!_history)throw"History support not enabled";_history.snapshotForHistory(p,l)}catch(b){log(a,"Error during history snapshot for "+i+": "+formatError(b),"ERROR")}e(b,c,a,h),log(a,"Process content for request "+i+" in "+(new Date-m)+"ms","DEBUG")}triggerEvent(a,"after.success.ic",[a,b,c,h,i]),maybeInvokeLocalAction(a,"-success")}catch(b){log(a,"Error processing successful request "+i+" : "+formatError(b),"ERROR")}}},error:function(b,d,e){triggerEvent(a,"error.ic",[a,d,e,b]);var f=closestAttrValue(a,"ic-on-error");f&&globalEval(f,[["elt",a],["status",d],["str",e],["xhr",b]]),processHeaders(a,b),maybeInvokeLocalAction(a,"-error"),log(a,"AJAX request "+i+" to "+c+" experienced an error: "+e,"ERROR")},complete:function(b,c){log(a,"AJAX request "+i+" completed in "+(new Date-j)+"ms","DEBUG"),requestCleanup(g,f,a);try{$.contains(document,a[0])?triggerEvent(a,"complete.ic",[a,d,c,b,i]):triggerEvent($("body"),"complete.ic",[a,d,c,b,i])}catch(b){log(a,"Error during complete.ic event for "+i+" : "+formatError(b),"ERROR")}var e=closestAttrValue(a,"ic-on-complete");e&&globalEval(e,[["elt",a],["xhr",b],["status",c]]),maybeInvokeLocalAction(a,"-complete")}};"string"!=$.type(d)&&(k.dataType=null,k.processData=!1,k.contentType=!1),triggerEvent($(document),"beforeAjaxSend.ic",[k,a]),k.cancel?requestCleanup(g,f,a):$.ajax(k)}function findGlobalIndicator(a){var b=$([]);a=$(a);var c=closestAttrValue(a,"ic-global-indicator");return c&&"false"!==c&&(b=$(c).first()),b}function findIndicator(a){var b=$([]);if(a=$(a),getICAttribute(a,"ic-indicator"))b=$(getICAttribute(a,"ic-indicator")).first();else if(b=a.find(".ic-indicator").first(),0==b.length){var c=closestAttrValue(a,"ic-indicator");c?b=$(c).first():a.next().is(".ic-indicator")&&(b=a.next())}return b}function processIncludes(a,b){if(0==$.trim(b).indexOf("{")){var c=$.parseJSON(b);$.each(c,function(b,c){a=appendData(a,b,c)})}else $(b).each(function(){var b=$(this).serializeArray();$.each(b,function(b,c){a=appendData(a,c.name,c.value)})});return a}function processLocalVars(a,b){return $(b.split(",")).each(function(){var b=$.trim(this),c=localStorage.getItem(b);c&&(a=appendData(a,b,c))}),a}function appendData(a,b,c){return"string"===$.type(a)?("string"!==$.type(c)&&(c=JSON.stringify(c)),a+"&"+b+"="+encodeURIComponent(c)):(a.append(b,c),a)}function getParametersForElement(a,b,c){var d=getTarget(b),e=null;if(b.is("form")&&"multipart/form-data"==b.attr("enctype"))e=new FormData(b[0]),e=appendData(e,"ic-request",!0);else{e="ic-request=true";var f=b.closest("form");if(b.is("form")||"GET"!=a&&f.length>0){e+="&"+f.serialize();var g=b.data("ic-last-clicked-button");g&&(e=appendData(e,g.name,g.value))}else e+="&"+b.serialize()}var h=closestAttrValue(b,"ic-prompt");if(h){var i=prompt(h);if(!i)return null;var j=closestAttrValue(b,"ic-prompt-name")||"ic-prompt-value";e=appendData(e,j,i)}b.attr("id")&&(e=appendData(e,"ic-element-id",b.attr("id"))),b.attr("name")&&(e=appendData(e,"ic-element-name",b.attr("name"))),getICAttribute(d,"ic-id")&&(e=appendData(e,"ic-id",getICAttribute(d,"ic-id"))),d.attr("id")&&(e=appendData(e,"ic-target-id",d.attr("id"))),c&&c.attr("id")&&(e=appendData(e,"ic-trigger-id",c.attr("id"))),c&&c.attr("name")&&(e=appendData(e,"ic-trigger-name",c.attr("name")));var k=closestAttrValue(b,"ic-include");k&&(e=processIncludes(e,k));var l=closestAttrValue(b,"ic-local-vars");l&&(e=processLocalVars(e,l)),$(getICAttributeSelector("ic-global-include")).each(function(){e=processIncludes(e,getICAttribute($(this),"ic-global-include"))}),e=appendData(e,"ic-current-url",currentUrl());var m=closestAttrValue(b,"ic-select-from-response");return m&&(e=appendData(e,"ic-select-from-response",m)),log(b,"request parameters "+e,"DEBUG"),e}function maybeSetIntercoolerInfo(a){getIntercoolerId(getTarget(a)),1!=a.data("elementAdded.ic")&&(a.data("elementAdded.ic",!0),triggerEvent(a,"elementAdded.ic"))}function getIntercoolerId(a){return getICAttribute(a,"ic-id")||setICAttribute(a,"ic-id",uuid()),getICAttribute(a,"ic-id")}function processNodes(a){a=$(a),a.length>1?a.each(function(){processNodes(this)}):(processMacros(a),processEnhancement(a),processSources(a),processPolling(a),processEventSources(a),processTriggerOn(a),processRemoveAfter(a),processAddClasses(a),processRemoveClasses(a))}function fireReadyStuff(a){triggerEvent(a,"nodesProcessed.ic"),$.each(_readyHandlers,function(b,c){try{c(a)}catch(b){log(a,formatError(b),"ERROR")}})}function autoFocus(a){a.find("[autofocus]").last().focus()}function processMacros(a){$.each(_MACROS,function(b,c){0==a.closest(".ic-ignore").length&&(a.is("["+c+"]")&&processMacro(c,a),a.find("["+c+"]").each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&processMacro(c,a)}))})}function processSources(a){0==a.closest(".ic-ignore").length&&(a.is(getICAttributeSelector("ic-src"))&&maybeSetIntercoolerInfo(a),a.find(getICAttributeSelector("ic-src")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&maybeSetIntercoolerInfo(a)}))}function processPolling(a){0==a.closest(".ic-ignore").length&&(a.is(getICAttributeSelector("ic-poll"))&&(maybeSetIntercoolerInfo(a),startPolling(a)),a.find(getICAttributeSelector("ic-poll")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&(maybeSetIntercoolerInfo(a),startPolling(a))}))}function processTriggerOn(a){0==a.closest(".ic-ignore").length&&(handleTriggerOn(a),a.find(getICAttributeSelector("ic-trigger-on")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&handleTriggerOn(a)}))}function processRemoveAfter(a){0==a.closest(".ic-ignore").length&&(handleRemoveAfter(a),a.find(getICAttributeSelector("ic-remove-after")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&handleRemoveAfter(a)}))}function processAddClasses(a){0==a.closest(".ic-ignore").length&&(handleAddClasses(a),a.find(getICAttributeSelector("ic-add-class")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&handleAddClasses(a)}))}function processRemoveClasses(a){0==a.closest(".ic-ignore").length&&(handleRemoveClasses(a),a.find(getICAttributeSelector("ic-remove-class")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&handleRemoveClasses(a)}))}function processEnhancement(a){0==a.closest(".ic-ignore").length&&("true"===closestAttrValue(a,"ic-enhance")?enhanceDomTree(a):a.find(getICAttributeSelector("ic-enhance")).each(function(){enhanceDomTree($(this))}))}function processEventSources(a){0==a.closest(".ic-ignore").length&&(handleEventSource(a),a.find(getICAttributeSelector("ic-sse-src")).each(function(){var a=$(this);0==a.closest(".ic-ignore").length&&handleEventSource(a)}))}function startPolling(a){if(null==a.data("ic-poll-interval-id")&&"true"!=getICAttribute(a,"ic-pause-polling")){var b=parseInterval(getICAttribute(a,"ic-poll"));if(null!=b){var c=icSelectorFor(a),d=parseInt(getICAttribute(a,"ic-poll-repeats"))||-1,e=0;log(a,"POLL: Starting poll for element "+c,"DEBUG");var f=setInterval(function(){var b=$(c);triggerEvent(a,"onPoll.ic",b),0==b.length||e==d||a.data("ic-poll-interval-id")!=f?(log(a,"POLL: Clearing poll for element "+c,"DEBUG"),clearTimeout(f)):fireICRequest(b),e++},b);a.data("ic-poll-interval-id",f)}}}function cancelPolling(a){null!=a.data("ic-poll-interval-id")&&(clearTimeout(a.data("ic-poll-interval-id")),a.data("ic-poll-interval-id",null))}function refreshDependencies(a,b){log(b,"refreshing dependencies for path "+a,"DEBUG"),$(getICAttributeSelector("ic-src")).each(function(){var c=!1,d=$(this);"GET"==verbFor(d)&&"ignore"!=getICAttribute(d,"ic-deps")&&(isDependent(a,getICAttribute(d,"ic-src"))?null!=b&&$(b)[0]==d[0]||(fireICRequest(d),c=!0):(isICDepsDependent(a,getICAttribute(d,"ic-deps"))||"*"==getICAttribute(d,"ic-deps"))&&(null!=b&&$(b)[0]==d[0]||(fireICRequest(d),c=!0))),c&&log(d,"depends on path "+a+", refreshing...","DEBUG")})}function isICDepsDependent(a,b){if(b)for(var c=b.split(","),d=0;d0){var f=a.split(":");d=f[0],e=parseInterval(f[1])}else d=a;setTimeout(function(){b[c](d)},e)}function handleAddClasses(a){if(a=$(a),getICAttribute(a,"ic-add-class"))for(var b=getICAttribute(a,"ic-add-class").split(","),c=b.length,d=0;d0&®isterSSE(i,f[0].substr(4))}else if($(getTriggeredElement(a)).on(g,function(b){var c=closestAttrValue(a,"ic-on-beforeTrigger");if(c&&0==globalEval(c,[["elt",a],["evt",b],["elt",a]]))return log(a,"ic-trigger cancelled by ic-on-beforeTrigger","DEBUG"),!1;if("changed"==h){var d=a.val(),e=a.data("ic-previous-val");a.data("ic-previous-val",d),d!=e&&fireICRequest(a)}else if("once"==h){var f=a.data("ic-already-triggered");a.data("ic-already-triggered",!0),!0!==f&&fireICRequest(a)}else fireICRequest(a);return!preventDefault(a,b)||(b.preventDefault(),!1)}),g&&0==g.indexOf("timeout:")){var j=parseInterval(g.split(":")[1]);setTimeout(function(){$(getTriggeredElement(a)).trigger(g)},j)}}}}function macroIs(a,b){return a==fixICAttributeName(b)}function processMacro(a,b){macroIs(a,"ic-post-to")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-post-to")),setIfAbsent(b,"ic-verb","POST"),setIfAbsent(b,"ic-trigger-on","default"),setIfAbsent(b,"ic-deps","ignore")),macroIs(a,"ic-put-to")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-put-to")),setIfAbsent(b,"ic-verb","PUT"),setIfAbsent(b,"ic-trigger-on","default"),setIfAbsent(b,"ic-deps","ignore")),macroIs(a,"ic-patch-to")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-patch-to")),setIfAbsent(b,"ic-verb","PATCH"),setIfAbsent(b,"ic-trigger-on","default"),setIfAbsent(b,"ic-deps","ignore")),macroIs(a,"ic-get-from")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-get-from")),setIfAbsent(b,"ic-trigger-on","default"),setIfAbsent(b,"ic-deps","ignore")),macroIs(a,"ic-delete-from")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-delete-from")),setIfAbsent(b,"ic-verb","DELETE"),setIfAbsent(b,"ic-trigger-on","default"),setIfAbsent(b,"ic-deps","ignore")),macroIs(a,"ic-action")&&setIfAbsent(b,"ic-trigger-on","default");var c=null,d=null;if(macroIs(a,"ic-style-src")){c=getICAttribute(b,"ic-style-src").split(":");var e=c[0];d=c[1],setIfAbsent(b,"ic-src",d),setIfAbsent(b,"ic-target","this.style."+e)}if(macroIs(a,"ic-attr-src")){c=getICAttribute(b,"ic-attr-src").split(":");var f=c[0];d=c[1],setIfAbsent(b,"ic-src",d),setIfAbsent(b,"ic-target","this."+f)}macroIs(a,"ic-prepend-from")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-prepend-from")),setIfAbsent(b,"ic-swap-style","prepend")),macroIs(a,"ic-append-from")&&(setIfAbsent(b,"ic-src",getICAttribute(b,"ic-append-from")),setIfAbsent(b,"ic-swap-style","append"))}function isLocalLink(a){return location.hostname===a[0].hostname&&a.attr("href")&&!a.attr("href").startsWith("#")}function enhanceAnchor(a){"true"===closestAttrValue(a,"ic-enhance")&&isLocalLink(a)&&(setIfAbsent(a,"ic-src",a.attr("href")),setIfAbsent(a,"ic-trigger-on","default"),setIfAbsent(a,"ic-deps","ignore"),setIfAbsent(a,"ic-push-url","true"))}function determineFormVerb(a){return a.find('input[name="_method"]').val()||a.attr("method")||a[0].method}function enhanceForm(a){"true"===closestAttrValue(a,"ic-enhance")&&(setIfAbsent(a,"ic-src",a.attr("action")),setIfAbsent(a,"ic-trigger-on","default"),setIfAbsent(a,"ic-deps","ignore"),setIfAbsent(a,"ic-verb",determineFormVerb(a)))}function enhanceDomTree(a){a.is("a")&&enhanceAnchor(a),a.find("a").each(function(){enhanceAnchor($(this))}),a.is("form")&&enhanceForm(a),a.find("form").each(function(){enhanceForm($(this))})}function setIfAbsent(a,b,c){null==getICAttribute(a,b)&&setICAttribute(a,b,c)}function isScrolledIntoView(a){if(a=$(a),0==a.height()&&0==a.width())return!1;var b=$(window).scrollTop(),c=b+$(window).height(),d=a.offset().top,e=d+a.height();return e>=b&&d<=c&&e<=c&&d>=b}function maybeScrollToTarget(a,b){if("false"!=closestAttrValue(a,"ic-scroll-to-target")&&("true"==closestAttrValue(a,"ic-scroll-to-target")||"true"==closestAttrValue(b,"ic-scroll-to-target"))){var c=-50;closestAttrValue(a,"ic-scroll-offset")?c=parseInt(closestAttrValue(a,"ic-scroll-offset")):closestAttrValue(b,"ic-scroll-offset")&&(c=parseInt(closestAttrValue(b,"ic-scroll-offset")));var d=b.offset().top,e=$(window).scrollTop(),f=e+window.innerHeight;(df)&&(c+=d,$("html,body").animate({scrollTop:c},400))}}function getTransitionDuration(a,b){var c=closestAttrValue(a,"ic-transition-duration");if(c)return parseInterval(c);if(c=closestAttrValue(b,"ic-transition-duration"))return parseInterval(c);b=$(b);var d=0,e=b.css("transition-duration");e&&(d+=parseInterval(e));var f=b.css("transition-delay");return f&&(d+=parseInterval(f)),d}function closeSSESource(a){var b=a.data("ic-event-sse-source");try{b&&b.close()}catch(b){log(a,"Error closing ServerSentEvent source"+b,"ERROR")}}function beforeSwapCleanup(a){a.find(getICAttributeSelector("ic-sse-src")).each(function(){closeSSESource($(this))}),triggerEvent(a,"beforeSwap.ic")}function processICResponse(a,b,c,d){if(a&&""!=a&&" "!=a){log(b,"response content: \n"+a,"DEBUG");var e=getTarget(b),f=closestAttrValue(b,"ic-transform-response");f&&(a=globalEval(f,[["content",a],["url",d],["elt",b]]));var g=maybeFilter(a,closestAttrValue(b,"ic-select-from-response"));"true"==closestAttrValue(b,"ic-fix-ids")&&fixIDs(g);var h=function(){if("true"==closestAttrValue(b,"ic-replace-target")){try{beforeSwapCleanup(e),closeSSESource(e),e.replaceWith(g),e=g}catch(a){log(b,formatError(a),"ERROR")}processNodes(g),fireReadyStuff(e),autoFocus(e)}else{if("prepend"==getICAttribute(b,"ic-swap-style"))prepend(e,g),processNodes(g),fireReadyStuff(e),autoFocus(e);else if("append"==getICAttribute(b,"ic-swap-style"))append(e,g),processNodes(g),fireReadyStuff(e),autoFocus(e);else{try{beforeSwapCleanup(e),e.empty().append(g)}catch(a){log(b,formatError(a),"ERROR")}e.children().each(function(){processNodes(this)}),fireReadyStuff(e),autoFocus(e)}1!=c&&maybeScrollToTarget(b,e);var a=b.closest(getICAttributeSelector("ic-switch-class")),d=a.attr(fixICAttributeName("ic-switch-class"));d&&(a.children().removeClass(d),a.children().each(function(){($.contains($(this)[0],$(b)[0])||$(this)[0]==$(b)[0])&&($(this).addClass(d),$(this).addClass(d))}))}};if(0==e.length)return void log(b,"Invalid target for element: "+getICAttribute(b.closest(getICAttributeSelector("ic-target")),"ic-target"),"ERROR");var i=getTransitionDuration(b,e);e.addClass("ic-transitioning"),setTimeout(function(){try{h()}catch(a){log(b,"Error during content swap : "+formatError(a),"ERROR")}setTimeout(function(){try{e.removeClass("ic-transitioning"),_history&&_history.updateHistory(),triggerEvent(e,"complete_transition.ic",[e])}catch(a){log(b,"Error during transition complete : "+formatError(a),"ERROR")}},20)},i)}else log(b,"Empty response, nothing to do here.","DEBUG")}function maybeFilter(a,b){var c;if($.zepto){var d=createDocument(a);c=$(d).find("body").contents()}else c=$($.parseHTML(a,null,!0));return b?walkTree(c,b).contents():c}function walkTree(a,b){return a.filter(b).add(a.find(b))}function fixIDs(a){var b={};walkTree(a,"[id]").each(function(){var a,c=$(this).attr("id");do{a="ic-fixed-id-"+uuid()}while($("#"+a).length>0);b[c]=a,$(this).attr("id",a)}),walkTree(a,"label[for]").each(function(){var a=$(this).attr("for");$(this).attr("for",b[a]||a)}),walkTree(a,"*").each(function(){$.each(this.attributes,function(){-1!==this.value.indexOf("#")&&(this.value=this.value.replace(/#([-_A-Za-z0-9]+)/g,function(a,c){return"#"+(b[c]||c)}))})})}function getStyleTarget(a){var b=closestAttrValue(a,"ic-target");return b&&0==b.indexOf("this.style.")?b.substr(11):null}function getAttrTarget(a){var b=closestAttrValue(a,"ic-target");return b&&0==b.indexOf("this.")?b.substr(5):null}function fireICRequest(a,b){a=$(a);var c=a;a.is(getICAttributeSelector("ic-src"))||void 0!=getICAttribute(a,"ic-action")||(a=a.closest(getICAttributeSelector("ic-src")));var d=closestAttrValue(a,"ic-confirm");if((!d||confirm(d))&&("true"!=closestAttrValue(a,"ic-disable-when-doc-hidden")||!document.hidden)&&("true"!=closestAttrValue(a,"ic-disable-when-doc-inactive")||document.hasFocus())&&a.length>0){var e=uuid();a.data("ic-event-id",e);var f=function(){if(1==a.data("ic-request-in-flight"))return void a.data("ic-next-request",{req:f});if(a.data("ic-event-id")==e){var d=getStyleTarget(a),g=d?null:getAttrTarget(a),h=verbFor(a),i=getICAttribute(a,"ic-src");if(i){var j=b||function(b){d?a.css(d,b):g?a.attr(g,b):(processICResponse(b,a,!1,i),"GET"!=h&&refreshDependencies(getICAttribute(a,"ic-src"),a))},k=getParametersForElement(h,a,c);k&&handleRemoteRequest(a,h,i,k,j)}maybeInvokeLocalAction(a,"")}},g=closestAttrValue(a,"ic-trigger-delay");g?setTimeout(f,parseInterval(g)):f()}}function maybeInvokeLocalAction(a,b){var c=getICAttribute(a,"ic"+b+"-action");c&&invokeLocalAction(a,c,b)}function invokeLocalAction(a,b,c){var d=closestAttrValue(a,"ic"+c+"-action-target");null===d&&""!==c&&(d=closestAttrValue(a,"ic-action-target"));var e=null;e=d?getTargetImpl(a,"ic-action-target"):getTarget(a);var f=b.split(";"),g=[],h=0;$.each(f,function(a,b){var c=$.trim(b),d=c,f=[];c.indexOf(":")>0&&(d=c.substr(0,c.indexOf(":")),f=computeArgs(c.substr(c.indexOf(":")+1,c.length))),""==d||("delay"==d?(null==h&&(h=0),h+=parseInterval(f[0]+"")):(null==h&&(h=420),g.push([h,makeApplyAction(e,d,f)]),h=null))}),h=0,$.each(g,function(a,b){h+=b[0],setTimeout(b[1],h)})}function computeArgs(args){try{return eval("["+args+"]")}catch(a){return[$.trim(args)]}}function makeApplyAction(a,b,c){return function(){var d=a[b]||window[b];d?d.apply(a,c):log(a,"Action "+b+" was not found","ERROR")}}function newIntercoolerHistory(a,b,c,d){function e(){for(var b=[],e=0;e=0)log(e,"URL found in LRU list, moving to end","INFO"),c.splice(d,1),c.push(b);else if(log(e,"URL not found in LRU list, adding","INFO"),c.push(b),c.length>s.slotLimit){var f=c.shift();log(e,"History overflow, removing local history for "+f,"INFO"),a.removeItem(r+f)}return a.setItem(q,JSON.stringify(s)),c}function g(b){var d=JSON.stringify(b);try{a.setItem(b.id,d)}catch(f){try{e(),a.setItem(b.id,d)}catch(a){log(m($("body")),"Unable to save intercooler history with entire history cleared, is something else eating local storage? History Limit:"+c,"ERROR")}}}function h(a,b,c,d){var e={url:c,id:r+c,content:a,yOffset:b,timestamp:(new Date).getTime(),title:d};return f(c),g(e),e}function i(a){if(null==a.onpopstate||1!=a.onpopstate["ic-on-pop-state-handler"]){var b=a.onpopstate;a.onpopstate=function(a){triggerEvent(m($("body")),"handle.onpopstate.ic"),l(a)||b&&b(a),triggerEvent(m($("body")),"pageLoad.ic")},a.onpopstate["ic-on-pop-state-handler"]=!0}}function j(){t&&(k(t.newUrl,currentUrl(),t.oldHtml,t.yOffset,t.oldTitle),t=null)}function k(a,c,d,e,f){var g=h(d,e,c,f);b.replaceState({"ic-id":g.id},"","");var i=m($("body")),j=h(i.html(),window.pageYOffset,a,document.title);b.pushState({"ic-id":j.id},"",a),triggerEvent(i,"pushUrl.ic",[i,j])}function l(b){var c=b.state;if(c&&c["ic-id"]){var d=JSON.parse(a.getItem(c["ic-id"]));if(d)return processICResponse(d.content,m($("body")),!0),d.yOffset&&setTimeout(function(){window.scrollTo(0,d.yOffset)},100),d.title&&(document.title=d.title),!0;$.get(currentUrl(),{"ic-restore-history":!0},function(a,b){processICResponse(m(createDocument(a)).html(),m($("body")),!0)})}return!1}function m(a){var b=a.find(getICAttributeSelector("ic-history-elt"));return b.length>0?b:a}function n(a,b){var c=m($("body"));triggerEvent(c,"beforeHistorySnapshot.ic",[c]),t={newUrl:a,oldHtml:c.html(),yOffset:window.pageYOffset,oldTitle:b}}function o(){var b="",c=[];for(var d in a)c.push(d);c.sort();var e=0;for(var f in c){var g=2*a[c[f]].length;e+=g,b+=c[f]+"="+(g/1024/1024).toFixed(2)+" MB\n"}return b+"\nTOTAL LOCAL STORAGE: "+(e/1024/1024).toFixed(2)+" MB"}function p(){return s}var q="ic-history-support",r="ic-hist-elt-",s=JSON.parse(a.getItem(q)),t=null;return function(a){return null==a||a.slotLimit!=c||a.historyVersion!=d||null==a.lruList}(s)&&(log(m($("body")),"Intercooler History configuration changed, clearing history","INFO"),e()),null==s&&(s={slotLimit:c,historyVersion:d,lruList:[]}),{clearHistory:e,updateHistory:j,addPopStateHandler:i,snapshotForHistory:n,_internal:{addPopStateHandler:i,supportData:p,dumpLocalStorage:o,updateLRUList:f}}}function getSlotLimit(){return 20}function refresh(a){return"string"==typeof a||a instanceof String?refreshDependencies(a):fireICRequest(a),Intercooler}function init(){var a=$("body");processNodes(a),fireReadyStuff(a),_history&&_history.addPopStateHandler(window),$.zepto&&($("body").data("zeptoDataTest",{}),"string"==typeof $("body").data("zeptoDataTest")&&log(null,"!!!! Please include the data module with Zepto! Intercooler requires full data support to function !!!!","ERROR"))}"undefined"!=typeof Zepto&&null==$&&(window.$=Zepto);var USE_DATA="true"==$('meta[name="intercoolerjs:use-data-prefix"]').attr("content"),USE_ACTUAL_HTTP_METHOD="true"==$('meta[name="intercoolerjs:use-actual-http-method"]').attr("content"),_MACROS=$.map(["ic-get-from","ic-post-to","ic-put-to","ic-patch-to","ic-delete-from","ic-style-src","ic-attr-src","ic-prepend-from","ic-append-from","ic-action"],function(a){return fixICAttributeName(a)}),_scrollHandler=null,_UUID=1,_readyHandlers=[],_isDependentFunction=function(a,b){if(!a||!b)return!1;var c=a.split(/[\?#]/,1)[0].split("/").filter(function(a){return""!=a 4 | }),d=b.split(/[\?#]/,1)[0].split("/").filter(function(a){return""!=a});return""!=c&&""!=d&&(d.slice(0,c.length).join("/")==c.join("/")||c.slice(0,d.length).join("/")==d.join("/"))},_history=null;try{_history=newIntercoolerHistory(localStorage,window.history,20,.1)}catch(a){log($("body"),"Could not initialize history","WARN")}return $.ajaxTransport&&$.ajaxTransport("text",function(a,b){if("#"==b.url[0]){var c=fixICAttributeName("ic-local-"),d=$(b.url),e=[],f=200,g="OK";d.each(function(a,b){$.each(b.attributes,function(a,b){if(b.name.substr(0,c.length)==c){var d=b.name.substring(c.length);if("status"==d){var h=b.value.match(/(\d+)\s?(.*)/);null!=h?(f=h[1],g=h[2]):(f="500",g="Attribute Error")}else e.push(d+": "+b.value)}})});var h=d.length>0?d.html():"";return{send:function(a,b){b(f,g,{html:h},e.join("\n"))},abort:function(){}}}return null}),$(function(){init()}),{refresh:refresh,history:_history,triggerRequest:fireICRequest,processNodes:processNodes,closestAttrValue:closestAttrValue,verbFor:verbFor,isDependent:isDependent,getTarget:getTarget,processHeaders:processHeaders,startPolling:startPolling,cancelPolling:cancelPolling,setIsDependentFunction:function(a){_isDependentFunction=a},ready:function(a){_readyHandlers.push(a)},_internal:{init:init,replaceOrAddMethod:replaceOrAddMethod,initEventSource:function(a,b){return new EventSource(a,{withCredentials:b})},globalEval:globalEval,getLocalURL:getLocalURL}}}();return Intercooler}); -------------------------------------------------------------------------------- /src/static/js/site.js: -------------------------------------------------------------------------------- 1 | window.dataLayer = window.dataLayer || []; 2 | function gtag(){dataLayer.push(arguments);} 3 | gtag('js', new Date()); 4 | gtag('config', 'UA-50414207-5'); 5 | --------------------------------------------------------------------------------