]> git.ipfire.org Git - ipfire.org.git/commitdiff
location: Create a page that explains how to report problems master
authorMichael Tremer <michael.tremer@ipfire.org>
Tue, 9 Apr 2024 15:05:57 +0000 (15:05 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Tue, 9 Apr 2024 15:07:18 +0000 (15:07 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
338 files changed:
.gitignore
.gitmodules
Dockerfile [deleted file]
Makefile.am
configure.ac
m4/.gitignore [new file with mode: 0644]
m4/ax_python_module.m4 [new file with mode: 0644]
migrate.sql [new file with mode: 0644]
requirements.txt [deleted file]
src/backend/accounts.py
src/backend/analytics.py [new file with mode: 0644]
src/backend/asterisk.py [new file with mode: 0644]
src/backend/base.py
src/backend/blog.py
src/backend/bugzilla.py [new file with mode: 0644]
src/backend/cache.py [new file with mode: 0644]
src/backend/database.py
src/backend/fireinfo.py
src/backend/httpclient.py [new file with mode: 0644]
src/backend/hwdata.py
src/backend/iuse.py
src/backend/lists.py [new file with mode: 0644]
src/backend/memcached.py [deleted file]
src/backend/messages.py
src/backend/misc.py
src/backend/nopaste.py
src/backend/ratelimit.py
src/backend/releases.py
src/backend/talk.py [deleted file]
src/backend/toots.py [new file with mode: 0644]
src/backend/tweets.py [deleted file]
src/backend/util.py
src/backend/wiki.py
src/backend/zeiterfassung.py
src/bootstrap [deleted submodule]
src/crontab/ipfire
src/error-pages/.gitignore [new file with mode: 0644]
src/error-pages/500.markdown [new file with mode: 0644]
src/error-pages/502.markdown [new file with mode: 0644]
src/error-pages/503.markdown [new file with mode: 0644]
src/error-pages/504.markdown [new file with mode: 0644]
src/error-pages/Gemfile [new file with mode: 0644]
src/error-pages/Gemfile.lock [new file with mode: 0644]
src/error-pages/_config.yml [new file with mode: 0644]
src/error-pages/_layouts/error.html [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Black.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Black.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-BlackItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-BlackItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Bold.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Bold.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-BoldItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-BoldItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraBold.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraBold.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraLight.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraLight.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraLightItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ExtraLightItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Italic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Italic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Light.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Light.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-LightItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-LightItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Medium.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Medium.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-MediumItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-MediumItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Regular.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Regular.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-SemiBold.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-SemiBold.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-SemiBoldItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-SemiBoldItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Thin.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-Thin.woff2 [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ThinItalic.ttf [new file with mode: 0644]
src/error-pages/assets/fonts/Prompt-ThinItalic.woff2 [new file with mode: 0644]
src/error-pages/assets/main.sass [new file with mode: 0644]
src/font-awesome
src/fonts [deleted submodule]
src/sass/_code-highlighting.sass [new file with mode: 0644]
src/sass/_fonts.sass [new file with mode: 0644]
src/sass/_icons.sass [new file with mode: 0644]
src/sass/_variables.sass [new file with mode: 0644]
src/sass/listing.sass [new file with mode: 0644]
src/sass/main.sass [new file with mode: 0644]
src/scripts/ipfire.org-webapp.in
src/scripts/ipfire.org.in
src/scss/_code-highlighting.scss [deleted file]
src/scss/_fonts.scss [deleted file]
src/scss/_icons.scss [deleted file]
src/scss/_variables.scss [deleted file]
src/scss/listing.scss [deleted file]
src/scss/style.scss [deleted file]
src/static/fonts/prompt/DESCRIPTION.en_us.html [new file with mode: 0644]
src/static/fonts/prompt/METADATA.pb [new file with mode: 0644]
src/static/fonts/prompt/OFL.txt [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Black.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-BlackItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Bold.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-BoldItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-ExtraBold.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-ExtraBoldItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-ExtraLight.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-ExtraLightItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Italic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Light.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-LightItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Medium.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-MediumItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Regular.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-SemiBold.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-SemiBoldItalic.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-Thin.ttf [new file with mode: 0644]
src/static/fonts/prompt/Prompt-ThinItalic.ttf [new file with mode: 0644]
src/static/img/auth/join.jpg [new file with mode: 0644]
src/static/img/downloads/cloud/aws.svg [new file with mode: 0644]
src/static/img/downloads/cloud/exoscale.svg [new file with mode: 0644]
src/static/img/downloads/cloud/hetzner.svg [new file with mode: 0644]
src/static/img/fdroid-logo.svg [new file with mode: 0644]
src/static/img/kyberio-logo.svg [new file with mode: 0644]
src/static/js/jquery-3.3.1.min.js [deleted file]
src/static/js/jquery-3.6.0.min.js [new file with mode: 0644]
src/static/js/maps.js
src/static/js/popper.min.js [deleted file]
src/static/js/popper.min.js.map [deleted file]
src/static/js/site.js [new file with mode: 0644]
src/static/videos/.gitignore [new file with mode: 0644]
src/static/videos/firewall.jpg [new file with mode: 0644]
src/static/videos/firewall.mp4 [new file with mode: 0644]
src/templates/analytics/docs.html [new file with mode: 0644]
src/templates/analytics/index.html [new file with mode: 0644]
src/templates/analytics/modules/summary.html [new file with mode: 0644]
src/templates/auth/activate.html
src/templates/auth/activated.html
src/templates/auth/join-success.html [new file with mode: 0644]
src/templates/auth/join.html [new file with mode: 0644]
src/templates/auth/login.html
src/templates/auth/messages/donation-reminder.html
src/templates/auth/messages/donation-reminder.txt
src/templates/auth/messages/join.html [new file with mode: 0644]
src/templates/auth/messages/join.txt [new file with mode: 0644]
src/templates/auth/messages/password-reset.html
src/templates/auth/messages/password-reset.txt
src/templates/auth/messages/profile-setup-2.html
src/templates/auth/messages/profile-setup.html
src/templates/auth/messages/register.html [deleted file]
src/templates/auth/messages/register.txt [deleted file]
src/templates/auth/modules/password.html [new file with mode: 0644]
src/templates/auth/modules/password.js [moved from src/templates/people/modules/password.js with 76% similarity]
src/templates/auth/password-reset-initiation.html
src/templates/auth/password-reset-successful.html
src/templates/auth/password-reset.html
src/templates/auth/register-spam.html [deleted file]
src/templates/auth/register-success.html [deleted file]
src/templates/auth/register.html [deleted file]
src/templates/base.html
src/templates/blog/author.html [deleted file]
src/templates/blog/base.html [deleted file]
src/templates/blog/compose.html [deleted file]
src/templates/blog/delete.html
src/templates/blog/drafts.html
src/templates/blog/feed.xml
src/templates/blog/index.html
src/templates/blog/messages/announcement.html
src/templates/blog/messages/announcement.txt
src/templates/blog/modules/history-navigation.html
src/templates/blog/modules/list.html
src/templates/blog/modules/post.html [deleted file]
src/templates/blog/modules/posts.html [deleted file]
src/templates/blog/post.html
src/templates/blog/publish.html
src/templates/blog/search-results.html [deleted file]
src/templates/blog/tag.html [deleted file]
src/templates/blog/write.html [new file with mode: 0644]
src/templates/blog/year.html
src/templates/docs/404.html [new file with mode: 0644]
src/templates/docs/base.html [moved from src/templates/wiki/base.html with 62% similarity]
src/templates/docs/confirm-delete.html [new file with mode: 0644]
src/templates/docs/confirm-restore.html [new file with mode: 0644]
src/templates/docs/diff.html [moved from src/templates/wiki/diff.html with 53% similarity]
src/templates/docs/edit.html [new file with mode: 0644]
src/templates/docs/files/detail.html [new file with mode: 0644]
src/templates/docs/files/index.html [new file with mode: 0644]
src/templates/docs/modules/diff.html [new file with mode: 0644]
src/templates/docs/modules/header.html [new file with mode: 0644]
src/templates/docs/modules/list.html [new file with mode: 0644]
src/templates/docs/page.html [new file with mode: 0644]
src/templates/docs/recent-changes.html [new file with mode: 0644]
src/templates/docs/revisions.html [new file with mode: 0644]
src/templates/docs/search-results.html [new file with mode: 0644]
src/templates/docs/tree.html [new file with mode: 0644]
src/templates/docs/watchlist.html [new file with mode: 0644]
src/templates/donate/donate.html
src/templates/donate/messages/ask-again.txt
src/templates/donate/messages/christmas-1.html
src/templates/donate/messages/christmas-1.txt
src/templates/donate/messages/christmas-2.html
src/templates/donate/messages/christmas-2.txt
src/templates/donate/messages/christmas-3.html
src/templates/donate/messages/christmas-3.txt
src/templates/donate/messages/christmas-4.html
src/templates/donate/messages/christmas-4.txt
src/templates/donate/thank-you.html
src/templates/download/release.html [deleted file]
src/templates/downloads/cloud.html [new file with mode: 0644]
src/templates/downloads/mirrors.html [new file with mode: 0644]
src/templates/downloads/release.html [new file with mode: 0644]
src/templates/downloads/thank-you.html [new file with mode: 0644]
src/templates/error.html
src/templates/fireinfo/admin.html
src/templates/fireinfo/driver.html
src/templates/fireinfo/index.html
src/templates/fireinfo/modules/table-devices-and-groups.html
src/templates/fireinfo/modules/table-devices.html
src/templates/fireinfo/processors.html
src/templates/fireinfo/profile.html
src/templates/fireinfo/releases.html
src/templates/fireinfo/vendor.html
src/templates/fireinfo/vendors.html
src/templates/index.html
src/templates/lists/index.html [new file with mode: 0644]
src/templates/location/base.html [deleted file]
src/templates/location/blacklists.html [deleted file]
src/templates/location/download.html [deleted file]
src/templates/location/how-to-use.html [deleted file]
src/templates/location/how-to-use/cli.html [new file with mode: 0644]
src/templates/location/how-to-use/dns.html [new file with mode: 0644]
src/templates/location/how-to-use/index.html [new file with mode: 0644]
src/templates/location/how-to-use/python.html [new file with mode: 0644]
src/templates/location/index.html
src/templates/location/install.html [new file with mode: 0644]
src/templates/location/lookup.html
src/templates/location/report-a-problem.html [new file with mode: 0644]
src/templates/messages/base-promo.html
src/templates/messages/base.html
src/templates/messages/fonts.sass [new file with mode: 0644]
src/templates/messages/main.sass [new file with mode: 0644]
src/templates/messages/main.scss [deleted file]
src/templates/mirrors/index.html [deleted file]
src/templates/mirrors/mirror.html [deleted file]
src/templates/modules/christmas-banner.html [deleted file]
src/templates/modules/ipfire-logo.html [new file with mode: 0644]
src/templates/modules/menu.html [deleted file]
src/templates/modules/progress-bar.html
src/templates/nopaste/create.html
src/templates/nopaste/upload.html [new file with mode: 0644]
src/templates/nopaste/view.html
src/templates/people/base.html [deleted file]
src/templates/people/call.html [deleted file]
src/templates/people/calls.html [deleted file]
src/templates/people/conferences.html [deleted file]
src/templates/people/group.html [deleted file]
src/templates/people/groups.html [deleted file]
src/templates/people/index.html [deleted file]
src/templates/people/messages/new-account.txt
src/templates/people/modules/accounts-list.html [deleted file]
src/templates/people/modules/accounts-new.html [deleted file]
src/templates/people/modules/agent.html [deleted file]
src/templates/people/modules/cdr.html [deleted file]
src/templates/people/modules/channels.html [deleted file]
src/templates/people/modules/mos.html [deleted file]
src/templates/people/modules/password.html [deleted file]
src/templates/people/modules/registrations.html [deleted file]
src/templates/people/modules/sip-status.html [deleted file]
src/templates/people/passwd.html [deleted file]
src/templates/people/search.html [deleted file]
src/templates/people/sip.html [deleted file]
src/templates/people/stats.html [deleted file]
src/templates/people/subscribed.html [deleted file]
src/templates/people/unsubscribed.html [deleted file]
src/templates/people/user-edit.html [deleted file]
src/templates/people/user.html [deleted file]
src/templates/people/users.html [deleted file]
src/templates/static/about.html [new file with mode: 0644]
src/templates/static/features.html [deleted file]
src/templates/static/help.html [new file with mode: 0644]
src/templates/static/legal.html
src/templates/static/partners.html [new file with mode: 0644]
src/templates/static/sitemap.html [new file with mode: 0644]
src/templates/static/support.html [deleted file]
src/templates/users/delete.html [new file with mode: 0644]
src/templates/users/deleted.html [new file with mode: 0644]
src/templates/users/edit.html [new file with mode: 0644]
src/templates/users/groups/index.html [new file with mode: 0644]
src/templates/users/groups/show.html [new file with mode: 0644]
src/templates/users/index.html [new file with mode: 0644]
src/templates/users/modules/list.html [new file with mode: 0644]
src/templates/users/passwd.html [new file with mode: 0644]
src/templates/users/show.html [new file with mode: 0644]
src/templates/users/subscribe.html [new file with mode: 0644]
src/templates/users/subscribed.html [new file with mode: 0644]
src/templates/users/unsubscribe.html [moved from src/templates/people/unsubscribe.html with 59% similarity]
src/templates/users/unsubscribed.html [new file with mode: 0644]
src/templates/voip/index.html [new file with mode: 0644]
src/templates/voip/modules/conferences.html [new file with mode: 0644]
src/templates/voip/modules/outbound-registrations.html [new file with mode: 0644]
src/templates/voip/modules/queues.html [new file with mode: 0644]
src/templates/voip/modules/registrations.html [new file with mode: 0644]
src/templates/wiki/404.html [deleted file]
src/templates/wiki/confirm-delete.html [deleted file]
src/templates/wiki/confirm-restore.html [deleted file]
src/templates/wiki/edit.html [deleted file]
src/templates/wiki/files/detail.html [deleted file]
src/templates/wiki/files/index.html [deleted file]
src/templates/wiki/modules/diff.html [deleted file]
src/templates/wiki/modules/list.html [deleted file]
src/templates/wiki/modules/navbar.html [deleted file]
src/templates/wiki/page.html [deleted file]
src/templates/wiki/recent-changes.html [deleted file]
src/templates/wiki/revisions.html [deleted file]
src/templates/wiki/search-results.html [deleted file]
src/templates/wiki/tree.html [deleted file]
src/templates/wiki/watchlist.html [deleted file]
src/third-party/bulma [new submodule]
src/web/__init__.py
src/web/analytics.py [new file with mode: 0644]
src/web/auth.py
src/web/base.py
src/web/blog.py
src/web/docs.py [moved from src/web/wiki.py with 73% similarity]
src/web/donate.py
src/web/downloads.py [moved from src/web/download.py with 66% similarity]
src/web/fireinfo.py
src/web/handlers.py
src/web/iuse.py
src/web/lists.py [new file with mode: 0644]
src/web/location.py
src/web/mirrors.py [deleted file]
src/web/nopaste.py
src/web/people.py [deleted file]
src/web/ui_modules.py
src/web/users.py [new file with mode: 0644]
src/web/voip.py [new file with mode: 0644]

index 777242a85e2449edd8a2850815efeae356c9080f..6868503ffd5f25813cf76faf041fba81ea80910a 100644 (file)
@@ -4,20 +4,20 @@
 /build-aux
 /configure
 /config.*
-/src/scss/fonts.css
-/src/scss/listing.css
-/src/scss/main.css
+/src/sass/*.css
 /src/scripts/ipfire.org
 /src/scripts/ipfire.org-webapp
 /src/static/favicon.ico
+/src/static/fonts/**/*.woff2
 /src/static/img/apple-touch-icon-*-precomposed.png
 /src/systemd/ipfire.org-webapp-*.service
-/src/templates/messages/main.css
+/src/templates/messages/*.css
 /ipfire.org.conf.sample
 .DS_Store
 Makefile
 Makefile.in
 stamp-*
+*@*.jpg
 *.bak
 *.py[co]
 *.tar.gz
index 2933dfd534822b1b97c5f3d715cd00cf3048bb06..eb73fcb7d9d754b107a3abb9d2ed631d626cb33a 100644 (file)
@@ -1,13 +1,3 @@
-[submodule "src/bootstrap"]
-       path = src/bootstrap
-       url = https://github.com/twbs/bootstrap.git
-[submodule "src/fonts"]
-       path = src/fonts
-       url = https://github.com/google/fonts.git
-
-[submodule "src/font-awesome"]
-       path = src/font-awesome
-       url = https://github.com/FortAwesome/Font-Awesome.git
 [submodule "src/payment-font"]
        path = src/payment-font
        url = https://github.com/AlexanderPoellmann/PaymentFont.git
@@ -17,3 +7,9 @@
 [submodule "src/flag-icons"]
        path = src/flag-icons
        url = https://github.com/lipis/flag-icon-css.git
+[submodule "src/third-party/bulma"]
+       path = src/third-party/bulma
+       url = https://git.ipfire.org/pub/git/thirdparty/bulma.git
+[submodule "src/font-awesome"]
+       path = src/font-awesome
+       url = https://git.ipfire.org/pub/git/thirdparty/Font-Awesome.git
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644 (file)
index c59ddb0..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-FROM centos:latest
-
-EXPOSE 80
-
-# Enable EPEL
-RUN yum install -y epel-release
-
-# Install all updates
-RUN yum update -y
-
-# Install required packages
-RUN yum install -y \
-       autoconf \
-       automake \
-       curl-devel \
-       gcc \
-       make \
-       openldap-devel \
-       python34 \
-       python34-devel \
-       python34-pip \
-       sassc \
-       \
-       /usr/share/hwdata/pci.ids \
-       /usr/share/hwdata/usb.ids
-
-# Install Python packages
-ADD requirements.txt .
-RUN pip3 install -r requirements.txt
-
-# Copy code into the container
-COPY . /build/ipfire.org
-WORKDIR /build/ipfire.org
-
-# Install the webapp
-RUN ./autogen.sh && ./configure --prefix=/usr --sysconfdir=/etc \
-       && make -j4 && make install
-
-# Go back to /root
-WORKDIR /root
-
-# Run the webapp
-CMD ["ipfire.org-webapp", "--debug", "--logging=debug", "--port=80"]
index e59e9c7a6a3fc6c1028faa170bd8c64f50e1e5e4..95220c2e3d987f9bf9ce08a86fb04d60e75092c2 100644 (file)
@@ -49,16 +49,21 @@ CLEANFILES += \
 backend_PYTHON = \
        src/backend/__init__.py \
        src/backend/accounts.py \
+       src/backend/analytics.py \
+       src/backend/asterisk.py \
        src/backend/base.py \
        src/backend/blog.py \
+       src/backend/bugzilla.py \
+       src/backend/cache.py \
        src/backend/campaigns.py \
        src/backend/countries.py \
        src/backend/database.py \
        src/backend/decorators.py \
        src/backend/fireinfo.py \
+       src/backend/httpclient.py \
        src/backend/hwdata.py \
        src/backend/iuse.py \
-       src/backend/memcached.py \
+       src/backend/lists.py \
        src/backend/messages.py \
        src/backend/mirrors.py \
        src/backend/misc.py \
@@ -68,8 +73,7 @@ backend_PYTHON = \
        src/backend/releases.py \
        src/backend/resolver.py \
        src/backend/settings.py \
-       src/backend/talk.py \
-       src/backend/tweets.py \
+       src/backend/toots.py \
        src/backend/util.py \
        src/backend/wiki.py \
        src/backend/zeiterfassung.py
@@ -80,21 +84,23 @@ backenddir = $(pythondir)/ipfire
 
 web_PYTHON = \
        src/web/__init__.py \
+       src/web/analytics.py \
        src/web/auth.py \
        src/web/base.py \
        src/web/blog.py \
        src/web/boot.py \
+       src/web/docs.py \
        src/web/donate.py \
-       src/web/download.py \
+       src/web/downloads.py \
        src/web/fireinfo.py \
        src/web/handlers.py \
        src/web/iuse.py \
+       src/web/lists.py \
        src/web/location.py \
-       src/web/mirrors.py \
        src/web/nopaste.py \
-       src/web/people.py \
        src/web/ui_modules.py \
-       src/web/wiki.py
+       src/web/users.py \
+       src/web/voip.py
 
 webdir = $(backenddir)/web
 
@@ -109,45 +115,57 @@ templates_DATA = \
 
 templatesdir = $(datadir)/templates
 
+templates_analytics_DATA = \
+       src/templates/analytics/docs.html \
+       src/templates/analytics/index.html
+
+templates_analyticsdir = $(templatesdir)/analytics
+
+templates_analytics_modules_DATA = \
+       src/templates/analytics/modules/summary.html
+
+templates_analytics_modulesdir = $(templates_analyticsdir)/modules
+
 templates_auth_DATA = \
        src/templates/auth/activate.html \
        src/templates/auth/activated.html \
+       src/templates/auth/join.html \
+       src/templates/auth/join-success.html \
        src/templates/auth/login.html \
        src/templates/auth/password-reset.html \
        src/templates/auth/password-reset-initiation.html \
-       src/templates/auth/password-reset-successful.html \
-       src/templates/auth/register.html \
-       src/templates/auth/register-spam.html \
-       src/templates/auth/register-success.html
+       src/templates/auth/password-reset-successful.html
 
 templates_authdir = $(templatesdir)/auth
 
 templates_auth_messages_DATA = \
        src/templates/auth/messages/donation-reminder.html \
        src/templates/auth/messages/donation-reminder.txt \
+       src/templates/auth/messages/join.html \
+       src/templates/auth/messages/join.txt \
        src/templates/auth/messages/password-reset.html \
        src/templates/auth/messages/password-reset.txt \
        src/templates/auth/messages/profile-setup.html \
        src/templates/auth/messages/profile-setup.txt \
        src/templates/auth/messages/profile-setup-2.html \
-       src/templates/auth/messages/profile-setup-2.txt \
-       src/templates/auth/messages/register.html \
-       src/templates/auth/messages/register.txt
+       src/templates/auth/messages/profile-setup-2.txt
 
 templates_auth_messagesdir = $(templates_authdir)/messages
 
+templates_auth_modules_DATA = \
+       src/templates/auth/modules/password.html \
+       src/templates/auth/modules/password.js
+
+templates_auth_modulesdir = $(templates_authdir)/modules
+
 templates_blog_DATA = \
-       src/templates/blog/author.html \
-       src/templates/blog/base.html \
-       src/templates/blog/compose.html \
        src/templates/blog/delete.html \
        src/templates/blog/drafts.html \
        src/templates/blog/feed.xml \
        src/templates/blog/index.html \
        src/templates/blog/post.html \
        src/templates/blog/publish.html \
-       src/templates/blog/search-results.html \
-       src/templates/blog/tag.html \
+       src/templates/blog/write.html \
        src/templates/blog/year.html
 
 templates_blogdir = $(templatesdir)/blog
@@ -160,9 +178,7 @@ templates_blog_messagesdir = $(templates_blogdir)/messages
 
 templates_blog_modules_DATA = \
        src/templates/blog/modules/history-navigation.html \
-       src/templates/blog/modules/list.html \
-       src/templates/blog/modules/post.html \
-       src/templates/blog/modules/posts.html
+       src/templates/blog/modules/list.html
 
 templates_blog_modulesdir = $(templates_blogdir)/modules
 
@@ -189,10 +205,42 @@ templates_donate_messages_DATA = \
 
 templates_donate_messagesdir = $(templates_donatedir)/messages
 
-templates_download_DATA = \
-       src/templates/download/release.html
+templates_docs_DATA = \
+       src/templates/docs/404.html \
+       src/templates/docs/base.html \
+       src/templates/docs/confirm-delete.html \
+       src/templates/docs/confirm-restore.html \
+       src/templates/docs/diff.html \
+       src/templates/docs/edit.html \
+       src/templates/docs/page.html \
+       src/templates/docs/recent-changes.html \
+       src/templates/docs/revisions.html \
+       src/templates/docs/search-results.html \
+       src/templates/docs/tree.html \
+       src/templates/docs/watchlist.html
+
+templates_docsdir = $(templatesdir)/docs
+
+templates_docs_files_DATA = \
+       src/templates/docs/files/detail.html \
+       src/templates/docs/files/index.html
+
+templates_docs_filesdir = $(templates_docsdir)/files
+
+templates_docs_modules_DATA = \
+       src/templates/docs/modules/diff.html \
+       src/templates/docs/modules/header.html \
+       src/templates/docs/modules/list.html
 
-templates_downloaddir = $(templatesdir)/download
+templates_docs_modulesdir = $(templates_docsdir)/modules
+
+templates_downloads_DATA = \
+       src/templates/downloads/cloud.html \
+       src/templates/downloads/mirrors.html \
+       src/templates/downloads/release.html \
+       src/templates/downloads/thank-you.html
+
+templates_downloadsdir = $(templatesdir)/downloads
 
 templates_fireinfo_DATA = \
        src/templates/fireinfo/admin.html \
@@ -214,30 +262,36 @@ templates_fireinfo_modules_DATA = \
 templates_fireinfo_modulesdir = $(templates_fireinfodir)/modules
 
 templates_location_DATA = \
-       src/templates/location/base.html \
-       src/templates/location/blacklists.html \
-       src/templates/location/download.html \
-       src/templates/location/how-to-use.html \
        src/templates/location/index.html \
-       src/templates/location/lookup.html
+       src/templates/location/install.html \
+       src/templates/location/lookup.html \
+       src/templates/location/report-a-problem.html
 
 templates_locationdir = $(templatesdir)/location
 
+templates_location_how_to_use_DATA = \
+       src/templates/location/how-to-use/cli.html \
+       src/templates/location/how-to-use/dns.html \
+       src/templates/location/how-to-use/index.html \
+       src/templates/location/how-to-use/python.html
+
+templates_location_how_to_usedir = $(templates_locationdir)/how-to-use
+
+templates_lists_DATA = \
+       src/templates/lists/index.html
+
+templates_listsdir = $(templatesdir)/lists
+
 templates_messages_DATA = \
        src/templates/messages/base.html \
        src/templates/messages/base-promo.html \
+       src/templates/messages/fonts.css \
        src/templates/messages/main.css
 
 templates_messagesdir = $(templatesdir)/messages
 
-templates_mirrors_DATA = \
-       src/templates/mirrors/index.html \
-       src/templates/mirrors/mirror.html
-
-templates_mirrorsdir = $(templatesdir)/mirrors
-
 templates_modules_DATA = \
-       src/templates/modules/christmas-banner.html \
+       src/templates/modules/ipfire-logo.html \
        src/templates/modules/map.html \
        src/templates/modules/progress-bar.html
 
@@ -254,6 +308,7 @@ templates_netbootdir = $(templatesdir)/netboot
 
 templates_nopaste_DATA = \
        src/templates/nopaste/create.html \
+       src/templates/nopaste/upload.html \
        src/templates/nopaste/view.html
 
 templates_nopastedir = $(templatesdir)/nopaste
@@ -263,117 +318,95 @@ templates_nopaste_modules_DATA = \
 
 templates_nopaste_modulesdir = $(templates_nopastedir)/modules
 
-templates_people_DATA = \
-       src/templates/people/base.html \
-       src/templates/people/conferences.html \
-       src/templates/people/call.html \
-       src/templates/people/calls.html \
-       src/templates/people/group.html \
-       src/templates/people/groups.html \
-       src/templates/people/index.html \
-       src/templates/people/passwd.html \
-       src/templates/people/search.html \
-       src/templates/people/sip.html \
-       src/templates/people/stats.html \
-       src/templates/people/subscribed.html \
-       src/templates/people/unsubscribe.html \
-       src/templates/people/unsubscribed.html \
-       src/templates/people/user.html \
-       src/templates/people/user-edit.html \
-       src/templates/people/users.html
-
-templates_peopledir = $(templatesdir)/people
-
 templates_people_messages_DATA = \
        src/templates/people/messages/new-account.txt
 
 templates_people_messagesdir = $(templates_peopledir)/messages
 
-templates_people_modules_DATA = \
-       src/templates/people/modules/accounts-list.html \
-       src/templates/people/modules/accounts-new.html \
-       src/templates/people/modules/agent.html \
-       src/templates/people/modules/cdr.html \
-       src/templates/people/modules/channels.html \
-       src/templates/people/modules/mos.html \
-       src/templates/people/modules/password.html \
-       src/templates/people/modules/password.js \
-       src/templates/people/modules/registrations.html \
-       src/templates/people/modules/sip-status.html
-
-templates_people_modulesdir = $(templates_peopledir)/modules
-
 templates_static_DATA = \
-       src/templates/static/features.html \
+       src/templates/static/about.html \
        src/templates/static/legal.html \
-       src/templates/static/support.html
+       src/templates/static/help.html \
+       src/templates/static/partners.html \
+       src/templates/static/sitemap.html
 
 templates_staticdir = $(templatesdir)/static
 
-templates_wiki_DATA = \
-       src/templates/wiki/404.html \
-       src/templates/wiki/base.html \
-       src/templates/wiki/confirm-delete.html \
-       src/templates/wiki/confirm-restore.html \
-       src/templates/wiki/diff.html \
-       src/templates/wiki/edit.html \
-       src/templates/wiki/page.html \
-       src/templates/wiki/recent-changes.html \
-       src/templates/wiki/revisions.html \
-       src/templates/wiki/search-results.html \
-       src/templates/wiki/tree.html \
-       src/templates/wiki/watchlist.html
+templates_users_DATA = \
+       src/templates/users/delete.html \
+       src/templates/users/deleted.html \
+       src/templates/users/edit.html \
+       src/templates/users/index.html \
+       src/templates/users/passwd.html \
+       src/templates/users/show.html \
+       src/templates/users/subscribe.html \
+       src/templates/users/subscribed.html \
+       src/templates/users/unsubscribe.html \
+       src/templates/users/unsubscribed.html
 
-templates_wikidir = $(templatesdir)/wiki
+templates_usersdir = $(templatesdir)/users
+
+templates_users_groups_DATA = \
+       src/templates/users/groups/index.html \
+       src/templates/users/groups/show.html
+
+templates_users_groupsdir = $(templates_usersdir)/groups
+
+templates_users_modules_DATA = \
+       src/templates/users/modules/list.html
 
-templates_wiki_files_DATA = \
-       src/templates/wiki/files/detail.html \
-       src/templates/wiki/files/index.html
+templates_users_modulesdir = $(templates_usersdir)/modules
 
-templates_wiki_filesdir = $(templates_wikidir)/files
+templates_voip_DATA = \
+       src/templates/voip/index.html
+
+templates_voipdir = $(templatesdir)/voip
+
+templates_voip_modules_DATA = \
+       src/templates/voip/modules/conferences.html \
+       src/templates/voip/modules/outbound-registrations.html \
+       src/templates/voip/modules/queues.html \
+       src/templates/voip/modules/registrations.html
+
+templates_voip_modulesdir = $(templates_voipdir)/modules
+
+templates_wikidir = $(templatesdir)/wiki
 
 templates_wiki_messages_DATA = \
        src/templates/wiki/messages/page-changed.txt
 
 templates_wiki_messagesdir = $(templates_wikidir)/messages
 
-templates_wiki_modules_DATA = \
-       src/templates/wiki/modules/diff.html \
-       src/templates/wiki/modules/list.html \
-       src/templates/wiki/modules/navbar.html
-
-templates_wiki_modulesdir = $(templates_wikidir)/modules
-
 # ------------------------------------------------------------------------------
 
-SCSS_FILES = \
-       src/scss/style.scss \
-       src/scss/_code-highlighting.scss \
-       src/scss/_fonts.scss \
-       src/scss/_icons.scss \
-       src/scss/_variables.scss
+SASS_FILES = \
+       src/sass/main.sass \
+       src/sass/_code-highlighting.sass \
+       src/sass/_fonts.sass \
+       src/sass/_icons.sass \
+       src/sass/_variables.sass
 
 EXTRA_DIST += \
-       src/scss/listing.scss \
-       src/templates/messages/main.scss
+       src/sass/listing.sass \
+       src/templates/messages/fonts.sass \
+       src/templates/messages/main.sass
 
 CLEANFILES += \
+       src/templates/messages/fonts.css \
        src/templates/messages/main.css
 
 static_DATA = \
        src/static/favicon.ico \
        src/static/robots.txt \
-       src/scss/fonts.css \
-       src/scss/listing.css \
-       src/scss/main.css
+       src/sass/listing.css \
+       src/sass/main.css
 
 CLEANFILES += \
-       src/scss/fonts.css \
-       src/scss/listing.css \
-       src/scss/main.css
+       src/sass/listing.css \
+       src/sass/main.css
 
 EXTRA_DIST += \
-       $(SCSS_FILES)
+       $(SASS_FILES)
 
 staticdir = $(datadir)/static
 
@@ -908,29 +941,31 @@ static_flags_4x3_DATA = \
 
 static_flags_4x3dir = $(static_flagsdir)/4x3
 
-static_fonts_DATA = \
-       src/fonts/ofl/mukta/Mukta-Bold.ttf \
-       src/fonts/ofl/mukta/Mukta-ExtraBold.ttf \
-       src/fonts/ofl/mukta/Mukta-ExtraLight.ttf \
-       src/fonts/ofl/mukta/Mukta-Light.ttf \
-       src/fonts/ofl/mukta/Mukta-Medium.ttf \
-       src/fonts/ofl/mukta/Mukta-Regular.ttf \
-       src/fonts/ofl/mukta/Mukta-SemiBold.ttf \
+dist_static_fonts_DATA = \
+       src/static/fonts/prompt/Prompt-Black.ttf \
+       src/static/fonts/prompt/Prompt-BlackItalic.ttf \
+       src/static/fonts/prompt/Prompt-Bold.ttf \
+       src/static/fonts/prompt/Prompt-BoldItalic.ttf \
+       src/static/fonts/prompt/Prompt-ExtraBold.ttf \
+       src/static/fonts/prompt/Prompt-ExtraBoldItalic.ttf \
+       src/static/fonts/prompt/Prompt-ExtraLight.ttf \
+       src/static/fonts/prompt/Prompt-ExtraLightItalic.ttf \
+       src/static/fonts/prompt/Prompt-Italic.ttf \
+       src/static/fonts/prompt/Prompt-Light.ttf \
+       src/static/fonts/prompt/Prompt-LightItalic.ttf \
+       src/static/fonts/prompt/Prompt-Medium.ttf \
+       src/static/fonts/prompt/Prompt-MediumItalic.ttf \
+       src/static/fonts/prompt/Prompt-Regular.ttf \
+       src/static/fonts/prompt/Prompt-SemiBold.ttf \
+       src/static/fonts/prompt/Prompt-SemiBoldItalic.ttf \
+       src/static/fonts/prompt/Prompt-Thin.ttf \
+       src/static/fonts/prompt/Prompt-ThinItalic.ttf \
        \
-       src/font-awesome/webfonts/fa-brands-400.eot \
-       src/font-awesome/webfonts/fa-brands-400.svg \
        src/font-awesome/webfonts/fa-brands-400.ttf \
-       src/font-awesome/webfonts/fa-brands-400.woff \
        src/font-awesome/webfonts/fa-brands-400.woff2 \
-       src/font-awesome/webfonts/fa-regular-400.eot \
-       src/font-awesome/webfonts/fa-regular-400.svg \
        src/font-awesome/webfonts/fa-regular-400.ttf \
-       src/font-awesome/webfonts/fa-regular-400.woff \
        src/font-awesome/webfonts/fa-regular-400.woff2 \
-       src/font-awesome/webfonts/fa-solid-900.eot \
-       src/font-awesome/webfonts/fa-solid-900.svg \
        src/font-awesome/webfonts/fa-solid-900.ttf \
-       src/font-awesome/webfonts/fa-solid-900.woff \
        src/font-awesome/webfonts/fa-solid-900.woff2 \
        \
        src/payment-font/fonts/paymentfont-webfont.eot \
@@ -938,8 +973,36 @@ static_fonts_DATA = \
        src/payment-font/fonts/paymentfont-webfont.ttf \
        src/payment-font/fonts/paymentfont-webfont.woff
 
+static_fonts_DATA = \
+       src/static/fonts/prompt/Prompt-Black.woff2 \
+       src/static/fonts/prompt/Prompt-BlackItalic.woff2 \
+       src/static/fonts/prompt/Prompt-Bold.woff2 \
+       src/static/fonts/prompt/Prompt-BoldItalic.woff2 \
+       src/static/fonts/prompt/Prompt-ExtraBold.woff2 \
+       src/static/fonts/prompt/Prompt-ExtraBoldItalic.woff2 \
+       src/static/fonts/prompt/Prompt-ExtraLight.woff2 \
+       src/static/fonts/prompt/Prompt-ExtraLightItalic.woff2 \
+       src/static/fonts/prompt/Prompt-Italic.woff2 \
+       src/static/fonts/prompt/Prompt-Light.woff2 \
+       src/static/fonts/prompt/Prompt-LightItalic.woff2 \
+       src/static/fonts/prompt/Prompt-Medium.woff2 \
+       src/static/fonts/prompt/Prompt-MediumItalic.woff2 \
+       src/static/fonts/prompt/Prompt-Regular.woff2 \
+       src/static/fonts/prompt/Prompt-SemiBold.woff2 \
+       src/static/fonts/prompt/Prompt-SemiBoldItalic.woff2 \
+       src/static/fonts/prompt/Prompt-Thin.woff2 \
+       src/static/fonts/prompt/Prompt-ThinItalic.woff2
+
 static_fontsdir = $(staticdir)/fonts
 
+EXTRA_DIST += \
+       src/static/fonts/prompt/DESCRIPTION.en_us.html \
+       src/static/fonts/prompt/METADATA.pb \
+       src/static/fonts/prompt/OFL.txt
+
+CLEANFILES += \
+       $(static_fonts_DATA)
+
 static_img_DATA = \
        src/static/img/apple-touch-icon-192x192-precomposed.png \
        src/static/img/apple-touch-icon-180x180-precomposed.png \
@@ -952,14 +1015,38 @@ static_img_DATA = \
        src/static/img/bash-logo.svg \
        src/static/img/debian-logo.svg \
        src/static/img/default-avatar.jpg \
+       src/static/img/fdroid-logo.svg \
        src/static/img/ipfire-tux.png \
        src/static/img/iuse-not-found.png \
+       src/static/img/kyberio-logo.svg \
        src/static/img/lightningwirelabs-logo.svg \
        src/static/img/python-logo.svg \
        src/static/img/tor.svg
 
 static_imgdir = $(staticdir)/img
 
+# From https://www.pexels.com/photo/123-let-s-go-imaginary-text-704767/
+
+dist_static_img_auth_DATA = \
+       src/static/img/auth/join.jpg
+
+static_img_auth_DATA = \
+       src/static/img/auth/join@600.jpg
+
+CLEANFILES += \
+       src/static/img/auth/join@600.jpg
+
+static_img_authdir = $(static_imgdir)/auth
+
+static_img_downloadsdir = $(static_imgdir)/downloads
+
+dist_static_img_downloads_cloud_DATA = \
+       src/static/img/downloads/cloud/aws.svg \
+       src/static/img/downloads/cloud/exoscale.svg \
+       src/static/img/downloads/cloud/hetzner.svg
+
+static_img_downloads_clouddir = $(static_img_downloadsdir)/cloud
+
 static_images_tux_DATA = \
        src/static/img/tux/ipfire_tux_16x16.png \
        src/static/img/tux/ipfire_tux_20x20.png \
@@ -980,18 +1067,29 @@ static_images_DATA = \
 
 static_imagesdir = $(staticdir)/images
 
+EXTRA_DIST += \
+       src/static/videos/firewall.mp4
+
+CLEANFILES += \
+       $(static_videos_DATA)
+
+static_videos_DATA = \
+       src/static/videos/firewall.jpg \
+       src/static/videos/firewall@1920.av1.mp4 \
+       src/static/videos/firewall@1920.h265.mp4 \
+       src/static/videos/firewall@1920.h264.mp4 \
+       src/static/videos/firewall@1920.vp9.mp4
+
+static_videosdir = $(staticdir)/videos
+
 static_js_DATA = \
-       src/bootstrap/dist/js/bootstrap.min.js \
-       src/bootstrap/dist/js/bootstrap.min.js.map \
-       \
        src/static/js/Control.Geocoder.min.js \
        src/static/js/editor.js \
-       src/static/js/jquery-3.3.1.min.js \
+       src/static/js/jquery-3.6.0.min.js \
        src/static/js/leaflet.min.js \
        src/static/js/maps.js \
-       src/static/js/popper.min.js \
-       src/static/js/popper.min.js.map \
        src/static/js/prettify.js \
+       src/static/js/site.js \
        \
        src/static/js/zxcvbn/dist/zxcvbn.js \
        src/static/js/zxcvbn/dist/zxcvbn.js.map
@@ -1010,6 +1108,29 @@ static_netbootdir = $(staticdir)/netboot
 
 # ------------------------------------------------------------------------------
 
+EXTRA_DIST += \
+       src/error-pages/Gemfile \
+       src/error-pages/Gemfile.lock \
+       src/error-pages/_config.yml \
+       src/error-pages/_layouts/error.html \
+       src/error-pages/assets/main.sass \
+       src/error-pages/500.markdown \
+       src/error-pages/502.markdown \
+       src/error-pages/503.markdown \
+       src/error-pages/504.markdown
+
+.PHONY: error-pages
+error-pages:
+       $(AM_V_GEN)cd src/error-pages && JEKYLL_ENV=production \
+               $(JEKYLL) build --quiet --incremental
+
+.PHONY: upload-error-pages
+upload-error-pages: error-pages
+       rsync --verbose --progress --recursive --delete -e "ssh -p 222" --exclude="feed.xml" \
+               src/error-pages/_site/ root@fw01.haj.ipfire.org:/etc/haproxy/errors/
+
+# ------------------------------------------------------------------------------
+
 dist_cron_DATA = \
        src/crontab/ipfire
 
@@ -1053,15 +1174,11 @@ SED_PROCESS = \
 %: %.in Makefile
        $(SED_PROCESS)
 
-%.css: _%.scss Makefile
+%.css: %.sass Makefile
        $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
                $(SASSC) --style compressed $< > $@
 
-%.css: %.scss Makefile
-       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
-               $(SASSC) --style compressed $< > $@
-
-src/scss/main.css: $(SCSS_FILES) Makefile
+src/sass/main.css: $(SASS_FILES) Makefile
        $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
                $(SASSC) --style compressed $< > $@
 
@@ -1077,11 +1194,112 @@ src/static/img/apple-touch-icon-%-precomposed.png: src/static/img/ipfire-tux.png
                -extent $(patsubst src/static/img/apple-touch-icon-%-precomposed.png,%,$@)x$(patsubst src/static/img/apple-touch-icon-%-precomposed.png,%,$@) \
                $< $@
 
+# Resizes images for being used in messages which are 600px wide
+%@600.jpg: %.jpg
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(CONVERT) -units PixelsPerInch $< -resize 600x -strip -quality 85 $@
+
+# Fonts
+
+%.woff2: %.ttf
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(WOFF2_COMPRESS) $^ >/dev/null
+
+# Video Stuff
+
+# Multi-threading options for faster encoding
+FFMPEG_MT = \
+       -row-mt 1 \
+       -threads $(shell getconf _NPROCESSORS_ONLN) \
+       -tile-columns 2
+
+# Enable to log less
+#FFMPEG += \
+#      -loglevel quiet
+
+# AV1
+src/static/videos/firewall@%.av1.mp4: src/static/videos/firewall.mp4
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(FFMPEG) -y \
+               -i $^ \
+               -map_metadata -1 \
+               -an \
+               -c:v libsvtav1 \
+               -b:v 0 \
+               -crf 31 \
+               -strict experimental \
+               -preset 3 \
+               -pix_fmt yuv420p \
+               -movflags +faststart \
+               -vf scale=$(patsubst src/static/videos/firewall@%.av1.mp4,%,$@):-2:flags=lanczos,fps=25 \
+               $(FFMPEG_MT) \
+               $@
+
+# H.256
+src/static/videos/firewall@%.h265.mp4: src/static/videos/firewall.mp4
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(FFMPEG) -y \
+               -i $^ \
+               -map_metadata -1 \
+               -an \
+               -c:v libx265 \
+               -b:v 0 \
+               -crf 27 \
+               -preset veryslow \
+               -pix_fmt yuv420p \
+               -movflags +faststart \
+               -tag:v hvc1 \
+               -vf scale=$(patsubst src/static/videos/firewall@%.h265.mp4,%,$@):-2:flags=lanczos,fps=25 \
+               $(FFMPEG_MT) \
+               $@
+
+# H.264
+src/static/videos/firewall@%.h264.mp4: src/static/videos/firewall.mp4
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(FFMPEG) -y \
+               -i $^ \
+               -map_metadata -1 \
+               -an \
+               -c:v libx264 \
+               -b:v 0 \
+               -crf 27 \
+               -preset veryslow \
+               -profile:v main \
+               -pix_fmt yuv420p \
+               -movflags +faststart \
+               -vf scale=$(patsubst src/static/videos/firewall@%.h264.mp4,%,$@):-2:flags=lanczos,fps=25 \
+               $(FFMPEG_MT) \
+               $@
+
+# VP9
+src/static/videos/firewall@%.vp9.mp4: src/static/videos/firewall.mp4
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(FFMPEG) -y \
+               -i $^ \
+               -map_metadata -1 \
+               -an \
+               -c:v libvpx-vp9 \
+               -b:v 0 \
+               -crf 31 \
+               -deadline best \
+               -preset veryslow \
+               -pix_fmt yuv420p \
+               -movflags +faststart \
+               -vf scale=$(patsubst src/static/videos/firewall@%.vp9.mp4,%,$@):-2:flags=lanczos,fps=25 \
+               $(FFMPEG_MT) \
+               $@
+
+# Cover image
+src/static/videos/firewall.jpg: src/static/videos/firewall.mp4
+       $(AM_V_GEN)$(MKDIR_P) $(dir $@) && \
+       $(FFMPEG) -y \
+               -i $^ \
+               -map_metadata -1 \
+               -qscale:v 1 \
+               -frames:v 1 \
+               -vf scale=1920:-2 \
+               $@
+
 .PHONY: update
 update:
        for i in src/systemd/ipfire.org-webapp-*.service; do systemctl restart $$(basename $${i}) && sleep 5; done
-
-# Docker
-.PHONY: docker
-docker: Dockerfile
-       docker build -t "ipfire/webapp:$(PACKAGE_VERSION)" .
index b71677ca11a817c42151fc30230d98033d843a8b..17ef12942811dd93e3b82d0bb49a68c0765dcb50 100644 (file)
@@ -6,6 +6,7 @@ AC_INIT([ipfire.org],
        [ipfire.org],
        [https://www.ipfire.org/])
 
+AC_CONFIG_MACRO_DIR([m4])
 AC_CONFIG_AUX_DIR([build-aux])
 
 AC_PREFIX_DEFAULT([/usr])
@@ -26,9 +27,26 @@ AC_PROG_MKDIR_P
 AC_PROG_SED
 
 # Python
-AM_PATH_PYTHON([3.4])
-
-# scss
+AM_PATH_PYTHON([3.11])
+
+AX_PYTHON_MODULE([PIL], [fatal])
+AX_PYTHON_MODULE([feedparser], [fatal])
+AX_PYTHON_MODULE([html2text], [fatal])
+AX_PYTHON_MODULE([iso3166], [fatal])
+AX_PYTHON_MODULE([jsonschema], [fatal])
+AX_PYTHON_MODULE([kerberos], [fatal])
+AX_PYTHON_MODULE([ldap], [fatal])
+AX_PYTHON_MODULE([magic], [fatal])
+AX_PYTHON_MODULE([panoramisk], [fatal])
+AX_PYTHON_MODULE([phonenumbers], [fatal])
+AX_PYTHON_MODULE([psycopg], [fatal])
+AX_PYTHON_MODULE([pycares], [fatal])
+AX_PYTHON_MODULE([pynliner], [fatal])
+AX_PYTHON_MODULE([redis.asyncio], [fatal])
+AX_PYTHON_MODULE([tornado], [fatal])
+AX_PYTHON_MODULE([zxcvbn], [fatal])
+
+# sass
 AC_CHECK_PROG(SASSC, [sassc], [sassc])
 if test -z "${SASSC}"; then
        AC_MSG_ERROR([sassc is required])
@@ -40,6 +58,24 @@ if test -z "${CONVERT}"; then
        AC_MSG_ERROR([convert is required])
 fi
 
+# ffmpeg
+AC_CHECK_PROG(FFMPEG, [ffmpeg], [ffmpeg])
+if test -z "${FFMPEG}"; then
+       AC_MSG_ERROR([ffmpeg is required])
+fi
+
+# jekyll
+AC_CHECK_PROG(JEKYLL, [jekyll], [jekyll])
+if test -z "${JEKYLL}"; then
+       AC_MSG_ERROR([jekyll is required])
+fi
+
+# WOFF2
+AC_CHECK_PROG(WOFF2_COMPRESS, [woff2_compress], [woff2_compress])
+if test -z "${WOFF2_COMPRESS}"; then
+       AC_MSG_ERROR([woff2_compress is required])
+fi
+
 # ------------------------------------------------------------------------------
 
 AC_ARG_WITH([systemd],
diff --git a/m4/.gitignore b/m4/.gitignore
new file mode 100644 (file)
index 0000000..55eaa80
--- /dev/null
@@ -0,0 +1,6 @@
+intltool.m4
+libtool.m4
+ltoptions.m4
+ltsugar.m4
+ltversion.m4
+lt~obsolete.m4
diff --git a/m4/ax_python_module.m4 b/m4/ax_python_module.m4
new file mode 100644 (file)
index 0000000..f0f873d
--- /dev/null
@@ -0,0 +1,56 @@
+# ===========================================================================
+#     https://www.gnu.org/software/autoconf-archive/ax_python_module.html
+# ===========================================================================
+#
+# SYNOPSIS
+#
+#   AX_PYTHON_MODULE(modname[, fatal, python])
+#
+# DESCRIPTION
+#
+#   Checks for Python module.
+#
+#   If fatal is non-empty then absence of a module will trigger an error.
+#   The third parameter can either be "python" for Python 2 or "python3" for
+#   Python 3; defaults to Python 3.
+#
+# LICENSE
+#
+#   Copyright (c) 2008 Andrew Collier
+#
+#   Copying and distribution of this file, with or without modification, are
+#   permitted in any medium without royalty provided the copyright notice
+#   and this notice are preserved. This file is offered as-is, without any
+#   warranty.
+
+#serial 9
+
+AU_ALIAS([AC_PYTHON_MODULE], [AX_PYTHON_MODULE])
+AC_DEFUN([AX_PYTHON_MODULE],[
+    if test -z $PYTHON;
+    then
+        if test -z "$3";
+        then
+            PYTHON="python3"
+        else
+            PYTHON="$3"
+        fi
+    fi
+    PYTHON_NAME=`basename $PYTHON`
+    AC_MSG_CHECKING($PYTHON_NAME module: $1)
+    $PYTHON -c "import $1" 2>/dev/null
+    if test $? -eq 0;
+    then
+        AC_MSG_RESULT(yes)
+        eval AS_TR_CPP(HAVE_PYMOD_$1)=yes
+    else
+        AC_MSG_RESULT(no)
+        eval AS_TR_CPP(HAVE_PYMOD_$1)=no
+        #
+        if test -n "$2"
+        then
+            AC_MSG_ERROR(failed to find required module $1)
+            exit 1
+        fi
+    fi
+])
diff --git a/migrate.sql b/migrate.sql
new file mode 100644 (file)
index 0000000..78a4178
--- /dev/null
@@ -0,0 +1,188 @@
+START TRANSACTION;
+
+-- CREATE INDEX fireinfo_search ON fireinfo USING gin (blob) WHERE expired_at IS NULL;
+-- CREATE UNIQUE INDEX fireinfo_current ON fireinfo USING btree (profile_id) WHERE expired_at IS NULL
+
+-- CREATE INDEX fireinfo_releases_current ON fireinfo USING hash((blob->'system'->'release')) WHERE expired_at IS NULL;
+-- CREATE INDEX fireinfo_releases ON fireinfo USING hash((blob->'system'->'release'));
+
+-- CREATE INDEX fireinfo_arches_current ON fireinfo USING hash((blob->'cpu'->'arch')) WHERE blob->'cpu'->'arch' IS NOT NULL AND expired_at IS NULL;
+-- CREATE INDEX fireinfo_arches ON fireinfo USING hash((blob->'cpu'->'arch')) WHERE blob->'cpu'->'arch' IS NOT NULL;
+
+-- CREATE INDEX fireinfo_cpu_vendors ON fireinfo USING hash((blob->'cpu'->'vendor')) WHERE blob->'cpu'->'vendor' IS NOT NULL;
+
+-- CREATE INDEX fireinfo_hypervisor_vendors_current ON fireinfo USING hash((blob->'hypervisor'->'vendor')) WHERE expired_at IS NULL AND CAST((blob->'system'->'virtual') AS boolean) IS TRUE;
+
+-- XXX virtual index
+
+TRUNCATE TABLE fireinfo;
+
+--EXPLAIN
+
+INSERT INTO fireinfo
+
+SELECT
+       p.public_id AS profile_id,
+       p.time_created AS created_at,
+       (
+               CASE
+                       WHEN p.time_valid <= CURRENT_TIMESTAMP THEN p.time_valid
+                       ELSE NULL
+               END
+       ) AS expired_at,
+       0 AS version,
+       (
+               -- Empty the profile if we don't have any data
+               CASE WHEN profile_arches.arch_id IS NULL THEN NULL
+
+               -- Otherwise do some hard work...
+               ELSE
+                       -- CPU
+                       jsonb_build_object('cpu',
+                               jsonb_build_object(
+                                       'arch',         arches.name,
+                                       'bogomips',     profile_processors.bogomips,
+                                       'speed',        profile_processors.clock_speed,
+
+                                       'vendor',       processors.vendor,
+                                       'model',        processors.model,
+                                       'model_string', processors.model_string,
+                                       'stepping',     processors.stepping,
+                                       'flags',        processors.flags,
+                                       'family',       processors.family,
+                                       'count',        processors.core_count
+                               )
+                       )
+
+                       -- System
+                       || jsonb_build_object('system',
+                               jsonb_build_object(
+                                       'kernel',    kernels.name,
+                                       'language',  profile_languages.language,
+                                       'memory',    profile_memory.amount,
+                                       'release',   releases.name,
+                                       'root_size', profile_storage.amount,
+                                       'vendor',    systems.vendor,
+                                       'model',     systems.model,
+                                       'virtual',   CASE WHEN hypervisors.id IS NULL THEN FALSE ELSE TRUE END
+                               )
+                       )
+
+                       -- Hypervisor
+                       || CASE
+                               WHEN hypervisors.id IS NULL THEN jsonb_build_object()
+                               ELSE
+                                       jsonb_build_object(
+                                               'hypervisor',
+                                               json_build_object('vendor', hypervisors.name)
+                                       )
+                               END
+
+                       -- Devices
+                       || jsonb_build_object('devices', devices.devices)
+
+                       -- Networks
+                       || jsonb_build_object('networks',
+                               jsonb_build_object(
+                                       'green',  profile_networks.has_green,
+                                       'blue',   profile_networks.has_blue,
+                                       'orange', profile_networks.has_orange,
+                                       'red',    profile_networks.has_red
+                               )
+                       )
+               END
+       ) AS blob,
+       p.time_updated AS last_updated_at,
+       p.private_id AS private_id,
+       locations.location AS country_code
+
+FROM fireinfo_profiles p
+
+LEFT JOIN
+       fireinfo_profiles_locations locations ON p.id = locations.profile_id
+
+LEFT JOIN
+       fireinfo_profiles_arches profile_arches ON p.id = profile_arches.profile_id
+
+LEFT JOIN
+       fireinfo_arches arches ON profile_arches.arch_id = arches.id
+
+LEFT JOIN
+       (
+               SELECT
+                       profile_devices.profile_id AS profile_id,
+                       jsonb_agg(
+                               jsonb_build_object(
+                                       'deviceclass', devices.deviceclass,
+                                       'subsystem',   devices.subsystem,
+                                       'vendor',      devices.vendor,
+                                       'model',       devices.model,
+                                       'sub_vendor',  devices.sub_vendor,
+                                       'sub_model',   devices.sub_model,
+                                       'driver',      devices.driver
+                               )
+                       ) AS devices
+               FROM
+                       fireinfo_profiles_devices profile_devices
+               LEFT JOIN
+                       fireinfo_devices devices ON profile_devices.device_id = devices.id
+               GROUP BY
+                       profile_devices.profile_id
+       ) devices ON p.id = devices.profile_id
+
+LEFT JOIN
+       fireinfo_profiles_processors profile_processors ON p.id = profile_processors.profile_id
+
+LEFT JOIN
+       fireinfo_processors processors ON profile_processors.processor_id = processors.id
+
+LEFT JOIN
+       fireinfo_profiles_kernels profile_kernels ON p.id = profile_kernels.profile_id
+
+LEFT JOIN
+       fireinfo_kernels kernels ON profile_kernels.kernel_id = kernels.id
+
+LEFT JOIN
+       fireinfo_profiles_languages profile_languages ON p.id = profile_languages.profile_id
+
+LEFT JOIN
+       fireinfo_profiles_memory profile_memory ON p.id = profile_memory.profile_id
+
+LEFT JOIN
+       fireinfo_profiles_releases profile_releases ON p.id = profile_releases.profile_id
+
+LEFT JOIN
+       fireinfo_releases releases ON profile_releases.release_id = releases.id
+
+LEFT JOIN
+       fireinfo_profiles_storage profile_storage ON p.id = profile_storage.profile_id
+
+LEFT JOIN
+       fireinfo_profiles_systems profile_systems ON p.id = profile_systems.profile_id
+
+LEFT JOIN
+       fireinfo_systems systems ON profile_systems.system_id  = systems.id
+
+LEFT JOIN
+       fireinfo_profiles_virtual profile_virtual ON p.id = profile_virtual.profile_id
+
+LEFT JOIN
+       fireinfo_hypervisors hypervisors ON profile_virtual.hypervisor_id = hypervisors.id
+
+LEFT JOIN
+       fireinfo_profiles_networks profile_networks ON p.id = profile_networks.profile_id
+
+--WHERE
+-- XXX TO FIND A PROFILE WITH DATA
+--     profile_processors.profile_id IS NOT NULL
+
+-- XXX TO FIND A VIRTUAL PROFILE
+--profile_virtual.hypervisor_id IS NOT NULL
+
+--ORDER BY
+--     time_created DESC
+
+--LIMIT 1
+;
+
+COMMIT;
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644 (file)
index 5158c37..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-asn1crypto==0.24.0
-backports-abc==0.5
-certifi==2019.3.9
-cffi==1.11.5
-chardet==3.0.4
-cryptography==2.3.1
-ecdsa==0.13
-feedparser==5.2.1
-file-magic==0.4.0
-html5lib==1.0.1
-idna==2.7
-iso3166==0.9
-ldap3==2.5.1
-Markdown==3.1.1
-oauthlib==3.0.1
-phonenumbers==8.9.15
-Pillow==5.3.0
-psycopg2-binary==2.7.5
-py-dateutil==2.2
-pyasn1==0.4.4
-pyasn1-modules==0.2.2
-pycares==2.3.0
-pycparser==2.19
-pycrypto==2.6.1
-pycurl==7.43.0
-Pygments==2.4.2
-python-ldap==3.1.0
-python3-memcached==1.51
-requests==2.21.0
-requests-oauthlib==1.2.0
-sgmllib3k==1.0.0
-six==1.11.0
-textile==3.0.3
-tornado==6.0.2
-twython==3.7.0
-urllib3==1.24.3
-webencodings==0.5.1
-yabencode==0.2.0
-zxcvbn==4.4.27
index a3c8297bc65e9082a478b4999df748c4833f7b77..397f217c2a8c32f40466d98bf397ad60f4802d87 100644 (file)
@@ -1,18 +1,21 @@
 #!/usr/bin/python
 # encoding: utf-8
 
+import asyncio
 import base64
 import datetime
 import hashlib
 import hmac
 import iso3166
 import json
+import kerberos
 import ldap
 import ldap.modlist
 import logging
 import os
 import phonenumbers
 import re
+import socket
 import sys
 import time
 import tornado.httpclient
@@ -25,11 +28,11 @@ from . import util
 from .decorators import *
 from .misc import Object
 
-INT_MAX = (2**31) - 1
-
 # Set the client keytab name
 os.environ["KRB5_CLIENT_KTNAME"] = "/etc/ipfire.org/ldap.keytab"
 
+FQDN = socket.gethostname()
+
 class LDAPObject(Object):
        def init(self, dn, attrs=None):
                self.dn = dn
@@ -40,6 +43,8 @@ class LDAPObject(Object):
                if isinstance(other, self.__class__):
                        return self.dn == other.dn
 
+               return NotImplemented
+
        @property
        def ldap(self):
                return self.accounts.ldap
@@ -92,15 +97,6 @@ class LDAPObject(Object):
                # Run modify operation
                self.ldap.modify_s(self.dn, modlist)
 
-               # Clear cache
-               self._clear_cache()
-
-       def _clear_cache(self):
-               """
-                       Clears cache
-               """
-               pass
-
        def _set(self, key, values):
                current = self._get(key)
 
@@ -159,6 +155,15 @@ class LDAPObject(Object):
        def _delete_string(self, key, value):
                return self._delete_strings(key, [value,])
 
+       def _delete_dn(self, dn):
+               logging.debug("Deleting %s" % dn)
+
+               # Authenticate before performing any delete operations
+               self.accounts._authenticate()
+
+               # Run delete operation
+               self.ldap.delete_s(dn)
+
        @property
        def objectclasses(self):
                return self._get_strings("objectClass")
@@ -173,14 +178,7 @@ class Accounts(Object):
                self.search_base = self.settings.get("ldap_search_base")
 
        def __len__(self):
-               count = self.memcache.get("accounts:count")
-
-               if count is None:
-                       count = self._count("(objectClass=person)")
-
-                       self.memcache.set("accounts:count", count, 300)
-
-               return count
+               return self._count("(objectClass=person)")
 
        def __iter__(self):
                accounts = self._search("(objectClass=person)")
@@ -208,7 +206,7 @@ class Accounts(Object):
                # Authenticate against LDAP server using Kerberos
                self.ldap.sasl_gssapi_bind_s()
 
-       def test_ldap(self):
+       async def test_ldap(self):
                logging.info("Testing LDAP connection...")
 
                self._authenticate()
@@ -283,13 +281,7 @@ class Accounts(Object):
                        return attrs
 
        def get_by_dn(self, dn):
-               attrs = self.memcache.get("accounts:%s:attrs" % dn)
-               if attrs is None:
-                       attrs = self._get_attrs(dn)
-                       assert attrs, dn
-
-                       # Cache all attributes for 5 min
-                       self.memcache.set("accounts:%s:attrs" % dn, attrs, 300)
+               attrs = self._get_attrs(dn)
 
                return Account(self.backend, dn, attrs)
 
@@ -297,6 +289,22 @@ class Accounts(Object):
        def _format_date(t):
                return t.strftime("%Y%m%d%H%M%SZ")
 
+       def get_recently_registered(self, limit=None):
+               # Check the last two weeks
+               t = datetime.datetime.utcnow() - datetime.timedelta(days=14)
+
+               # Fetch all accounts created after t
+               accounts = self.get_created_after(t)
+
+               # Order by creation date and put latest first
+               accounts.sort(key=lambda a: a.created_at, reverse=True)
+
+               # Cap at the limit
+               if accounts and limit:
+                       accounts = accounts[:limit]
+
+               return accounts
+
        def get_created_after(self, ts):
                return self._search("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
 
@@ -304,8 +312,30 @@ class Accounts(Object):
                return self._count("(&(objectClass=person)(createTimestamp>=%s))" % self._format_date(ts))
 
        def search(self, query):
-               accounts = self._search("(&(objectClass=person)(|(cn=*%s*)(uid=*%s*)(displayName=*%s*)(mail=*%s*)))" \
-                       % (query, query, query, query))
+               # Try finding an exact match
+               account = self._search_one(
+                       "(&"
+                               "(objectClass=person)"
+                               "(|"
+                                       "(uid=%s)"
+                                       "(mail=%s)"
+                                       "(mailAlternateAddress=%s)"
+                               ")"
+                       ")" % (query, query, query))
+               if account:
+                       return [account]
+
+               # Otherwise search for a substring match
+               accounts = self._search(
+                       "(&"
+                               "(objectClass=person)"
+                               "(|"
+                                       "(cn=*%s*)"
+                                       "(uid=*%s*)"
+                                       "(displayName=*%s*)"
+                                       "(mail=*%s*)"
+                               ")"
+                       ")" % (query, query, query, query))
 
                return sorted(accounts)
 
@@ -404,14 +434,6 @@ class Accounts(Object):
 
                return res.c or 0
 
-       async def check_spam(self, email, address):
-               sfs = StopForumSpam(self.backend, email, address)
-
-               # Get spam score
-               score = await sfs.check()
-
-               return score >= 50
-
        def auth(self, username, password):
                # Find account
                account = self.backend.accounts.find_account(username)
@@ -420,9 +442,9 @@ class Accounts(Object):
                if account and account.check_password(password):
                        return account
 
-       # Registration
+       # Join
 
-       def register(self, uid, email, first_name, last_name, country_code=None):
+       def join(self, uid, email, first_name, last_name, country_code=None):
                # Convert all uids to lowercase
                uid = uid.lower()
 
@@ -452,7 +474,7 @@ class Accounts(Object):
                        uid, activation_code, email, first_name, last_name, country_code)
 
                # Send an account activation email
-               self.backend.messages.send_template("auth/messages/register",
+               self.backend.messages.send_template("auth/messages/join",
                        priority=100, uid=uid, activation_code=activation_code, email=email,
                        first_name=first_name, last_name=last_name)
 
@@ -571,6 +593,20 @@ class Accounts(Object):
                # Cleanup expired account password resets
                self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()")
 
+       async def _delete(self, *args, **kwargs):
+               """
+                       Deletes given users
+               """
+               # Who is deleting?
+               who = self.get_by_uid("ms")
+
+               for uid in args:
+                       account = self.get_by_uid(uid)
+
+                       # Delete the account
+                       with self.db.transaction():
+                               await account.delete(who)
+
        # Discourse
 
        def decode_discourse_payload(self, payload, signature):
@@ -612,7 +648,7 @@ class Accounts(Object):
                ret = {}
 
                for country in iso3166.countries:
-                       count = self._count("(&(objectClass=person)(st=%s))" % country.alpha2)
+                       count = self._count("(&(objectClass=person)(c=%s))" % country.alpha2)
 
                        if count:
                                ret[country] = count
@@ -644,9 +680,11 @@ class Account(LDAPObject):
                if isinstance(other, self.__class__):
                        return self.name < other.name
 
-       def _clear_cache(self):
-               # Delete cached attributes
-               self.memcache.delete("accounts:%s:attrs" % self.dn)
+               return NotImplemented
+
+       @property
+       def kerberos_principal_dn(self):
+               return "krbPrincipalName=%s@IPFIRE.ORG,cn=IPFIRE.ORG,cn=krb5,dc=ipfire,dc=org" % self.uid
 
        @lazy_property
        def kerberos_attributes(self):
@@ -720,19 +758,24 @@ class Account(LDAPObject):
 
                logging.debug("Checking credentials for %s" % self.dn)
 
-               # Create a new LDAP connection
-               ldap_uri = self.backend.settings.get("ldap_uri")
-               conn = ldap.initialize(ldap_uri)
+               # Set keytab to use
+               os.environ["KRB5_KTNAME"] = "/etc/ipfire.org/www.keytab"
 
+               # Check the credentials against the Kerberos database
                try:
-                       conn.simple_bind_s(self.dn, password.encode("utf-8"))
-               except ldap.INVALID_CREDENTIALS:
-                       logging.debug("Account credentials are invalid for %s" % self)
+                       kerberos.checkPassword(self.uid, password, "www/%s" % FQDN, "IPFIRE.ORG")
+
+               # Catch any authentication errors
+               except kerberos.BasicAuthError as e:
+                       logging.debug("Could not authenticate %s: %s" % (self.uid, e))
+
                        return False
 
-               logging.info("Successfully authenticated %s" % self)
+               # Otherwise return True
+               else:
+                       logging.info("Successfully authenticated %s" % self)
 
-               return True
+                       return True
 
        def check_password_quality(self, password):
                """
@@ -784,6 +827,12 @@ class Account(LDAPObject):
        def has_sip(self):
                return "sipUser" in self.classes or "sipRoutingObject" in self.classes
 
+       def is_blog_author(self):
+               return self.is_member_of_group("blog-authors")
+
+       def is_lwl(self):
+               return self.is_member_of_group("lwl-staff")
+
        def can_be_managed_by(self, account):
                """
                        Returns True if account is allowed to manage this account
@@ -807,6 +856,56 @@ class Account(LDAPObject):
        def name(self):
                return self._get_string("cn")
 
+       # Delete
+
+       async def delete(self, user):
+               """
+                       Deletes this user
+               """
+               # Check if this user can be deleted
+               if not self.can_be_deleted_by(user):
+                       raise RuntimeError("Cannot delete user %s" % self)
+
+               logging.info("Deleting user %s" % self)
+
+               async with asyncio.TaskGroup() as tasks:
+                       t = datetime.datetime.now()
+
+                       # Disable this account on Bugzilla
+                       tasks.create_task(
+                               self._disable_on_bugzilla("Deleted by %s, %s" % (user, t)),
+                       )
+
+                       # XXX Delete on Discourse
+
+               # Delete on LDAP
+               self._delete()
+
+       def can_be_deleted_by(self, user):
+               """
+                       Return True if the user can be deleted by user
+               """
+               # Check permissions
+               if not self.can_be_managed_by(user):
+                       return False
+
+               # Cannot delete shell users
+               if self.has_shell():
+                       return False
+
+               # Looks okay
+               return True
+
+       def _delete(self):
+               """
+                       Deletes this object from LDAP
+               """
+               # Delete the Kerberos Principal
+               self._delete_dn(self.kerberos_principal_dn)
+
+               # Delete this object
+               self._delete_dn(self.dn)
+
        # Nickname
 
        def get_nickname(self):
@@ -886,7 +985,7 @@ class Account(LDAPObject):
                if self.country_name:
                        address.append(self.country_name)
 
-               return address
+               return [line for line in address if line]
 
        def get_street(self):
                return self._get_string("street") or self._get_string("homePostalAddress")
@@ -912,12 +1011,19 @@ class Account(LDAPObject):
 
        postal_code = property(get_postal_code, set_postal_code)
 
-       # XXX This should be c
-       def get_country_code(self):
+       def get_state(self):
                return self._get_string("st")
 
+       def set_state(self, state):
+               self._set_string("st", state)
+
+       state = property(get_state, set_state)
+
+       def get_country_code(self):
+               return self._get_string("c")
+
        def set_country_code(self, country_code):
-               self._set_string("st", country_code)
+               self._set_string("c", country_code)
 
        country_code = property(get_country_code, set_country_code)
 
@@ -926,6 +1032,34 @@ class Account(LDAPObject):
                if self.country_code:
                        return self.backend.get_country_name(self.country_code)
 
+       @property
+       def initials(self):
+               initials = []
+
+               # If a nickname is set, only use the nickname
+               if self.nickname and len(self.nickname) >= 2:
+                       for m in re.findall(r"(\w+)", self.nickname):
+                               initials.append(m[0])
+
+                       # If we only detected one character, we will use the first two
+                       if len(initials) < 2:
+                               initials = [self.nickname[0], self.nickname[1]]
+
+               # Otherwise use the first and last name
+               else:
+                       if self.first_name:
+                               initials.append(self.first_name[0])
+
+                       if self.last_name:
+                               initials.append(self.last_name[0])
+
+               # Truncate to two initials
+               initials = initials[:2]
+
+               return [i.upper() for i in initials]
+
+       # Email
+
        @property
        def email(self):
                return self._get_string("mail")
@@ -934,6 +1068,12 @@ class Account(LDAPObject):
        def email_to(self):
                return "%s <%s>" % (self, self.email)
 
+       @lazy_property
+       def alternate_email_addresses(self):
+               addresses = self._get_strings("mailAlternateAddress")
+
+               return sorted(addresses)
+
        # Mail Routing Address
 
        def get_mail_routing_address(self):
@@ -964,10 +1104,6 @@ class Account(LDAPObject):
        def sip_url(self):
                return "%s@ipfire.org" % self.sip_id
 
-       @lazy_property
-       def agent_status(self):
-               return self.backend.talk.freeswitch.get_agent_status(self)
-
        def uses_sip_forwarding(self):
                if self.sip_routing_address:
                        return True
@@ -1030,23 +1166,21 @@ class Account(LDAPObject):
 
        sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
 
-       @lazy_property
-       def sip_registrations(self):
-               sip_registrations = []
+       # SIP Registrations
 
-               for reg in self.backend.talk.freeswitch.get_sip_registrations(self.sip_url):
-                       reg.account = self
+       async def get_sip_registrations(self):
+               if not self.has_sip():
+                       return []
 
-                       sip_registrations.append(reg)
+               return await self.backend.asterisk.get_registrations(self.sip_id)
 
-               return sip_registrations
+       # SIP Channels
 
-       @lazy_property
-       def sip_channels(self):
-               return self.backend.talk.freeswitch.get_sip_channels(self)
+       async def get_sip_channels(self):
+               if not self.has_sip():
+                       return []
 
-       def get_cdr(self, date=None, limit=None):
-               return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
+               return await self.backend.asterisk.get_sip_channels(self.sip_id)
 
        # Phone Numbers
 
@@ -1124,26 +1258,39 @@ class Account(LDAPObject):
 
        # Avatar
 
-       def has_avatar(self):
-               has_avatar = self.memcache.get("accounts:%s:has-avatar" % self.uid)
-               if has_avatar is None:
-                       has_avatar = True if self.get_avatar() else False
+       @lazy_property
+       def avatar_hash(self):
+               # Fetch the timestamp (or fall back to the last LDAP change)
+               t = self._fetch_avatar_timestamp() or self.modified_at
+
+               # Create the payload
+               payload = "%s-%s" % (self.uid, t)
 
-                       # Cache avatar status for up to 24 hours
-                       self.memcache.set("accounts:%s:has-avatar" % self.uid, has_avatar, 3600 * 24)
+               # Compute a hash over the payload
+               h = hashlib.new("blake2b", payload.encode())
 
-               return has_avatar
+               return h.hexdigest()[:7]
 
-       def avatar_url(self, size=None):
-               url = "https://people.ipfire.org/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
+       def avatar_url(self, size=None, absolute=False):
+               # This cannot be async because we are calling it from the template engine
+               url = "/users/%s.jpg?h=%s" % (self.uid, self.avatar_hash)
+
+               # Return an absolute URL
+               if absolute:
+                       url = urllib.parse.urljoin("https://www.ipfire.org", url)
 
                if size:
                        url += "&size=%s" % size
 
                return url
 
-       def get_avatar(self, size=None):
-               photo = self._get_bytes("jpegPhoto")
+       async def get_avatar(self, size=None, format=None):
+               # Check the PostgreSQL database
+               photo = self._fetch_avatar()
+
+               # Fall back to LDAP
+               if not photo:
+                       photo = self._get_bytes("jpegPhoto")
 
                # Exit if no avatar is available
                if not photo:
@@ -1153,39 +1300,101 @@ class Account(LDAPObject):
                if size is None:
                        return photo
 
-               # Try to retrieve something from the cache
-               avatar = self.memcache.get("accounts:%s:avatar:%s" % (self.dn, size))
+               # Compose the cache key
+               cache_key = "accounts:%s:avatar:%s:%s:%s" \
+                       % (self.uid, self.avatar_hash, format or "N/A", size)
+
+               # Try to fetch the data from the cache
+               async with await self.backend.cache.pipeline() as p:
+                       # Fetch the key
+                       await p.get(cache_key)
+
+                       # Reset the TTL
+                       await p.expire(cache_key, 86400)
+
+                       # Execute the pipeline
+                       avatar, _ = await p.execute()
+
+               # Return the cached value (if any)
                if avatar:
                        return avatar
 
                # Generate a new thumbnail
-               avatar = util.generate_thumbnail(photo, size, square=True)
+               avatar = util.generate_thumbnail(photo, size, square=True, format=format)
 
-               # Save to cache for 15m
-               self.memcache.set("accounts:%s:avatar:%s" % (self.dn, size), avatar, 900)
+               # Save to cache for 24h
+               await self.backend.cache.set(cache_key, avatar, 86400)
 
                return avatar
 
-       @property
-       def avatar_hash(self):
-               hash = self.memcache.get("accounts:%s:avatar-hash" % self.dn)
-               if not hash:
-                       h = hashlib.new("md5")
-                       h.update(self.get_avatar() or b"")
-                       hash = h.hexdigest()[:7]
-
-                       self.memcache.set("accounts:%s:avatar-hash" % self.dn, hash, 86400)
+       def _fetch_avatar(self):
+               """
+                       Fetches the original avatar blob as being uploaded by the user
+               """
+               res = self.db.get("""
+                       SELECT
+                               blob
+                       FROM
+                               account_avatars
+                       WHERE
+                               uid = %s
+                       AND
+                               deleted_at IS NULL
+                       """, self.uid,
+               )
 
-               return hash
+               if res:
+                       return res.blob
+
+       def _fetch_avatar_timestamp(self):
+               res = self.db.get("""
+                       SELECT
+                               created_at
+                       FROM
+                               account_avatars
+                       WHERE
+                               uid = %s
+                       AND
+                               deleted_at IS NULL
+                       """, self.uid,
+               )
 
-       def upload_avatar(self, avatar):
-               self._set("jpegPhoto", avatar)
+               if res:
+                       return res.created_at
+
+       async def upload_avatar(self, avatar):
+               # Remove all previous avatars
+               self.db.execute("""
+                       UPDATE
+                               account_avatars
+                       SET
+                               deleted_at = CURRENT_TIMESTAMP
+                       WHERE
+                               uid = %s
+                       AND
+                               deleted_at IS NULL
+                       """, self.uid,
+               )
 
-               # Delete cached avatar status
-               self.memcache.delete("accounts:%s:has-avatar" % self.dn)
+               # Store the new avatar in the database
+               self.db.execute("""
+                       INSERT INTO
+                               account_avatars
+                       (
+                               uid,
+                               blob
+                       )
+                       VALUES
+                       (
+                               %s, %s
+                       )
+                       """, self.uid, avatar,
+               )
 
-               # Delete avatar hash
-               self.memcache.delete("accounts:%s:avatar-hash" % self.dn)
+               # Remove anything in the LDAP database
+               photo = self._get_bytes("jpegPhoto")
+               if photo:
+                       self._delete("jpegPhoto", [photo])
 
        # Consent to promotional emails
 
@@ -1206,65 +1415,25 @@ class Account(LDAPObject):
                set_contents_to_promotional_emails,
        )
 
+       # Bugzilla
 
-class StopForumSpam(Object):
-       def init(self, email, address):
-               self.email, self.address = email, address
-
-       async def send_request(self, **kwargs):
-               arguments = {
-                       "json" : "1",
-               }
-               arguments.update(kwargs)
-
-               # Create request
-               request = tornado.httpclient.HTTPRequest(
-                       "https://api.stopforumspam.org/api", method="POST",
-                       connect_timeout=2, request_timeout=5)
-               request.body = urllib.parse.urlencode(arguments)
-
-               # Send the request
-               response = await self.backend.http_client.fetch(request)
-
-               # Decode the JSON response
-               return json.loads(response.body.decode())
-
-       async def check_address(self):
-               response = await self.send_request(ip=self.address)
-
-               try:
-                       confidence = response["ip"]["confidence"]
-               except KeyError:
-                       confidence = 100
-
-               logging.debug("Confidence for %s: %s" % (self.address, confidence))
-
-               return confidence
-
-       async def check_email(self):
-               response = await self.send_request(email=self.email)
-
-               try:
-                       confidence = response["email"]["confidence"]
-               except KeyError:
-                       confidence = 100
-
-               logging.debug("Confidence for %s: %s" % (self.email, confidence))
+       async def _disable_on_bugzilla(self, text=None):
+               """
+                       Disables the user on Bugzilla
+               """
+               user = await self.backend.bugzilla.get_user(self.email)
 
-               return confidence
+               # Do nothing if the user does not exist
+               if not user:
+                       return
 
-       async def check(self, threshold=95):
-               """
-                       This function tries to detect if we have a spammer.
+               # Disable the user
+               await user.disable(text)
 
-                       To honour the privacy of our users, we only send the IP
-                       address and username and if those are on the database, we
-                       will send the email address as well.
-               """
-               confidences = [await self.check_address(), await self.check_email()]
+       # Mailman
 
-               # Build a score based on the lowest confidence
-               return 100 - min(confidences)
+       async def get_lists(self):
+               return await self.backend.lists.get_subscribed_lists(self)
 
 
 class Groups(Object):
@@ -1344,6 +1513,8 @@ class Group(LDAPObject):
                if isinstance(other, self.__class__):
                        return (self.description or self.gid) < (other.description or other.gid)
 
+               return NotImplemented
+
        def __bool__(self):
                return True
 
diff --git a/src/backend/analytics.py b/src/backend/analytics.py
new file mode 100644 (file)
index 0000000..34da983
--- /dev/null
@@ -0,0 +1,220 @@
+#!/usr/bin/python3
+
+import datetime
+import json
+import urllib.parse
+
+from . import misc
+from .decorators import *
+
+INVALID_REFERRERS = (
+       # Broken schema
+       "://",
+
+       # Localhost
+       "http://localhost",
+       "https://localhost",
+       "http://127.0.0.1",
+       "https://127.0.0.1",
+)
+
+class Analytics(misc.Object):
+       def log_unique_visit(self, address, referrer, country_code=None, user_agent=None,
+                       host=None, uri=None, source=None, medium=None, campaign=None, content=None,
+                       term=None, q=None):
+               """
+                       Logs a unique visit to this a page
+               """
+               asn, query_args, bot = None, None, False
+
+               if referrer:
+                       # Parse referrer
+                       url = urllib.parse.urlparse(referrer)
+
+                       # Remove everything after ? and #
+                       referrer = "%s://%s%s" % (url.scheme, url.netloc, url.path)
+
+                       # Drop anything that isn't valid
+                       for invalid_referrer in INVALID_REFERRERS:
+                               if referrer.startswith(invalid_referrer):
+                                       referrer = None
+                                       break
+
+               # Fetch the ASN
+               if address:
+                       asn = address.asn
+
+               # Strip URI
+               if uri:
+                       uri, _, query_args = uri.partition("?")
+
+               # Parse query arguments
+               if query_args:
+                       query_args = urllib.parse.parse_qs(query_args)
+
+               # Mark bots
+               if user_agent:
+                       bot = "bot" in user_agent.lower()
+
+               # Split q
+               if q:
+                       q = q.split()
+
+               self.db.execute("""
+                       INSERT INTO
+                               analytics_unique_visits
+                       (
+                               host,
+                               uri,
+                               query_args,
+                               country_code,
+                               asn,
+                               referrer,
+                               user_agent,
+                               q,
+                               bot,
+                               source,
+                               medium,
+                               campaign,
+                               content,
+                               term
+                       )
+                       VALUES
+                       (
+                               %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
+                       )
+                       """,
+                       host, uri, json.dumps(query_args or {}), country_code, asn, referrer or "",
+                       user_agent, q, bot, source or "", medium or "", campaign or "", content or "",
+                       term or "",
+               )
+
+       def get_total_page_views(self, host, since=None):
+               # Make since an absolute timestamp
+               if since and isinstance(since, datetime.timedelta):
+                       since = datetime.datetime.utcnow() - since
+
+               if since:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS c
+                               FROM
+                                       analytics_unique_visits
+                               WHERE
+                                       host = %s
+                               AND
+                                       created_at >= %s
+                               """, host, since,
+                       )
+               else:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS c
+                               FROM
+                                       analytics_unique_visits
+                               WHERE
+                                       host = %s
+                               """, host,
+                       )
+
+               if res and res.c:
+                       return res.c
+
+               return 0
+
+       def get_page_views(self, host, uri, since=None):
+               # Make since an absolute timestamp
+               if since and isinstance(since, datetime.timedelta):
+                       since = datetime.datetime.utcnow() - since
+
+               if since:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS c
+                               FROM
+                                       analytics_unique_visits
+                               WHERE
+                                       host = %s
+                               AND
+                                       uri = %s
+                               AND
+                                       created_at >= %s
+                               """, host, uri, since,
+                       )
+               else:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS c
+                               FROM
+                                       analytics_unique_visits
+                               WHERE
+                                       host = %s
+                               AND
+                                       uri = %s
+                               """, host, uri,
+                       )
+
+               if res and res.c:
+                       return res.c
+
+               return 0
+
+       # Popular Pages
+
+       def get_most_popular_docs_pages(self, host, since=None, offset=None, limit=None):
+               # Make since an absolute timestamp
+               if since and isinstance(since, datetime.timedelta):
+                       since = datetime.datetime.utcnow() - since
+
+               pages = self.backend.wiki._get_pages("""
+                       SELECT
+                               wiki.*,
+                               COUNT(*) AS _c
+                       FROM
+                               wiki_current
+                       LEFT JOIN
+                               wiki ON wiki_current.id = wiki.id
+                       LEFT JOIN
+                               analytics_unique_visits
+                                       ON (CASE WHEN wiki.page = '/' THEN '/docs'
+                                               ELSE '/docs' || wiki.page END) = analytics_unique_visits.uri
+                       WHERE
+                               host = %s
+                       AND
+                               uri LIKE '/docs%%'
+                       GROUP BY
+                               wiki.id
+                       ORDER BY
+                               _c DESC
+                       LIMIT
+                               %s
+                       OFFSET
+                               %s
+                       """, host, limit, offset,
+               )
+
+               return list(pages)
+
+       # Search
+
+       def get_search_queries(self, host, uri, limit=None):
+               res = self.db.query("""
+                       SELECT
+                               q,
+                               COUNT(*) AS c
+                       FROM
+                               analytics_unique_visits
+                       WHERE
+                               host = %s
+                       AND
+                               uri = %s
+                       AND
+                               q IS NOT NULL
+                       GROUP BY
+                               q
+                       LIMIT
+                               %s
+                       """, host, uri, limit,
+               )
+
+               return { " ".join(row.q) : row.c for row in res }
diff --git a/src/backend/asterisk.py b/src/backend/asterisk.py
new file mode 100644 (file)
index 0000000..f182776
--- /dev/null
@@ -0,0 +1,363 @@
+#!/usr/bin/python3
+
+import asyncio
+import datetime
+import logging
+import panoramisk
+import urllib.parse
+
+from . import accounts
+from . import misc
+from .decorators import *
+
+# Make this less verbose
+logging.getLogger("panoramisk").setLevel(logging.INFO)
+
+class Asterisk(misc.Object):
+       def init(self):
+               self.__manager = None
+
+               loop = asyncio.get_event_loop()
+
+               # Connect as soon as the event loop starts
+               loop.create_task(self.connect())
+
+       @property
+       def manager(self):
+               if not self.__manager:
+                       raise RuntimeError("Asterisk is not connected")
+
+               return self.__manager
+
+       async def connect(self):
+               """
+                       Connects to Asterisk
+               """
+               manager = panoramisk.Manager(
+                       host     = self.settings.get("asterisk-ami-host"),
+                       username = self.settings.get("asterisk-ami-username"),
+                       secret   = self.settings.get("asterisk-ami-secret"),
+
+                       on_connect=self._on_connect,
+               )
+
+               # Connect
+               await manager.connect()
+
+               return manager
+
+       def _on_connect(self, manager):
+               logging.debug("Connection to Asterisk established")
+
+               # Close any existing connections
+               if self.__manager:
+                       self.__manager.close()
+
+               self.__manager = manager
+
+       async def _fetch(self, cls, action, filter=None, data={}):
+               objects = []
+
+               # Collect arguments
+               args = { "Action" : action } | data
+
+               # Run the action and parse all messages
+               for data in await self.manager.send_action(args):
+                       if not "Event" in data or not data.Event == cls.event:
+                               continue
+
+                       # Create the object and append it to the list
+                       o = cls(self.backend, data)
+
+                       # Filter out anything unwanted
+                       if filter and not o.matches(filter):
+                               continue
+
+                       objects.append(o)
+
+               return objects
+
+       async def get_sip_channels(self, filter=None):
+               return await self._fetch(Channel, "CoreShowChannels", filter=filter)
+
+       async def get_registrations(self, filter=None):
+               return await self._fetch(Registration, "PJSIPShowContacts", filter=filter)
+
+       async def get_outbound_registrations(self):
+               return await self._fetch(OutboundRegistration, "PJSIPShowRegistrationsOutbound")
+
+       async def get_queues(self):
+               # Fetch all queues
+               queues = { q.name : q for q in await self._fetch(Queue, "QueueSummary") }
+
+               # Fetch all members
+               for member in await self._fetch(QueueMember, "QueueStatus"):
+                       # Append to the matching queue
+                       try:
+                               queues[member.queue].members.append(member)
+                       except KeyError:
+                               pass
+
+               return queues.values()
+
+       async def get_conferences(self):
+               conferences = await self._fetch(Conference, "ConfbridgeListRooms")
+
+               # Fetch everything else
+               async with asyncio.TaskGroup() as tasks:
+                       for c in conferences:
+                               tasks.create_task(c._fetch())
+
+               return conferences
+
+
+class Channel(misc.Object):
+       event = "CoreShowChannel"
+
+       def init(self, data):
+               self.data = data
+
+       def __str__(self):
+               return self.connected_line
+
+       @property
+       def account_code(self):
+               return self.data.AccountCode
+
+       @property
+       def connected_line(self):
+               return self.data.ConnectedLineName or self.data.ConnectedLineNum
+
+       def matches(self, filter):
+               return filter in (
+                       self.data.CallerIDNum,
+               )
+
+       @property
+       def duration(self):
+               h, m, s = self.data.Duration.split(":")
+
+               try:
+                       h, m, s = int(h), int(m), int(s)
+               except TypeError:
+                       return 0
+
+               return datetime.timedelta(hours=h, minutes=m, seconds=s)
+
+       def is_connected(self):
+               return self.data.ChannelStateDesc == "Up"
+
+       def is_ringing(self):
+               return self.data.ChannelStateDesc == "Ringing"
+
+
+class Registration(misc.Object):
+       event = "ContactList"
+
+       def init(self, data):
+               self.data = data
+
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       if isinstance(self.user, accounts.Account):
+                               if isinstance(other.user, accounts.Account):
+                                       return self.user < other.user
+                               else:
+                                       return self.user.name < other.user
+                       else:
+                               if isinstance(other.user, accounts.Account):
+                                       return self.user < other.user.name
+                               else:
+                                       return self.user < other.user
+
+               return NotImplemented
+
+       def __str__(self):
+               return self.user_agent
+
+       def matches(self, filter):
+               return self.data.Endpoint == filter
+
+       @lazy_property
+       def uri(self):
+               return urllib.parse.urlparse(self.data.Uri)
+
+       @lazy_property
+       def uri_params(self):
+               params = {}
+
+               for param in self.uri.params.split(";"):
+                       key, _, value = param.partition("=")
+
+                       params[key] = value
+
+               return params
+
+       @property
+       def transport(self):
+               return self.uri_params.get("transport")
+
+       @lazy_property
+       def user(self):
+               return self.backend.accounts.get_by_sip_id(self.data.Endpoint) or self.data.Endpoint
+
+       @property
+       def address(self):
+               # Remove the user
+               user, _, address = self.uri.path.partition("@")
+
+               # Remove the port
+               address, _, port = address.rpartition(":")
+
+               return address
+
+       @property
+       def user_agent(self):
+               return self.data.UserAgent.replace("_", " ")
+
+       @property
+       def roundtrip(self):
+               try:
+                       return int(self.data.RoundtripUsec) / 1000
+               except ValueError:
+                       pass
+
+
+class OutboundRegistration(misc.Object):
+       event = "OutboundRegistrationDetail"
+
+       def init(self, data):
+               self.data = data
+
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       return self.server < other.server or self.username < other.username
+
+               return NotImplemented
+
+       @lazy_property
+       def uri(self):
+               return urllib.parse.urlparse(self.data.ClientUri)
+
+       @property
+       def server(self):
+               username, _, server = self.uri.path.partition("@")
+
+               return server
+
+       @property
+       def username(self):
+               username, _, server = self.uri.path.partition("@")
+
+               return username
+
+       @property
+       def status(self):
+               return self.data.Status
+
+
+class Queue(misc.Object):
+       event = "QueueSummary"
+
+       def init(self, data):
+               self.data = data
+
+               self.members = []
+
+       def __str__(self):
+               return self.name
+
+       @property
+       def name(self):
+               return self.data.Queue
+
+       def is_available(self):
+               return self.data.Available == "1"
+
+       @property
+       def callers(self):
+               return int(self.data.Callers)
+
+
+class QueueMember(misc.Object):
+       event = "QueueMember"
+
+       def init(self, data):
+               self.data = data
+
+       def __str__(self):
+               return self.name
+
+       @property
+       def name(self):
+               return self.data.Name
+
+       @property
+       def queue(self):
+               return self.data.Queue
+
+       @property
+       def calls_taken(self):
+               return int(self.data.CallsTaken)
+
+       def is_in_call(self):
+               return self.data.InCall == "1"
+
+       @property
+       def last_call_at(self):
+               return datetime.datetime.fromtimestamp(int(self.data.LastCall))
+
+       @property
+       def logged_in_at(self):
+               return datetime.datetime.fromtimestamp(int(self.data.LoginTime))
+
+       # XXX status?
+
+
+class Conference(misc.Object):
+       event = "ConfbridgeListRooms"
+
+       def init(self, data):
+               self.data = data
+
+       def __str__(self):
+               return self.name
+
+       @property
+       def name(self):
+               return self.data.Conference
+
+       async def _fetch(self):
+               # Fetch all members
+               self.members = await self.backend.asterisk._fetch(
+                       ConferenceMember, "ConfbridgeList", data={ "Conference" : self.name, })
+
+
+class ConferenceMember(misc.Object):
+       event = "ConfbridgeList"
+
+       def init(self, data):
+               self.data = data
+
+       def __str__(self):
+               return self.name
+
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       return not self.duration < other.duration
+
+               return NotImplemented
+
+       @property
+       def name(self):
+               return "%s <%s>" % (self.data.CallerIDName, self.data.CallerIDNum)
+
+       def is_admin(self):
+               return self.data.Admin == "Yes"
+
+       def is_muted(self):
+               return self.data.Muted == "Yes"
+
+       @property
+       def duration(self):
+               return datetime.timedelta(seconds=int(self.data.AnsweredTime))
index 00497a298cfa6ce8c3e2d3909ff7e366415f59ad..a3daa358c0ab4eb86489c48d72f74ed41faf9043 100644 (file)
@@ -3,17 +3,23 @@
 import configparser
 import io
 import location
+import logging
 import ssl
 import tempfile
 import tornado.httpclient
 
 from . import accounts
+from . import asterisk
+from . import analytics
 from . import blog
+from . import bugzilla
+from . import cache
 from . import campaigns
 from . import database
 from . import fireinfo
+from . import httpclient
 from . import iuse
-from . import memcached
+from . import lists
 from . import messages
 from . import mirrors
 from . import netboot
@@ -22,8 +28,7 @@ from . import ratelimit
 from . import releases
 from . import resolver
 from . import settings
-from . import talk
-from . import tweets
+from . import toots
 from . import util
 from . import wiki
 from . import zeiterfassung
@@ -32,13 +37,19 @@ from .decorators import *
 DEFAULT_CONFIG = io.StringIO("""
 [global]
 debug = false
+environment = testing
 
 data_dir      =
 static_dir    = %(data_dir)s/static
 templates_dir = %(data_dir)s/templates
 """)
 
+# Setup logging
+log = logging.getLogger(__name__)
+
 class Backend(object):
+       version = 0
+
        def __init__(self, configfile, debug=False):
                # Read configuration file.
                self.config = self.read_config(configfile)
@@ -50,24 +61,24 @@ class Backend(object):
                self.setup_database()
 
                # Create HTTPClient
-               self.http_client = tornado.httpclient.AsyncHTTPClient(
-                       defaults = {
-                               "User-Agent" : "IPFireWebApp",
-                       }
-               )
-               # Initialize settings first.
+               self.http_client = httpclient.HTTPClient(self)
+
+               # Initialize the cache
+               self.cache = cache.Cache(self)
+
+               # Initialize settings first
                self.settings = settings.Settings(self)
-               self.memcache = memcached.Memcached(self)
 
                # Initialize backend modules.
                self.accounts = accounts.Accounts(self)
+               self.analytics = analytics.Analytics(self)
+               self.bugzilla = bugzilla.Bugzilla(self)
                self.fireinfo = fireinfo.Fireinfo(self)
                self.iuse = iuse.IUse(self)
                self.mirrors = mirrors.Mirrors(self)
                self.netboot = netboot.NetBoot(self)
                self.nopaste = nopaste.Nopaste(self)
                self.releases = releases.Releases(self)
-               self.talk = talk.Talk(self)
 
                self.blog = blog.Blog(self)
                self.wiki = wiki.Wiki(self)
@@ -84,6 +95,13 @@ class Backend(object):
 
                return cp
 
+       @property
+       def environment(self):
+               """
+                       Returns whether this is running in "production" or "testing"
+               """
+               return self.config.get("global", "environment")
+
        def setup_database(self):
                """
                        Sets up the database connection.
@@ -95,7 +113,7 @@ class Backend(object):
                        "password" : self.config.get("database", "password"),
                }
 
-               self.db = database.Connection(**credentials)
+               self.db = database.Connection(self, **credentials)
 
        @lazy_property
        def ssl_context(self):
@@ -132,9 +150,9 @@ class Backend(object):
 
        async def run_task(self, task, *args, **kwargs):
                tasks = {
+                       "accounts:delete"     : self.accounts._delete,
                        "announce-blog-posts" : self.blog.announce,
                        "check-mirrors"       : self.mirrors.check_all,
-                       "check-spam"          : self.accounts.check_spam,
                        "cleanup"             : self.cleanup,
                        "get-all-emails"      : self.accounts.get_all_emails,
                        "launch-campaigns"    : self.campaigns.launch_manually,
@@ -144,7 +162,7 @@ class Backend(object):
                        "send-message"        : self.messages.send_cli,
                        "send-all-messages"   : self.messages.queue.send_all,
                        "test-ldap"           : self.accounts.test_ldap,
-                       "tweet"               : self.tweets.tweet,
+                       "toot"                : self.toots.toot,
                        "update-blog-feeds"   : self.blog.update_feeds,
                }
 
@@ -153,6 +171,11 @@ class Backend(object):
                if not func:
                        raise ValueError("Unknown task: %s" % task)
 
+               # Check if we are running in production
+               if not self.environment == "production":
+                       log.warning("Refusing to run task '%s' in '%s' environment" % (task, self.environment))
+                       return
+
                # Run the task
                r = await func(*args, **kwargs)
 
@@ -161,6 +184,10 @@ class Backend(object):
                if r:
                        raise SystemExit(r)
 
+       @lazy_property
+       def asterisk(self):
+               return asterisk.Asterisk(self)
+
        @lazy_property
        def campaigns(self):
                return campaigns.Campaigns(self)
@@ -169,6 +196,10 @@ class Backend(object):
        def groups(self):
                return accounts.Groups(self)
 
+       @lazy_property
+       def lists(self):
+               return lists.Lists(self)
+
        @lazy_property
        def messages(self):
                return messages.Messages(self)
@@ -178,11 +209,18 @@ class Backend(object):
                return location.Database("/var/lib/location/database.db")
 
        def get_country_name(self, country_code):
-               country = self.location.get_country(country_code)
+               try:
+                       country = self.location.get_country(country_code)
+
+               # In case the country code was invalid, we return it again
+               except ValueError:
+                       return country_code
 
                if country:
                        return country.name
 
+               return country_code
+
        @lazy_property
        def ratelimiter(self):
                return ratelimit.RateLimiter(self)
@@ -192,8 +230,8 @@ class Backend(object):
                return resolver.Resolver(tries=2, timeout=2, domains=[])
 
        @lazy_property
-       def tweets(self):
-               return tweets.Tweets(self)
+       def toots(self):
+               return toots.Toots(self)
 
        async def cleanup(self):
                # Cleanup message queue
@@ -203,3 +241,7 @@ class Backend(object):
                # Cleanup in accounts
                with self.db.transaction():
                        self.accounts.cleanup()
+
+               # Cleanup nopasts
+               with self.db.transaction():
+                       self.nopaste.cleanup()
index b92b02767e3a392c67ee22de36fb9ed9b846d564..ff1ff91e32591888bf7ca85663503bb9bc8880b1 100644 (file)
@@ -4,13 +4,12 @@ import datetime
 import feedparser
 import html2text
 import markdown
-import markdown.extensions
-import markdown.preprocessors
 import re
 import textile
 import unicodedata
 
 from . import misc
+from . import wiki
 from .decorators import *
 
 class Blog(misc.Object):
@@ -30,20 +29,18 @@ class Blog(misc.Object):
                return self._get_post("SELECT * FROM blog \
                        WHERE id = %s", id)
 
-       def get_by_slug(self, slug, published=True):
-               if published:
-                       return self._get_post("SELECT * FROM blog \
-                               WHERE slug = %s AND published_at <= NOW()", slug)
-
+       def get_by_slug(self, slug):
                return self._get_post("SELECT * FROM blog \
                        WHERE slug = %s", slug)
 
        def get_newest(self, limit=None):
-               return self._get_posts("SELECT * FROM blog \
+               posts = self._get_posts("SELECT * FROM blog \
                        WHERE published_at IS NOT NULL \
                                AND published_at <= NOW() \
                        ORDER BY published_at DESC LIMIT %s", limit)
 
+               return list(posts)
+
        def get_by_tag(self, tag, limit=None):
                return self._get_posts("SELECT * FROM blog \
                        WHERE published_at IS NOT NULL \
@@ -51,14 +48,6 @@ class Blog(misc.Object):
                                AND %s = ANY(tags) \
                        ORDER BY published_at DESC LIMIT %s", tag, limit)
 
-       def get_by_author(self, author, limit=None):
-               return self._get_posts("SELECT * FROM blog \
-                       WHERE (author = %s OR author_uid = %s) \
-                               AND published_at IS NOT NULL \
-                               AND published_at <= NOW() \
-                       ORDER BY published_at DESC LIMIT %s",
-                       author.name, author.uid, limit)
-
        def get_by_year(self, year):
                return self._get_posts("SELECT * FROM blog \
                        WHERE EXTRACT(year FROM published_at) = %s \
@@ -66,25 +55,22 @@ class Blog(misc.Object):
                                AND published_at <= NOW() \
                        ORDER BY published_at DESC", year)
 
-       def get_drafts(self, author=None, limit=None):
-               if author:
-                       return self._get_posts("SELECT * FROM blog \
-                               WHERE author_uid = %s \
-                                       AND (published_at IS NULL OR published_at > NOW()) \
-                               ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
-                               author.uid, limit)
-
+       def get_drafts(self, author, limit=None):
                return self._get_posts("SELECT * FROM blog \
-                       WHERE (published_at IS NULL OR published_at > NOW()) \
-                       ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s", limit)
+                       WHERE author_uid = %s \
+                               AND (published_at IS NULL OR published_at > NOW()) \
+                       ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
+                       author.uid, limit)
 
        def search(self, query, limit=None):
-               return self._get_posts("SELECT blog.* FROM blog \
+               posts = self._get_posts("SELECT blog.* FROM blog \
                        LEFT JOIN blog_search_index search_index ON blog.id = search_index.post_id \
                        WHERE search_index.document @@ websearch_to_tsquery('english', %s) \
                                ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC \
                        LIMIT %s", query, query, limit)
 
+               return list(posts)
+
        def has_had_recent_activity(self, **kwargs):
                t = datetime.timedelta(**kwargs)
 
@@ -132,7 +118,7 @@ class Blog(misc.Object):
                if lang == "markdown":
                        return markdown.markdown(text,
                                extensions=[
-                                       PrettyLinksExtension(),
+                                       wiki.PrettyLinksExtension(),
                                        "codehilite",
                                        "fenced_code",
                                        "footnotes",
@@ -145,14 +131,15 @@ class Blog(misc.Object):
                elif lang == "textile":
                        return textile.textile(text)
 
-               return text
+               else:
+                       return text
 
        def refresh(self):
                """
                        Needs to be called after a post has been changed
                        and updates the search index.
                """
-               self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
+               self.db.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY blog_search_index")
 
        @property
        def years(self):
@@ -351,9 +338,21 @@ class Post(misc.Object):
                return self.backend.releases._get_release("SELECT * FROM releases \
                        WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
 
-       def is_editable(self, editor):
+       def is_editable(self, user):
+               # Anonymous users cannot do anything
+               if not user:
+                       return False
+
+               # Admins can edit anything
+               if user.is_admin():
+                       return True
+
+               # User must have permission for the blog
+               if not user.is_blog_author():
+                       return False
+
                # Authors can edit their own posts
-               return self.author == editor
+               return self.author == user
 
        def update(self, title, text, tags=[]):
                """
@@ -404,25 +403,3 @@ class Post(misc.Object):
                        # Mark this post as announced
                        self.db.execute("UPDATE blog SET announced_at = CURRENT_TIMESTAMP \
                                WHERE id = %s", self.id)
-
-
-class PrettyLinksExtension(markdown.extensions.Extension):
-       def extendMarkdown(self, md):
-               md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10)
-               md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10)
-
-
-class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor):
-       regex = re.compile(r"(?:#(\d{5,}))", re.I)
-
-       def run(self, lines):
-               for line in lines:
-                       yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line)
-
-
-class CVELinksPreprocessor(markdown.preprocessors.Preprocessor):
-       regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)")
-
-       def run(self, lines):
-               for line in lines:
-                       yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line)
diff --git a/src/backend/bugzilla.py b/src/backend/bugzilla.py
new file mode 100644 (file)
index 0000000..3e494ef
--- /dev/null
@@ -0,0 +1,135 @@
+#!/usr/bin/python3
+
+import json
+import urllib.parse
+
+from . import httpclient
+from . import misc
+from .decorators import *
+
+class BugzillaError(Exception):
+       pass
+
+class Bugzilla(misc.Object):
+       def init(self, api_key=None):
+               if api_key is None:
+                       api_key = self.settings.get("bugzilla-api-key")
+
+               # Store the API key
+               self.api_key = api_key
+
+       @property
+       def url(self):
+               """
+                       Returns the base URL of a Bugzilla instance
+               """
+               return self.settings.get("bugzilla-url")
+
+       def make_url(self, *args, **kwargs):
+               """
+                       Composes a URL based on the base URL
+               """
+               url = urllib.parse.urljoin(self.url, *args)
+
+               # Append any query arguments
+               if kwargs:
+                       url = "%s?%s" % (url, urllib.parse.urlencode(kwargs))
+
+               return url
+
+       async def _request(self, method, url, data=None):
+               if data is None:
+                       data = {}
+
+               # Headers
+               headers = {
+                       # Authenticate all requests
+                       "X-BUGZILLA-API-KEY" : self.api_key,
+               }
+
+               # Make the URL
+               url = self.make_url(url)
+
+               # Fallback authentication because some API endpoints
+               # do not accept the API key in the header
+               data |= { "api_key" : self.api_key }
+
+               # Encode body
+               body = None
+
+               # For GET requests, append query arguments
+               if method == "GET":
+                       if data:
+                               url = "%s?%s" % (url, urllib.parse.urlencode(data))
+
+               # For POST/PUT encode all arguments as JSON
+               elif method in ("POST", "PUT"):
+                       headers |= {
+                               "Content-Type" : "application/json",
+                       }
+
+                       body = json.dumps(data)
+
+               # Send the request and wait for a response
+               res = await self.backend.http_client.fetch(
+                       url, method=method, headers=headers, body=body)
+
+               # Decode JSON response
+               body = json.loads(res.body)
+
+               # Check for any errors
+               if "error" in body:
+                       # Fetch code and message
+                       code, message = body.get("code"), body.get("message")
+
+                       # Handle any so far unhandled errors
+                       raise BugzillaError(message)
+
+               # Return an empty response
+               return body
+
+       async def get_user(self, uid):
+               """
+                       Fetches a user from Bugzilla
+               """
+               try:
+                       response = await self._request("GET", "/rest/user/%s" % uid)
+
+               # Return nothing if the user could not be found
+               except httpclient.HTTPError as e:
+                       if e.code == 404:
+                               return
+
+                       raise e
+
+               # Return the user object
+               for data in response.get("users"):
+                       return User(self.backend, data)
+
+
+
+class User(misc.Object):
+       def init(self, data):
+               self.data = data
+
+       @property
+       def id(self):
+               return self.data.get("id")
+
+       async def _update(self, **kwargs):
+               # Send the request
+               await self.backend.bugzilla._request("PUT", "/rest/user/%s" % self.id, **kwargs)
+
+               # XXX apply changes to the User object?
+
+       async def disable(self, text=None):
+               """
+                       Disables this user
+               """
+               if not text:
+                       text = "DISABLED"
+
+               # Update the user
+               await self._update(data={
+                       "login_denied_text" : text,
+               })
diff --git a/src/backend/cache.py b/src/backend/cache.py
new file mode 100644 (file)
index 0000000..54bb527
--- /dev/null
@@ -0,0 +1,114 @@
+#!/usr/bin/python3
+
+import asyncio
+import logging
+import redis.asyncio
+
+from .decorators import *
+
+# Setup logging
+log = logging.getLogger()
+
+class Cache(object):
+       def __init__(self, backend):
+               self.backend = backend
+
+               # Stores connections assigned to tasks
+               self.__connections = {}
+
+               # Create a connection pool
+               self.pool = redis.asyncio.connection.ConnectionPool.from_url(
+                       "redis://localhost:6379/0",
+               )
+
+       async def connection(self, *args, **kwargs):
+               """
+                       Returns a connection from the pool
+               """
+               # Fetch the current task
+               task = asyncio.current_task()
+
+               assert task, "Could not determine task"
+
+               # Try returning the same connection to the same task
+               try:
+                       return self.__connections[task]
+               except KeyError:
+                       pass
+
+               # Fetch a new connection from the pool
+               conn = await redis.asyncio.Redis(
+                       connection_pool=self.pool,
+                       single_connection_client=True,
+               )
+
+               # Store the connection
+               self.__connections[task] = conn
+
+               log.debug("Assigning cache connection %s to %s" % (conn, task))
+
+               # When the task finishes, release the connection
+               task.add_done_callback(self.__release_connection)
+
+               return conn
+
+       def __release_connection(self, task):
+               loop = asyncio.get_running_loop()
+
+               # Retrieve the connection
+               try:
+                       conn = self.__connections[task]
+               except KeyError:
+                       return
+
+               log.debug("Releasing cache connection %s of %s" % (conn, task))
+
+               # Delete it
+               del self.__connections[task]
+
+               # Return the connection back into the pool
+               asyncio.run_coroutine_threadsafe(conn.close(), loop)
+
+       async def _run(self, command, *args, **kwargs):
+               # Fetch our connection
+               conn = await self.connection()
+
+               # Get the function
+               func = getattr(conn, command)
+
+               # Call the function
+               return await func(*args, **kwargs)
+
+       async def get(self, *args, **kwargs):
+               """
+                       Fetches the value of a cached key
+               """
+               return await self._run("get", *args, **kwargs)
+
+       async def set(self, *args, **kwargs):
+               """
+                       Puts something into the cache
+               """
+               return await self._run("set", *args, **kwargs)
+
+       async def delete(self, *args, **kwargs):
+               """
+                       Deletes the key from the cache
+               """
+               return await self._run("delete", *args, **kwargs)
+
+       async def transaction(self, *args, **kwargs):
+               """
+                       Returns a new transaction
+               """
+               conn = await self.connection()
+
+               return await conn.transaction(*args, **kwargs)
+
+       async def pipeline(self, *args, **kwargs):
+               """
+                       Returns a new pipeline
+               """
+               conn = await self.connection()
+
+               return conn.pipeline(*args, **kwargs)
index f79cf1283c68dd994aa2fe1fdc5cf9eaf1852b98..d45031caba4e432a966dc19c6faf31f60d07b37f 100644 (file)
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/python
 
 """
        A lightweight wrapper around psycopg2.
@@ -8,8 +8,17 @@
        as torndb.
 """
 
+import asyncio
+import itertools
 import logging
-import psycopg2
+import psycopg
+import psycopg_pool
+import time
+
+from . import misc
+
+# Setup logging
+log = logging.getLogger()
 
 class Connection(object):
        """
@@ -28,57 +37,111 @@ class Connection(object):
                We explicitly set the timezone to UTC and the character encoding to
                UTF-8 on all connections to avoid time zone and encoding errors.
        """
-       def __init__(self, host, database, user=None, password=None):
-               self.host = host
-               self.database = database
-
-               self._db = None
-               self._db_args = {
-                       "host"     : host,
-                       "database" : database,
-                       "user"     : user,
-                       "password" : password,
-               }
+       def __init__(self, backend, host, database, user=None, password=None):
+               self.backend = backend
 
-               try:
-                       self.reconnect()
-               except Exception:
-                       logging.error("Cannot connect to database on %s", self.host, exc_info=True)
+               # Stores connections assigned to tasks
+               self.__connections = {}
+
+               # Create a connection pool
+               self.pool = psycopg_pool.ConnectionPool(
+                       "postgresql://%s:%s@%s/%s" % (user, password, host, database),
+
+                       # Callback to configure any new connections
+                       configure=self.__configure,
+
+                       # Set limits for min/max connections in the pool
+                       min_size=8,
+                       max_size=512,
+
+                       # Give clients up to one minute to retrieve a connection
+                       timeout=60,
 
-       def __del__(self):
-               self.close()
+                       # Close connections after they have been idle for a few seconds
+                       max_idle=5,
+               )
 
-       def close(self):
+       def __configure(self, conn):
                """
-                       Closes this database connection.
+                       Configures any newly opened connections
                """
-               if getattr(self, "_db", None) is not None:
-                       self._db.close()
-                       self._db = None
+               # Enable autocommit
+               conn.autocommit = True
 
-       def reconnect(self):
+               # Return any rows as dicts
+               conn.row_factory = psycopg.rows.dict_row
+
+               # Automatically convert DataObjects
+               conn.adapters.register_dumper(misc.Object, misc.ObjectDumper)
+
+       def connection(self, *args, **kwargs):
                """
-                       Closes the existing database connection and re-opens it.
+                       Returns a connection from the pool
                """
-               self.close()
+               # Fetch the current task
+               task = asyncio.current_task()
+
+               assert task, "Could not determine task"
+
+               # Try returning the same connection to the same task
+               try:
+                       return self.__connections[task]
+               except KeyError:
+                       pass
+
+               # Fetch a new connection from the pool
+               conn = self.__connections[task] = self.pool.getconn(*args, **kwargs)
+
+               log.debug("Assigning database connection %s to %s" % (conn, task))
 
-               self._db = psycopg2.connect(**self._db_args)
-               self._db.autocommit = True
+               # When the task finishes, release the connection
+               task.add_done_callback(self.__release_connection)
 
-               # Initialize the timezone setting.
-               self.execute("SET TIMEZONE TO 'UTC'")
+               return conn
+
+       def __release_connection(self, task):
+               # Retrieve the connection
+               try:
+                       conn = self.__connections[task]
+               except KeyError:
+                       return
+
+               log.debug("Releasing database connection %s of %s" % (conn, task))
+
+               # Delete it
+               del self.__connections[task]
+
+               # Return the connection back into the pool
+               self.pool.putconn(conn)
+
+       def _execute(self, cursor, execute, query, parameters):
+               # Store the time we started this query
+               t = time.monotonic()
+
+               try:
+                       log.debug("Running SQL query %s" % (query % parameters))
+               except Exception:
+                       pass
+
+               # Execute the query
+               execute(query, parameters)
+
+               # How long did this take?
+               elapsed = time.monotonic() - t
+
+               # Log the query time
+               log.debug("  Query time: %.2fms" % (elapsed * 1000))
 
        def query(self, query, *parameters, **kwparameters):
                """
                        Returns a row list for the given query and parameters.
                """
-               cursor = self._cursor()
-               try:
-                       self._execute(cursor, query, parameters, kwparameters)
-                       column_names = [d[0] for d in cursor.description]
-                       return [Row(zip(column_names, row)) for row in cursor]
-               finally:
-                       cursor.close()
+               conn = self.connection()
+
+               with conn.cursor() as cursor:
+                       self._execute(cursor, cursor.execute, query, parameters or kwparameters)
+
+                       return [Row(row) for row in cursor]
 
        def get(self, query, *parameters, **kwparameters):
                """
@@ -94,89 +157,48 @@ class Connection(object):
 
        def execute(self, query, *parameters, **kwparameters):
                """
-                       Executes the given query, returning the lastrowid from the query.
+                       Executes the given query.
                """
-               return self.execute_lastrowid(query, *parameters, **kwparameters)
+               conn = self.connection()
 
-       def execute_lastrowid(self, query, *parameters, **kwparameters):
-               """
-                       Executes the given query, returning the lastrowid from the query.
-               """
-               cursor = self._cursor()
-               try:
-                       self._execute(cursor, query, parameters, kwparameters)
-                       return cursor.lastrowid
-               finally:
-                       cursor.close()
-
-       def execute_rowcount(self, query, *parameters, **kwparameters):
-               """
-                       Executes the given query, returning the rowcount from the query.
-               """
-               cursor = self._cursor()
-               try:
-                       self._execute(cursor, query, parameters, kwparameters)
-                       return cursor.rowcount
-               finally:
-                       cursor.close()
+               with conn.cursor() as cursor:
+                       self._execute(cursor, cursor.execute, query, parameters or kwparameters)
 
        def executemany(self, query, parameters):
                """
                        Executes the given query against all the given param sequences.
-
-                       We return the lastrowid from the query.
                """
-               return self.executemany_lastrowid(query, parameters)
+               conn = self.connection()
 
-       def executemany_lastrowid(self, query, parameters):
-               """
-                       Executes the given query against all the given param sequences.
+               with conn.cursor() as cursor:
+                       self._execute(cursor, cursor.executemany, query, parameters)
 
-                       We return the lastrowid from the query.
+       def transaction(self):
                """
-               cursor = self._cursor()
-               try:
-                       cursor.executemany(query, parameters)
-                       return cursor.lastrowid
-               finally:
-                       cursor.close()
-
-       def executemany_rowcount(self, query, parameters):
+                       Creates a new transaction on the current tasks' connection
                """
-                       Executes the given query against all the given param sequences.
+               conn = self.connection()
 
-                       We return the rowcount from the query.
-               """
-               cursor = self._cursor()
+               return conn.transaction()
 
-               try:
-                       cursor.executemany(query, parameters)
-                       return cursor.rowcount
-               finally:
-                       cursor.close()
-
-       def _ensure_connected(self):
-               if self._db is None:
-                       logging.warning("Database connection was lost...")
-
-                       self.reconnect()
-
-       def _cursor(self):
-               self._ensure_connected()
-               return self._db.cursor()
+       def fetch_one(self, cls, query, *args, **kwargs):
+               """
+                       Takes a class and a query and will return one object of that class
+               """
+               # Execute the query
+               res = self.get(query, *args)
 
-       def _execute(self, cursor, query, parameters, kwparameters):
-               logging.debug("SQL Query: %s" % (query % (kwparameters or parameters)))
+               # Return an object (if possible)
+               if res:
+                       return cls(self.backend, res.id, res, **kwargs)
 
-               try:
-                       return cursor.execute(query, kwparameters or parameters)
-               except (OperationalError, psycopg2.ProgrammingError):
-                       logging.error("Error connecting to database on %s", self.host)
-                       self.close()
-                       raise
+       def fetch_many(self, cls, query, *args, **kwargs):
+               # Execute the query
+               res = self.query(query, *args)
 
-       def transaction(self):
-               return Transaction(self)
+               # Return a generator with objects
+               for row in res:
+                       yield cls(self.backend, row.id, row, **kwargs)
 
 
 class Row(dict):
@@ -186,24 +208,3 @@ class Row(dict):
                        return self[name]
                except KeyError:
                        raise AttributeError(name)
-
-
-class Transaction(object):
-       def __init__(self, db):
-               self.db = db
-
-               self.db.execute("START TRANSACTION")
-
-       def __enter__(self):
-               return self
-
-       def __exit__(self, exctype, excvalue, traceback):
-               if exctype is not None:
-                       self.db.execute("ROLLBACK")
-               else:
-                       self.db.execute("COMMIT")
-
-
-# Alias some common exceptions
-IntegrityError = psycopg2.IntegrityError
-OperationalError = psycopg2.OperationalError
index d7f78456b9119f97523979168e3f510947069ac2..702739bd6dfe10eeeab7a98aeabc95a793dd05cd 100644 (file)
@@ -2,13 +2,15 @@
 
 import datetime
 import iso3166
+import json
+import jsonschema
 import logging
 import re
 
-from . import database
 from . import hwdata
 from . import util
 from .misc import Object
+from .decorators import *
 
 N_ = lambda x: x
 
@@ -80,31 +82,213 @@ CPU_STRINGS = (
        (r"Feroceon .*", r"ARM Feroceon"),
 )
 
-IGNORED_DEVICES = ["usb",]
-
-class ProfileDict(object):
-       def __init__(self, data):
-               self._data = data
+PROFILE_SCHEMA = {
+       "$schema"     : "https://json-schema.org/draft/2020-12/schema",
+       "$id"         : "https://fireinfo.ipfire.org/profile.schema.json",
+       "title"       : "Fireinfo Profile",
+       "description" : "Fireinfo Profile",
+       "type"        : "object",
 
+       # Properties
+       "properties" : {
+               # Processor
+               "cpu" : {
+                       "type" : "object",
+                       "properties" : {
+                               "arch" : {
+                                       "type"    : "string",
+                                       "pattern" : r"^[a-z0-9\_]{,8}$",
+                               },
+                               "count" : {
+                                       "type" : "integer",
+                               },
+                               "family" : {
+                                       "type" : ["integer", "null"],
+                               },
+                               "flags" : {
+                                       "type" : "array",
+                                       "items" : {
+                                               "type"    : "string",
+                                               "pattern" : r"^.{,24}$",
+                                       },
+                               },
+                               "model" : {
+                                       "type" : ["integer", "null"],
+                               },
+                               "model_string" : {
+                                       "type" : ["string", "null"],
+                                       "pattern" : r"^.{,80}$",
+                               },
+                               "speed" : {
+                                       "type" : "number",
+                               },
+                               "stepping" : {
+                                       "type" : ["integer", "null"],
+                               },
+                               "vendor" : {
+                                       "type"    : "string",
+                                       "pattern" : r"^.{,80}$",
+                               },
+                       },
+                       "additionalProperties" : False,
+                       "required" : [
+                               "arch",
+                               "count",
+                               "family",
+                               "flags",
+                               "model",
+                               "model_string",
+                               "speed",
+                               "stepping",
+                               "vendor",
+                       ],
+               },
 
-class ProfileNetwork(ProfileDict):
-       def __eq__(self, other):
-               if other is None:
-                       return False
+               # Devices
+               "devices" : {
+                       "type" : "array",
+                       "items" : {
+                               "type" : "object",
+                               "properties" : {
+                                       "deviceclass" : {
+                                               "type"    : ["string", "null"],
+                                               "pattern" : r"^.{,20}$",
+                                       },
+                                       "driver" : {
+                                               "type"    : ["string", "null"],
+                                               "pattern" : r"^.{,24}$",
+                                       },
+                                       "model" : {
+                                               "type"    : "string",
+                                               "pattern" : r"^[a-z0-9]{4}$",
+                                       },
+                                       "sub_model" : {
+                                               "type"    : ["string", "null"],
+                                               "pattern" : r"^[a-z0-9]{4}$",
+                                       },
+                                       "sub_vendor" : {
+                                               "type"    : ["string", "null"],
+                                               "pattern" : r"^[a-z0-9]{4}$",
+                                       },
+                                       "subsystem" : {
+                                               "type"    : "string",
+                                               "pattern" : r"^[a-z]{3}$",
+                                       },
+                                       "vendor" : {
+                                               "type"    : ["string", "null"],
+                                               "pattern" : r"^[a-z0-9]{4}$",
+                                       },
+                               },
+                               "additionalProperties" : False,
+                               "required" : [
+                                       "deviceclass",
+                                       "driver",
+                                       "model",
+                                       "subsystem",
+                                       "vendor",
+                               ],
+                       },
+               },
 
-               if not self.has_red == other.has_red:
-                       return False
+               # Network
+               "network" : {
+                       "type" : "object",
+                       "properties" : {
+                               "blue" : {
+                                       "type" : "boolean",
+                               },
+                               "green" : {
+                                       "type" : "boolean",
+                               },
+                               "orange" : {
+                                       "type" : "boolean",
+                               },
+                               "red" : {
+                                       "type" : "boolean",
+                               },
+                       },
+                       "additionalProperties" : False,
+               },
 
-               if not self.has_green == other.has_green:
-                       return False
+               # System
+               "system" : {
+                       "type" : "object",
+                       "properties" : {
+                               "kernel_release" : {
+                                       "type"    : "string",
+                                       "pattern" : r"^.{,40}$",
+                               },
+                               "language" : {
+                                       "type"    : "string",
+                                       "pattern" : r"^[a-z]{2}(\.utf8)?$",
+                               },
+                               "memory" : {
+                                       "type" : "integer",
+                               },
+                               "model" : {
+                                       "type"    : ["string", "null"],
+                                       "pattern" : r"^.{,80}$",
+                               },
+                               "release" : {
+                                       "type"    : "string",
+                                       "pattern" : r"^.{,80}$",
+                               },
+                               "root_size" : {
+                                       "type" : ["number", "null"],
+                               },
+                               "vendor" : {
+                                       "type"    : ["string", "null"],
+                                       "pattern" : r"^.{,80}$",
+                               },
+                               "virtual" : {
+                                       "type" : "boolean"
+                               },
+                       },
+                       "additionalProperties" : False,
+                       "required" : [
+                               "kernel_release",
+                               "language",
+                               "memory",
+                               "model",
+                               "release",
+                               "root_size",
+                               "vendor",
+                               "virtual",
+                       ],
+               },
 
-               if not self.has_orange == other.has_orange:
-                       return False
+               # Hypervisor
+               "hypervisor" : {
+                       "type" : "object",
+                       "properties" : {
+                               "vendor" : {
+                                       "type"    : "string",
+                                       "pattern" : r"^.{,40}$",
+                               },
+                       },
+                       "additionalProperties" : False,
+                       "required" : [
+                               "vendor",
+                       ],
+               },
 
-               if not self.has_blue == other.has_blue:
-                       return False
+               # Error - BogoMIPS
+               "bogomips" : {
+                       "type" : "number",
+               },
+       },
+       "additionalProperties" : False,
+       "required" : [
+               "cpu",
+               "devices",
+               "network",
+               "system",
+       ],
+}
 
-               return True
+class Network(Object):
+       def init(self, blob):
+               self.blob = blob
 
        def __iter__(self):
                ret = []
@@ -116,33 +300,28 @@ class ProfileNetwork(ProfileDict):
                return iter(ret)
 
        def has_zone(self, name):
-               return self._data.get("has_%s" % name)
+               return self.blob.get(name, False)
 
        @property
        def has_red(self):
-               return self._data.get("has_red", False)
+               return self.has_zone("red")
 
        @property
        def has_green(self):
-               return self._data.get("has_green", False)
+               return self.has_zone("green")
 
        @property
        def has_orange(self):
-               return self._data.get("has_orange", False)
+               return self.has_zone("orange")
 
        @property
        def has_blue(self):
-               return self._data.get("has_blue", False)
+               return self.has_zone("blue")
 
 
 class Processor(Object):
-       def __init__(self, backend, id, data=None, clock_speed=None, bogomips=None):
-               Object.__init__(self, backend)
-
-               self.id = id
-               self.__data = data
-               self.__clock_speed = clock_speed
-               self.__bogomips = bogomips
+       def init(self, blob):
+               self.blob = blob
 
        def __str__(self):
                s = []
@@ -159,42 +338,37 @@ class Processor(Object):
                return " ".join(s)
 
        @property
-       def data(self):
-               if self.__data is None:
-                       self.__data = self.db.get("SELECT * FROM fireinfo_processors \
-                               WHERE id = %s", self.id)
-
-               return self.__data
+       def arch(self):
+               return self.blob.get("arch")
 
        @property
        def vendor(self):
+               vendor = self.blob.get("vendor")
+
                try:
-                       return CPU_VENDORS[self.data.vendor]
+                       return CPU_VENDORS[vendor]
                except KeyError:
-                       return self.data.vendor
+                       return vendor
 
        @property
        def family(self):
-               return self.data.family
+               return self.blob.get("family")
 
        @property
        def model(self):
-               return self.data.model
+               return self.blob.get("model")
 
        @property
        def stepping(self):
-               return self.data.stepping
+               return self.blob.get("stepping")
 
        @property
        def model_string(self):
-               if self.data.model_string:
-                       s = self.data.model_string.split()
-
-                       return " ".join((e for e in s if e))
+               return self.blob.get("model_string")
 
        @property
        def flags(self):
-               return self.data.flags
+               return self.blob.get("flags")
 
        def has_flag(self, flag):
                return flag in self.flags
@@ -207,7 +381,7 @@ class Processor(Object):
 
        @property
        def core_count(self):
-               return self.data.core_count
+               return self.blob.get("count", 1)
 
        @property
        def count(self):
@@ -218,7 +392,7 @@ class Processor(Object):
 
        @property
        def clock_speed(self):
-               return self.__clock_speed
+               return self.blob.get("speed", 0)
 
        def format_clock_speed(self):
                if not self.clock_speed:
@@ -349,11 +523,8 @@ class Device(Object):
                }
        }
 
-       def __init__(self, backend, id, data=None):
-               Object.__init__(self, backend)
-
-               self.id = id
-               self.__data = data
+       def init(self, blob):
+               self.blob = blob
 
        def __repr__(self):
                return "<%s vendor=%s model=%s>" % (self.__class__.__name__,
@@ -361,7 +532,9 @@ class Device(Object):
 
        def __eq__(self, other):
                if isinstance(other, self.__class__):
-                       return self.id == other.id
+                       return self.blob == other.blob
+
+               return NotImplemented
 
        def __lt__(self, other):
                if isinstance(other, self.__class__):
@@ -371,53 +544,41 @@ class Device(Object):
                                self.model_string < other.model_string or \
                                self.model < other.model
 
-       @property
-       def data(self):
-               if self.__data is None:
-                       assert self.id
-
-                       self.__data = self.db.get("SELECT * FROM fireinfo_devices \
-                               WHERE id = %s", self.id)
-
-               return self.__data
+               return NotImplemented
 
        def is_showable(self):
-               if self.driver in IGNORED_DEVICES:
-                       return False
-
-               if self.driver in ("pcieport", "hub"):
+               if self.driver in ("usb", "pcieport", "hub"):
                        return False
 
                return True
 
        @property
        def subsystem(self):
-               return self.data.subsystem
+               return self.blob.get("subsystem")
 
        @property
        def model(self):
-               return self.data.model
+               return self.blob.get("model")
 
-       @property
+       @lazy_property
        def model_string(self):
-               return self.fireinfo.get_model_string(self.subsystem,
-                               self.vendor, self.model)
+               return self.fireinfo.get_model_string(self.subsystem, self.vendor, self.model)
 
        @property
        def vendor(self):
-               return self.data.vendor
+               return self.blob.get("vendor")
 
-       @property
+       @lazy_property
        def vendor_string(self):
                return self.fireinfo.get_vendor_string(self.subsystem, self.vendor)
 
        @property
        def driver(self):
-               return self.data.driver
+               return self.blob.get("driver")
 
-       @property
+       @lazy_property
        def cls(self):
-               classid = self.data.deviceclass
+               classid = self.blob.get("deviceclass")
 
                if self.subsystem == "pci":
                        classid = classid[:-4]
@@ -433,145 +594,87 @@ class Device(Object):
                except KeyError:
                        return "N/A"
 
-       @property
-       def percentage(self):
-               return self.data.get("percentage", None)
-
-
-class Profile(Object):
-       def __init__(self, backend, id, data=None):
-               Object.__init__(self, backend)
-
-               self.id = id
-               self.__data = data
-
-       def __repr__(self):
-               return "<%s %s>" % (self.__class__.__name__, self.public_id)
-
-       def __cmp__(self, other):
-               return cmp(self.id, other.id)
-
-       def is_showable(self):
-               if self.arch_id:
-                       return True
-
-               return False
 
-       @property
-       def data(self):
-               if self.__data is None:
-                       self.__data = self.db.get("SELECT * FROM fireinfo_profiles \
-                               WHERE id = %s", self.id)
-
-               return self.__data
+class System(Object):
+       def init(self, blob):
+               self.blob = blob
 
        @property
-       def public_id(self):
-               return self.data.public_id
+       def language(self):
+               return self.blob.get("language")
 
        @property
-       def private_id(self):
-               raise NotImplementedError
+       def vendor(self):
+               return self.blob.get("vendor")
 
        @property
-       def time_created(self):
-               return self.data.time_created
+       def model(self):
+               return self.blob.get("model")
 
        @property
-       def time_updated(self):
-               return self.data.time_updated
-
-       def updated(self, profile_parser=None, country_code=None, when=None):
-               valid = self.settings.get_int("fireinfo_profile_days_valid", 14)
-
-               self.db.execute("UPDATE fireinfo_profiles \
-                       SET \
-                               time_updated = then_or_now(%s), \
-                               time_valid = then_or_now(%s) + INTERVAL '%s days', \
-                               updates = updates + 1 \
-                       WHERE id = %s", when, when, valid, self.id)
-
-               if profile_parser:
-                       self.set_processor_speeds(
-                               profile_parser.processor_clock_speed,
-                               profile_parser.processor_bogomips,
-                       )
-
-               if country_code:
-                       self.set_country_code(country_code)
+       def release(self):
+               return self.blob.get("release")
 
-               self.log_profile_update()
+       # Memory
 
-       def log_profile_update(self):
-               # Log that an update was performed for this profile id
-               self.db.execute("INSERT INTO fireinfo_profiles_log(public_id) \
-                       VALUES(%s)", self.public_id)
+       @property
+       def memory(self):
+               return self.blob.get("memory") * 1024
 
-       def expired(self, when=None):
-               self.db.execute("UPDATE fireinfo_profiles \
-                       SET time_valid = then_or_now(%s) WHERE id = %s", when, self.id)
+       @property
+       def friendly_memory(self):
+               return util.format_size(self.memory or 0)
 
-       def parse(self, parser):
-               # Processor
-               self.processor = parser.processor
-               self.set_processor_speeds(parser.processor_clock_speed, parser.processor_bogomips)
+       @property
+       def storage(self):
+               return self.blob.get("storage_size", 0)
 
-               # All devices
-               self.devices = parser.devices
+       def is_virtual(self):
+               return self.blob.get("virtual", False)
 
-               # System
-               self.system_id = parser.system_id
 
-               # Memory
-               self.memory = parser.memory
+class Hypervisor(Object):
+       def init(self, blob):
+               self.blob = blob
 
-               # Storage
-               self.storage = parser.storage
+       def __str__(self):
+               return self.vendor
 
-               # Kernel
-               self.kernel_id = parser.kernel_id
+       @property
+       def vendor(self):
+               return self.blob.get("vendor")
 
-               # Arch
-               self.arch_id = parser.arch_id
 
-               # Release
-               self.release_id = parser.release_id
+class Profile(Object):
+       def init(self, profile_id, private_id, created_at, expired_at, version, blob,
+                       last_updated_at, country_code, **kwargs):
+               self.profile_id      = profile_id
+               self.private_id      = private_id
+               self.created_at      = created_at
+               self.expired_at      = expired_at
+               self.version         = version
+               self.blob            = blob
+               self.last_updated_at = last_updated_at
+               self.country_code    = country_code
 
-               # Language
-               self.language = parser.language
+       def __repr__(self):
+               return "<%s %s>" % (self.__class__.__name__, self.profile_id)
 
-               # Virtual
-               if parser.virtual:
-                       self.hypervisor_id = parser.hypervisor_id
+       def is_showable(self):
+               return True if self.blob else False
 
-               # Network
-               self.network = parser.network
+       @property
+       def public_id(self):
+               """
+                       An alias for the profile ID
+               """
+               return self.profile_id
 
        # Location
 
        @property
        def location(self):
-               if not hasattr(self, "_location"):
-                       res = self.db.get("SELECT location FROM fireinfo_profiles_locations \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._location = res.location
-                       else:
-                               self._location = None
-
-               return self._location
-
-       def set_country_code(self, country_code):
-               if self.location == country_code:
-                       return
-
-               self.db.execute("DELETE FROM fireinfo_profiles_locations \
-                       WHERE profile_id = %s", self.id)
-               self.db.execute("INSERT INTO fireinfo_profiles_locations(profile_id, location) \
-                       VALUES(%s, %s)", self.id, country_code)
-
-               self._location = country_code
+               return self.country_code
 
        @property
        def location_string(self):
@@ -579,1384 +682,629 @@ class Profile(Object):
 
        # Devices
 
-       @property
-       def device_ids(self):
-               if not hasattr(self, "_device_ids"):
-                       res = self.db.query("SELECT device_id FROM fireinfo_profiles_devices \
-                               WHERE profile_id = %s", self.id)
-
-                       self._device_ids = sorted([r.device_id for r in res])
-
-               return self._device_ids
-
-       def get_devices(self):
-               if not hasattr(self, "_devices"):
-                       res = self.db.query("SELECT * FROM fireinfo_devices \
-                               LEFT JOIN fireinfo_profiles_devices ON \
-                                       fireinfo_devices.id = fireinfo_profiles_devices.device_id \
-                               WHERE fireinfo_profiles_devices.profile_id = %s", self.id)
-
-                       self._devices = []
-                       for row in res:
-                               device = Device(self.backend, row.id, row)
-                               self._devices.append(device)
-
-               return self._devices
-
-       def set_devices(self, devices):
-               device_ids = [d.id for d in devices]
-
-               self.db.execute("DELETE FROM fireinfo_profiles_devices WHERE profile_id = %s", self.id)
-               self.db.executemany("INSERT INTO fireinfo_profiles_devices(profile_id, device_id) \
-                       VALUES(%s, %s)", ((self.id, d) for d in device_ids))
-
-               self._devices = devices
-               self._device_ids = device_ids
-
-       devices = property(get_devices, set_devices)
-
-       def count_device(self, subsystem, vendor, model):
-               counter = 0
-
-               for dev in self.devices:
-                       if dev.subsystem == subsystem and dev.vendor == vendor and dev.model == model:
-                               counter += 1
-
-               return counter
+       @lazy_property
+       def devices(self):
+               return [Device(self.backend, blob) for blob in self.blob.get("devices", [])]
 
        # System
 
-       def get_system_id(self):
-               if not hasattr(self, "_system_id"):
-                       res = self.db.get("SELECT system_id AS id FROM fireinfo_profiles_systems \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._system_id = res.id
-                       else:
-                               self._system_id = None
-
-               return self._system_id
-
-       def set_system_id(self, system_id):
-               self.db.execute("DELETE FROM fireinfo_profiles_systems WHERE profile_id = %s", self.id)
-
-               if system_id:
-                       self.db.execute("INSERT INTO fireinfo_profiles_systems(profile_id, system_id) \
-                               VALUES(%s, %s)", self.id, system_id)
-
-               self._system_id = None
-               if hasattr(self, "_system"):
-                       del self._system
-
-       system_id = property(get_system_id, set_system_id)
-
-       @property
+       @lazy_property
        def system(self):
-               if not hasattr(self, "_system"):
-                       res = self.db.get("SELECT fireinfo_systems.vendor AS vendor, fireinfo_systems.model AS model \
-                               FROM fireinfo_profiles_systems \
-                               LEFT JOIN fireinfo_systems ON fireinfo_profiles_systems.system_id = fireinfo_systems.id \
-                               WHERE fireinfo_profiles_systems.profile_id = %s", self.id)
-
-                       if res:
-                               self._system = (res.vendor, res.model)
-                       else:
-                               self._system = (None, None)
-
-               return self._system
-
-       @property
-       def system_vendor(self):
-               try:
-                       v, m = self.system
-                       return v
-               except TypeError:
-                       pass
-
-       @property
-       def system_model(self):
-               try:
-                       v, m = self.system
-                       return m
-               except TypeError:
-                       pass
-
-       @property
-       def appliance_id(self):
-               if not hasattr(self, "_appliance_id"):
-                       appliances = (
-                               ("fountainnetworks-duo-box", self._appliance_test_fountainnetworks_duo_box),
-                               ("fountainnetworks-prime", self._appliance_test_fountainnetworks_prime),
-                               ("lightningwirelabs-eco-plus", self._appliance_test_lightningwirelabs_eco_plus),
-                               ("lightningwirelabs-eco", self._appliance_test_lightningwirelabs_eco),
-                       )
-
-                       self._appliance_id = None
-                       for name, test_func in appliances:
-                               if not test_func():
-                                       continue
-
-                               self._appliance_id = name
-                               break
-
-               return self._appliance_id
-
-       @property
-       def appliance(self):
-               if self.appliance_id == "fountainnetworks-duo-box":
-                       return "Fountain Networks - IPFire Duo Box"
-
-               elif self.appliance_id == "fountainnetworks-prime":
-                       return "Fountain Networks - IPFire Prime Box"
-
-               elif self.appliance_id == "lightningwirelabs-eco-plus":
-                       return "Lightning Wire Labs - IPFire Eco Plus Appliance"
-
-               elif self.appliance_id == "lightningwirelabs-eco":
-                       return "Lightning Wire Labs - IPFire Eco Appliance"
-
-       def _appliance_test_fountainnetworks_duo_box(self):
-               if not self.processor.vendor == "Intel":
-                       return False
-
-               if not self.processor.model_string == "Intel(R) Celeron(R) 2957U @ 1.40GHz":
-                       return False
-
-               if not self.count_device("pci", "10ec", "8168") == 2:
-                       return False
-
-               # WiFi module
-               #if self.count_device("usb", "148f", "5572") < 1:
-               #       return False
-
-               return True
-
-       def _appliance_test_fountainnetworks_prime(self):
-               if not self.system in (("SECO", None), ("SECO", "0949")):
-                       return False
-
-               # Must have a wireless device
-               if self.count_device("usb", "148f", "5572") < 1:
-                       return False
-
-               return True
-
-       def _appliance_test_lightningwirelabs_eco(self):
-               if not self.system == ("MSI", "MS-9877"):
-                       return False
-
-               # Must have four Intel network adapters
-               network_adapters_count = self.count_device("pci", "8086", "10d3")
-               if not network_adapters_count == 4:
-                       return False
-
-               return True
-
-       def _appliance_test_lightningwirelabs_eco_plus(self):
-               if not self.system_vendor == "ASUS":
-                       return False
-
-               if not self.system_model.startswith("P9A-I/2550"):
-                       return False
-
-               # Must have four Intel network adapters
-               network_adapters_count = self.count_device("pci", "8086", "1f41")
-               if not network_adapters_count == 4:
-                       return False
-
-               return True
-
-       # Processors
-
-       @property
-       def processor_id(self):
-               if hasattr(self, "_processor"):
-                       return self._processor.id
-
-               if not hasattr(self, "_processor_id"):
-                       res = self.db.get("SELECT processor_id FROM fireinfo_profiles_processors \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._processor_id = res.processor_id
-                       else:
-                               self._processor_id = None
-
-               return self._processor_id
-
-       def get_processor(self):
-               if not self.processor_id:
-                       return
-
-               if not hasattr(self, "_processor"):
-                       res = self.db.get("SELECT * FROM fireinfo_profiles_processors \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._processor = self.fireinfo.get_processor_by_id(res.processor_id,
-                                       clock_speed=res.clock_speed, bogomips=res.bogomips)
-                       else:
-                               self._processor = None
-
-               return self._processor
-
-       def set_processor(self, processor):
-               self.db.execute("DELETE FROM fireinfo_profiles_processors \
-                       WHERE profile_id = %s", self.id)
-
-               if processor:
-                       self.db.execute("INSERT INTO fireinfo_profiles_processors(profile_id, processor_id) \
-                               VALUES(%s, %s)", self.id, processor.id)
-
-               self._processor = processor
-
-       processor = property(get_processor, set_processor)
-
-       def set_processor_speeds(self, clock_speed, bogomips):
-               self.db.execute("UPDATE fireinfo_profiles_processors \
-                       SET clock_speed = %s, bogomips = %s WHERE profile_id = %s",
-                       clock_speed, bogomips, self.id)
-
-       # Compat
-       @property
-       def cpu(self):
-               return self.processor
-
-       # Memory
-
-       def get_memory(self):
-               if not hasattr(self, "_memory"):
-                       res = self.db.get("SELECT amount FROM fireinfo_profiles_memory \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._memory = res.amount * 1024
-                       else:
-                               self._memory = None
-
-               return self._memory
-
-       def set_memory(self, amount):
-               if self.memory == amount:
-                       return
-
-               amount /= 1024
-
-               self.db.execute("DELETE FROM fireinfo_profiles_memory WHERE profile_id = %s", self.id)
-               if amount:
-                       self.db.execute("INSERT INTO fireinfo_profiles_memory(profile_id, amount) \
-                               VALUES(%s, %s)", self.id, amount)
-
-               self._memory = amount * 1024
-
-       memory = property(get_memory, set_memory)
-
-       @property
-       def friendly_memory(self):
-               return util.format_size(self.memory or 0)
-
-       # Storage
-
-       def get_storage(self):
-               if not hasattr(self, "_storage"):
-                       res = self.db.get("SELECT amount FROM fireinfo_profiles_storage \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._storage = res.amount * 1024
-                       else:
-                               self._storage = None
-
-               return self._storage
-
-       def set_storage(self, amount):
-               if self.storage == amount:
-                       return
-
-               amount /= 1024
-
-               self.db.execute("DELETE FROM fireinfo_profiles_storage WHERE profile_id = %s", self.id)
-               if amount:
-                       self.db.execute("INSERT INTO fireinfo_profiles_storage(profile_id, amount) \
-                               VALUES(%s, %s)", self.id, amount)
-
-               self._storage = amount * 1024
-
-       storage = property(get_storage, set_storage)
-
-       @property
-       def friendly_storage(self):
-               return util.format_size(self.storage)
-
-       # Kernel
-
-       def get_kernel_id(self):
-               if not hasattr(self, "_kernel_id"):
-                       res = self.db.get("SELECT fireinfo_profiles_kernels.kernel_id AS id FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_kernels ON fireinfo_profiles.id = fireinfo_profiles_kernels.profile_id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._kernel_id = res.id
-                       else:
-                               self._kernel_id = None
-
-               return self._kernel_id
-
-       def set_kernel_id(self, kernel_id):
-               if self.kernel_id == kernel_id:
-                       return
-
-               self.db.execute("DELETE FROM fireinfo_profiles_kernels WHERE profile_id = %s", self.id)
-               if kernel_id:
-                       self.db.execute("INSERT INTO fireinfo_profiles_kernels(profile_id, kernel_id) \
-                               VALUES(%s, %s)", self.id, kernel_id)
-
-               self._kernel_id = kernel_id
-               if hasattr(self, "_kernel"):
-                       del self._kernel
-
-       kernel_id = property(get_kernel_id, set_kernel_id)
-
-       @property
-       def kernel(self):
-               if not hasattr(self, "_kernel"):
-                       res = self.db.get("SELECT fireinfo_kernels.name AS name FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_kernels ON fireinfo_profiles.id = fireinfo_profiles_kernels.profile_id \
-                               LEFT JOIN fireinfo_kernels ON fireinfo_kernels.id = fireinfo_profiles_kernels.kernel_id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._kernel = res.name
-                       else:
-                               self._kernel = None
-
-               return self._kernel
-
-       # Arch
-
-       def get_arch_id(self):
-               if not hasattr(self, "_arch_id"):
-                       res = self.db.get("SELECT fireinfo_profiles_arches.arch_id AS id FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_arches ON fireinfo_profiles.id = fireinfo_profiles_arches.profile_id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
+               return System(self.backend, self.blob.get("system", {}))
 
-                       if res:
-                               self._arch_id = res.id
-                       else:
-                               self._arch_id = None
-
-               return self._arch_id
-
-       def set_arch_id(self, arch_id):
-               if self.arch_id == arch_id:
-                       return
-
-               self.db.execute("DELETE FROM fireinfo_profiles_arches WHERE profile_id = %s", self.id)
-               if arch_id:
-                       self.db.execute("INSERT INTO fireinfo_profiles_arches(profile_id, arch_id) \
-                               VALUES(%s, %s)", self.id, arch_id)
-
-               self._arch_id = None
-               if hasattr(self, "_arch"):
-                       del self._arch
-
-       arch_id = property(get_arch_id, set_arch_id)
+       # Processor
 
        @property
-       def arch(self):
-               if not hasattr(self, "_arch"):
-                       res = self.db.get("SELECT fireinfo_arches.name AS name FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_arches ON fireinfo_profiles.id = fireinfo_profiles_arches.profile_id \
-                               LEFT JOIN fireinfo_arches ON fireinfo_arches.id = fireinfo_profiles_arches.arch_id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._arch = res.name
-                       else:
-                               self._arch = None
-
-               return self._arch
-
-       # Release
-
-       def get_release_id(self):
-               if not hasattr(self, "_release_id"):
-                       res = self.db.get("SELECT fireinfo_profiles_releases.release_id AS id FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_releases ON fireinfo_profiles.id = fireinfo_profiles_releases.profile_id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._release_id = res.id
-                       else:
-                               self._release_id = None
-
-               return self._release_id
-
-       def set_release_id(self, release_id):
-               if self.release_id == release_id:
-                       return
-
-               self.db.execute("DELETE FROM fireinfo_profiles_releases WHERE profile_id = %s", self.id)
-               if release_id:
-                       self.db.execute("INSERT INTO fireinfo_profiles_releases(profile_id, release_id) \
-                               VALUES(%s, %s)", self.id, release_id)
-
-               self._release_id = release_id
-               if hasattr(self, "_release"):
-                       del self._release
-
-       release_id = property(get_release_id, set_release_id)
-
-       @property
-       def release(self):
-               if not hasattr(self, "_release"):
-                       res = self.db.get("SELECT fireinfo_releases.name AS name FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_releases ON fireinfo_profiles.id = fireinfo_profiles_releases.profile_id \
-                               LEFT JOIN fireinfo_releases ON fireinfo_profiles_releases.release_id = fireinfo_releases.id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._release = self._format_release(res.name)
-                       else:
-                               self._release = None
-
-               return self._release
-
-       @staticmethod
-       def _format_release(r):
-               if not r:
-                       return r
-
-               # Remove the development header
-               r = r.replace("Development Build: ", "")
-
-               pairs = (
-                       ("-beta", " - Beta "),
-                       ("-rc", " - Release Candidate "),
-                       ("core", "Core Update "),
-                       ("beta", "Beta "),
-               )
-
-               for k, v in pairs:
-                       r = r.replace(k, v)
-
-               return r
-
-       @property
-       def release_short(self):
-               pairs = (
-                       (r"Release Candidate (\d+)", r"RC\1"),
-               )
-
-               s = self.release
-               for pattern, repl in pairs:
-                       if re.search(pattern, s) is None:
-                               continue
-
-                       s = re.sub(pattern, repl, s)
-
-               return s
+       def processor(self):
+               return Processor(self.backend, self.blob.get("cpu", {}))
 
        # Virtual
 
-       @property
-       def virtual(self):
-               if not hasattr(self, "_virtual"):
-                       res = self.db.get("SELECT 1 FROM fireinfo_profiles_virtual \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._virtual = True
-                       else:
-                               self._virtual = False
-
-               return self._virtual
-
-       def get_hypervisor_id(self):
-               if not hasattr(self, "_hypervisor_id"):
-                       res = self.db.get("SELECT fireinfo_profiles_virtual.hypervisor_id AS id FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_virtual ON fireinfo_profiles.id = fireinfo_profiles_virtual.profile_id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._hypervisor_id = res.id
-                       else:
-                               self._hypervisor_id = None
-
-               return self._hypervisor_id
-
-       def set_hypervisor_id(self, hypervisor_id):
-               self.db.execute("DELETE FROM fireinfo_profiles_virtual WHERE profile_id = %s", self.id)
-               self.db.execute("INSERT INTO fireinfo_profiles_virtual(profile_id, hypervisor_id) \
-                       VALUES(%s, %s)", self.id, hypervisor_id)
-
-               self._hypervisor_id = hypervisor_id
-
-       hypervisor_id = property(get_hypervisor_id, set_hypervisor_id)
+       def is_virtual(self):
+               return self.system.is_virtual()
 
        @property
        def hypervisor(self):
-               if not hasattr(self, "_hypervisor"):
-                       res = self.db.get("SELECT fireinfo_hypervisors.name AS hypervisor FROM fireinfo_profiles \
-                               LEFT JOIN fireinfo_profiles_virtual ON fireinfo_profiles.id = fireinfo_profiles_virtual.profile_id \
-                               LEFT JOIN fireinfo_hypervisors ON fireinfo_profiles_virtual.hypervisor_id = fireinfo_hypervisors.id \
-                               WHERE fireinfo_profiles.id = %s", self.id)
-
-                       if res:
-                               self._hypervisor = res.hypervisor
-                       else:
-                               self._hypervisor = None
-
-               return self._hypervisor
-
-       # Language
-
-       def get_language(self):
-               if not hasattr(self, "_language"):
-                       res = self.db.get("SELECT language FROM fireinfo_profiles_languages \
-                               WHERE profile_id = %s", self.id)
-
-                       if res:
-                               self._language = res.language
-                       else:
-                               self._language = None
-
-               return self._language
-
-       def set_language(self, language):
-               self.db.execute("DELETE FROM fireinfo_profiles_languages WHERE profile_id = %s", self.id)
-
-               if language:
-                       self.db.execute("INSERT INTO fireinfo_profiles_languages(profile_id, language) \
-                               VALUES(%s, %s)", self.id, language)
-
-               self._language = language
-
-       language = property(get_language, set_language)
+               return Hypervisor(self.backend, self.blob.get("hypervisor"))
 
        # Network
 
-       def get_network(self):
-               if not hasattr(self, "_network"):
-                       res = self.db.get("SELECT * FROM fireinfo_profiles_networks \
-                               WHERE profile_id = %s", self.id)
-
-                       if not res:
-                               res = {}
-
-                       self._network = ProfileNetwork(res)
-
-               return self._network
-
-       def set_network(self, network):
-               self.db.execute("DELETE FROM fireinfo_profiles_networks WHERE profile_id = %s", self.id)
-
-               if network:
-                       self.db.execute("INSERT INTO fireinfo_profiles_networks(profile_id, \
-                               has_red, has_green, has_orange, has_blue) VALUES(%s, %s, %s, %s, %s)",
-                               self.id, network.has_red, network.has_green, network.has_orange, network.has_blue)
-
-               self._network = network
-
-       network = property(get_network, set_network)
-
-
-class ProfileData(Object):
-       def __init__(self, backend, id, data=None, profile=None):
-               Object.__init__(self, backend)
-
-               self.id = id
-               self._data = data
-               self._profile = profile
-
-       @property
-       def data(self):
-               if self._data is None:
-                       self._data = self.db.get("SELECT * FROM fireinfo_profile_data \
-                               WHERE id = %s", self.id)
-
-               return self._data
-
-       @property
-       def profile(self):
-               if not self._profile:
-                       self._profile = self.fireinfo.get_profile_by_id(self.profile_id)
-
-               return self._profile
-
-       @property
-       def profile_id(self):
-               return self.data.profile_id
-
-
-class ProfileParserError(Exception):
-       pass
-
-
-class ProfileParser(Object):
-       __device_args = (
-               "subsystem",
-               "vendor",
-               "model",
-               "sub_vendor",
-               "sub_model",
-               "driver",
-               "deviceclass",
-       )
-
-       __processor_args = (
-               "vendor",
-               "model_string",
-               "family",
-               "model",
-               "stepping",
-               "core_count",
-               "flags",
-       )
-
-       def __init__(self, backend, public_id, blob=None):
-               Object.__init__(self, backend)
-
-               self.public_id = public_id
-               self.private_id = None
-               self.devices = []
-               self.processor = None
-               self.processor_clock_speed = None
-               self.processor_bogomips = None
-               self.system_id = None
-               self.memory = None
-               self.storage = None
-               self.kernel = None
-               self.kernel_id = None
-               self.arch = None
-               self.arch_id = None
-               self.release = None
-               self.release_id = None
-               self.language = None
-               self.virtual = None
-               self.hypervisor_id = None
-               self.network = None
-
-               self.__parse_blob(blob)
-
-       def equals(self, other):
-               if not self.processor_id == other.processor_id:
-                       return False
-
-               if not self.device_ids == other.device_ids:
-                       return False
-
-               if not self.system_id == other.system_id:
-                       return False
-
-               if not self.memory == other.memory:
-                       return False
-
-               if not self.storage == other.storage:
-                       return False
-
-               if not self.kernel_id == other.kernel_id:
-                       return False
-
-               if not self.arch_id == other.arch_id:
-                       return False
-
-               if not self.release_id == other.release_id:
-                       return False
-
-               if not self.language == other.language:
-                       return False
-
-               if not self.virtual == other.virtual:
-                       return False
-
-               if other.virtual:
-                       if not self.hypervisor_id == other.hypervisor_id:
-                               return False
-
-               if not self.network == other.network:
-                       return False
-
-               return True
-
-       def __parse_blob(self, blob):
-               _profile = blob.get("profile", {})
-               self.private_id = blob.get("private_id")
-
-               # Do not try to parse an empty profile
-               if not _profile:
-                       return
-
-               # Processor
-               _processor = _profile.get("cpu", {})
-               self.__parse_processor(_processor)
-
-               # Find devices
-               _devices = _profile.get("devices", [])
-               self.__parse_devices(_devices)
-
-               # System
-               _system = _profile.get("system")
-               if _system:
-                       self.__parse_system(_system)
-
-                       # Memory (convert to bytes)
-                       memory = _system.get("memory", None)
-                       if memory:
-                               self.memory = memory * 1024
-
-                       # Storage size (convert to bytes)
-                       storage = _system.get("root_size", None)
-                       if storage:
-                               self.storage = storage * 1024
-
-                       # Kernel
-                       kernel = _system.get("kernel_release", None)
-                       if kernel:
-                               self.__parse_kernel(kernel)
-
-                       # Release
-                       release = _system.get("release", None)
-                       if release:
-                               self.__parse_release(release)
-
-                       # Language
-                       language = _system.get("language", None)
-                       if language:
-                               self.__parse_language(language)
-
-                       # Virtual
-                       self.virtual = _system.get("virtual", False)
-                       if self.virtual:
-                               hypervisor = _profile.get("hypervisor")
-                               self.__parse_hypervisor(hypervisor)
-
-               # Network
-               _network = _profile.get("network")
-               if _network:
-                       self.__parse_network(_network)
-
-       @property
-       def device_ids(self):
-               return sorted([d.id for d in self.devices])
-
-       def __parse_devices(self, _devices):
-               self.devices = []
-
-               for _device in _devices:
-                       args = {}
-                       for arg in self.__device_args:
-                               args[arg] = _device.get(arg, None)
-
-                       # Skip if the subsystem is not set
-                       if not args.get("subsystem", None):
-                               continue
-
-                       # Find the device or create a new one.
-                       device = self.fireinfo.get_device(**args)
-                       if not device:
-                               device = self.fireinfo.create_device(**args)
-
-                       self.devices.append(device)
-
-       def __parse_system(self, system):
-               vendor = system.get("vendor", None)
-               if not vendor:
-                       vendor = None
-
-               model = system.get("model", None)
-               if not model:
-                       model = None
-
-               self.system_id = self.fireinfo.get_system(vendor, model)
-               if not self.system_id:
-                       self.system_id = self.fireinfo.create_system(vendor, model)
-
-       @property
-       def processor_id(self):
-               if not self.processor:
-                       return
-
-               return self.processor.id
-
-       def __parse_processor(self, _processor):
-               args = {}
-               for arg in self.__processor_args:
-                       if arg == "core_count":
-                               _arg = "count"
-                       else:
-                               _arg = arg
-
-                       args[arg] = _processor.get(_arg, None)
-
-               self.processor = self.fireinfo.get_processor(**args)
-               if not self.processor:
-                       self.processor = self.fireinfo.create_processor(**args)
-
-               self.processor_clock_speed = _processor.get("speed", None)
-               self.processor_bogomips = _processor.get("bogomips", None)
-
-               arch = _processor.get("arch", None)
-               if arch:
-                       self.__parse_arch(arch)
-
-       def __parse_kernel(self, kernel):
-               self.kernel_id = self.fireinfo.get_kernel(kernel)
-               if not self.kernel_id:
-                       self.kernel_id = self.fireinfo.create_kernel(kernel)
-                       assert self.kernel_id
-
-               self.kernel = kernel
-
-       def __parse_arch(self, arch):
-               self.arch_id = self.fireinfo.get_arch(arch)
-               if not self.arch_id:
-                       self.arch_id = self.fireinfo.create_arch(arch)
-
-               self.arch = arch
-
-       def __parse_release(self, release):
-               # Remove the arch bit
-               if release:
-                       r = [e for e in release.split() if e]
-                       for s in ("(x86_64)", "(i586)", "(armv5tel)"):
-                               try:
-                                       r.remove(s)
-                                       break
-                               except ValueError:
-                                       pass
-
-                       release = " ".join(r)
-
-               self.release_id = self.fireinfo.get_release(release)
-               if not self.release_id:
-                       self.release_id = self.fireinfo.create_release(release)
-                       assert self.release_id
-
-               self.release = release
-
-       def __parse_language(self, language):
-               self.language = language
-               self.language, delim, rest = self.language.partition(".")
-               self.language, delim, rest = self.language.partition("_")
-
-       def __parse_hypervisor(self, hypervisor):
-               vendor = hypervisor.get("vendor", "other")
-
-               if vendor in ("other", "unknown"):
-                       self.hypervisor_id = None
-                       return
-
-               self.hypervisor_id = self.fireinfo.get_hypervisor(vendor)
-               if not self.hypervisor_id:
-                       self.hypervisor_id = self.fireinfo.create_hypervisor(vendor)
-
-       def __parse_network(self, network):
-               self.network = ProfileNetwork({
-                       "has_red"    : network.get("red", False),
-                       "has_green"  : network.get("green", False),
-                       "has_orange" : network.get("orange", False),
-                       "has_blue"   : network.get("blue", False),
-               })
+       @lazy_property
+       def network(self):
+               return Network(self.backend, self.blob.get("network", {}))
 
 
 class Fireinfo(Object):
-       def get_profile_count(self, when=None):
-               res = self.db.get("SELECT COUNT(*) AS count FROM fireinfo_profiles \
-                       WHERE then_or_now(%s) BETWEEN time_created AND time_valid", when)
-
-               if res:
-                       return res.count
+       async def expire(self):
+               """
+                       Called to expire any profiles that have not been updated in a fortnight
+               """
+               self.db.execute("UPDATE fireinfo SET expired_at = CURRENT_TIMESTAMP \
+                       WHERE last_updated_at <= CURRENT_TIMESTAMP - %s", datetime.timedelta(days=14))
 
-       def get_total_updates_count(self, when=None):
-               res = self.db.get("SELECT COUNT(*) + SUM(updates) AS count \
-                       FROM fireinfo_profiles WHERE time_created <= then_or_now(%s)", when)
+       def _get_profile(self, query, *args, **kwargs):
+               res = self.db.get(query, *args, **kwargs)
 
                if res:
-                       return res.count
+                       return Profile(self.backend, **res)
 
-       # Parser
+       def get_profile_count(self, when=None):
+               if when:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS count
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       created_at <= %s
+                               AND
+                                       (
+                                               expired_at IS NULL
+                                       OR
+                                               expired_at > %s
+                                       )
+                       """)
+               else:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS count
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               """,
+                       )
 
-       def parse_profile(self, public_id, blob):
-               return ProfileParser(self.backend, public_id, blob)
+               return res.count if res else 0
 
        # Profiles
 
-       def profile_exists(self, public_id):
-               res = self.db.get("SELECT id FROM fireinfo_profiles \
-                       WHERE public_id = %s LIMIT 1", public_id)
-
-               if res:
-                       return True
-
-               return False
-
-       def profile_rate_limit_active(self, public_id, when=None):
-               res = self.db.get("SELECT COUNT(*) AS count FROM fireinfo_profiles_log \
-                       WHERE public_id = %s AND ts >= then_or_now(%s) - INTERVAL '60 minutes'",
-                        public_id, when)
-
-               if res and res.count >= 10:
-                       return True
-
-               return False
-
-       def is_private_id_change_permitted(self, public_id, private_id, when=None):
-               # Check if a profile exists with a different private id that is still valid
-               res = self.db.get("SELECT 1 FROM fireinfo_profiles \
-                       WHERE public_id = %s AND NOT private_id = %s \
-                       AND time_valid >= then_or_now(%s) LIMIT 1", public_id, private_id, when)
-
-               if res:
-                       return False
-
-               return True
-
-       def get_profile(self, public_id, private_id=None, when=None):
-               res = self.db.get("SELECT * FROM fireinfo_profiles \
-                       WHERE public_id = %s AND \
-                               (CASE WHEN %s IS NULL THEN TRUE ELSE private_id = %s END) AND \
-                               then_or_now(%s) BETWEEN time_created AND time_valid \
-                       ORDER BY time_updated DESC LIMIT 1",
-                       public_id, private_id, private_id, when)
-
-               if res:
-                       return Profile(self.backend, res.id, res)
-
-       def get_profile_with_data(self, public_id, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT * FROM profiles JOIN fireinfo_profiles ON profiles.id = fireinfo_profiles.id \
-                               WHERE public_id = %s ORDER BY time_updated DESC LIMIT 1", when, public_id)
-
-               if res:
-                       return Profile(self.backend, res.id, res)
-
-       def get_profiles(self, public_id):
-               res = self.db.query("SELECT * FROM fireinfo_profiles \
-                       WHERE public_id = %s ORDER BY time_created DESC", public_id)
-
-               profiles = []
-               for row in res:
-                       profile = Profile(self.backend, row.id, row)
-                       profiles.append(profile)
-
-               return profiles
-
-       def create_profile(self, public_id, private_id, when=None):
-               valid = self.settings.get_int("fireinfo_profile_days_valid", 14)
-
-               res = self.db.get("INSERT INTO fireinfo_profiles(public_id, private_id, \
-                       time_created, time_updated, time_valid) VALUES(%s, %s, then_or_now(%s), \
-                       then_or_now(%s), then_or_now(%s) + INTERVAL '%s days') RETURNING id",
-                       public_id, private_id, when, when, when, valid)
-
-               if res:
-                       p = Profile(self.backend, res.id)
-                       p.log_profile_update()
-
-                       return p
-
-       # Devices
-
-       def create_device(self, subsystem, vendor, model, sub_vendor=None, sub_model=None,
-                       driver=None, deviceclass=None):
-               res = self.db.get("INSERT INTO fireinfo_devices(subsystem, vendor, model, \
-                               sub_vendor, sub_model, driver, deviceclass) VALUES(%s, %s, %s, %s, %s, %s, %s) \
-                               RETURNING id", subsystem, vendor, model, sub_vendor, sub_model, driver, deviceclass)
-
-               if res:
-                       return Device(self.backend, res.id)
-
-       def get_device(self, subsystem, vendor, model, sub_vendor=None, sub_model=None,
-                       driver=None, deviceclass=None):
-               res = self.db.get("SELECT * FROM fireinfo_devices \
-                       WHERE subsystem = %s AND vendor = %s AND model = %s \
-                       AND sub_vendor IS NOT DISTINCT FROM %s \
-                       AND sub_model IS NOT DISTINCT FROM %s \
-                       AND driver IS NOT DISTINCT FROM %s \
-                       AND deviceclass IS NOT DISTINCT FROM %s \
-                       LIMIT 1", subsystem, vendor, model, sub_vendor,
-                               sub_model, driver, deviceclass)
-
-               if res:
-                       return Device(self.backend, res.id, res)
-
-       # System
-
-       def create_system(self, vendor, model):
-               res = self.db.get("INSERT INTO fireinfo_systems(vendor, model) \
-                       VALUES(%s, %s) RETURNING id", vendor, model)
-
-               if res:
-                       return res.id
-
-       def get_system(self, vendor, model):
-               res = self.db.get("SELECT id FROM fireinfo_systems WHERE vendor IS NOT DISTINCT FROM %s \
-                       AND model IS NOT DISTINCT FROM %s LIMIT 1", vendor, model)
-
-               if res:
-                       return res.id
-
-       # Processors
-
-       def create_processor(self, vendor, model_string, family, model, stepping, core_count, flags=None):
-               res = self.db.get("INSERT INTO fireinfo_processors(vendor, model_string, \
-                       family, model, stepping, core_count, flags) VALUES(%s, %s, %s, %s, %s, %s, %s) \
-                       RETURNING id", vendor or None, model_string or None, family, model, stepping, core_count, flags)
-
-               if res:
-                       return Processor(self.backend, res.id)
-
-       def get_processor_by_id(self, processor_id, **kwargs):
-               res = self.db.get("SELECT * FROM fireinfo_processors \
-                       WHERE id = %s", processor_id)
-
-               if res:
-                       return Processor(self.backend, res.id, data=res, **kwargs)
-
-       def get_processor(self, vendor, model_string, family, model, stepping, core_count, flags=None):
-               if flags is None:
-                       flags = []
-
-               res = self.db.get("SELECT * FROM fireinfo_processors \
-                       WHERE vendor IS NOT DISTINCT FROM %s AND model_string IS NOT DISTINCT FROM %s \
-                       AND family IS NOT DISTINCT FROM %s AND model IS NOT DISTINCT FROM %s \
-                       AND stepping IS NOT DISTINCT FROM %s AND core_count = %s \
-                       AND flags <@ %s AND flags @> %s", vendor or None, model_string or None,
-                       family, model, stepping, core_count, flags, flags)
-
-               if res:
-                       return Processor(self.backend, res.id, res)
-
-       # Kernel
-
-       def create_kernel(self, kernel):
-               res = self.db.get("INSERT INTO fireinfo_kernels(name) VALUES(%s) \
-                       RETURNING id", kernel)
-
-               if res:
-                       return res.id
-
-       def get_kernel(self, kernel):
-               res = self.db.get("SELECT id FROM fireinfo_kernels WHERE name = %s", kernel)
-
-               if res:
-                       return res.id
-
-       # Arch
-
-       def create_arch(self, arch):
-               res = self.db.get("INSERT INTO fireinfo_arches(name) VALUES(%s) \
-                       RETURNING id", arch)
-
-               if res:
-                       return res.id
-
-       def get_arch(self, arch):
-               res = self.db.get("SELECT id FROM fireinfo_arches WHERE name = %s", arch)
+       def get_profile(self, profile_id, when=None):
+               if when:
+                       return self._get_profile("""
+                               SELECT
+                                       *
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       profile_id = %s
+                               AND
+                                       %s BETWEEN created_at AND expired_at
+                               """, profile_id,
+                       )
 
-               if res:
-                       return res.id
+               return self._get_profile("""
+                       SELECT
+                               *
+                       FROM
+                               fireinfo
+                       WHERE
+                               profile_id = %s
+                       AND
+                               expired_at IS NULL
+                       """, profile_id,
+               )
 
-       # Release
+       # Handle profile
 
-       def create_release(self, release):
-               res = self.db.get("INSERT INTO fireinfo_releases(name) VALUES(%s) \
-                       RETURNING id", release)
+       def handle_profile(self, profile_id, blob, country_code=None, asn=None, when=None):
+               private_id = blob.get("private_id", None)
+               assert private_id
 
-               if res:
-                       return res.id
+               now = datetime.datetime.utcnow()
 
-       def get_release(self, release):
-               res = self.db.get("SELECT id FROM fireinfo_releases WHERE name = %s", release)
+               # Fetch the profile version
+               version = blob.get("profile_version")
 
-               if res:
-                       return res.id
+               # Extract the profile
+               profile = blob.get("profile")
 
-       def get_release_penetration(self, release, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT COUNT(*)::float / (SELECT COUNT(*) FROM profiles) AS penetration FROM profiles \
-                       LEFT JOIN fireinfo_profiles_releases ON profiles.id = fireinfo_profiles_releases.profile_id \
-                       WHERE fireinfo_profiles_releases.release_id = %s", when, release.fireinfo_id)
+               if profile:
+                       # Validate the profile
+                       self._validate(profile_id, version, profile)
 
-               if res:
-                       return res.penetration
+                       # Pre-process the profile
+                       profile = self._preprocess(profile)
 
-       def get_random_country_penetration(self):
-               res = self.db.get("SELECT * FROM fireinfo_country_percentages \
-                       ORDER BY RANDOM() LIMIT 1")
+               # Fetch the previous profile
+               prev = self.get_profile(profile_id)
 
-               if res:
-                       return database.Row({
-                               "country"    : iso3166.countries.get(res.location),
-                               "percentage" : res.count,
-                       })
+               if prev:
+                       # Check if the private ID matches
+                       if not prev.private_id == private_id:
+                               logging.error("Private ID for profile %s does not match" % profile_id)
+                               return False
 
-       # Hypervisor
+                       # Check when the last update was
+                       elif now - prev.last_updated_at < datetime.timedelta(hours=6):
+                               logging.warning("Profile %s has been updated too soon" % profile_id)
+                               return False
 
-       def create_hypervisor(self, hypervisor):
-               res = self.db.get("INSERT INTO fireinfo_hypervisors(name) VALUES(%s) \
-                       RETURNING id", hypervisor)
+                       # Check if the profile has changed
+                       elif prev.version == version and prev.blob == blob:
+                               logging.debug("Profile %s has not changed" % profile_id)
 
-               if res:
-                       return res.id
+                               # Update the timestamp
+                               self.db.execute("UPDATE fireinfo SET last_updated_at = CURRENT_TIMESTAMP \
+                                       WHERE profile_id = %s AND expired_at IS NULL", profile_id)
 
-       def get_hypervisor(self, hypervisor):
-               res = self.db.get("SELECT id FROM fireinfo_hypervisors WHERE name = %s",
-                       hypervisor)
+                               return True
 
-               if res:
-                       return res.id
+                       # Delete the previous profile
+                       self.db.execute("UPDATE fireinfo SET expired_at = CURRENT_TIMESTAMP \
+                               WHERE profile_id = %s AND expired_at IS NULL", profile_id)
 
-       # Handle profile
+               # Serialise the profile
+               if profile:
+                       profile = json.dumps(profile)
+
+               # Store the new profile
+               self.db.execute("""
+                       INSERT INTO
+                               fireinfo
+                       (
+                               profile_id,
+                               private_id,
+                               version,
+                               blob,
+                               country_code,
+                               asn
+                       )
+                       VALUES
+                       (
+                               %s,
+                               %s,
+                               %s,
+                               %s,
+                               %s,
+                               %s
+                       )
+                       """, profile_id, private_id, version, profile, country_code, asn,
+               )
 
-       def handle_profile(self, *args, **kwargs):
-               self.db.execute("START TRANSACTION")
+       def _validate(self, profile_id, version, blob):
+               """
+                       Validate the profile
+               """
+               if not version == 0:
+                       raise ValueError("Unsupported profile version")
 
-               # Wrap all the handling of the profile in a huge transaction.
+               # Validate the blob
                try:
-                       self._handle_profile(*args, **kwargs)
-
-               except:
-                       self.db.execute("ROLLBACK")
-                       raise
-
-               else:
-                       self.db.execute("COMMIT")
-
-       def _handle_profile(self, public_id, profile_blob, country_code=None, when=None):
-               private_id = profile_blob.get("private_id", None)
-               assert private_id
-
-               # Check if the profile already exists in the database.
-               profile = self.fireinfo.get_profile(public_id, private_id=private_id, when=when)
-
-               # Check if the update can actually be updated
-               if profile and self.fireinfo.profile_rate_limit_active(public_id, when=when):
-                       logging.warning("There were too many updates for this profile in the last hour: %s" % public_id)
-                       return
-
-               elif not self.is_private_id_change_permitted(public_id, private_id, when=when):
-                       logging.warning("Changing private id is not permitted for profile: %s" % public_id)
-                       return
+                       return jsonschema.validate(blob, schema=PROFILE_SCHEMA)
 
-               # Parse the profile
-               profile_parser = self.parse_profile(public_id, profile_blob)
+               # Raise a ValueError instead which is easier to handle later on
+               except jsonschema.exceptions.ValidationError as e:
+                       raise ValueError("%s" % e) from e
 
-               # If a profile exists, check if it matches and if so, just update the
-               # timestamp.
-               if profile:
-                       # Check if the profile has changed. If so, update the data.
-                       if profile_parser.equals(profile):
-                               profile.updated(profile_parser, country_code=country_code, when=when)
-                               return
+       def _preprocess(self, blob):
+               """
+                       Modifies the profile before storing it
+               """
+               # Remove the architecture from the release string
+               blob["system"]["release"]= self._filter_release(blob["system"]["release"])
 
-                       # If it does not match, we assume that it is expired and
-                       # create a new profile.
-                       profile.expired(when=when)
+               return blob
 
-               # Replace the old profile with a new one
-               profile = self.fireinfo.create_profile(public_id, private_id, when=when)
-               profile.parse(profile_parser)
+       def _filter_release(self, release):
+               """
+                       Removes the arch part
+               """
+               r = [e for e in release.split() if e]
 
-               if country_code:
-                       profile.set_country_code(country_code)
+               for s in ("(x86_64)", "(aarch64)", "(i586)", "(armv6l)", "(armv5tel)", "(riscv64)"):
+                       try:
+                               r.remove(s)
+                               break
+                       except ValueError:
+                               pass
 
-               return profile
+               return " ".join(r)
 
        # Data outputs
 
        def get_random_profile(self, when=None):
-               # Check if the architecture exists so that we pick a profile with some data
-               res = self.db.get("SELECT public_id FROM fireinfo_profiles \
-                       LEFT JOIN fireinfo_profiles_arches ON fireinfo_profiles.id = fireinfo_profiles_arches.profile_id \
-                       WHERE fireinfo_profiles_arches.profile_id IS NOT NULL \
-                       AND then_or_now(%s) BETWEEN time_created AND time_valid ORDER BY RANDOM() LIMIT 1", when)
+               if when:
+                       return self._get_profile("""
+                               SELECT
+                                       *
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       created_at <= %s
+                               AND
+                                       (
+                                               expired_at IS NULL
+                                       OR
+                                               expired_at > %s
+                                       )
+                               AND
+                                       blob IS NOT NULL
+                               ORDER BY
+                                       RANDOM()
+                               LIMIT
+                                       1
+                               """, when, when,
+                       )
 
-               if res:
-                       return res.public_id
+               return self._get_profile("""
+                       SELECT
+                               *
+                       FROM
+                               fireinfo
+                       WHERE
+                               expired_at IS NULL
+                       AND
+                               blob IS NOT NULL
+                       ORDER BY
+                               RANDOM()
+                       LIMIT
+                               1
+               """)
 
        def get_active_profiles(self, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_at(%s) AS id) \
-                       SELECT COUNT(*) AS with_data, (SELECT COUNT(*) FROM profiles) AS count FROM profiles \
-                       LEFT JOIN fireinfo_profiles_releases ON profiles.id = fireinfo_profiles_releases.profile_id \
-                       WHERE fireinfo_profiles_releases.profile_id IS NOT NULL", when)
+               if when:
+                       raise NotImplementedError
 
-               if res:
-                       return res.with_data, res.count
-
-       def get_archive_size(self, when=None):
-               res = self.db.get("SELECT COUNT(*) AS count FROM fireinfo_profiles \
-                       WHERE time_created <= then_or_now(%s)", when)
+               else:
+                       res = self.db.get("""
+                               SELECT
+                                       COUNT(*) AS total_profiles,
+                                       COUNT(*) FILTER (WHERE blob IS NOT NULL) AS active_profiles
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                       """)
 
                if res:
-                       return res.count
-
-       def get_geo_location_map(self, when=None, minimum_percentage=0):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_at(%s) AS id) \
-                       SELECT location, COUNT(location)::float / (SELECT COUNT(*) FROM profiles) AS count FROM profiles \
-                       LEFT JOIN fireinfo_profiles_locations ON profiles.id = fireinfo_profiles_locations.profile_id \
-                       WHERE fireinfo_profiles_locations.location IS NOT NULL GROUP BY location \
-                       HAVING COUNT(location)::float / (SELECT COUNT(*) FROM profiles) >= %s ORDER BY count DESC",
-                       when, minimum_percentage)
-
-               return list(((r.location, r.count) for r in res))
+                       return res.active_profiles, res.total_profiles
+
+       def get_geo_location_map(self, when=None):
+               if when:
+                       res = self.db.query("""
+                               SELECT
+                                       country_code,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       created_at <= %s
+                               AND
+                                       (
+                                               expired_at IS NULL
+                                       OR
+                                               expired_at > %s
+                                       )
+                               AND
+                                       country_code IS NOT NULL
+                               GROUP BY
+                                       country_code
+                       """, when, when)
+               else:
+                       res = self.db.query("""
+                               SELECT
+                                       country_code,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       country_code IS NOT NULL
+                               GROUP BY
+                                       country_code
+                       """)
+
+               return { row.country_code : row.p for row in res }
+
+       def get_asn_map(self, when=None):
+               if when:
+                       res = self.db.query("""
+                               SELECT
+                                       asn,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p,
+                                       COUNT(*) AS c
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       created_at <= %s
+                               AND
+                                       (
+                                               expired_at IS NULL
+                                       OR
+                                               expired_at > %s
+                                       )
+                               AND
+                                       asn IS NOT NULL
+                               GROUP BY
+                                       asn
+                       """, when, when)
+               else:
+                       res = self.db.query("""
+                               SELECT
+                                       asn,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p,
+                                       COUNT(*) AS c
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       asn IS NOT NULL
+                               GROUP BY
+                                       asn
+                       """)
+
+               return { self.backend.location.get_as(row.asn) : (row.c, row.p) for row in res }
 
        @property
        def cpu_vendors(self):
-               res = self.db.query("SELECT DISTINCT vendor FROM fireinfo_processors ORDER BY vendor")
+               res = self.db.query("""
+                       SELECT DISTINCT
+                               blob->'cpu'->'vendor' AS vendor
+                       FROM
+                               fireinfo
+                       WHERE
+                               blob->'cpu'->'vendor' IS NOT NULL
+                       """,
+               )
 
-               return (CPU_VENDORS.get(r.vendor, r.vendor) for r in res)
+               return sorted((CPU_VENDORS.get(row.vendor, row.vendor) for row in res))
 
        def get_cpu_vendors_map(self, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT COALESCE(vendor, %s) AS vendor, COUNT(vendor)::float / (SELECT COUNT(*) FROM profiles) AS count FROM profiles \
-                       LEFT JOIN fireinfo_profiles_processors ON profiles.id = fireinfo_profiles_processors.profile_id \
-                       LEFT JOIN fireinfo_processors ON fireinfo_profiles_processors.processor_id = fireinfo_processors.id \
-                       WHERE NOT fireinfo_profiles_processors.processor_id IS NULL GROUP BY vendor ORDER BY count DESC", when, "Unknown")
-
-               return ((CPU_VENDORS.get(r.vendor, r.vendor), r.count) for r in res)
-
-       def get_cpu_clock_speeds(self, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT AVG(fireinfo_profiles_processors.clock_speed) AS avg, \
-                       STDDEV(fireinfo_profiles_processors.clock_speed) AS stddev, \
-                       MIN(fireinfo_profiles_processors.clock_speed) AS min, \
-                       MAX(fireinfo_profiles_processors.clock_speed) AS max FROM profiles \
-                       LEFT JOIN fireinfo_profiles_processors ON profiles.id = fireinfo_profiles_processors.profile_id \
-                       WHERE NOT fireinfo_profiles_processors.processor_id IS NULL \
-                       AND fireinfo_profiles_processors.clock_speed > 0 \
-                       AND fireinfo_profiles_processors.clock_speed < fireinfo_profiles_processors.bogomips \
-                       AND fireinfo_profiles_processors.bogomips <= %s", when, 10000)
+               if when:
+                       raise NotImplementedError
 
-               if res:
-                       return (res.avg or 0, res.stddev or 0, res.min or 0, res.max or 0)
-
-       def get_cpus_with_platform_and_flag(self, platform, flag, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id), \
-                       processors AS (SELECT fireinfo_processors.id AS id, fireinfo_processors.flags AS flags FROM profiles \
-                       LEFT JOIN fireinfo_profiles_processors ON profiles.id = fireinfo_profiles_processors.profile_id \
-                       LEFT JOIN fireinfo_processors ON fireinfo_profiles_processors.processor_id = fireinfo_processors.id \
-                       LEFT JOIN fireinfo_profiles_arches ON profiles.id = fireinfo_profiles_arches.profile_id \
-                       LEFT JOIN fireinfo_arches ON fireinfo_profiles_arches.arch_id = fireinfo_arches.id \
-                       WHERE NOT fireinfo_profiles_processors.processor_id IS NULL \
-                       AND fireinfo_arches.platform = %s AND NOT 'hypervisor' = ANY(fireinfo_processors.flags)) \
-                       SELECT (COUNT(*)::float / (SELECT NULLIF(COUNT(*), 0) FROM processors)) AS count FROM processors \
-                       WHERE %s = ANY(processors.flags)", when, platform, flag)
-
-               return res.count or 0
-
-       def get_common_cpu_flags_by_platform(self, platform, when=None):
-               if platform == "arm":
-                       flags = (
-                               "lpae", "neon", "thumb", "thumbee", "vfpv3", "vfpv4",
-                       )
-               elif platform == "x86":
-                       flags = (
-                               "aes", "avx", "avx2", "lm", "mmx", "mmxext", "nx", "pae",
-                               "pni", "popcnt", "sse", "sse2", "rdrand", "ssse3", "sse4a",
-                               "sse4_1", "sse4_2", "pclmulqdq", "rdseed",
-                       )
                else:
-                       return
+                       res = self.db.query("""
+                               SELECT
+                                       NULLIF(blob->'cpu'->'vendor', '""'::jsonb) AS vendor,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       blob IS NOT NULL
+                               GROUP BY
+                                       NULLIF(blob->'cpu'->'vendor', '""'::jsonb)
+                       """)
+
+               return { CPU_VENDORS.get(row.vendor, row.vendor) : row.p for row in res }
+
+       def get_cpu_flags_map(self, when=None):
+               if when:
+                       raise NotImplementedError
 
-               ret = []
-               for flag in flags:
-                       ret.append((flag, self.get_cpus_with_platform_and_flag(platform, flag, when=when)))
+               else:
+                       res = self.db.query("""
+                               WITH arch_flags AS (
+                                       SELECT
+                                               ROW_NUMBER() OVER (PARTITION BY blob->'cpu'->'arch') AS id,
+                                               blob->'cpu'->'arch' AS arch,
+                                               blob->'cpu'->'flags' AS flags
+                                       FROM
+                                               fireinfo
+                                       WHERE
+                                               expired_at IS NULL
+                                       AND
+                                               blob->'cpu'->'arch' IS NOT NULL
+                                       AND
+                                               blob->'cpu'->'flags' IS NOT NULL
+
+                                       -- Filter out virtual systems
+                                       AND
+                                               CAST((blob->'system'->'virtual') AS boolean) IS FALSE
+                               )
+
+                               SELECT
+                                       arch,
+                                       flag,
+                                       fireinfo_percentage(
+                                               COUNT(*),
+                                               (
+                                                       SELECT
+                                                               MAX(id)
+                                                       FROM
+                                                               arch_flags __arch_flags
+                                                       WHERE
+                                                               arch_flags.arch = __arch_flags.arch
+                                               )
+                                       ) AS p
+                               FROM
+                                       arch_flags, jsonb_array_elements(arch_flags.flags) AS flag
+                               GROUP BY
+                                       arch, flag
+                       """)
+
+               result = {}
 
-               # Add virtual CPU flag "virt" for virtualization support
-               if platform == "x86":
-                       ret.append(("virt",
-                               self.get_cpus_with_platform_and_flag(platform, "vmx", when=when) + \
-                               self.get_cpus_with_platform_and_flag(platform, "svm", when=when)))
+               for row in res:
+                       try:
+                               result[row.arch][row.flag] = row.p
+                       except KeyError:
+                               result[row.arch] = { row.flag : row.p }
 
-               return sorted(ret, key=lambda x: x[1], reverse=True)
+               return result
 
        def get_average_memory_amount(self, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT AVG(fireinfo_profiles_memory.amount) AS avg FROM profiles \
-                       LEFT JOIN fireinfo_profiles_memory ON profiles.id = fireinfo_profiles_memory.profile_id", when)
-
-               if res:
-                       return res.avg or 0
+               if when:
+                       res = self.db.get("""
+                               SELECT
+                                       AVG(
+                                               CAST(blob->'system'->'memory' AS numeric)
+                                       ) AS memory
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       created_at <= %s
+                               AND
+                                       (
+                                               expired_at IS NULL
+                                       OR
+                                               expired_at > %s
+                                       )
+                       """, when)
+               else:
+                       res = self.db.get("""
+                               SELECT
+                                       AVG(
+                                               CAST(blob->'system'->'memory' AS numeric)
+                                       ) AS memory
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                       """,)
+
+               return res.memory if res else 0
 
        def get_arch_map(self, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT fireinfo_arches.name AS arch, COUNT(*)::float / (SELECT COUNT(*) FROM profiles) AS count \
-                       FROM profiles \
-                       LEFT JOIN fireinfo_profiles_arches ON profiles.id = fireinfo_profiles_arches.profile_id \
-                       LEFT JOIN fireinfo_arches ON fireinfo_profiles_arches.arch_id = fireinfo_arches.id \
-                       WHERE NOT fireinfo_profiles_arches.profile_id IS NULL \
-                       GROUP BY fireinfo_arches.id ORDER BY count DESC", when)
+               if when:
+                       raise NotImplementedError
 
-               return ((r.arch, r.count) for r in res)
+               else:
+                       res = self.db.query("""
+                               SELECT
+                                       blob->'cpu'->'arch' AS arch,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       blob->'cpu'->'arch' IS NOT NULL
+                               GROUP BY
+                                       blob->'cpu'->'arch'
+                       """)
+
+               return { row.arch : row.p for row in res }
 
        # Virtual
 
        def get_hypervisor_map(self, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id), \
-                       virtual_profiles AS (SELECT profiles.id AS profile_id, fireinfo_profiles_virtual.hypervisor_id FROM profiles \
-                               LEFT JOIN fireinfo_profiles_virtual ON profiles.id = fireinfo_profiles_virtual.profile_id \
-                               WHERE fireinfo_profiles_virtual.profile_id IS NOT NULL) \
-                       SELECT COALESCE(fireinfo_hypervisors.name, %s) AS name, \
-                               COUNT(*)::float / (SELECT COUNT(*) FROM virtual_profiles) AS count FROM virtual_profiles \
-                       LEFT JOIN fireinfo_hypervisors ON virtual_profiles.hypervisor_id = fireinfo_hypervisors.id \
-                       GROUP BY fireinfo_hypervisors.name ORDER BY count DESC", when, "unknown")
-
-               return ((r.name, r.count) for r in res)
+               if when:
+                       raise NotImplementedError
+               else:
+                       res = self.db.query("""
+                               SELECT
+                                       blob->'hypervisor'->'vendor' AS vendor,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       CAST((blob->'system'->'virtual') AS boolean) IS TRUE
+                               AND
+                                       blob->'hypervisor'->'vendor' IS NOT NULL
+                               GROUP BY
+                                       blob->'hypervisor'->'vendor'
+                       """)
+
+               return { row.vendor : row.p for row in res }
 
        def get_virtual_ratio(self, when=None):
-               res = self.db.get("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT COUNT(*)::float / (SELECT COUNT(*) FROM profiles) AS count FROM profiles \
-                       LEFT JOIN fireinfo_profiles_virtual ON profiles.id = fireinfo_profiles_virtual.profile_id \
-                       WHERE fireinfo_profiles_virtual.profile_id IS NOT NULL", when)
+               if when:
+                       raise NotImplementedError
 
-               if res:
-                       return res.count
+               else:
+                       res = self.db.get("""
+                               SELECT
+                                       fireinfo_percentage(
+                                               COUNT(*) FILTER (
+                                                       WHERE CAST((blob->'system'->'virtual') AS boolean) IS TRUE
+                                               ),
+                                               COUNT(*)
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       blob IS NOT NULL
+                       """)
+
+               return res.p if res else 0
 
        # Releases
 
        def get_releases_map(self, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT fireinfo_releases.name, COUNT(*)::float / (SELECT COUNT(*) FROM profiles) AS count FROM profiles \
-                       LEFT JOIN fireinfo_profiles_releases ON profiles.id = fireinfo_profiles_releases.profile_id \
-                       LEFT JOIN fireinfo_releases ON fireinfo_profiles_releases.release_id = fireinfo_releases.id \
-                       GROUP BY fireinfo_releases.name ORDER BY count DESC", when)
+               if when:
+                       raise NotImplementedError
 
-               return ((r.name, r.count) for r in res)
+               else:
+                       res = self.db.query("""
+                               SELECT
+                                       blob->'system'->'release' AS release,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       blob IS NOT NULL
+                               AND
+                                       blob->'system'->'release' IS NOT NULL
+                               GROUP BY
+                                       blob->'system'->'release'
+                       """)
+
+               return { row.release : row.p for row in res }
+
+       # Kernels
 
        def get_kernels_map(self, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT fireinfo_kernels.name, COUNT(*)::float / (SELECT COUNT(*) FROM profiles) AS count FROM profiles \
-                       LEFT JOIN fireinfo_profiles_kernels ON profiles.id = fireinfo_profiles_kernels.profile_id \
-                       LEFT JOIN fireinfo_kernels ON fireinfo_profiles_kernels.kernel_id = fireinfo_kernels.id \
-                       GROUP BY fireinfo_kernels.name ORDER BY count DESC", when)
+               if when:
+                       raise NotImplementedError
 
-               return ((r.name, r.count) for r in res)
-
-       def _process_devices(self, devices):
-               result = []
-
-               for dev in devices:
-                       dev = Device(self.backend, dev.get("id", None), dev)
-                       result.append(dev)
-
-               return result
-
-       def get_driver_map(self, driver, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id), \
-                       devices AS (SELECT * FROM profiles \
-                               LEFT JOIN fireinfo_profiles_devices ON profiles.id = fireinfo_profiles_devices.profile_id \
-                               LEFT JOIN fireinfo_devices ON fireinfo_profiles_devices.device_id = fireinfo_devices.id \
-                               WHERE driver = %s) \
-                       SELECT subsystem, model, vendor, driver, deviceclass, \
-                               COUNT(*)::float / (SELECT COUNT(*) FROM devices) AS percentage FROM devices \
-                               GROUP BY subsystem, model, vendor, driver, deviceclass \
-                               ORDER BY percentage DESC", when, driver)
-
-               return self._process_devices(res)
+               else:
+                       res = self.db.query("""
+                               SELECT
+                                       COALESCE(
+                        blob->'system'->'kernel_release',
+                        blob->'system'->'kernel'
+                    ) AS kernel,
+                                       fireinfo_percentage(
+                                               COUNT(*), SUM(COUNT(*)) OVER ()
+                                       ) AS p
+                               FROM
+                                       fireinfo
+                               WHERE
+                                       expired_at IS NULL
+                               AND
+                                       blob IS NOT NULL
+                               AND
+                                       (
+                        blob->'system'->'kernel_release' IS NOT NULL
+                    OR
+                        blob->'system'->'kernel' IS NOT NULL
+                    )
+                               GROUP BY
+                                       COALESCE(
+                        blob->'system'->'kernel_release',
+                        blob->'system'->'kernel'
+                    )
+                       """)
+
+               return { row.kernel : row.p for row in res }
 
        subsystem2class = {
                "pci" : hwdata.PCI(),
@@ -1980,15 +1328,45 @@ class Fireinfo(Object):
                return cls.get_device(vendor_id, model_id) or ""
 
        def get_vendor_list(self, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id) \
-                       SELECT DISTINCT fireinfo_devices.subsystem AS subsystem, fireinfo_devices.vendor AS vendor FROM profiles \
-                               LEFT JOIN fireinfo_profiles_devices ON profiles.id = fireinfo_profiles_devices.profile_id \
-                               LEFT JOIN fireinfo_devices ON fireinfo_profiles_devices.device_id = fireinfo_devices.id \
-                               WHERE NOT fireinfo_devices.driver = ANY(%s)", when, IGNORED_DEVICES)
+               if when:
+                       raise NotImplementedError
+
+               else:
+                       res = self.db.query("""
+                               WITH devices AS (
+                                       SELECT
+                                               jsonb_array_elements(blob->'devices') AS device
+                                       FROM
+                                               fireinfo
+                                       WHERE
+                                               expired_at IS NULL
+                                       AND
+                                               blob IS NOT NULL
+                                       AND
+                                               blob->'devices' IS NOT NULL
+                                       AND
+                                               jsonb_typeof(blob->'devices') = 'array'
+                               )
+
+                               SELECT
+                                       devices.device->'subsystem' AS subsystem,
+                                       devices.device->'vendor' AS vendor
+                               FROM
+                                       devices
+                               WHERE
+                                       devices.device->'subsystem' IS NOT NULL
+                               AND
+                                       devices.device->'vendor' IS NOT NULL
+                               AND
+                                       NOT devices.device->>'driver' = 'usb'
+                               GROUP BY
+                                       subsystem, vendor
+                       """)
 
                vendors = {}
+
                for row in res:
-                       vendor = self.get_vendor_string(row.subsystem, row.vendor)
+                       vendor = self.get_vendor_string(row.subsystem, row.vendor) or row.vendor
 
                        # Drop if vendor could not be determined
                        if vendor is None:
@@ -1999,17 +1377,111 @@ class Fireinfo(Object):
                        except KeyError:
                                vendors[vendor] = [(row.subsystem, row.vendor)]
 
-               vendors = list(vendors.items())
-               return sorted(vendors)
+               return vendors
+
+       def _get_devices(self, query, *args, **kwargs):
+               res = self.db.query(query, *args, **kwargs)
+
+               return [Device(self.backend, blob) for blob in res]
 
        def get_devices_by_vendor(self, subsystem, vendor, when=None):
-               res = self.db.query("WITH profiles AS (SELECT fireinfo_profiles_with_data_at(%s) AS id), \
-                       devices AS (SELECT * FROM profiles \
-                               LEFT JOIN fireinfo_profiles_devices ON profiles.id = fireinfo_profiles_devices.profile_id \
-                               LEFT JOIN fireinfo_devices ON fireinfo_profiles_devices.device_id = fireinfo_devices.id \
-                               WHERE NOT fireinfo_devices.driver = ANY(%s)), \
-                       vendor_devices AS (SELECT * FROM devices WHERE devices.subsystem = %s AND devices.vendor = %s) \
-                       SELECT subsystem, model, vendor, driver, deviceclass FROM vendor_devices \
-                               GROUP BY subsystem, model, vendor, driver, deviceclass", when, IGNORED_DEVICES, subsystem, vendor)
-
-               return self._process_devices(res)
+               if when:
+                       raise NotImplementedError
+
+               else:
+                       return self._get_devices("""
+                               WITH devices AS (
+                                       SELECT
+                                               jsonb_array_elements(blob->'devices') AS device
+                                       FROM
+                                               fireinfo
+                                       WHERE
+                                               expired_at IS NULL
+                                       AND
+                                               blob IS NOT NULL
+                                       AND
+                                               blob->'devices' IS NOT NULL
+                                       AND
+                                               jsonb_typeof(blob->'devices') = 'array'
+                               )
+
+                               SELECT
+                                       device.deviceclass,
+                                       device.subsystem,
+                                       device.vendor,
+                                       device.model,
+                                       device.driver
+                               FROM
+                                       devices,
+                                       jsonb_to_record(devices.device) AS device(
+                                               deviceclass text,
+                                               subsystem   text,
+                                               vendor      text,
+                                               sub_vendor  text,
+                                               model       text,
+                                               sub_model   text,
+                                               driver      text
+                                       )
+                               WHERE
+                                       devices.device->>'subsystem' = %s
+                               AND
+                                       devices.device->>'vendor' = %s
+                               AND
+                                       NOT devices.device->>'driver' = 'usb'
+                               GROUP BY
+                                       device.deviceclass,
+                                       device.subsystem,
+                                       device.vendor,
+                                       device.model,
+                                       device.driver
+                               """, subsystem, vendor,
+                       )
+
+       def get_devices_by_driver(self, driver, when=None):
+               if when:
+                       raise NotImplementedError
+
+               else:
+                       return self._get_devices("""
+                               WITH devices AS (
+                                       SELECT
+                                               jsonb_array_elements(blob->'devices') AS device
+                                       FROM
+                                               fireinfo
+                                       WHERE
+                                               expired_at IS NULL
+                                       AND
+                                               blob IS NOT NULL
+                                       AND
+                                               blob->'devices' IS NOT NULL
+                                       AND
+                                               jsonb_typeof(blob->'devices') = 'array'
+                               )
+
+                               SELECT
+                                       device.deviceclass,
+                                       device.subsystem,
+                                       device.vendor,
+                                       device.model,
+                                       device.driver
+                               FROM
+                                       devices,
+                                       jsonb_to_record(devices.device) AS device(
+                                               deviceclass text,
+                                               subsystem   text,
+                                               vendor      text,
+                                               sub_vendor  text,
+                                               model       text,
+                                               sub_model   text,
+                                               driver      text
+                                       )
+                               WHERE
+                                       devices.device->>'driver' = %s
+                               GROUP BY
+                                       device.deviceclass,
+                                       device.subsystem,
+                                       device.vendor,
+                                       device.model,
+                                       device.driver
+                               """, driver,
+                       )
diff --git a/src/backend/httpclient.py b/src/backend/httpclient.py
new file mode 100644 (file)
index 0000000..c8150ed
--- /dev/null
@@ -0,0 +1,184 @@
+###############################################################################
+#                                                                             #
+# Pakfire - The IPFire package management system                              #
+# Copyright (C) 2023 Pakfire development team                                 #
+#                                                                             #
+# This program is free software: you can redistribute it and/or modify        #
+# it under the terms of the GNU General Public License as published by        #
+# the Free Software Foundation, either version 3 of the License, or           #
+# (at your option) any later version.                                         #
+#                                                                             #
+# This program is distributed in the hope that it will be useful,             #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of              #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               #
+# GNU General Public License for more details.                                #
+#                                                                             #
+# You should have received a copy of the GNU General Public License           #
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.       #
+#                                                                             #
+###############################################################################
+
+import json
+import logging
+import tornado.curl_httpclient
+import tornado.httpclient
+import urllib.parse
+
+# Setup logging
+log = logging.getLogger()
+
+# Copy exceptions
+HTTPError = tornado.httpclient.HTTPError
+
+# Copy the request object
+HTTPRequest = tornado.httpclient.HTTPRequest
+
+class HTTPClient(tornado.curl_httpclient.CurlAsyncHTTPClient):
+       """
+               This is a wrapper over Tornado's HTTP client that performs some extra
+               logging and makes it easier to compose a request.
+       """
+       def __init__(self, backend, *args, **kwargs):
+               super().__init__(*args, **kwargs)
+
+               # Store a reference to the backend
+               self.backend = backend
+
+       def _configure_proxy(self, request):
+               """
+                       Configures the proxy
+               """
+               proxy = self.backend.settings.get("proxy")
+               if not proxy:
+                       return
+
+               # Split the configuration value
+               host, delim, port = proxy.partition(":")
+
+               # Convert port to integer
+               # Set port
+               try:
+                       port = int(port)
+               except (TypeError, ValueError):
+                       log.error("Could not decode proxy setting: %s" % proxy)
+                       return
+
+               # Set values
+               request.proxy_host, request.proxy_port = host, port
+
+       async def fetch(self, request, **kwargs):
+               """
+                       Sends a request
+               """
+               if not isinstance(request, HTTPRequest):
+                       request = HTTPRequest(url=request, **kwargs)
+
+               # Configure the proxy
+               self._configure_proxy(request)
+
+               # Set User-Agent
+               request.user_agent = "IPFireWebapp/%s" % self.backend.version
+
+               # Log what we are sending
+               log.debug("Sending %s request to %s:" % (request.method, request.url))
+
+               # Log headers
+               if request.headers:
+                       log_headers(request.headers)
+
+               # Log the body
+               if request.body:
+                       log_body(request.body)
+
+               # Send the request
+               try:
+                       response = await super().fetch(request)
+
+               # Log any errors
+               except tornado.httpclient.HTTPError as e:
+                       log.error("Received status %s:" % e.code)
+
+                       # Log the response body
+                       if e.response:
+                               log_body(e.response.body, level=logging.ERROR)
+
+                       # Raise the error
+                       raise e
+
+               # Log successful responses
+               else:
+                       log.debug("Received response: %s (%s - %s) in %.2fms:" % (
+                               response.effective_url, response.code, response.reason,
+                               response.request_time * 1000.0,
+                       ))
+
+                       # Log headers
+                       if response.headers:
+                               log_headers(response.headers)
+
+                       # Log body
+                       if response.body:
+                               log_body(response.body)
+
+               # Return the response
+               return response
+
+
+def log_headers(headers):
+       """
+               Helper function to log request/response headers
+       """
+       log.debug("  Headers:")
+
+       for header in sorted(headers):
+               log.debug("    %-32s: %s" % (header, headers[header]))
+
+def decode_body(body):
+       # Try parsing this as JSON and reformat it
+       try:
+               body = json.loads(body)
+               body = json.dumps(body, indent=4, sort_keys=True)
+       except (json.JSONDecodeError, UnicodeDecodeError):
+               pass
+       else:
+               return body
+
+       # Try parsing this as query arguments
+       try:
+               query = urllib.parse.parse_qs(body, strict_parsing=True)
+               if query:
+                       body = format_query(query)
+       except ValueError:
+               pass
+       else:
+               return body
+
+       # Decode as string
+       if isinstance(body, bytes):
+               try:
+                       body = body.decode()
+               except UnicodeError:
+                       pass
+               else:
+                       return body
+
+def format_query(query):
+       lines = []
+
+       for key in sorted(query):
+               for val in query[key]:
+                       lines.append(b" %-32s : %s" % (key, val))
+
+       return b"\n".join(lines)
+
+def log_body(body, level=logging.DEBUG):
+       """
+               Helper function to log the request/response body
+       """
+       body = decode_body(body)
+
+       if body:
+               log.log(level, "  Body:")
+
+               for line in body.splitlines():
+                       log.log(level, "    %s" % line)
index afcf4e10edf456c5f2d9eb03125c584578dc7de6..911ee0505e03247ea888a7e4d80542d2866af012 100644 (file)
@@ -15,6 +15,8 @@
 
 """ Query hwdata database and return decription of vendor and/or device. """
 
+import sys
+
 # pylint: disable=misplaced-bare-raise
 
 class USB(object):
index 99c9d0420181dcaa557149118f23d122fc9518cd..be28b790da090fb9af6b041619f9b6b996fac2a4 100644 (file)
@@ -46,7 +46,7 @@ class ImageObject(Object):
        def font(self):
                fontfile = os.path.join(
                        self.request.application.settings.get("static_path", ""),
-                       "fonts/Mukta-Regular.ttf"
+                       "fonts/Prompt-Regular.ttf"
                )
 
                return ImageFont.truetype(fontfile, 15, encoding="unic")
@@ -82,15 +82,11 @@ class Image1(ImageObject):
        def render(self):
                _ = self.locale.translate
 
-               line1 = [_("%s on %s") % (self.profile.release_short, self.profile.arch),]
+               line1 = [_("%s on %s") % (self.profile.system.release, self.profile.processor.arch),]
                line2 = []
 
-               # Show the appliance model in the second line if available
-               if self.profile.appliance:
-                       line2.append(self.profile.appliance)
-
                # Show the hypervisor vendor for virtual machines
-               elif self.profile.virtual:
+               if self.profile.system.is_virtual():
                        if self.profile.hypervisor:
                                line2.append(_("Virtualised on %s") % self.profile.hypervisor)
                        else:
@@ -101,7 +97,7 @@ class Image1(ImageObject):
                        if self.profile.processor:
                                line2.append(self.profile.processor.friendly_string)
 
-                       line2.append(self.profile.friendly_memory)
+                       line2.append(self.profile.system.friendly_memory)
 
                self.draw_text((225, 5), " | ".join(line1))
                self.draw_text((225, 23), "%s" % " - ".join(line2))
diff --git a/src/backend/lists.py b/src/backend/lists.py
new file mode 100644 (file)
index 0000000..d2adff4
--- /dev/null
@@ -0,0 +1,137 @@
+#!/usr/bin/python3
+
+import json
+import urllib.parse
+
+from . import accounts
+from . import misc
+
+class Lists(misc.Object):
+       @property
+       def url(self):
+               """
+                       Returns the base URL of a Mailman instance
+               """
+               return self.settings.get("mailman-url")
+
+       @property
+       def username(self):
+               return self.settings.get("mailman-username")
+
+       @property
+       def password(self):
+               return self.settings.get("mailman-password")
+
+       async def _request(self, method, url, data=None):
+               headers, body = {}, None
+
+               # URL
+               url = urllib.parse.urljoin(self.url, url)
+
+               # For GET requests, append query arguments
+               if method == "GET":
+                       if data:
+                               url = "%s?%s" % (url, urllib.parse.urlencode(data))
+
+               # For POST/PUT encode all arguments as JSON
+               elif method in ("POST", "PUT", "PATCH"):
+                       headers |= {
+                               "Content-Type" : "application/json",
+                       }
+
+                       body = json.dumps(data)
+
+               # Send the request and wait for a response
+               res = await self.backend.http_client.fetch(url, method=method,
+                       headers=headers, body=body,
+
+                       # Authentication
+                       auth_username=self.username, auth_password=self.password,
+               )
+
+               # Decode JSON response
+               body = json.loads(res.body)
+
+               # XXX handle errors
+
+               return body
+
+       # Lists
+
+       async def _get_lists(self, *args, **kwargs):
+               lists = []
+
+               # Fetch the response
+               response = await self._request(*args, **kwargs)
+
+               # Fetch entries
+               for entry in response.get("entries", []):
+                       list = List(self.backend, **entry)
+                       lists.append(list)
+
+               return lists
+
+       async def get_lists(self):
+               """
+                       Fetches all available lists
+               """
+               data = {
+                       "advertised" : True,
+               }
+
+               return await self._get_lists("GET", "/api/3.1/lists", data=data)
+
+       async def get_subscribed_lists(self, account):
+               data = {
+                       "subscriber" : account.email,
+                       "role"       : "member",
+               }
+
+               return await self._get_lists("GET", "/api/3.1/members/find", data=data)
+
+
+class List(misc.Object):
+       def init(self, list_id, **kwargs):
+               self.list_id = list_id
+
+               # Store all other data
+               self.data = kwargs
+
+       def __repr__(self):
+               return "<List %s>" % self.list_id
+
+       def __str__(self):
+               return self.display_name
+
+       def __eq__(self, other):
+               if isinstance(other, self.__class__):
+                       return self.list_id == other.list_id
+
+               return NotImplemented
+
+       def __lt__(self, other):
+               if isinstance(other, self.__class__):
+                       return self.list_id < other.list_id
+
+               return NotImplemented
+
+       def __len__(self):
+               return self.data.get("member_count")
+
+       @property
+       def display_name(self):
+               return self.data.get("display_name")
+
+       @property
+       def description(self):
+               return self.data.get("description")
+
+       @property
+       def archive_url(self):
+               return "https://lists.ipfire.org/hyperkitty/list/%s/" % self.list_id
+
+       async def subscribe(self, account):
+               pass # XXX TODO
+
+       async def unsubscribe(self, account):
+               pass # XXX TODO
diff --git a/src/backend/memcached.py b/src/backend/memcached.py
deleted file mode 100644 (file)
index 56a8cc8..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/python
-
-import logging
-import memcache
-
-from .misc import Object
-
-class Memcached(Object):
-       def init(self):
-               self._connection = memcache.Client(["localhost"], debug=1)
-
-       def get(self, key, *args, **kwargs):
-               key = self._sanitize_key(key)
-
-               logging.debug("Retrieving %s from cache..." % key)
-
-               ret = self._connection.get(key, *args, **kwargs)
-
-               if ret is None:
-                       logging.debug("Found nothing for %s" % key)
-               else:
-                       logging.debug("Found object for %s" % key)
-
-               return ret
-
-       def get_multi(self, keys, *args, **kwargs):
-               keys = (self._sanitize_key(key) for key in keys)
-
-               logging.debug("Retrieving keys from cache: %s" % keys)
-
-               ret = self._connection.get_multi(keys, *args, **kwargs)
-
-               if ret is None:
-                       logging.debug("Found nothing for %s" % keys)
-               else:
-                       logging.debug("Found objects for %s" % keys)
-
-               return ret
-
-       def add(self, key, data, *args, **kwargs):
-               key = self._sanitize_key(key)
-
-               if data is None:
-                       logging.debug("Putting nothing into cache for %s" % key)
-               else:
-                       logging.debug("Putting object into cache for %s" % key)
-
-               return self._connection.add(key, data, *args, **kwargs)
-
-       def set(self, key, data, *args, **kwargs):
-               key = self._sanitize_key(key)
-
-               if data is None:
-                       logging.debug("Putting nothing into cache for %s" % key)
-               else:
-                       logging.debug("Putting object into cache for %s" % key)
-
-               return self._connection.set(key, data, *args, **kwargs)
-
-       def delete(self, key, *args, **kwargs):
-               key = self._sanitize_key(key)
-
-               return self._connection.delete(key, *args, **kwargs)
-
-       def incr(self, key):
-               key = self._sanitize_key(key)
-
-               logging.debug("Incrementing key %s" % key)
-
-               return self._connection.incr(key)
-
-       @staticmethod
-       def _sanitize_key(key):
-               # Memcache does not seem to like any spaces
-               return key.replace(" ", "-")
index 817a6f10d4eccbe6a77940c47948526f19d5b51d..53f62baa33bda857dc0beaaee4f1aca3c36ea954 100644 (file)
@@ -1,10 +1,14 @@
 #!/usr/bin/python3
 
+import base64
 import email
 import email.mime.multipart
 import email.mime.text
 import email.utils
 import logging
+import mimetypes
+import os.path
+import pynliner
 import random
 import smtplib
 import socket
@@ -17,6 +21,9 @@ from . import misc
 from . import util
 from .decorators import *
 
+# Encode emails in UTF-8 by default
+email.charset.add_charset("utf-8", email.charset.SHORTEST, email.charset.QP, "utf-8")
+
 class Messages(misc.Object):
        @lazy_property
        def queue(self):
@@ -30,7 +37,12 @@ class Messages(misc.Object):
                templates_dir = self.backend.config.get("global", "templates_dir")
                assert templates_dir
 
-               return tornado.template.Loader(templates_dir, autoescape=None)
+               # Setup namespace
+               namespace = {
+                       "embed_image" : self.embed_image,
+               }
+
+               return tornado.template.Loader(templates_dir, namespace=namespace, autoescape=None)
 
        def make_recipient(self, recipient):
                # Use the contact instead of the account
@@ -137,6 +149,10 @@ class Messages(misc.Object):
                                except KeyError:
                                        message.add_header(header, value)
 
+                       # Inline any CSS
+                       if extension == "html":
+                               message_part = self._inline_css(message_part)
+
                        # Create a MIMEText object out of it
                        message_part = email.mime.text.MIMEText(
                                message_part.get_payload(), mimetype)
@@ -153,6 +169,44 @@ class Messages(misc.Object):
                if self.backend.debug:
                        self.template_loader.reset()
 
+       def _inline_css(self, part):
+               """
+                       Inlines any CSS into style attributes
+               """
+               # Fetch the payload
+               payload = part.get_payload()
+
+               # Setup Pynliner
+               p = pynliner.Pynliner().from_string(payload)
+
+               # Run the inlining
+               payload = p.run()
+
+               # Set the payload again
+               part.set_payload(payload)
+
+               return part
+
+       def embed_image(self, path):
+               static_dir = self.backend.config.get("global", "static_dir")
+               assert static_dir
+
+               # Make the path absolute
+               path = os.path.join(static_dir, path)
+
+               # Fetch the mimetype
+               mimetype, encoding = mimetypes.guess_type(path)
+
+               # Read the file
+               with open(path, "rb") as f:
+                       data = f.read()
+
+               # Convert data into base64
+               data = base64.b64encode(data)
+
+               # Return everything
+               return "data:%s;base64,%s" % (mimetype, data.decode())
+
        async def send_cli(self, template, recipient):
                """
                        Send a test message from the CLI
index f2f2e7532619fdb3872382fee74b61150f0bd0bb..12474435309f01f56336c185e30e259229f0b176 100644 (file)
@@ -1,5 +1,7 @@
 #!/usr/bin/python
 
+import psycopg.adapt
+
 class Object(object):
        def __init__(self, backend, *args, **kwargs):
                self.backend = backend
@@ -32,10 +34,14 @@ class Object(object):
        def iuse(self):
                return self.backend.iuse
 
-       @property
-       def memcache(self):
-               return self.backend.memcache
-
        @property
        def settings(self):
                return self.backend.settings
+
+
+# SQL Integration
+
+class ObjectDumper(psycopg.adapt.Dumper):
+       def dump(self, obj):
+               # Return the ID (as bytes)
+               return bytes("%s" % obj.id, "utf-8")
index db143d5bece6f37b1a26a07cb7c22c74241ef32f..43f5552a962611b34faf56ee1be90ac99f48ce28 100644 (file)
 #!/usr/bin/python3
 
+import asyncio
 import datetime
+import io
+import ipaddress
+import logging
+import magic
+import struct
+import tornado.iostream
+import tornado.tcpserver
 
+from . import base
 from .misc import Object
+from .decorators import *
+
+# Setup logging
+log = logging.getLogger(__name__)
+
+CHUNK_SIZE = 1024 ** 2
 
 class Nopaste(Object):
-       def create(self, subject, content, mimetype="text", expires=None, account=None, address=None):
-               self._cleanup_database()
+       def _get_paste(self, query, *args, **kwargs):
+               return self.db.fetch_one(Paste, query, *args, **kwargs)
+
+       def create(self, content, account, subject=None, mimetype=None, expires=None, address=None):
+               # Convert any text to bytes
+               if isinstance(content, str):
+                       content = content.encode("utf-8")
 
-               uid = None
-               if account:
-                       uid = account.uid
+               # Store the blob
+               blob_id = self._store_blob(content)
+
+               # Guess the mimetype if none set
+               if not mimetype:
+                       mimetype = magic.from_buffer(content, mime=True)
 
                if expires:
                        expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires)
 
                # http://blog.00null.net/easily-generating-random-strings-in-postgresql/
-               res = self.db.get("INSERT INTO nopaste(uuid, subject, content, time_expires, address, \
-                       uid, mimetype, size) VALUES(random_slug(), %s, %s, %s, %s, %s, %s, %s) RETURNING uuid",
-                       subject, content, expires or None, address, uid, mimetype, len(content))
+               paste = self._get_paste("""
+                       INSERT INTO
+                               nopaste
+                       (
+                               uuid,
+                               account,
+                               subject,
+                               expires_at,
+                               address,
+                               mimetype,
+                               size,
+                               blob_id
+                       )
+                       VALUES
+                       (
+                               random_slug(), %s, %s, %s, %s, %s, %s, %s
+                       )
+                       RETURNING
+                               *
+                       """, account.uid, subject, expires or None, address, mimetype, len(content), blob_id,
+               )
+
+               # Log result
+               log.info("Created a new paste (%s) of %s byte(s) from %s (%s - %s)" % (
+                       paste.uuid, paste.size, paste.address, paste.asn or "N/A", paste.country or "N/A",
+               ))
+
+               return paste
 
-               if res:
-                       return res.uuid
+       def _fetch_blob(self, id):
+               blob = self.db.get("""
+                       SELECT
+                               data
+                       FROM
+                               nopaste_blobs
+                       WHERE
+                               id = %s
+                       """, id,
+               )
+
+               if blob:
+                       return blob.data
+
+       def _store_blob(self, data):
+               """
+                       Stores the blob by sending it to the database and returning its ID
+               """
+               blob = self.db.get("""
+                       INSERT INTO
+                               nopaste_blobs
+                       (
+                               data
+                       )
+                       VALUES
+                       (
+                               %s
+                       )
+                       ON CONFLICT
+                       (
+                               digest(data, 'sha256')
+                       )
+                       DO UPDATE SET
+                               last_uploaded_at = CURRENT_TIMESTAMP
+                       RETURNING
+                               id
+                       """, data,
+               )
+
+               # Return the ID
+               return blob.id
 
        def get(self, uuid):
-               res = self.db.get("SELECT uuid, subject, time_created, time_expires, address, uid, \
-                       mimetype, views, size FROM nopaste WHERE uuid = %s AND (CASE WHEN time_expires \
-                       IS NULL THEN TRUE ELSE NOW() < time_expires END)", uuid)
-
-               if res:
-                       # Get the account that uploaded this if available
-                       res.account = None
-                       if res.uid:
-                               res.account = self.backend.accounts.get_by_uid(res.uid)
-
-                       # Touch the entry so it won't be deleted when it is still used
-                       self._update_lastseen(uuid)
-
-               return res
-
-       def get_content(self, uuid):
-               res = self.db.get("SELECT content FROM nopaste \
-                       WHERE uuid = %s", uuid)
-
-               if res:
-                       return bytes(res.content)
-
-       def _update_lastseen(self, uuid):
-               self.db.execute("UPDATE nopaste SET time_lastseen = NOW(), views = views + 1 \
-                       WHERE uuid = %s", uuid)
-
-       def _cleanup_database(self):
-               # Delete old pastes when they are expired or when they have not been
-               # accessed in a long time.
-               self.db.execute("DELETE FROM nopaste WHERE (CASE \
-                       WHEN time_expires IS NULL \
-                               THEN time_lastseen + INTERVAL '6 months' <= NOW() \
-                       ELSE NOW() >= time_expires END)")
+               paste = self._get_paste("""
+                       SELECT
+                               *
+                       FROM
+                               nopaste
+                       WHERE
+                               uuid = %s
+                       AND (
+                               expires_at >= CURRENT_TIMESTAMP
+                       OR
+                               expires_at IS NULL
+                       )
+                       """, uuid,
+               )
+
+               return paste
+
+       def cleanup(self):
+               """
+                       Removes all expired pastes and removes any unneeded blobs
+               """
+               # Remove all expired pastes
+               self.db.execute("""
+                       DELETE FROM
+                               nopaste
+                       WHERE
+                               expires_at < CURRENT_TIMESTAMP
+               """)
+
+               # Remove unneeded blobs
+               self.db.execute("""
+                       DELETE FROM
+                               nopaste_blobs
+                       WHERE NOT EXISTS
+                       (
+                               SELECT
+                                       1
+                               FROM
+                                       nopaste
+                               WHERE
+                                       nopaste.blob_id = nopaste_blobs.id
+                       )
+               """)
+
+
+class Paste(Object):
+       def init(self, id, data):
+               self.id, self.data = id, data
+
+       def __str__(self):
+               return self.subject or self.uuid
+
+       # UUID
+
+       @property
+       def uuid(self):
+               return self.data.uuid
+
+       # Subject
+
+       @property
+       def subject(self):
+               return self.data.subject
+
+       # Created At
+
+       @property
+       def created_at(self):
+               return self.data.created_at
+
+       time_created = created_at
+
+       # Expires At
+
+       @property
+       def expires_at(self):
+               return self.data.expires_at
+
+       time_expires = expires_at
+
+       # Account
+
+       @lazy_property
+       def account(self):
+               return self.backend.accounts.get_by_uid(self.data.account)
+
+       # Blob
+
+       @lazy_property
+       def blob(self):
+               return self.backend.nopaste._fetch_blob(self.data.blob_id)
+
+       content = blob
+
+       # Size
+
+       @property
+       def size(self):
+               return self.data.size
+
+       # MIME Type
+
+       @property
+       def mimetype(self):
+               return self.data.mimetype or "application/octet-stream"
+
+       # Address
+
+       @property
+       def address(self):
+               return self.data.address
+
+       # Location
+
+       @lazy_property
+       def location(self):
+                return self.backend.location.lookup("%s" % self.address)
+
+       # ASN
+
+       @lazy_property
+       def asn(self):
+               if self.location and self.location.asn:
+                       return self.backend.location.get_as(self.location.asn)
+
+       # Country
+
+       @lazy_property
+       def country(self):
+               if self.location and self.location.country_code:
+                       return self.backend.location.get_country(self.location.country_code)
+
+       # Viewed?
+
+       def viewed(self):
+               """
+                       Call this when this paste has been viewed/downloaded/etc.
+               """
+               self.db.execute("""
+                       UPDATE
+                               nopaste
+                       SET
+                               last_accessed_at = CURRENT_TIMESTAMP,
+                               views = views + 1
+                       WHERE
+                               id = %s
+                       """, self.id,
+               )
index ec99cc511ac7d4068efa42c406002a4050c0c57b..f4b2b4c129ec80aed31a1982097a5b65dd59ad20 100644 (file)
@@ -11,8 +11,6 @@ class RateLimiter(misc.Object):
 
 
 class RateLimiterRequest(misc.Object):
-       prefix = "ratelimit"
-
        def init(self, request, handler, minutes, limit):
                self.request = request
                self.handler = handler
@@ -21,81 +19,80 @@ class RateLimiterRequest(misc.Object):
                self.minutes = minutes
                self.limit   = limit
 
+               # What is the current time?
                self.now = datetime.datetime.utcnow()
 
-               # Fetch the current counter value from the cache
-               self.counter = self.get_counter()
-
-               # Increment the rate-limiting counter
-               self.increment_counter()
+               # When to expire?
+               self.expires_at = self.now + datetime.timedelta(minutes=self.minutes + 1)
 
-               # Write the header if we are not limited
-               if not self.is_ratelimited():
-                       self.write_headers()
+               self.prefix = "-".join((
+                       self.__class__.__name__,
+                       self.request.host,
+                       self.request.path,
+                       self.request.method,
+                       self.request.remote_ip,
+               ))
 
-       def is_ratelimited(self):
+       async def is_ratelimited(self):
                """
                        Returns True if the request is prohibited by the rate limiter
                """
+               counter = await self.get_counter()
+
                # The client is rate-limited when more requests have been
                # received than allowed.
-               return self.counter >= self.limit
+               if counter >= self.limit:
+                       return True
+
+               # Increment the counter
+               await self.increment_counter()
+
+               # If not ratelimited, write some headers
+               self.write_headers(counter=counter)
+
+       @property
+       def key(self):
+               return "%s-%s" % (self.prefix, self.now.strftime("%Y-%m-%d-%H:%M"))
+
+       @property
+       def keys_to_check(self):
+               for minute in range(self.minutes + 1):
+                       when = self.now - datetime.timedelta(minutes=minute)
+
+                       yield "%s-%s" % (self.prefix, when.strftime("%Y-%m-%d-%H:%M"))
 
-       def get_counter(self):
+       async def get_counter(self):
                """
                        Returns the number of requests that have been done in
                        recent time.
                """
-               keys = self.get_keys_to_check()
+               async with await self.backend.cache.pipeline() as p:
+                       for key in self.keys_to_check:
+                               await p.get(key)
 
-               res = self.memcache.get_multi(keys)
-               if res:
-                       return sum((int(e) for e in res.values()))
+                       # Run the pipeline
+                       res = await p.execute()
 
-               return 0
+               # Return the sum
+               return sum((int(e) for e in res if e))
 
-       def write_headers(self):
+       def write_headers(self, counter):
                # Send the limit to the user
                self.handler.set_header("X-Rate-Limit-Limit", self.limit)
 
                # Send the user how many requests are left for this time window
-               self.handler.set_header("X-Rate-Limit-Remaining",
-                       self.limit - self.counter)
+               self.handler.set_header("X-Rate-Limit-Remaining", self.limit - counter)
 
-               expires = self.now + datetime.timedelta(seconds=self.expires_after)
-               self.handler.set_header("X-Rate-Limit-Reset", expires.strftime("%s"))
+               # Send when the limit resets
+               self.handler.set_header("X-Rate-Limit-Reset", self.expires_at.strftime("%s"))
 
-       def get_key(self):
-               key_prefix = self.get_key_prefix()
+       async def increment_counter(self):
+               async with await self.backend.cache.pipeline() as p:
+                       # Increment the key
+                       await p.incr(self.key)
 
-               return "%s-%s" % (key_prefix, self.now.strftime("%Y-%m-%d-%H:%M"))
+                       # Set expiry
+                       await p.expireat(self.key, self.expires_at)
 
-       def get_keys_to_check(self):
-               key_prefix = self.get_key_prefix()
-
-               keys = []
-               for minute in range(self.minutes + 1):
-                       when = self.now - datetime.timedelta(minutes=minute)
-
-                       key = "%s-%s" % (key_prefix, when.strftime("%Y-%m-%d-%H:%M"))
-                       keys.append(key)
-
-               return keys
-
-       def get_key_prefix(self):
-               return "-".join((self.prefix, self.request.host, self.request.path,
-                       self.request.method, self.request.remote_ip,))
-
-       def increment_counter(self):
-               key = self.get_key()
-
-               # Add the key or increment if it already exists
-               if not self.memcache.add(key, "1", self.expires_after):
-                       self.memcache.incr(key)
-
-       @property
-       def expires_after(self):
-               """
-                       Returns the number of seconds after which the counter has reset.
-               """
-               return (self.minutes + 1) * 60
+                       # Run the pipeline
+                       await p.execute()
index a0a8cf93154adf8dc7446a14d0081d4485bdab2f..bed051d04ce477ecca4e5133fde0a48bb38ca5c2 100644 (file)
@@ -5,19 +5,11 @@ import logging
 import os
 import re
 import urllib.parse
-import yabencode
 
 from . import database
 from .misc import Object
 from .decorators import *
 
-TRACKERS = (
-    "http://ipv4.tracker.ipfire.org:6969/announce",
-    "udp://ipv4.tracker.ipfire.org:6969",
-    "http://ipv6.tracker.ipfire.org:6969/announce",
-    "udp://ipv6.tracker.ipfire.org:6969",
-)
-
 class File(Object):
        def __init__(self, backend, release, id, data=None):
                Object.__init__(self, backend)
@@ -59,9 +51,6 @@ class File(Object):
                if filename.endswith(".iso"):
                        return "iso"
 
-               elif filename.endswith(".torrent"):
-                       return "torrent"
-
                elif "xen" in filename:
                        if "downloader" in filename:
                                return "xen-downloader"
@@ -104,7 +93,6 @@ class File(Object):
                        "armv5tel"      : _("Flash Image"),
                        "armv5tel-scon" : _("Flash Image with serial console"),
                        "iso"           : _("ISO Image"),
-                       "torrent"       : _("Torrent File"),
                        "flash"         : _("Flash Image"),
                        "alix"          : _("Flash Image with serial console"),
                        "usbfdd"        : _("USB FDD Image"),
@@ -122,7 +110,6 @@ class File(Object):
        def prio(self):
                priorities = {
                        "iso"           : 10,
-                       "torrent"       : 20,
                        "flash"         : 40,
                        "alix"          : 41,
                        "usbfdd"        : 31,
@@ -132,7 +119,7 @@ class File(Object):
                        "xen"           : 50,
                        "xen-downloader": 51,
                }
-               
+
                try:
                        return priorities[self.type]
                except KeyError:
@@ -168,35 +155,6 @@ class File(Object):
 
                return "N/A"
 
-       @property
-       def torrent_hash(self):
-               return self.data.get("torrent_hash", None)
-
-       @property
-       def torrent_url(self):
-               if self.torrent_hash:
-                       return "%s.torrent" % self.url
-
-       @property
-       def magnet_link(self):
-               # Don't return anything if we have no torrent hash.
-               if self.torrent_hash is None:
-                       return
-
-               s = "magnet:?xt=urn:btih:%s" % self.torrent_hash
-
-               #s += "&xl=%d" % self.size
-               s += "&dn=%s" % urllib.parse.quote(self.basename)
-
-               # Add our tracker.
-               for tracker in TRACKERS:
-                       s += "&tr=%s" % tracker
-
-               # Add web download URL
-               s += "&as=%s" % urllib.parse.quote(self.url)
-
-               return s
-
 
 class Release(Object):
        def __init__(self, backend, id, data=None):
@@ -234,8 +192,12 @@ class Release(Object):
                                arches.append(arch)
                                break
 
-               # Add ARM if available
-               if "arm" in self.arches:
+               # Add aarch64 if available
+               if "aarch64" in self.arches:
+                       arches.append("aarch64")
+
+               # Add ARM before 2.27 if available
+               if "arm" in self.arches and self.sname < "ipfire-2.27-core159":
                        arches.append("arm")
 
                return arches
@@ -254,7 +216,7 @@ class Release(Object):
 
        @property
        def experimental_arches(self):
-               return ("aarch64",)
+               return []
 
        @property
        def files(self):
@@ -272,18 +234,6 @@ class Release(Object):
                        if f.arch == arch:
                                yield f
 
-       @property
-       def torrents(self):
-               torrents = []
-
-               for file in self.files:
-                       if not file.torrent_hash:
-                               continue
-
-                       torrents.append(file)
-
-               return torrents
-
        @property
        def name(self):
                return self.__data.name
@@ -300,16 +250,6 @@ class Release(Object):
                if self.__data.blog_id:
                        return self.backend.blog.get_by_id(self.__data.blog_id)
 
-       @property
-       def fireinfo_id(self):
-               name = self.sname.replace("ipfire-", "IPFire ").replace("-", " - ")
-
-               res = self.db.get("SELECT id FROM fireinfo_releases \
-                       WHERE name = %s", name)
-
-               if res:
-                       return res.id
-
        @property
        def stable(self):
                return self.__data.stable
@@ -368,7 +308,7 @@ class Release(Object):
                        if _filename in files:
                                continue
 
-                       if filename.endswith(".md5"):
+                       if filename.endswith(".b2") or filename.endswith(".md5"):
                                continue
 
                        logging.info("Hashing %s..." % filename)
@@ -376,39 +316,9 @@ class Release(Object):
                        hash_sha1   = self.__file_hash(filename, "sha1")
                        filesize = os.path.getsize(filename)
 
-                       # Check if there is a torrent download available for this file:
-                       torrent_hash = ""
-                       torrent_file = "%s.torrent" % filename
-                       if os.path.exists(torrent_file):
-                               torrent_hash = self.torrent_read_hash(torrent_file)
-
                        self.db.execute("INSERT INTO files(releases, filename, filesize, \
-                               sha256, sha1, torrent_hash) VALUES(%s, %s, %s, %s, %s, %s)",
-                               self.id, _filename, filesize, hash_sha256, hash_sha1, torrent_hash)
-
-               # Search for all files that miss a torrent hash.
-               files = self.db.query("SELECT id, filename FROM files \
-                       WHERE releases = %s AND torrent_hash IS NULL", self.id)
-
-               for file in files:
-                       path = os.path.join(basepath, file.filename)
-
-                       torrent_file = "%s.torrent" % path
-                       if os.path.exists(torrent_file):
-                               torrent_hash = self.torrent_read_hash(torrent_file)
-
-                               self.db.execute("UPDATE files SET torrent_hash = %s WHERE id = %s",
-                                       torrent_hash, file.id)
-
-       def torrent_read_hash(self, filename):
-               with open(filename, "rb") as f:
-                       metainfo = yabencode.decode(f)
-                       metainfo = yabencode.encode(metainfo["info"])
-
-                       h = hashlib.new("sha1")
-                       h.update(metainfo)
-
-                       return h.hexdigest()
+                               sha256, sha1) VALUES(%s, %s, %s, %s, %s)",
+                               self.id, _filename, filesize, hash_sha256, hash_sha1)
 
        def supports_arch(self, arch):
                return arch in ("x86_64", "i586")
@@ -451,10 +361,13 @@ class Release(Object):
 
        # Fireinfo Stuff
 
-       @property
-       def penetration(self):
+       def get_usage(self, when=None):
+               name = self.sname.replace("ipfire-", "IPFire ").replace("-", " - ")
+
                # Get penetration from fireinfo
-               return self.backend.fireinfo.get_release_penetration(self)
+               releases = self.backend.fireinfo.get_releases_map(when=when)
+
+               return releases.get(name, 0)
 
 
 class Releases(Object):
@@ -563,18 +476,6 @@ class Releases(Object):
 
                return releases
 
-       def get_file_for_torrent_hash(self, torrent_hash):
-               file = self.db.get("SELECT id, releases FROM files WHERE torrent_hash = %s LIMIT 1",
-                       torrent_hash)
-
-               if not file:
-                       return
-
-               release = Release(self.backend, file.releases)
-               file = File(self.backend, release, file.id)
-
-               return file
-
        async def scan_files(self, basepath="/pub/mirror"):
                for release in self:
                        logging.debug("Scanning %s..." % release)
diff --git a/src/backend/talk.py b/src/backend/talk.py
deleted file mode 100644 (file)
index 4215e7d..0000000
+++ /dev/null
@@ -1,356 +0,0 @@
-#!/usr/bin/python
-
-import ipaddress
-import logging
-import re
-import time
-
-from . import database
-
-from .misc import Object
-from .decorators import *
-
-class Freeswitch(Object):
-       @lazy_property
-       def db(self):
-               credentials = {
-                       "host"     : self.settings.get("freeswitch_database_host"),
-                       "database" : self.settings.get("freeswitch_database_name", "freeswitch"),
-                       "user"     : self.settings.get("freeswitch_database_user"),
-                       "password" : self.settings.get("freeswitch_database_password"),
-               }
-
-               return database.Connection(**credentials)
-
-       def get_sip_registrations(self, sip_uri):
-               logging.debug("Fetching SIP registrations for %s" % sip_uri)
-
-               user, delim, domain = sip_uri.partition("@")
-
-               res = self.db.query("SELECT * FROM sip_registrations \
-                       WHERE sip_user = %s AND sip_host = %s AND expires >= EXTRACT(epoch FROM CURRENT_TIMESTAMP) \
-                       ORDER BY contact", user, domain)
-
-               for row in res:
-                       yield SIPRegistration(self, data=row)
-
-       def _get_channels(self, query, *args):
-               res = self.db.query(query, *args)
-
-               channels = []
-               for row in res:
-                       c = Channel(self, data=row)
-                       channels.append(c)
-
-               return channels
-
-       def get_sip_channels(self, account):
-               return self._get_channels("SELECT * FROM channels \
-                       WHERE (direction = %s AND cid_num = %s) OR \
-                               (direction = %s AND (callee_num = %s OR callee_num = ANY(%s))) \
-                               AND callstate != %s ORDER BY created_epoch",
-                       "inbound", account.sip_id, "outbound", account.sip_id,
-                       account._all_telephone_numbers, "DOWN")
-
-       def get_cdr_by_account(self, account, date=None, limit=None):
-               res = self.db.query("SELECT * FROM cdr \
-                       WHERE ((caller_id_number = ANY(%s) AND bleg_uuid IS NOT NULL) \
-                               OR (destination_number = ANY(%s) AND bleg_uuid IS NULL)) \
-                               AND (%s IS NULL OR start_stamp::date = %s) \
-                       ORDER BY end_stamp DESC LIMIT %s", account._all_telephone_numbers,
-                       account._all_telephone_numbers, date, date, limit)
-
-               for row in res:
-                       yield CDR(self, data=row)
-
-       def get_call_by_uuid(self, uuid):
-               res = self.db.get("SELECT * FROM cdr \
-                       WHERE uuid = %s", uuid)
-
-               if res:
-                       return CDR(self, data=res)
-
-       def get_conferences(self):
-               res = self.db.query("SELECT DISTINCT application_data AS handle FROM channels \
-                       WHERE application = %s AND application_data LIKE %s \
-                       ORDER BY application_data", "conference", "%%@ipfire.org")
-
-               conferences = []
-               for row in res:
-                       c = Conference(self, row.handle)
-                       conferences.append(c)
-
-               return conferences
-
-       def get_agent_status(self, account):
-               res = self.db.get("SELECT status FROM agents \
-                       WHERE name = %s", account.sip_url)
-
-               if res:
-                       return res.status
-
-class SIPRegistration(object):
-       def __init__(self, freeswitch, data):
-               self.freeswitch = freeswitch
-               self.data = data
-
-       @lazy_property
-       def protocol(self):
-               m = re.match(r"Registered\(([A-Z]+)(\-NAT)?\)", self.data.status)
-
-               if m:
-                       return m.group(1)
-
-       @property
-       def network_ip(self):
-               return ipaddress.ip_address(self.data.network_ip)
-
-       @property
-       def network_port(self):
-               return self.data.network_port
-
-       @property
-       def user_agent(self):
-               return self.data.user_agent
-
-       def is_reachable(self):
-               return self.data.ping_status == "Reachable"
-
-       @lazy_property
-       def latency(self):
-               if self.is_reachable() and self.data.ping_time:
-                       return self.data.ping_time / 1000.0
-
-
-class Channel(object):
-       def __init__(self, freeswitch, data):
-               self.freeswitch = freeswitch
-               self.data = data
-
-       @property
-       def backend(self):
-               return self.freeswitch.backend
-
-       @property
-       def uuid(self):
-               return self.data.uuid
-
-       @property
-       def direction(self):
-               return self.data.direction
-
-       @lazy_property
-       def caller(self):
-               return self.backend.accounts.get_by_sip_id(self.caller_number)
-
-       @property
-       def caller_name(self):
-               return self.data.cid_name
-
-       @property
-       def caller_number(self):
-               return self.data.cid_num
-
-       @lazy_property
-       def callee(self):
-               return self.backend.accounts.get_by_sip_id(self.callee_number)
-
-       @property
-       def callee_name(self):
-               return self.data.callee_name
-
-       @property
-       def callee_number(self):
-               return self.data.callee_num
-
-       @property
-       def called_number(self):
-               return self.data.dest
-
-       @property
-       def state(self):
-               return self.data.callstate
-
-       @property
-       def application(self):
-               return self.data.application
-
-       @property
-       def application_data(self):
-               return self.data.application_data
-
-       @lazy_property
-       def conference(self):
-               if self.application == "conference":
-                       return Conference(self.freeswitch, self.application_data)
-
-       @property
-       def duration(self):
-               return time.time() - self.data.created_epoch
-
-       @property
-       def codec(self):
-               # We always assume a symmetric codec
-               return format_codec(self.data.write_codec, int(self.data.write_rate or 0), int(self.data.write_bit_rate or 0))
-
-       def is_secure(self):
-               if self.data.secure:
-                       return True
-
-               return False
-
-       @property
-       def secure(self):
-               try:
-                       transport_protocol, key_negotiation, cipher_suite = self.data.secure.split(":")
-               except:
-                       return
-
-               return "%s: %s" % (key_negotiation.upper(), cipher_suite.replace("_", "-"))
-
-
-class CDR(object):
-       def __init__(self, freeswitch, data):
-               self.freeswitch = freeswitch
-               self.data = data
-
-       @property
-       def backend(self):
-               return self.freeswitch.backend
-
-       @property
-       def db(self):
-               return self.freeswitch.db
-
-       @property
-       def uuid(self):
-               return self.data.uuid
-
-       @lazy_property
-       def bleg(self):
-               if self.data.bleg_uuid:
-                       return self.freeswitch.get_call_by_uuid(self.data.bleg_uuid)
-
-               # If we are the bleg, we need to search for one where UUID is the bleg
-               res = self.db.get("SELECT * FROM cdr WHERE bleg_uuid = %s", self.uuid)
-
-               if res:
-                       return CDR(self.freeswitch, data=res)
-
-       @property
-       def direction(self):
-               if self.data.bleg_uuid:
-                       return "inbound"
-
-               return "outbound"
-
-       @lazy_property
-       def caller(self):
-               return self.backend.accounts.get_by_phone_number(self.data.caller_id_number)
-
-       @property
-       def caller_number(self):
-               return self.data.caller_id_number
-
-       @lazy_property
-       def callee(self):
-               return self.backend.accounts.get_by_phone_number(self.data.destination_number)
-
-       @property
-       def callee_number(self):
-               return self.data.destination_number
-
-       @property
-       def time_start(self):
-               return self.data.start_stamp
-
-       @property
-       def time_answered(self):
-               return self.data.answer_stamp
-
-       @property
-       def duration(self):
-               return self.data.duration
-
-       @property
-       def codec(self):
-               return format_codec(self.data.write_codec, int(self.data.write_rate or 0), int(self.data.write_bit_rate or 0))
-
-       @property
-       def user_agent(self):
-               if self.data.user_agent:
-                       return self.data.user_agent.replace("_", " ")
-
-       @property
-       def size(self):
-               return sum((self.data.rtp_audio_in_raw_bytes or 0, self.data.rtp_audio_out_raw_bytes or 0))
-
-       @property
-       def mos(self):
-               return self.data.rtp_audio_in_mos
-
-
-class Conference(object):
-       def __init__(self, freeswitch, handle):
-               self.freeswitch = freeswitch
-               self.handle = handle
-
-       def __repr__(self):
-               return "<%s %s>" % (self.__class__.__name__, self.handle)
-
-       def __len__(self):
-               return len(self.channels)
-
-       def __eq__(self, other):
-               if isinstance(other, self.__class__):
-                       return self.handle == other.handle
-
-       def __iter__(self):
-               return iter(self.channels)
-
-       @lazy_property
-       def number(self):
-               m = re.match(r"conf(\d+)@", self.handle)
-               if m:
-                       i = m.group(1)
-
-                       return int(i)
-
-       @property
-       def sip_id(self):
-               return 900 + self.number
-
-       @lazy_property
-       def channels(self):
-               return self.freeswitch._get_channels("SELECT * FROM channels \
-                       WHERE application = %s AND application_data = %s \
-                       ORDER BY created_epoch", "conference", self.handle)
-
-
-class Talk(Object):
-       def init(self):
-               # Connect to FreeSWITCH
-               self.freeswitch = Freeswitch(self.backend)
-
-       @property
-       def conferences(self):
-               return self.freeswitch.get_conferences()
-
-
-def format_codec(name, bit_rate, bandwidth):
-       if not name:
-               return
-
-       s = [
-               name,
-       ]
-
-       if bit_rate:
-               s.append("%.0f kHz" % (bit_rate / 1000.0))
-
-       if bandwidth:
-               s.append("%.0f kBit/s" % (bandwidth / 1000.0))
-       else:
-               s.append("VBR")
-
-       return " ".join(s)
diff --git a/src/backend/toots.py b/src/backend/toots.py
new file mode 100644 (file)
index 0000000..271d4be
--- /dev/null
@@ -0,0 +1,59 @@
+#!/usr/bin/python3
+
+import datetime
+import logging
+import mastodon
+
+from .misc import Object
+
+class Toots(Object):
+       async def toot(self):
+               """
+                       Sends a random promotional toot
+               """
+               # Do not toot when there was a blog post
+               if self.backend.blog.has_had_recent_activity(hours=24):
+                       logging.debug("Won't toot because the blog has had activity")
+                       return
+
+               # Select a toot
+               toot = self._get_random_toot()
+               if not toot:
+                       logging.warning("Could not find anything to toot")
+                       return
+
+               # Toot the toot!
+               with self.db.transaction():
+                       self._toot(toot)
+
+       def _get_random_toot(self):
+               res = self.db.get(
+                       "WITH candidate_toots AS (SELECT id, \
+                               (CURRENT_TIMESTAMP - COALESCE(last_tooted_at, '1970-01-01')) * RANDOM() AS age \
+                               FROM toots \
+                               WHERE (last_tooted_at IS NULL OR last_tooted_at <= CURRENT_TIMESTAMP - INTERVAL '1 month') \
+                       ) \
+                       SELECT toots.* FROM candidate_toots \
+                               LEFT JOIN toots ON candidate_toots.id = toots.id \
+                               ORDER BY age DESC LIMIT 1")
+
+               return res
+
+       def _toot(self, toot):
+               logging.debug("Posting: %s" % toot.message)
+
+               # Update database status
+               self.db.execute("UPDATE toots \
+                       SET last_tooted_at = CURRENT_TIMESTAMP, total_toots = total_toots + 1 \
+                       WHERE id = %s", toot.id)
+
+               # Connect to Mastodon
+               conn = mastodon.Mastodon(
+                       client_id=self.settings.get("mastodon-client-key"),
+                       client_secret=self.settings.get("mastodon-client-secret"),
+                       access_token=self.settings.get("mastodon-access-token"),
+                       api_base_url="https://social.ipfire.org",
+               )
+
+               # Toot!
+               conn.toot(toot.message)
diff --git a/src/backend/tweets.py b/src/backend/tweets.py
deleted file mode 100644 (file)
index e5fc542..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/python3
-
-import datetime
-import logging
-import twython
-
-from .misc import Object
-
-class Tweets(Object):
-       async def tweet(self):
-               """
-                       Sends a random promotional tweet
-               """
-               # Do not tweet too often
-               if self.has_had_recent_activity(days=3):
-                       logging.debug("Won't tweet because we recently did it")
-                       return
-
-               # Do not tweet when there was a blog post
-               if self.backend.blog.has_had_recent_activity(hours=24):
-                       logging.debug("Won't tweet because the blog has had activity")
-                       return
-
-               # Select a tweet
-               tweet = self._get_random_tweet()
-               if not tweet:
-                       logging.warning("Could not find anything to tweet")
-                       return
-
-               # Tweet the tweet
-               with self.db.transaction():
-                       self._tweet(tweet)
-
-       def has_had_recent_activity(self, **kwargs):
-               t = datetime.timedelta(**kwargs)
-
-               res = self.db.get("SELECT COUNT(*) AS count FROM tweets \
-                       WHERE last_tweeted_at IS NOT NULL AND last_tweeted_at >= NOW() - %s", t)
-
-               if res and res.count > 0:
-                       return True
-
-               return False
-
-       def _get_random_tweet(self):
-               res = self.db.get(
-                       "WITH candidate_tweets AS (SELECT id, \
-                               (CURRENT_TIMESTAMP - COALESCE(last_tweeted_at, '1970-01-01')) * RANDOM() AS age \
-                               FROM tweets \
-                               WHERE (last_tweeted_at IS NULL OR last_tweeted_at <= CURRENT_TIMESTAMP - INTERVAL '1 month') \
-                       ) \
-                       SELECT tweets.* FROM candidate_tweets \
-                               LEFT JOIN tweets ON candidate_tweets.id = tweets.id \
-                               ORDER BY age DESC LIMIT 1")
-
-               return res
-
-       def _tweet(self, tweet):
-               logging.debug("Tweeting: %s" % tweet.message)
-
-               # Update database status
-               self.db.execute("UPDATE tweets \
-                       SET last_tweeted_at = CURRENT_TIMESTAMP, total_tweets = total_tweets + 1 \
-                       WHERE id = %s", tweet.id)
-
-               # Connect to twitter
-               twitter = twython.Twython(
-                       self.settings.get("twitter_consumer_key"),
-                       self.settings.get("twitter_consumer_secret"),
-                       self.settings.get("twitter_%s_access_token" % tweet.account),
-                       self.settings.get("twitter_%s_access_token_secret" % tweet.account),
-               )
-
-               # Update status
-               twitter.update_status(status=tweet.message)
index 2252818ea538455812d324c4787700224178f892..baf6dcb312bdc1d44544b7634ea8e2938050aa86 100644 (file)
@@ -1,8 +1,10 @@
 #!/usr/bin/python3
 
+import PIL.ExifTags
 import PIL.Image
 import PIL.ImageFilter
 import PIL.ImageOps
+import datetime
 import io
 import ipaddress
 import location
@@ -23,22 +25,6 @@ BLOCKLISTS = (
        "xbl.spamhaus.org",
 )
 
-BLACKLISTS = (
-       "b.barracudacentral.org",
-       "bl.spamcop.net",
-       "bl.blocklist.de",
-       "cbl.abuseat.org",
-       "dnsbl-1.uceprotect.net",
-       "dnsbl-2.uceprotect.net",
-       "dnsbl-3.uceprotect.net",
-       "dnsbl.abuse.ch",
-       "ix.dnsbl.manitu.net",
-       "pbl.spamhaus.org",
-       "sbl.spamhaus.org",
-       "xbl.spamhaus.org",
-       "zen.spamhaus.org",
-)
-
 class Address(Object):
        def init(self, address):
                self.address = ipaddress.ip_address(address)
@@ -148,11 +134,6 @@ class Address(Object):
                # Blocked, but no reason
                return return_code, None
 
-       async def get_blacklists(self):
-               blacklists = { bl : await self._resolve_blacklist(bl) for bl in BLACKLISTS }
-
-               return blacklists
-
 
 def format_size(s, max_unit=None):
        units = ("B", "kB", "MB", "GB", "TB")
@@ -171,6 +152,9 @@ def format_time(s, shorter=True):
        #_ = handler.locale.translate
        _ = lambda x: x
 
+       if isinstance(s, datetime.timedelta):
+               s = s.total_seconds()
+
        hrs, s = divmod(s, 3600)
        min, s = divmod(s, 60)
 
@@ -201,16 +185,46 @@ def normalize(s):
 
        return "-".join(s.split())
 
-def generate_thumbnail(data, size, square=False, **args):
-       assert data, "No image data received"
+def generate_thumbnail(image, size, square=False, format=None, quality=None, **args):
+       assert image, "No image data received"
+
+       if not isinstance(image, PIL.Image.Image):
+               image = io.BytesIO(image)
+
+               try:
+                       image = PIL.Image.open(image)
 
-       image = PIL.Image.open(io.BytesIO(data))
+               # If we cannot open the image, we return it in raw form
+               except PIL.UnidentifiedImageError as e:
+                       return image.getvalue()
 
        # Save image format
-       format = image.format
+       format = format or image.format or "JPEG"
+
+       # Fetch any EXIF data
+       try:
+               exif = image._getexif()
+       except AttributeError as e:
+               exif = None
+
+       # Rotate the image
+       if exif:
+               for tag in PIL.ExifTags.TAGS:
+                       if PIL.ExifTags.TAGS[tag] == "Orientation":
+                               try:
+                                       if exif[tag] == 3:
+                                               image = image.rotate(180, expand=True)
+                                       elif exif[tag] == 6:
+                                               image = image.rotate(270, expand=True)
+                                       elif exif[tag] == 8:
+                                               image = image.rotate( 90, expand=True)
+
+                               # Ignore if the orientation isn't encoded
+                               except KeyError:
+                                       pass
 
        # Remove any alpha-channels
-       if image.format == "JPEG" and not image.mode == "RGB":
+       if format == "JPEG" and not image.mode == "RGB":
                # Make a white background
                background = PIL.Image.new("RGBA", image.size, (255,255,255))
 
@@ -230,14 +244,26 @@ def generate_thumbnail(data, size, square=False, **args):
        else:
                image.thumbnail((size, size), PIL.Image.LANCZOS)
 
-       if image.format == "JPEG":
-               # Apply a gaussian blur to make compression easier
+       # Apply a gaussian blur to make compression easier
+       try:
                image = image.filter(PIL.ImageFilter.GaussianBlur(radius=0.05))
+       except ValueError:
+               pass
+
+       # Arguments to optimise the compression
+       args.update({
+               "subsampling" : "4:2:0",
+               "quality"     : quality or 72,
+       })
+
+       if image.format == "JPEG":
+               args.update({
+                       "qtables" : "web_low",
+               })
 
-               # Arguments to optimise the compression
+       elif image.format == "WEBP":
                args.update({
-                       "subsampling" : "4:2:0",
-                       "quality"     : 70,
+                       "lossless" : False,
                })
 
        with io.BytesIO() as f:
index c8a8ff8d44a8fffa045ed71bfcb62f8032f95fc0..e77a27b24cb4f7738232d1acc8484be785c2cbbb 100644 (file)
@@ -1,7 +1,11 @@
 #!/usr/bin/python3
 
 import difflib
+import hashlib
 import logging
+import markdown
+import markdown.extensions
+import markdown.preprocessors
 import os.path
 import re
 import urllib.parse
@@ -24,11 +28,17 @@ class Wiki(misc.Object):
                        return Page(self.backend, res.id, data=res)
 
        def __iter__(self):
-               return self._get_pages(
-                       "SELECT wiki.* FROM wiki_current current \
-                               LEFT JOIN wiki ON current.id = wiki.id \
-                               WHERE current.deleted IS FALSE \
-                               ORDER BY page",
+               return self._get_pages("""
+                       SELECT
+                               wiki.*
+                       FROM
+                               wiki_current current
+                       LEFT JOIN
+                               wiki ON current.id = wiki.id
+                       WHERE
+                               current.deleted IS FALSE
+                       ORDER BY page
+                       """,
                )
 
        def make_path(self, page, path):
@@ -48,34 +58,33 @@ class Wiki(misc.Object):
                # Normalise links
                return os.path.normpath(path)
 
-       def page_exists(self, path):
-               page = self.get_page(path)
+       def _make_url(self, path):
+               """
+                       Composes the URL out of the path
+               """
+               # Remove any leading slashes (if present)
+               path = path.removeprefix("/")
 
-               # Page must have been found and not deleted
-               return page and not page.was_deleted()
+               return os.path.join("/docs", path)
 
        def get_page_title(self, page, default=None):
-               # Try to retrieve title from cache
-               title = self.memcache.get("wiki:title:%s" % page)
-               if title:
-                       return title
-
-               # If the title has not been in the cache, we will
-               # have to look it up
                doc = self.get_page(page)
                if doc:
                        title = doc.title
                else:
                        title = os.path.basename(page)
 
-               # Save in cache for forever
-               self.memcache.set("wiki:title:%s" % page, title)
-
                return title
 
        def get_page(self, page, revision=None):
                page = Page.sanitise_page_name(page)
-               assert page
+
+               # Split the path into parts
+               parts = page.split("/")
+
+               # Check if this is an action
+               if any((part.startswith("_") for part in parts)):
+                       return
 
                if revision:
                        return self._get_page("SELECT * FROM wiki WHERE page = %s \
@@ -102,11 +111,24 @@ class Wiki(misc.Object):
                page = Page.sanitise_page_name(page)
 
                # Write page to the database
-               page = self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
-                       VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
+               page = self._get_page("""
+                       INSERT INTO
+                               wiki
+                       (
+                               page,
+                               author_uid,
+                               markdown,
+                               changes,
+                               address
+                       ) VALUES (
+                               %s, %s, %s, %s, %s
+                       )
+                       RETURNING *
+                       """, page, author.uid, content or None, changes, address,
+               )
 
-               # Update cache
-               self.memcache.set("wiki:title:%s" % page.page, page.title)
+               # Store any linked files
+               page._store_linked_files()
 
                # Send email to all watchers
                page._send_watcher_emails(excludes=[author])
@@ -121,22 +143,43 @@ class Wiki(misc.Object):
                # Just creates a blank last version of the page
                self.create_page(page, author=author, content=None, **kwargs)
 
-       def make_breadcrumbs(self, url):
-               # Split and strip all empty elements (double slashes)
-               parts = list(e for e in url.split("/") if e)
-
+       def make_breadcrumbs(self, path):
                ret = []
-               for part in ("/".join(parts[:i]) for i in range(1, len(parts))):
-                       ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part))))
 
-               return ret
+               while path:
+                       # Cut off everything after the last slash
+                       path, _, _ = path.rpartition("/")
+
+                       # Do not include the root
+                       if not path:
+                               break
+
+                       # Find the page
+                       page = self.get_page(path)
+
+                       # Append the URL and title to the output
+                       ret.append((
+                               page.url if page else self._make_url(path),
+                               page.title if page else os.path.basename(path),
+                       ))
+
+               # Return the breadcrumbs in order
+               return reversed(ret)
 
        def search(self, query, account=None, limit=None):
-               res = self._get_pages("SELECT wiki.* FROM wiki_search_index search_index \
-                       LEFT JOIN wiki ON search_index.wiki_id = wiki.id \
-                       WHERE search_index.document @@ websearch_to_tsquery('english', %s) \
-                               ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC",
-                       query, query)
+               res = self._get_pages("""
+                       SELECT
+                               wiki.*
+                       FROM
+                               wiki_search_index search_index
+                       LEFT JOIN
+                               wiki ON search_index.wiki_id = wiki.id
+                       WHERE
+                               search_index.document @@ websearch_to_tsquery('english', %s)
+                       ORDER BY
+                               ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC
+                       """, query, query,
+               )
 
                pages = []
                for page in res:
@@ -157,16 +200,28 @@ class Wiki(misc.Object):
                """
                        Needs to be called after a page has been changed
                """
-               self.db.execute("REFRESH MATERIALIZED VIEW wiki_search_index")
+               self.db.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY wiki_search_index")
 
        def get_watchlist(self, account):
-               pages = self._get_pages(
-                       "WITH pages AS (SELECT * FROM wiki_current \
-                                       LEFT JOIN wiki ON wiki_current.id = wiki.id) \
-                       SELECT * FROM wiki_watchlist watchlist \
-                               LEFT JOIN pages ON watchlist.page = pages.page \
-                               WHERE watchlist.uid = %s",
-                       account.uid,
+               pages = self._get_pages("""
+                       WITH pages AS (
+                               SELECT
+                                       *
+                               FROM
+                                       wiki_current
+                               LEFT JOIN
+                                       wiki ON wiki_current.id = wiki.id
+                       )
+
+                       SELECT
+                               *
+                       FROM
+                               wiki_watchlist watchlist
+                       JOIN
+                               pages ON watchlist.page = pages.page
+                       WHERE
+                               watchlist.uid = %s
+                       """, account.uid,
                )
 
                return sorted(pages)
@@ -174,8 +229,18 @@ class Wiki(misc.Object):
        # ACL
 
        def check_acl(self, page, account):
-               res = self.db.query("SELECT * FROM wiki_acls \
-                       WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page)
+               res = self.db.query("""
+                       SELECT
+                               *
+                       FROM
+                               wiki_acls
+                       WHERE
+                               %s ILIKE (path || '%%')
+                       ORDER BY
+                               LENGTH(path) DESC
+                       LIMIT 1
+                       """, page,
+               )
 
                for row in res:
                        # Access not permitted when user is not logged in
@@ -208,8 +273,18 @@ class Wiki(misc.Object):
                        return File(self.backend, res.id, data=res)
 
        def get_files(self, path):
-               files = self._get_files("SELECT * FROM wiki_files \
-                       WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path)
+               files = self._get_files("""
+                       SELECT
+                               *
+                       FROM
+                               wiki_files
+                       WHERE
+                               path = %s
+                       AND
+                               deleted_at IS NULL
+                       ORDER BY filename
+                       """, path,
+               )
 
                return list(files)
 
@@ -218,19 +293,52 @@ class Wiki(misc.Object):
 
                if revision:
                        # Fetch a specific revision
-                       return self._get_file("SELECT * FROM wiki_files \
-                               WHERE path = %s AND filename = %s AND created_at <= %s \
-                               ORDER BY created_at DESC LIMIT 1", path, filename, revision)
+                       return self._get_file("""
+                               SELECT
+                                       *
+                               FROM
+                                       wiki_files
+                               WHERE
+                                       path = %s
+                               AND
+                                       filename = %s
+                               AND
+                                       created_at <= %s
+                               ORDER BY
+                                       created_at DESC
+                                       LIMIT 1
+                               """, path, filename, revision,
+                       )
 
                # Fetch latest version
-               return self._get_file("SELECT * FROM wiki_files \
-                       WHERE path = %s AND filename = %s AND deleted_at IS NULL",
-                       path, filename)
+               return self._get_file("""
+                       SELECT
+                               *
+                       FROM
+                               wiki_files
+                       WHERE
+                               path = %s
+                       AND
+                               filename = %s
+                       AND
+                               deleted_at IS NULL
+                       """, path, filename,
+               )
 
        def get_file_by_path_and_filename(self, path, filename):
-               return self._get_file("SELECT * FROM wiki_files \
-                       WHERE path = %s AND filename = %s AND deleted_at IS NULL",
-                       path, filename)
+               return self._get_file("""
+                       SELECT
+                               *
+                       FROM
+                               wiki_files
+                       WHERE
+                               path = %s
+                       AND
+                               filename = %s
+                       AND
+                               deleted_at IS NULL
+                       """, path, filename,
+               )
 
        def upload(self, path, filename, data, mimetype, author, address):
                # Replace any existing files
@@ -239,19 +347,40 @@ class Wiki(misc.Object):
                        file.delete(author)
 
                # Upload the blob first
-               blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) \
-                       ON CONFLICT (digest(data, %s)) DO UPDATE SET data = EXCLUDED.data \
-                       RETURNING id", data, "MD5")
+               blob = self.db.get("""
+                       INSERT INTO
+                               wiki_blobs(data)
+                       VALUES
+                               (%s)
+                       ON CONFLICT
+                               (digest(data, %s))
+                       DO UPDATE
+                               SET data = EXCLUDED.data
+                       RETURNING id
+                       """, data, "MD5",
+               )
 
                # Create entry for file
-               return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \
-                       mimetype, blob_id, size) VALUES(%s,  %s, %s, %s, %s, %s, %s) RETURNING *", path,
-                       filename, author.uid, address, mimetype, blob.id, len(data))
-
-       def render(self, path, text):
-               r = WikiRenderer(self.backend, path)
+               return self._get_file("""
+                       INSERT INTO
+                               wiki_files
+                       (
+                               path,
+                               filename,
+                               author_uid,
+                               address,
+                               mimetype,
+                               blob_id,
+                               size
+                       ) VALUES (
+                               %s, %s, %s, %s, %s, %s, %s
+                       )
+                       RETURNING *
+                       """, path, filename, author.uid, address, mimetype, blob.id, len(data),
+               )
 
-               return r.render(text)
+       def render(self, path, text, **kwargs):
+               return WikiRenderer(self.backend, path, text, **kwargs)
 
 
 class Page(misc.Object):
@@ -266,6 +395,8 @@ class Page(misc.Object):
                if isinstance(other, self.__class__):
                        return self.id == other.id
 
+               return NotImplemented
+
        def __lt__(self, other):
                if isinstance(other, self.__class__):
                        if self.page == other.page:
@@ -273,6 +404,11 @@ class Page(misc.Object):
 
                        return self.page < other.page
 
+               return NotImplemented
+
+       def __hash__(self):
+               return hash(self.page)
+
        @staticmethod
        def sanitise_page_name(page):
                if not page:
@@ -293,11 +429,11 @@ class Page(misc.Object):
 
        @property
        def url(self):
-               return self.page
+               return self.backend.wiki._make_url(self.page)
 
        @property
        def full_url(self):
-               return "https://wiki.ipfire.org%s" % self.url
+               return "https://www.ipfire.org%s" % self.url
 
        @property
        def page(self):
@@ -330,7 +466,30 @@ class Page(misc.Object):
 
        @property
        def html(self):
-               return self.backend.wiki.render(self.page, self.markdown)
+               lines = []
+
+               # Strip off the first line if it contains a heading (as it will be shown separately)
+               for i, line in enumerate(self.markdown.splitlines()):
+                       if i == 0 and line.startswith("#"):
+                               continue
+
+                       lines.append(line)
+
+               renderer = self.backend.wiki.render(self.page, "\n".join(lines), revision=self.timestamp)
+
+               return renderer.html
+
+       # Linked Files
+
+       @property
+       def files(self):
+               renderer = self.backend.wiki.render(self.page, self.markdown, revision=self.timestamp)
+
+               return renderer.files
+
+       def _store_linked_files(self):
+               self.db.executemany("INSERT INTO wiki_linked_files(page_id, path) \
+                       VALUES(%s, %s)", ((self.id, file) for file in self.files))
 
        @property
        def timestamp(self):
@@ -372,19 +531,6 @@ class Page(misc.Object):
        def check_acl(self, account):
                return self.backend.wiki.check_acl(self.page, account)
 
-       # Sidebar
-
-       @lazy_property
-       def sidebar(self):
-               parts = self.page.split("/")
-
-               while parts:
-                       sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts))
-                       if sidebar:
-                               return sidebar
-
-                       parts.pop()
-
        # Watchers
 
        @lazy_property
@@ -473,9 +619,11 @@ class File(misc.Object):
                if isinstance(other, self.__class__):
                        return self.id == other.id
 
+               return NotImplemented
+
        @property
        def url(self):
-               return os.path.join(self.path, self.filename)
+               return "/docs%s" % os.path.join(self.path, self.filename)
 
        @property
        def path(self):
@@ -502,10 +650,23 @@ class File(misc.Object):
        def created_at(self):
                return self.data.created_at
 
+       timestamp = created_at
+
        def delete(self, author=None):
+               if not self.can_be_deleted():
+                       raise RuntimeError("Cannot delete %s" % self)
+
                self.db.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \
                        WHERE id = %s", author.uid if author else None, self.id)
 
+       def can_be_deleted(self):
+               # Cannot be deleted if still in use
+               if self.pages:
+                       return False
+
+               # Can be deleted
+               return True
+
        @property
        def deleted_at(self):
                return self.data.deleted_at
@@ -543,24 +704,68 @@ class File(misc.Object):
                if res:
                        return bytes(res.data)
 
-       def get_thumbnail(self, size):
+       async def get_thumbnail(self, size, format=None):
                assert self.is_bitmap_image()
 
-               cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size))
+               # Let thumbnails live in the cache for up to 24h
+               ttl = 24 * 3600
+
+               cache_key = ":".join((
+                       "wiki",
+                       "thumbnail",
+                       self.path,
+                       util.normalize(self.filename),
+                       self.created_at.isoformat(),
+                       format or "N/A",
+                       "%spx" % size,
+               ))
 
                # Try to fetch the data from the cache
-               thumbnail = self.memcache.get(cache_key)
+               async with await self.backend.cache.pipeline() as p:
+                       # Fetch the key
+                       await p.get(cache_key)
+
+                       # Reset the TTL
+                       await p.expire(cache_key, ttl)
+
+                       # Execute the pipeline
+                       thumbnail, _ = await p.execute()
+
+               # Return the cached value
                if thumbnail:
                        return thumbnail
 
                # Generate the thumbnail
-               thumbnail = util.generate_thumbnail(self.blob, size)
+               thumbnail = util.generate_thumbnail(self.blob, size, format=format, quality=95)
 
-               # Put it into the cache for forever
-               self.memcache.set(cache_key, thumbnail)
+               # Put it into the cache for 24h
+               await self.backend.cache.set(cache_key, thumbnail, ttl)
 
                return thumbnail
 
+       @property
+       def pages(self):
+               """
+                       Returns a list of all pages this file is linked by
+               """
+               pages = self.backend.wiki._get_pages("""
+                       SELECT
+                               wiki.*
+                       FROM
+                               wiki_linked_files
+                       JOIN
+                               wiki_current ON wiki_linked_files.page_id = wiki_current.id
+                       LEFT JOIN
+                               wiki ON wiki_linked_files.page_id = wiki.id
+                       WHERE
+                               wiki_linked_files.path = %s
+                       ORDER BY
+                               wiki.page
+                       """, os.path.join(self.path, self.filename),
+               )
+
+               return list(pages)
+
 
 class WikiRenderer(misc.Object):
        schemas = (
@@ -575,17 +780,54 @@ class WikiRenderer(misc.Object):
        )
 
        # Links
-       links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
+       _links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>")
 
        # Images
-       images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
+       _images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>")
 
-       def init(self, path):
+       def init(self, path, text, revision=None):
                self.path = path
+               self.text = text
+
+               # Optionally, the revision of the rendered page
+               self.revision = revision
+
+               # Markdown Renderer
+               self.renderer = Markdown(
+                       self.backend,
+                       extensions=[
+                               LinkedFilesExtractorExtension(),
+                               PrettyLinksExtension(),
+                               "codehilite",
+                               "fenced_code",
+                               "footnotes",
+                               "nl2br",
+                               "sane_lists",
+                               "tables",
+                               "toc",
+                       ],
+               )
+
+               # Render!
+               self.html = self._render()
 
        def _render_link(self, m):
                url, text = m.groups()
 
+               # Treat linkes starting with a double slash as absolute
+               if url.startswith("//"):
+                       # Remove the double-lash
+                       url = url.removeprefix("/")
+
+                       # Return a link
+                       return """<a href="%s">%s</a>""" % (url, text or url)
+
+               # External Links
+               for schema in self.schemas:
+                       if url.startswith(schema):
+                               return """<a class="link-external" href="%s">%s</a>""" % \
+                                       (url, text or url)
+
                # Emails
                if "@" in url:
                        # Strip mailto:
@@ -595,26 +837,50 @@ class WikiRenderer(misc.Object):
                        return """<a class="link-external" href="mailto:%s">%s</a>""" % \
                                (url, text or url)
 
-               # External Links
-               for schema in self.schemas:
-                       if url.startswith(schema):
-                               return """<a class="link-external" href="%s">%s</a>""" % \
-                                       (url, text or url)
-
                # Everything else must be an internal link
                path = self.backend.wiki.make_path(self.path, url)
 
-               return """<a href="%s">%s</a>""" % \
+               return """<a href="/docs%s">%s</a>""" % \
                        (path, text or self.backend.wiki.get_page_title(path))
 
        def _render_image(self, m):
                alt_text, url, caption = m.groups()
 
-               # Skip any absolute and external URLs
-               if url.startswith("/") or url.startswith("https://") or url.startswith("http://"):
-                       return """<figure class="figure"><img src="%s" class="figure-img img-fluid rounded" alt="%s">
-                               <figcaption class="figure-caption">%s</figcaption></figure>
-                       """ % (url, alt_text, caption or "")
+               # Compute a hash over the URL
+               h = hashlib.new("md5")
+               h.update(url.encode())
+               id = h.hexdigest()
+
+               html = """
+                       <div class="columns is-centered">
+                               <div class="column is-8">
+                                       <figure class="image modal-trigger" data-target="%(id)s">
+                                               <img src="/docs%(url)s?s=960&amp;%(args)s" alt="%(caption)s">
+
+                                               <figcaption class="figure-caption">%(caption)s</figcaption>
+                                       </figure>
+
+                                       <div class="modal is-large" id="%(id)s">
+                                               <div class="modal-background"></div>
+
+                                               <div class="modal-content">
+                                                       <p class="image">
+                                                               <img src="/docs%(url)s?s=2048&amp;%(args)s" alt="%(caption)s"
+                                                                       loading="lazy">
+                                                       </p>
+
+                                                       <a class="button is-small" href="/docs%(url)s?action=detail">
+                                                               <span class="icon">
+                                                                       <i class="fa-solid fa-circle-info"></i>
+                                                               </span>
+                                                       </a>
+                                               </div>
+
+                                               <button class="modal-close is-large" aria-label="close"></button>
+                                       </div>
+                               </div>
+                       </div>
+               """
 
                # Try to split query string
                url, delimiter, qs = url.partition("?")
@@ -622,32 +888,157 @@ class WikiRenderer(misc.Object):
                # Parse query arguments
                args = urllib.parse.parse_qs(qs)
 
+               # Skip any absolute and external URLs
+               if url.startswith("https://") or url.startswith("http://"):
+                       return html % {
+                               "caption"   : caption or "",
+                               "id"        : id,
+                               "url"       : url,
+                               "args"      : args,
+                       }
+
                # Build absolute path
                url = self.backend.wiki.make_path(self.path, url)
 
                # Find image
-               file = self.backend.wiki.get_file_by_path(url)
+               file = self.backend.wiki.get_file_by_path(url, revision=self.revision)
                if not file or not file.is_image():
                        return "<!-- Could not find image %s in %s -->" % (url, self.path)
 
-               # Scale down the image if not already done
-               if not "s" in args:
-                       args["s"] = "920"
+               # Remove any requested size
+               if "s" in args:
+                       del args["s"]
+
+               # Link the image that has been the current version at the time of the page edit
+               if file:
+                       args["revision"] = file.timestamp
 
-               return """<figure class="figure"><img src="%s?%s" class="figure-img img-fluid rounded" alt="%s">
-               <figcaption class="figure-caption">%s</figcaption></figure>
-               """ % (url, urllib.parse.urlencode(args), caption, caption or "")
+               return html % {
+                       "caption"   : caption or "",
+                       "id"        : id,
+                       "url"       : url,
+                       "args"      : urllib.parse.urlencode(args),
+               }
 
-       def render(self, text):
+       def _render(self):
                logging.debug("Rendering %s" % self.path)
 
-               # Borrow this from the blog
-               text = self.backend.blog._render_text(text, lang="markdown")
+               # Render...
+               text = self.renderer.convert(self.text)
 
                # Postprocess links
-               text = self.links.sub(self._render_link, text)
+               text = self._links.sub(self._render_link, text)
 
                # Postprocess images to <figure>
-               text = self.images.sub(self._render_image, text)
+               text = self._images.sub(self._render_image, text)
 
                return text
+
+       @lazy_property
+       def files(self):
+               """
+                       A list of all linked files that have been part of the rendered markup
+               """
+               files = []
+
+               for url in self.renderer.files:
+                       # Skip external images
+                       if url.startswith("https://") or url.startswith("http://"):
+                               continue
+
+                       # Make the URL absolute
+                       url = self.backend.wiki.make_path(self.path, url)
+
+                       # Check if this is a file (it could also just be a page)
+                       file = self.backend.wiki.get_file_by_path(url)
+                       if file:
+                               files.append(url)
+
+               return files
+
+
+class Markdown(markdown.Markdown):
+       def __init__(self, backend, *args, **kwargs):
+               # Store the backend
+               self.backend = backend
+
+               # Call inherited setup routine
+               super().__init__(*args, **kwargs)
+
+
+class PrettyLinksExtension(markdown.extensions.Extension):
+       def extendMarkdown(self, md):
+               # Create links to Bugzilla
+               md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10)
+
+               # Create links to CVE
+               md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10)
+
+               # Link mentioned users
+               md.preprocessors.register(UserMentionPreprocessor(md), "user-mention", 10)
+
+
+class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor):
+       regex = re.compile(r"(?:#(\d{5,}))", re.I)
+
+       def run(self, lines):
+               for line in lines:
+                       yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line)
+
+
+class CVELinksPreprocessor(markdown.preprocessors.Preprocessor):
+       regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)")
+
+       def run(self, lines):
+               for line in lines:
+                       yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line)
+
+
+class UserMentionPreprocessor(markdown.preprocessors.Preprocessor):
+       regex = re.compile(r"@(\w+)")
+
+       def run(self, lines):
+               for line in lines:
+                       yield self.regex.sub(self._replace, line)
+
+       def _replace(self, m):
+               # Fetch the user's handle
+               uid, = m.groups()
+
+               # Fetch the user
+               user = self.md.backend.accounts.get_by_uid(uid)
+
+               # If the user was not found, we put back the matched text
+               if not user:
+                       return m.group(0)
+
+               # Link the user
+               return "[%s](//users/%s)" % (user, user.uid)
+
+
+class LinkedFilesExtractor(markdown.treeprocessors.Treeprocessor):
+       """
+               Finds all Linked Files
+       """
+       def __init__(self, *args, **kwargs):
+               super().__init__(*args, **kwargs)
+
+               self.md.files = []
+
+       def run(self, root):
+               # Find all images and store the URLs
+               for image in root.findall(".//img"):
+                       src = image.get("src")
+
+                       self.md.files.append(src)
+
+               # Find all links
+               for link in root.findall(".//a"):
+                       href = link.get("href")
+
+                       self.md.files.append(href)
+
+
+class LinkedFilesExtractorExtension(markdown.extensions.Extension):
+    def extendMarkdown(self, md):
+        md.treeprocessors.register(LinkedFilesExtractor(md), "linked-files-extractor", 10)
index c999a0ca093ee36f9b7d9c6e23c5b29e72d75322..c156a0a333abd1060f173a0876a63b154bce388e 100644 (file)
@@ -60,6 +60,8 @@ class ZeiterfassungClient(Object):
                logging.debug("Sending request to %s:" % request.url)
                for header in sorted(request.headers):
                        logging.debug(" %s: %s" % (header, request.headers[header]))
+               if request.body:
+                       logging.debug("%s" % json.dumps(kwargs, indent=4, sort_keys=True))
 
                # Send the request
                response = await self.backend.http_client.fetch(request)
@@ -72,15 +74,21 @@ class ZeiterfassungClient(Object):
 
                # Fetch the whole body
                body = response.body
+               if body:
+                       # Decode the JSON response
+                       body = json.loads(body)
+
+                       # Log what we have received in a human-readable way
+                       logging.debug("%s" % json.dumps(body, indent=4, sort_keys=True))
 
                # Fetch the signature
                signature = response.headers.get("Hash")
                if not signature:
                        raise RuntimeError("Could not find signature on response")
 
-               expected_signature = self._sign_response(body)
+               expected_signature = self._sign_response(response.body)
                if not hmac.compare_digest(expected_signature, signature):
                        raise RuntimeError("Invalid signature: %s" % signature)
 
-               # Decode the JSON response
-               return json.loads(body)
+               # Return the body
+               return body
diff --git a/src/bootstrap b/src/bootstrap
deleted file mode 160000 (submodule)
index 7a6da5e..0000000
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 7a6da5e3e7ad7c749dde806546a35d4d4259d965
index 4122f187ae79f1c03d06480725e6ca871dc269e9..e93d81da3580752fc5e1e6f65fbc8f95642f3aa7 100644 (file)
@@ -1,25 +1,25 @@
 SHELL=/bin/bash
 
 # Update blog feeds once an hour
-0 * * * *      nobody  ipfire.org update-blog-feeds
+0 * * * *      nobody  ipfire.org --logging=error update-blog-feeds
 
 # Scan for release files once an hour
-0 * * * *      nobody  ipfire.org scan-files
+0 * * * *      nobody  ipfire.org --logging=error scan-files
 
 # Send messages
-* * * * *      nobody  flock /tmp/.ipfire.org.send-all-messages.lock ipfire.org send-all-messages
+* * * * *      nobody  flock /tmp/.ipfire.org.send-all-messages.lock ipfire.org --logging=error send-all-messages
 
 # Run campaigns
-*/5 * * * *    nobody  ipfire.org run-campaigns
+*/5 * * * *    nobody  ipfire.org --logging=error run-campaigns
 
 # Announce blog posts
-*/5 * * * *    nobody  ipfire.org announce-blog-posts
+*/5 * * * *    nobody  flock /tmp/.ipfire.org.announce-blog-posts ipfire.org --logging=error announce-blog-posts
 
 # Cleanup once an hour
-30 * * * *     nobody  ipfire.org cleanup
+30 * * * *     nobody  ipfire.org --logging=error cleanup
 
 # Check mirrors once every 30 min
 */30 * * * *   nobody  ipfire.org --logging=error check-mirrors
 
-# Tweet once a week
-0 8 * * *      nobody  sleep ${RANDOM} && ipfire.org tweet
+# Toot once a day
+0 8 * * *      nobody  sleep ${RANDOM} && ipfire.org --logging=error toot
diff --git a/src/error-pages/.gitignore b/src/error-pages/.gitignore
new file mode 100644 (file)
index 0000000..d8c03cd
--- /dev/null
@@ -0,0 +1,3 @@
+/.jekyll-cache
+/.jekyll-metadata
+/_site
diff --git a/src/error-pages/500.markdown b/src/error-pages/500.markdown
new file mode 100644 (file)
index 0000000..c0881ad
--- /dev/null
@@ -0,0 +1,7 @@
+---
+layout: error
+permalink: 500.http
+
+error-code: 500
+error-description: Internal Server Error
+---
diff --git a/src/error-pages/502.markdown b/src/error-pages/502.markdown
new file mode 100644 (file)
index 0000000..61dd04c
--- /dev/null
@@ -0,0 +1,7 @@
+---
+layout: error
+permalink: 502.http
+
+error-code: 502
+error-description: Bad Gateway
+---
diff --git a/src/error-pages/503.markdown b/src/error-pages/503.markdown
new file mode 100644 (file)
index 0000000..b7af89e
--- /dev/null
@@ -0,0 +1,7 @@
+---
+layout: error
+permalink: 503.http
+
+error-code: 503
+error-description: Service Unavailable
+---
diff --git a/src/error-pages/504.markdown b/src/error-pages/504.markdown
new file mode 100644 (file)
index 0000000..b4213ed
--- /dev/null
@@ -0,0 +1,7 @@
+---
+layout: error
+permalink: 504.http
+
+error-code: 504
+error-description: Gateway Timeout
+---
diff --git a/src/error-pages/Gemfile b/src/error-pages/Gemfile
new file mode 100644 (file)
index 0000000..1bccf29
--- /dev/null
@@ -0,0 +1,33 @@
+source "https://rubygems.org"
+# Hello! This is where you manage which Jekyll version is used to run.
+# When you want to use a different version, change it below, save the
+# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
+#
+#     bundle exec jekyll serve
+#
+# This will help ensure the proper Jekyll version is running.
+# Happy Jekylling!
+gem "jekyll", "~> 4.3.1"
+# This is the default theme for new Jekyll sites. You may change this to anything you like.
+gem "minima", "~> 2.5"
+# If you want to use GitHub Pages, remove the "gem "jekyll"" above and
+# uncomment the line below. To upgrade, run `bundle update github-pages`.
+# gem "github-pages", group: :jekyll_plugins
+# If you have any plugins, put them here!
+group :jekyll_plugins do
+  gem "jekyll-feed", "~> 0.12"
+end
+
+# Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem
+# and associated library.
+platforms :mingw, :x64_mingw, :mswin, :jruby do
+  gem "tzinfo", ">= 1", "< 3"
+  gem "tzinfo-data"
+end
+
+# Performance-booster for watching directories on Windows
+gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin]
+
+# Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem
+# do not have a Java counterpart.
+gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby]
diff --git a/src/error-pages/Gemfile.lock b/src/error-pages/Gemfile.lock
new file mode 100644 (file)
index 0000000..0dd3681
--- /dev/null
@@ -0,0 +1,79 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    addressable (2.8.1)
+      public_suffix (>= 2.0.2, < 6.0)
+    colorator (1.1.0)
+    concurrent-ruby (1.1.6)
+    em-websocket (0.5.1)
+      eventmachine (>= 0.12.9)
+      http_parser.rb (~> 0.6.0)
+    eventmachine (1.3.0.dev.1)
+    ffi (1.15.5)
+    forwardable-extended (2.6.0)
+    http_parser.rb (0.6.0)
+    i18n (1.10.0)
+      concurrent-ruby (~> 1.0)
+    jekyll (4.3.1)
+      addressable (~> 2.4)
+      colorator (~> 1.0)
+      em-websocket (~> 0.5)
+      i18n (~> 1.0)
+      jekyll-sass-converter (>= 2.0, < 4.0)
+      jekyll-watch (~> 2.0)
+      kramdown (~> 2.3, >= 2.3.1)
+      kramdown-parser-gfm (~> 1.0)
+      liquid (>= 4.0, < 6)
+      mercenary (>= 0.3.6, < 0.5)
+      pathutil (~> 0.9)
+      rouge (>= 3.0, < 5.0)
+      terminal-table (>= 1.8, < 4.0)
+      webrick (~> 1.7)
+    jekyll-feed (0.17.0)
+      jekyll (>= 3.7, < 5.0)
+    jekyll-sass-converter (2.2.0)
+      sassc (> 2.0.1, < 3.0)
+    jekyll-seo-tag (2.8.0)
+      jekyll (>= 3.8, < 5.0)
+    jekyll-watch (2.2.1)
+      listen (~> 3.0)
+    kramdown (2.4.0)
+      rexml
+    kramdown-parser-gfm (1.1.0)
+      kramdown (~> 2.0)
+    liquid (5.4.0)
+    listen (3.7.0)
+      rb-inotify (~> 0.9, >= 0.9.10)
+    mercenary (0.4.0)
+    minima (2.5.1)
+      jekyll (>= 3.5, < 5.0)
+      jekyll-feed (~> 0.9)
+      jekyll-seo-tag (~> 2.1)
+    pathutil (0.16.1)
+      forwardable-extended (~> 2.6)
+    public_suffix (4.0.6)
+    rb-inotify (0.10.1)
+      ffi (~> 1.0)
+    rexml (3.2.5)
+    rouge (3.30.0)
+    sassc (2.4.0)
+      ffi (~> 1.9)
+    terminal-table (3.0.2)
+      unicode-display_width (>= 1.1.1, < 3)
+    unicode-display_width (1.6.1)
+    webrick (1.8.1)
+
+PLATFORMS
+  x86_64-linux
+
+DEPENDENCIES
+  http_parser.rb (~> 0.6.0)
+  jekyll (~> 4.3.1)
+  jekyll-feed (~> 0.12)
+  minima (~> 2.5)
+  tzinfo (>= 1, < 3)
+  tzinfo-data
+  wdm (~> 0.1.1)
+
+BUNDLED WITH
+   2.3.15
diff --git a/src/error-pages/_config.yml b/src/error-pages/_config.yml
new file mode 100644 (file)
index 0000000..2fb6eae
--- /dev/null
@@ -0,0 +1,8 @@
+# Welcome to Jekyll!
+
+title: IPFire.org
+email: hostmaster@ipfire.org
+url: "https://www.ipfire.org"
+
+sass:
+    style: compressed
diff --git a/src/error-pages/_layouts/error.html b/src/error-pages/_layouts/error.html
new file mode 100644 (file)
index 0000000..45f9d38
--- /dev/null
@@ -0,0 +1,49 @@
+HTTP/1.1 {{ page.error-code }} {{ page.error-description }}
+Cache-Control: no-cache
+Connection: close
+Content-Type: text/html
+
+<!DOCTYPE html>
+<html lang="en">
+       <head>
+               <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+
+               <meta name="author" content="IPFire.org - IPFire Development Team" />
+
+               <title>{{ site.title }} - {% if page.title %}{{ page.title }}{% endif %}</title>
+
+               <!-- styling stuff -->
+               <meta name="viewport" content="width=device-width, initial-scale=1.0">
+               <link rel="stylesheet" href="/.errors/assets/main.css">
+       </head>
+
+       <body>
+               <nav class="navbar" role="navigation" aria-label="main navigation">
+                       <div class="container">
+                               <div class="navbar-brand">
+                                       <a class="navbar-item is-size-4" href="/">
+                                               <strong>
+                                                       IPFire<span class="has-text-primary">_</span>
+                                               </strong>
+                                       </a>
+                               </div>
+                       </div>
+               </nav>
+
+               <div class="hero is-primary is-fullheight-with-navbar">
+                       <div class="hero-body">
+                               <div class="container">
+                                       <p class="title">Oops, Something Went Wrong</p>
+
+                                       {% if page.error-description %}
+                                               <p class="subtitle">{{ page.error-code }} &dash; {{ page.error-description }}</p>
+                                       {% endif %}
+
+                                       <div class="content">
+                                               {{ content }}
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </body>
+</html>
diff --git a/src/error-pages/assets/fonts/Prompt-Black.ttf b/src/error-pages/assets/fonts/Prompt-Black.ttf
new file mode 100644 (file)
index 0000000..624fe85
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Black.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Black.woff2 b/src/error-pages/assets/fonts/Prompt-Black.woff2
new file mode 100644 (file)
index 0000000..f08773f
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Black.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-BlackItalic.ttf b/src/error-pages/assets/fonts/Prompt-BlackItalic.ttf
new file mode 100644 (file)
index 0000000..49ebb16
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-BlackItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-BlackItalic.woff2 b/src/error-pages/assets/fonts/Prompt-BlackItalic.woff2
new file mode 100644 (file)
index 0000000..da28bfc
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-BlackItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-Bold.ttf b/src/error-pages/assets/fonts/Prompt-Bold.ttf
new file mode 100644 (file)
index 0000000..6cdd89b
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Bold.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Bold.woff2 b/src/error-pages/assets/fonts/Prompt-Bold.woff2
new file mode 100644 (file)
index 0000000..20f5632
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Bold.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-BoldItalic.ttf b/src/error-pages/assets/fonts/Prompt-BoldItalic.ttf
new file mode 100644 (file)
index 0000000..a9effd7
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-BoldItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-BoldItalic.woff2 b/src/error-pages/assets/fonts/Prompt-BoldItalic.woff2
new file mode 100644 (file)
index 0000000..5f04318
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-BoldItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraBold.ttf b/src/error-pages/assets/fonts/Prompt-ExtraBold.ttf
new file mode 100644 (file)
index 0000000..ded930f
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraBold.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraBold.woff2 b/src/error-pages/assets/fonts/Prompt-ExtraBold.woff2
new file mode 100644 (file)
index 0000000..bd931f5
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraBold.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.ttf b/src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.ttf
new file mode 100644 (file)
index 0000000..6c076ed
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.woff2 b/src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.woff2
new file mode 100644 (file)
index 0000000..343cc3b
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraBoldItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraLight.ttf b/src/error-pages/assets/fonts/Prompt-ExtraLight.ttf
new file mode 100644 (file)
index 0000000..7f94676
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraLight.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraLight.woff2 b/src/error-pages/assets/fonts/Prompt-ExtraLight.woff2
new file mode 100644 (file)
index 0000000..a2899f6
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraLight.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraLightItalic.ttf b/src/error-pages/assets/fonts/Prompt-ExtraLightItalic.ttf
new file mode 100644 (file)
index 0000000..4ca72e3
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraLightItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-ExtraLightItalic.woff2 b/src/error-pages/assets/fonts/Prompt-ExtraLightItalic.woff2
new file mode 100644 (file)
index 0000000..0f605a9
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ExtraLightItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-Italic.ttf b/src/error-pages/assets/fonts/Prompt-Italic.ttf
new file mode 100644 (file)
index 0000000..0545734
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Italic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Italic.woff2 b/src/error-pages/assets/fonts/Prompt-Italic.woff2
new file mode 100644 (file)
index 0000000..6b182c1
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Italic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-Light.ttf b/src/error-pages/assets/fonts/Prompt-Light.ttf
new file mode 100644 (file)
index 0000000..0c5d1eb
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Light.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Light.woff2 b/src/error-pages/assets/fonts/Prompt-Light.woff2
new file mode 100644 (file)
index 0000000..90e58c9
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Light.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-LightItalic.ttf b/src/error-pages/assets/fonts/Prompt-LightItalic.ttf
new file mode 100644 (file)
index 0000000..8bb339a
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-LightItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-LightItalic.woff2 b/src/error-pages/assets/fonts/Prompt-LightItalic.woff2
new file mode 100644 (file)
index 0000000..f43243e
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-LightItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-Medium.ttf b/src/error-pages/assets/fonts/Prompt-Medium.ttf
new file mode 100644 (file)
index 0000000..adf72c9
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Medium.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Medium.woff2 b/src/error-pages/assets/fonts/Prompt-Medium.woff2
new file mode 100644 (file)
index 0000000..53b5d92
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Medium.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-MediumItalic.ttf b/src/error-pages/assets/fonts/Prompt-MediumItalic.ttf
new file mode 100644 (file)
index 0000000..7b87fdb
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-MediumItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-MediumItalic.woff2 b/src/error-pages/assets/fonts/Prompt-MediumItalic.woff2
new file mode 100644 (file)
index 0000000..47d1bd8
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-MediumItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-Regular.ttf b/src/error-pages/assets/fonts/Prompt-Regular.ttf
new file mode 100644 (file)
index 0000000..2a736db
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Regular.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Regular.woff2 b/src/error-pages/assets/fonts/Prompt-Regular.woff2
new file mode 100644 (file)
index 0000000..03c10ac
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Regular.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-SemiBold.ttf b/src/error-pages/assets/fonts/Prompt-SemiBold.ttf
new file mode 100644 (file)
index 0000000..011a050
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-SemiBold.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-SemiBold.woff2 b/src/error-pages/assets/fonts/Prompt-SemiBold.woff2
new file mode 100644 (file)
index 0000000..0404c79
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-SemiBold.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-SemiBoldItalic.ttf b/src/error-pages/assets/fonts/Prompt-SemiBoldItalic.ttf
new file mode 100644 (file)
index 0000000..3bec3f7
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-SemiBoldItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-SemiBoldItalic.woff2 b/src/error-pages/assets/fonts/Prompt-SemiBoldItalic.woff2
new file mode 100644 (file)
index 0000000..d41593b
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-SemiBoldItalic.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-Thin.ttf b/src/error-pages/assets/fonts/Prompt-Thin.ttf
new file mode 100644 (file)
index 0000000..a3b80b4
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Thin.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-Thin.woff2 b/src/error-pages/assets/fonts/Prompt-Thin.woff2
new file mode 100644 (file)
index 0000000..7782624
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-Thin.woff2 differ
diff --git a/src/error-pages/assets/fonts/Prompt-ThinItalic.ttf b/src/error-pages/assets/fonts/Prompt-ThinItalic.ttf
new file mode 100644 (file)
index 0000000..f64319a
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ThinItalic.ttf differ
diff --git a/src/error-pages/assets/fonts/Prompt-ThinItalic.woff2 b/src/error-pages/assets/fonts/Prompt-ThinItalic.woff2
new file mode 100644 (file)
index 0000000..edfaa87
Binary files /dev/null and b/src/error-pages/assets/fonts/Prompt-ThinItalic.woff2 differ
diff --git a/src/error-pages/assets/main.sass b/src/error-pages/assets/main.sass
new file mode 100644 (file)
index 0000000..19e9c7a
--- /dev/null
@@ -0,0 +1,34 @@
+---
+---
+
+@charset "utf-8"
+
+// Import variables
+@import "../sass/_variables.sass"
+
+// Import only the Bulma stuff that we actually need
+@import "../third-party/bulma/sass/utilities/_all.sass"
+
+// The basic stuff
+@import "../third-party/bulma/sass/base/minireset.sass"
+@import "../third-party/bulma/sass/base/generic.sass"
+
+// Elements
+@import "../third-party/bulma/sass/elements/container.sass"
+@import "../third-party/bulma/sass/elements/content.sass"
+@import "../third-party/bulma/sass/elements/title.sass"
+
+// Helpers
+@import "../third-party/bulma/sass/helpers/color.sass"
+@import "../third-party/bulma/sass/helpers/typography.sass"
+
+// Components
+@import "../third-party/bulma/sass/components/navbar.sass"
+
+// Layout
+@import "../third-party/bulma/sass/layout/hero.sass"
+
+// Import fonts
+$fonts-baseurl: "/.errors/assets/fonts"
+
+@import "../sass/_fonts"
index e8bec4b362ca23832aed6087be398c998da27cf4..f0c25837a3fe0e03783b939559e088abcbfb3c4b 160000 (submodule)
@@ -1 +1 @@
-Subproject commit e8bec4b362ca23832aed6087be398c998da27cf4
+Subproject commit f0c25837a3fe0e03783b939559e088abcbfb3c4b
diff --git a/src/fonts b/src/fonts
deleted file mode 160000 (submodule)
index 3ca591d..0000000
--- a/src/fonts
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 3ca591dae7372a26e254ec6d22e7b453813b9530
diff --git a/src/sass/_code-highlighting.sass b/src/sass/_code-highlighting.sass
new file mode 100644 (file)
index 0000000..7ebe058
--- /dev/null
@@ -0,0 +1,97 @@
+.codehilite
+       .hll
+               background-color: #ffffcc
+
+       // Comment, Comment.Hashbang, Comment.Multiline, Comment.PreprocFile,
+       // Comment.Single, Comment.Special, Comment.Preproc
+       .c, .ch, .cm, .cpf, .c1, .cs, .ch, .cp
+               color: $grey
+               font-style: italic
+
+       // Error
+       .err
+               border: 1px solid $red
+
+       // Keyword
+       .k, .kp, .kr, .kt
+               color: $green
+
+       // Keyword.Constant
+       .kc
+               color: $cyan
+
+       // Keyword.Declaration
+       .kd
+               color: $blue
+
+       // Keyword.Namespace
+       .kn
+               color: $orange
+
+       // Literal.Number, Literal.Number.*
+       .m, .mb, .mf, .mh, .mi, .mo, .il
+               color: $cyan
+
+       // Literal.String, Literal.String.*
+       .s, .sa, .sb, .sc, .dl, .s1, .s2, .sh
+               color: $cyan
+
+       // Literal.String.Doc
+       .sd
+               color: $red
+               font-style: italic
+
+       // Literal.String.Escape
+       .se
+               color: $red
+               font-weight: bold
+
+       // Literal.String.Interpol
+       .si
+               color: $red
+               font-weight: bold
+
+       // Literal.String.Other
+       .sx
+               color: $cyan
+
+       // Literal.String.Regex
+       .sr
+               color: $cyan
+
+       // Literal.String.Symbol
+       .ss
+               color: $cyan
+
+       // Name.Builtin
+       .nb
+               color: $red
+
+       // Name.Builtin.Pseudo
+       .bp
+               color: $blue
+
+       // Name.Class
+       .nc
+               color: $blue
+
+       // Name.Decorator
+       .nd
+               color: $blue
+
+       // Name.Entity
+       .ni
+               color: $purple
+
+       // Name.Exception
+       .ne
+               color: $yellow
+
+       // Name.Function
+       .nf
+               color: $blue
+
+       // Operator.Word
+       .ow
+               color: $green
+               font-weight: bold
diff --git a/src/sass/_fonts.sass b/src/sass/_fonts.sass
new file mode 100644 (file)
index 0000000..a7dc361
--- /dev/null
@@ -0,0 +1,49 @@
+$fonts-baseurl: "/static/fonts" !default
+
+/* latin-ext */
+@font-face
+       font-family: "Prompt"
+       font-style: normal
+       font-weight: 400
+       src: local("Prompt Regular"), local("Prompt-Regular"), url(#{$fonts-baseurl}/Prompt-Regular.woff2) format("woff2"), url(#{$fonts-baseurl}/Prompt-Regular.ttf) format("truetype")
+       unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF
+
+/* latin */
+@font-face
+       font-family: "Prompt"
+       font-style: normal
+       font-weight: 400
+       src: local("Prompt Regular"), local("Prompt-Regular"), url(#{$fonts-baseurl}/Prompt-Regular.woff2) format("woff2"), url(#{$fonts-baseurl}/Prompt-Regular.ttf) format("truetype")
+       unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD
+
+/* latin-ext */
+@font-face
+       font-family: 'Prompt'
+       font-style: normal
+       font-weight: 500
+       src: local('Prompt Medium'), local('Prompt-Medium'), url(#{$fonts-baseurl}/Prompt-Medium.woff2) format("woff2"), url(#{$fonts-baseurl}/Prompt-Medium.ttf) format("truetype")
+       unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF
+
+/* latin */
+@font-face
+       font-family: 'Prompt'
+       font-style: normal
+       font-weight: 500
+       src: local('Prompt Medium'), local('Prompt-Medium'), url(#{$fonts-baseurl}/Prompt-Medium.woff2) format("woff2"), url(#{$fonts-baseurl}/Prompt-Medium.ttf) format("truetype")
+       unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD
+
+/* latin-ext */
+@font-face
+       font-family: "Prompt"
+       font-style: normal
+       font-weight: 700
+       src: local("Prompt Bold"), local("Prompt-Bold"), url(#{$fonts-baseurl}/Prompt-Bold.woff2) format("woff2"), url(#{$fonts-baseurl}/Prompt-Bold.ttf) format("truetype")
+       unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF
+
+/* latin */
+@font-face
+       font-family: "Prompt"
+       font-style: normal
+       font-weight: 700
+       src: local("Prompt Bold"), local("Prompt-Bold"), url(#{$fonts-baseurl}/Prompt-Bold.woff2) format("woff2"), url(#{$fonts-baseurl}/Prompt-Bold.ttf) format("truetype")
+       unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD
diff --git a/src/sass/_icons.sass b/src/sass/_icons.sass
new file mode 100644 (file)
index 0000000..31f38c0
--- /dev/null
@@ -0,0 +1,23 @@
+// Font Awesome
+$fa-font-path:                         "fonts"
+
+$fa-font-base-size:                    1rem
+
+@import "../font-awesome/scss/fontawesome"
+@import "../font-awesome/scss/solid"
+@import "../font-awesome/scss/regular"
+@import "../font-awesome/scss/brands"
+
+// PaymentFont
+
+$pf-font-path:              "fonts"
+
+$pf-font-size-base:         1rem
+
+@import "../payment-font/sass/paymentfont"
+
+// Flags
+
+$flag-icon-css-path:        "flags"
+
+@import "../flag-icons/sass/flag-icon"
diff --git a/src/sass/_variables.sass b/src/sass/_variables.sass
new file mode 100644 (file)
index 0000000..533cbf9
--- /dev/null
@@ -0,0 +1,73 @@
+// Import some basic variables from Bulma
+@import "../third-party/bulma/sass/utilities/initial-variables.sass"
+
+// Global Settings
+$family-sans-serif:                            Prompt, sans-serif
+
+$size-1:                                               3rem
+$size-2:                                               2.5rem
+$size-3:                                               2rem
+$size-4:                                               1.5rem
+$size-5:                                               1.25rem
+$size-6:                                               1rem
+$size-7:                                               0.75rem
+
+// Make titles slightly larger
+$title-size:                                   $size-2
+
+// Colour Palette
+$primary:                                              #ff2e52
+$primary-invert:                               #ffffff
+$secondary:                                            #46ffc0
+$secondary-invert:                             #000000
+$success:                                              #1ae210
+$success-invert:                #ffffff
+$danger:                                               #ac001a
+$warning:                                              #f3ff50
+
+// Pride Colours
+$pride-red:                                            #e40303
+$pride-orange:                                 #ff8c00
+$pride-yellow:                                 #ffed00
+$pride-green:                                  #008026
+$pride-blue:                                   #24408e
+$pride-purple:                                 #732982
+
+// Custom Colours
+$lwl:                                                  #6534C8
+
+$custom-colors: ("secondary" : ($secondary, $secondary-invert), "lwl" : ($lwl, $white), "pride-red" : ($pride-red, $white), "pride-orange" : ($pride-orange, $black), "pride-yellow" : ($pride-yellow, $black), "pride-green" : ($pride-green, $white), "pride-blue" : ($pride-blue, $white), "pride-purple" : ($pride-purple, $white))
+
+// Use the primary colour for links
+$link:                                                 $primary
+
+// Use black for titles
+$title-color:                   $grey-darker
+
+// Use dark grey for text
+$text:                          $grey-darker
+
+// Use the primary color for code
+$code:                                                 $primary
+
+@import "../third-party/bulma/sass/utilities/derived-variables.sass"
+
+// Notifications
+$notification-padding:          1.25rem 1.5rem
+$notification-padding-ltr:      $notification-padding
+$notification-padding-rtl:      $notification-padding
+
+// Breadcrumbs
+$breadcrumb-item-color:         $primary
+$breadcrumb-item-hover-color:   $primary-dark
+$breadcrumb-item-active-color:  $primary
+
+// section
+$section-padding:               3rem 1.5rem
+$section-padding-desktop:       3rem 0.5rem
+
+// Footer
+$footer-padding:                               3rem 1.5rem 3rem
+
+// Images
+$dimensions:                                   16 24 32 48 64 96 128 192 256 512
diff --git a/src/sass/listing.sass b/src/sass/listing.sass
new file mode 100644 (file)
index 0000000..67ec60b
--- /dev/null
@@ -0,0 +1,23 @@
+@import "main.sass"
+
+// Make the body stretch over the entire screen
+body
+       @extend .container
+
+       // Add some space around the content
+       padding: 3rem 1rem;
+
+h1
+       @extend .title, .is-3
+
+// Make all tables .table by default
+table
+    @extend .table, .is-fullwidth, .is-bordered, .is-striped, .is-hoverable
+
+// Fix to show the bottom line of the table
+table
+       tr
+               &:last-child
+                       td,
+                       th
+                               border-bottom-width: 1px !important
diff --git a/src/sass/main.sass b/src/sass/main.sass
new file mode 100644 (file)
index 0000000..ffcef4c
--- /dev/null
@@ -0,0 +1,65 @@
+@charset "utf-8"
+
+// Import variables
+@import "_variables.sass"
+
+// Import Bulma
+@import "../third-party/bulma/sass/utilities/_all.sass"
+@import "../third-party/bulma/sass/base/_all.sass"
+@import "../third-party/bulma/sass/components/_all.sass"
+@import "../third-party/bulma/sass/elements/_all.sass"
+@import "../third-party/bulma/sass/form/_all.sass"
+@import "../third-party/bulma/sass/grid/_all.sass"
+@import "../third-party/bulma/sass/helpers/_all.sass"
+@import "../third-party/bulma/sass/layout/_all.sass"
+
+// Import fonts
+@import "_fonts"
+
+// Import icons
+@import "_icons"
+
+// Import Code Highlighting
+@import "_code-highlighting"
+
+// Custom CSS
+
+html, body
+       min-height: 100vh;
+
+#hero-index
+       position: relative
+       video
+               width: 100%;
+               height: 100%;
+               position: absolute;
+               object-fit: cover;
+               z-index: 0;
+
+.footer
+       a
+               color: $grey
+               &:hover
+                       color: $black
+
+.modal
+       &.is-large
+               .modal-content
+                       +from($modal-breakpoint)
+                               width: $widescreen
+                               max-height: 100%
+
+// The PDF Viewer
+.pdf-viewer
+       width: 100%
+       min-height: 40rem
+
+// Used to wrap text on buttons
+.wrap-text
+    height: max-content;
+    white-space: inherit;
+
+.map
+       width: 100%;
+       height: 100%;
+       min-height: 24rem;
index 77271cf89ade36a6f2a52815f6d559c5dcb5431a..6c57ecf22035316f1e4e257c573b73a97c05e608 100755 (executable)
@@ -1,14 +1,15 @@
 #!@PYTHON@
 
-import tornado.ioloop
+import asyncio
 import tornado.options
 
 tornado.options.define("debug", type=bool, default=False, help="Enable debug mode")
 tornado.options.define("port", type=int, default=8001, help="Port to listen on")
 
+import ipfire.nopaste
 from ipfire.web import Application
 
-def run():
+async def run():
        tornado.options.parse_command_line()
 
        # Initialize application
@@ -16,7 +17,8 @@ def run():
                debug=tornado.options.options.debug)
        app.listen(tornado.options.options.port, xheaders=True)
 
-       # Launch IOLoop
-       tornado.ioloop.IOLoop.current().start()
+       # Wait for forever
+       await asyncio.Event().wait()
 
-run()
+# Wrap everything in an event loop
+asyncio.run(run())
index f74e0816c07624d60cafdcd786772808fc922898..fa81706126b8b0ae18a023c72e1d9894bafc9464 100644 (file)
@@ -1,32 +1,13 @@
 #!@PYTHON@
 
+import asyncio
 import sys
-import tornado.ioloop
 import tornado.options
 
 import ipfire
 
-class TaskRunner(object):
-       def __init__(self, *args, **kwargs):
-               self.backend = ipfire.Backend(*args, **kwargs)
-
-               # Create an IOLoop
-               self.ioloop = tornado.ioloop.IOLoop.current()
-
-       def run_task(self, name, *args, **kwargs):
-               """
-                       This method runs the task with the given name and
-                       arguments asynchronically and exits the program in
-                       case on a non-zero exit code
-               """
-               async def task():
-                       await self.backend.run_task(name, *args, **kwargs)
-
-               return self.ioloop.run_sync(task)
-
-
-def main():
-       z = TaskRunner("@configsdir@/@PACKAGE_NAME@.conf")
+async def main():
+       backend = ipfire.Backend("@configsdir@/@PACKAGE_NAME@.conf")
 
        if len(sys.argv) < 2:
                sys.stderr.write("Argument needed\n")
@@ -36,6 +17,6 @@ def main():
        args = tornado.options.parse_command_line()
 
        # Run the task
-       z.run_task(*args)
+       await backend.run_task(*args)
 
-main()
+asyncio.run(main())
diff --git a/src/scss/_code-highlighting.scss b/src/scss/_code-highlighting.scss
deleted file mode 100644 (file)
index feaf754..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-.codehilite {
-       .hll {
-               background-color: #ffffcc;
-       }
-
-       /*
-               Comment, Comment.Hashbang, Comment.Multiline, Comment.PreprocFile,
-               Comment.Single, Comment.Special, Comment.Preproc
-       */
-       .c, .ch, .cm, .cpf, .c1, .cs, .ch, .cp {
-               color: $gray-400;
-               font-style: italic;
-       }
-
-       /* Error */
-       .err {
-               border: 1px solid $red;
-       }
-
-       /* Keyword */
-       .k, .kp, .kr, .kt {
-               color: $green;
-       }
-
-       /* Keyword.Constant */
-       .kc {
-               color: $cyan;
-       }
-
-       /* Keyword.Declaration */
-       .kd {
-               color: $blue;
-       }
-
-       /* Keyword.Namespace */
-       .kn {
-               color: $orange;
-       }
-
-       /*
-               Literal.Number, Literal.Number.*
-       */
-       .m, .mb, .mf, .mh, .mi, .mo, .il {
-               color: $cyan;
-       }
-
-       /* Literal.String, Literal.String.* */
-       .s, .sa, .sb, .sc, .dl, .s1, .s2, .sh, {
-               color: $cyan;
-       }
-
-       /* Literal.String.Doc */
-       .sd {
-               color: $red;
-               font-style: italic;
-       }
-
-       /* Literal.String.Escape */
-       .se {
-               color: $yellow;
-               font-weight: bold;
-       }
-
-       /* Literal.String.Interpol */
-       .si {
-               color: $yellow;
-               font-weight: bold;
-       }
-
-       /* Literal.String.Other */
-       .sx {
-               color: $cyan;
-       }
-
-       /* Literal.String.Regex */
-       .sr {
-               color: $cyan;
-       }
-
-       /* Literal.String.Symbol */
-       .ss {
-               color: $cyan;
-       }
-
-       /* Name.Builtin */
-       .nb {
-               color: $red;
-       }
-
-       /* Name.Builtin.Pseudo */
-       .bp {
-               color: $blue;
-       }
-
-       /* Name.Class */
-       .nc {
-               color: $blue;
-       }
-
-       /* Name.Decorator */
-       .nd {
-               color: $blue;
-       }
-
-       /* Name.Entity */
-       .ni {
-               color: $purple;
-       }
-
-       /* Name.Exception */
-       .ne {
-               color: $yellow;
-       }
-
-       /* Name.Function */
-       .nf {
-               color: $blue;
-       }
-
-       /* Operator.Word */
-       .ow {
-               color: $green;
-               font-weight: bold;
-       }
-}
diff --git a/src/scss/_fonts.scss b/src/scss/_fonts.scss
deleted file mode 100644 (file)
index 69a0b10..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-/* latin-ext */
-@font-face {
-       font-family: "Mukta";
-       font-style: normal;
-       font-weight: 400;
-       src: local("Mukta Regular"), local("Mukta-Regular"), url(/static/fonts/Mukta-Regular.ttf) format("truetype");
-       unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-
-/* latin */
-@font-face {
-       font-family: "Mukta";
-       font-style: normal;
-       font-weight: 400;
-       src: local("Mukta Regular"), local("Mukta-Regular"), url(/static/fonts/Mukta-Regular.ttf) format("truetype");
-       unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-
-/* latin-ext */
-@font-face {
-       font-family: 'Mukta';
-       font-style: normal;
-       font-weight: 500;
-       src: local('Mukta Medium'), local('Mukta-Medium'), url(/static/fonts/Mukta-Medium.ttf) format("truetype");
-       unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-
-/* latin */
-@font-face {
-       font-family: 'Mukta';
-       font-style: normal;
-       font-weight: 500;
-       src: local('Mukta Medium'), local('Mukta-Medium'), url(/static/fonts/Mukta-Medium.ttf) format("truetype");
-       unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-
-/* latin-ext */
-@font-face {
-       font-family: "Mukta";
-       font-style: normal;
-       font-weight: 700;
-       src: local("Mukta Bold"), local("Mukta-Bold"), url(/static/fonts/Mukta-Bold.ttf) format("truetype");
-       unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-
-/* latin */
-@font-face {
-       font-family: "Mukta";
-       font-style: normal;
-       font-weight: 700;
-       src: local("Mukta Bold"), local("Mukta-Bold"), url(/static/fonts/Mukta-Bold.ttf) format("truetype");
-       unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
diff --git a/src/scss/_icons.scss b/src/scss/_icons.scss
deleted file mode 100644 (file)
index 4fc4206..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-// Font Awesome
-$fa-font-path:                         "fonts";
-
-$fa-font-base-size:                    $font-size-base;
-
-@import "../font-awesome/scss/fontawesome";
-@import "../font-awesome/scss/solid";
-@import "../font-awesome/scss/regular";
-@import "../font-awesome/scss/brands";
-
-// PaymentFont
-
-$pf-font-path:              "fonts";
-
-$pf-font-size-base:         $font-size-base;
-
-@import "../payment-font/sass/paymentfont";
-
-// Flags
-
-$flag-icon-css-path:        "flags";
-
-@import "../flag-icons/sass/flag-icon";
diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss
deleted file mode 100644 (file)
index 772a14d..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
-       Colours
-*/
-$black:                                                #000000;
-$white:                                                #ffffff;
-
-// Grays
-$gray-400:                                     #ede8e8;
-$gray-800:                                     #31353c;
-
-$red:                                          #ee2e31;
-$yellow:                                       #e7e247;
-$cyan:                                         #2ee8c8;
-
-// Brand colours
-$lwl:                                          #4b0082;
-$twitter:                                      #00acee;
-
-// Theme
-$primary:                                      $red;
-$secondary:                                    #757575;
-$light:                                                $gray-400;
-$dark:                                         $gray-800;
-
-$theme-colors: (
-       "lwl"                                   : $lwl,
-       "twitter"               : $twitter,
-);
-
-$body-bg:                                      $dark;
-$body-color:                           $white;
-$code-color:                           $white;
-$pre-color:                                    $white;
-$table-color:                          inherit;
-
-$border-radius:                                5px;
-$border-radius-lg:                     5px;
-
-// Fonts
-$font-family-sans-serif:       "Mukta", sans-serif;
-
-$font-weight-light:                    200;
-$font-weight-normal:           400;
-$font-weight-bold:                     700;
-
-// Typo
-$font-size-base:                       1.125rem;
-$lead-font-size:                       1.5rem;
-$small-font-size:                      85%;
-
-$line-height-base:                     1.5;
-
-$blockquote-font-size:         $font-size-base * 1.125;
-
-// Headings
-$headings-font-weight:         500;
-$headings-line-height:         1.15;
-$headings-margin-bottom:       1.25rem;
-
-$h1-font-size:                         3rem;
-$h2-font-size:                         2.5rem;
-$h3-font-size:                         2.25rem;
-$h4-font-size:                         2rem;
-$h5-font-size:                         1.5rem;
-$h6-font-size:                         1.25rem;
-
-$display1-size:                                4rem;
-
-// Links
-$link-color:                           $primary;
-
-// Navbar
-$navbar-brand-font-size:       3rem;
-$navbar-brand-height:          5rem;
-$navbar-brand-padding-y:       1rem;
-$navbar-height:                                $navbar-brand-height + ($navbar-brand-padding-y * 2);
-
-$navbar-nav-link-padding-x:    1rem;
-
-// Buttons
-$btn-border-width:                     3px;
-$btn-padding-x:                                1.5rem;
-$btn-padding-y:                                .5rem;
-
-// Progress
-$progress-height:                      1rem * $line-height-base;
-
-// Breadcrumbs
-$breadcrumb-bg:                                white;
diff --git a/src/scss/listing.scss b/src/scss/listing.scss
deleted file mode 100644 (file)
index c40a5b5..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-@import "variables";
-
-// Import bootstrap
-@import "../bootstrap/scss/functions";
-@import "../bootstrap/scss/variables";
-@import "../bootstrap/scss/mixins";
-@import "../bootstrap/scss/reboot";
-
-// Import components we need
-@import "../bootstrap/scss/type";
-@import "../bootstrap/scss/tables";
-
-// Include fonts
-@import "_fonts.scss";
-
-body {
-    padding: $spacer * 2;
-}
-
-// Make all tables .table by default
-table {
-    @extend .table;
-
-    width: auto;
-}
-
-hr {
-    display: none;
-}
diff --git a/src/scss/style.scss b/src/scss/style.scss
deleted file mode 100644 (file)
index ba8d6ce..0000000
+++ /dev/null
@@ -1,434 +0,0 @@
-@import "variables";
-
-// Use all Bootstrap modules that we want
-@import "../bootstrap/scss/functions";
-@import "../bootstrap/scss/variables";
-@import "../bootstrap/scss/mixins";
-@import "../bootstrap/scss/root";
-@import "../bootstrap/scss/reboot";
-@import "../bootstrap/scss/type";
-@import "../bootstrap/scss/images";
-@import "../bootstrap/scss/code";
-@import "../bootstrap/scss/grid";
-@import "../bootstrap/scss/tables";
-@import "../bootstrap/scss/forms";
-@import "../bootstrap/scss/buttons";
-@import "../bootstrap/scss/transitions";
-@import "../bootstrap/scss/dropdown";
-@import "../bootstrap/scss/button-group";
-@import "../bootstrap/scss/input-group";
-@import "../bootstrap/scss/custom-forms";
-@import "../bootstrap/scss/nav";
-@import "../bootstrap/scss/navbar";
-@import "../bootstrap/scss/card";
-@import "../bootstrap/scss/pagination";
-@import "../bootstrap/scss/breadcrumb";
-@import "../bootstrap/scss/badge";
-@import "../bootstrap/scss/alert";
-@import "../bootstrap/scss/progress";
-@import "../bootstrap/scss/media";
-@import "../bootstrap/scss/list-group";
-@import "../bootstrap/scss/close";
-@import "../bootstrap/scss/modal";
-@import "../bootstrap/scss/spinners";
-@import "../bootstrap/scss/utilities";
-@import "../bootstrap/scss/print";
-
-// Include fonts
-@import "_fonts.scss";
-
-// Custom stuff
-@import "icons";
-@import "code-highlighting";
-
-// Makes everything white with dark text on it
-.inverse {
-       background-color: $body-color;
-       color: $body-bg;
-}
-
-body {
-       display: flex;
-       min-height: 100vh;
-       flex-flow: column;
-
-       // Make the wiki slightly narrower
-       &.wiki-ipfire-org {
-               @include media-breakpoint-up(xl) {
-                       .container {
-                               max-width: 900px;
-                       }
-               }
-       }
-}
-
-// Buttons
-.btn {
-       text-transform: uppercase;
-}
-
-@each $color, $value in $theme-colors {
-       .glow-#{$color} {
-               color: white;
-               background-color: rgba($value, .15);
-       }
-}
-
-code {
-       background-color: $dark;
-       border-radius: $border-radius;
-       padding: 0.1rem 0.2rem;
-}
-
-pre {
-       background-color: $dark;
-       border-radius: $border-radius;
-       padding: 0.5rem;
-
-       code {
-               background-color: inherit;
-               padding: 0;
-       }
-
-       &.pre-light {
-               background-color: $white;
-               color: $body-bg;
-       }
-}
-
-.card {
-       @extend .inverse;
-
-       // Reset padding for sections
-       section {
-               padding: 0;
-       }
-}
-
-.list-group {
-       .list-group-item {
-               color: $body-bg;
-       }
-}
-
-.nav {
-       .nav-link {
-               color: $white;
-
-               &.active {
-                       color: $link-color;
-                       border-left: 2px solid $link-color;
-               }
-       }
-}
-
-.navbar {
-       background-color: rgba($gray-400, .06);
-
-       // Webkit is being stupid and cannot figure out the height
-       // of the navbar by itself
-       min-height: $navbar-height;
-
-       .navbar-brand {
-               color: inherit;
-       }
-
-       .navbar-nav {
-               .nav-link {
-                       color: inherit;
-
-                       &.active {
-                               border-bottom: 2px solid $link-color;
-                       }
-               }
-       }
-}
-
-header {
-       &.cover {
-               position: relative;
-               width: 100%;
-               height: auto;
-               min-height: 42rem;
-
-               @include media-breakpoint-up(lg) {
-                       height: calc(100vh - #{$navbar-height});
-               }
-       }
-}
-
-.icon-large {
-       font-size: 8em;
-
-       @include media-breakpoint-up(md) {
-               font-size: 6em;
-       }
-
-       @include media-breakpoint-up(lg) {
-               font-size: 8em;
-       }
-
-       @include media-breakpoint-up(xl) {
-               font-size: 10em;
-       }
-}
-
-footer {
-       margin-top: auto;
-
-       .footer {
-               margin-top: $spacer * 3;
-
-               .footer-info {
-                       padding: 2rem 0 0 0;
-
-                       color: $white;
-                       background-color: rgba($gray-400, .04);
-
-                       @include media-breakpoint-down(md) {
-                               padding-top: 31px;
-                       }
-
-                       a, .btn-link {
-                               color: inherit;
-
-                               &:hover {
-                                       color: inherit;
-                               }
-                       }
-
-                       ul {
-                               li {
-                                       margin-bottom: $spacer / 4;
-                               }
-                       }
-               }
-
-               .copyright {
-                       background-color: rgba($gray-400, .06);
-                       padding: $spacer 0;
-
-                       font-size: $small-font-size;
-
-                       a {
-                               color: $text-muted;
-                       }
-               }
-       }
-}
-
-.map {
-       min-height: 24rem;
-}
-
-// Sections
-
-section {
-       padding: 3rem 1rem;
-
-       @include media-breakpoint-up(md) {
-               padding: 5rem 0;
-       }
-
-       @include media-breakpoint-up(md) {
-               // Reset large headlines to normal size on mobile devices
-               h1 {
-                       font-size: $display1-size;
-                       font-weight: $display1-weight;
-                       line-height: $display-line-height;
-               }
-       }
-}
-
-blockquote {
-       @extend .blockquote;
-
-       // Add a light border to the left
-       border-left: 0.5rem solid $light;
-       padding: $spacer;
-
-       quotes: "“" "”" "“" "”";
-
-       // Quote
-       &:before{
-               color: $light;
-               font-size: $display1-size;
-               line-height: 0;
-               margin-right: 0.25em;
-               vertical-align: -0.4em;
-       }
-
-       &:before {
-               content: open-quote;
-       }
-
-       p {
-               margin-bottom: 0;
-       }
-}
-
-.blog-post {
-       .blog-header {
-               h4 {
-                       margin-bottom: 0;
-
-                       a {
-                               color: $dark;
-                       }
-               }
-       }
-
-       .blog-content {
-               h1, h2, h3, h4, h5, h6 {
-                       font-size: 1.375rem;
-                       font-weight: $headings-font-weight;
-                       line-height: $headings-line-height;
-                       margin-bottom: 0.25rem;
-               }
-
-               img {
-                       @include img-fluid;
-
-                       // Center all images
-                       display: block;
-                       margin-left: auto;
-                       margin-right: auto;
-
-                       // Add some extra margin to the top & bottom
-                       padding: ($spacer * 2) 0 ($spacer * 2) 0;
-               }
-       }
-
-       &.lightning-wire-labs {
-               .blog-header {
-                       h5 {
-                               a {
-                                       color: $lwl;
-                               }
-                       }
-
-                       a {
-                               color: $lwl;
-                       }
-               }
-
-               .blog-content {
-                       a {
-                               color: $lwl;
-                       }
-               }
-       }
-}
-
-.wiki-content {
-       h1, h2, h3, h4, h5, h6 {
-               font-weight: $headings-font-weight;
-               line-height: $headings-line-height;
-               margin-bottom: 0.5rem;
-       }
-
-       h1 {
-               font-size: $h4-font-size;
-       }
-
-       h2 {
-               font-size: $h5-font-size;
-       }
-
-       h3, h4, h5, h6 {
-               font-size: $h6-font-size;
-       }
-
-       figure {
-               // Center images
-               display: table;
-               margin-right: auto;
-               margin-left: auto;
-
-               // Add some extra margin to the top & bottom
-               padding: ($spacer * 2) 0 ($spacer * 2) 0;
-       }
-
-       blockquote {
-               @extend .blockquote;
-       }
-
-       table {
-               @extend .table;
-               @extend .table-sm;
-               @extend .table-striped;
-
-               // Apply CSS classes for alignment
-               thead {
-                       th[align="left"], td[align="left"] {
-                               @extend .text-left;
-                       }
-
-                       th[align="center"], td[align="center"] {
-                               @extend .text-center;
-                       }
-
-                       th[align="right"], td[align="right"] {
-                               @extend .text-right;
-                       }
-               }
-       }
-
-       .footnote {
-               font-size: $small-font-size;
-
-               ol {
-                       margin-bottom: 0;
-
-                       li {
-                               p {
-                                       margin-bottom: 0;
-                               }
-                       }
-               }
-       }
-}
-
-#preview {
-       // Hide the spinner by default
-       #spinner {
-               display: none;
-       }
-
-       #preview-content {
-               @include transition(opacity .5s linear);
-       }
-
-       &.updating {
-               // Show the spinner during updates
-               #spinner {
-                       display: block;
-               }
-
-               // While updating, we face out the content
-               #preview-content {
-                       opacity: 0.5;
-               }
-       }
-}
-
-hr.divider {
-       border-color: rgba($dark, .15);
-       margin-top: 2rem;
-       margin-bottom: 3rem;
-}
-
-.circle {
-       position: relative;
-       p.fireinfo_per {
-               color: $gray-800;
-               position: absolute;
-               top: calc(50% - 18px);
-               width: 100%;
-       }
-}
-
-.pdf-viewer {
-       width: 100%;
-       min-height: 32rem;
-}
diff --git a/src/static/fonts/prompt/DESCRIPTION.en_us.html b/src/static/fonts/prompt/DESCRIPTION.en_us.html
new file mode 100644 (file)
index 0000000..a214c1e
--- /dev/null
@@ -0,0 +1,15 @@
+<p>
+Prompt in Thai means &#8220;ready,&#8221; the same as in English. 
+Prompt is a loopless Thai and sans Latin typeface. 
+The simple and geometric Latin was developed to work harmoniously with the loopless Thai that has wide proportions and airy negative space. 
+It is suitable for both web and print usage, such as magazines, newspapers, and posters. 
+</p>
+<p>
+A similarity between some glyphs such as ก ถ ภ ฤ ฦ, ฎ ฏ, บ ป, ข ช is something to take into consideration because it might lead to confusion when typesetting very short texts. 
+Formal loopless Thai typefaces are simplified, compared to traditional looped Thai types, and this simplification has to be done properly in order to preserve the essense of each character. 
+The size and position of Thai vowel and tone marks has been managed carefully, because they are all relevant to readability, legibility, and overall texture.
+</p>
+<p>
+The Prompt project is led by Cadson Demak, a type foundry in Thailand. 
+To contribute, see <a href="https://github.com/cadsondemak/prompt">github.com/cadsondemak/prompt</a>
+</p>
\ No newline at end of file
diff --git a/src/static/fonts/prompt/METADATA.pb b/src/static/fonts/prompt/METADATA.pb
new file mode 100644 (file)
index 0000000..a5d90fd
--- /dev/null
@@ -0,0 +1,172 @@
+name: "Prompt"
+designer: "Cadson Demak"
+license: "OFL"
+category: "SANS_SERIF"
+date_added: "2016-06-20"
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 100
+  filename: "Prompt-Thin.ttf"
+  post_script_name: "Prompt-Thin"
+  full_name: "Prompt Thin"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 100
+  filename: "Prompt-ThinItalic.ttf"
+  post_script_name: "Prompt-ThinItalic"
+  full_name: "Prompt Thin Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 200
+  filename: "Prompt-ExtraLight.ttf"
+  post_script_name: "Prompt-ExtraLight"
+  full_name: "Prompt ExtraLight"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 200
+  filename: "Prompt-ExtraLightItalic.ttf"
+  post_script_name: "Prompt-ExtraLightItalic"
+  full_name: "Prompt ExtraLight Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 300
+  filename: "Prompt-Light.ttf"
+  post_script_name: "Prompt-Light"
+  full_name: "Prompt Light"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 300
+  filename: "Prompt-LightItalic.ttf"
+  post_script_name: "Prompt-LightItalic"
+  full_name: "Prompt Light Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 400
+  filename: "Prompt-Regular.ttf"
+  post_script_name: "Prompt-Regular"
+  full_name: "Prompt Regular"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 400
+  filename: "Prompt-Italic.ttf"
+  post_script_name: "Prompt-Italic"
+  full_name: "Prompt Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 500
+  filename: "Prompt-Medium.ttf"
+  post_script_name: "Prompt-Medium"
+  full_name: "Prompt Medium"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 500
+  filename: "Prompt-MediumItalic.ttf"
+  post_script_name: "Prompt-MediumItalic"
+  full_name: "Prompt Medium Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 600
+  filename: "Prompt-SemiBold.ttf"
+  post_script_name: "Prompt-SemiBold"
+  full_name: "Prompt SemiBold"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 600
+  filename: "Prompt-SemiBoldItalic.ttf"
+  post_script_name: "Prompt-SemiBoldItalic"
+  full_name: "Prompt SemiBold Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 700
+  filename: "Prompt-Bold.ttf"
+  post_script_name: "Prompt-Bold"
+  full_name: "Prompt Bold"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 700
+  filename: "Prompt-BoldItalic.ttf"
+  post_script_name: "Prompt-BoldItalic"
+  full_name: "Prompt Bold Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 800
+  filename: "Prompt-ExtraBold.ttf"
+  post_script_name: "Prompt-ExtraBold"
+  full_name: "Prompt ExtraBold"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 800
+  filename: "Prompt-ExtraBoldItalic.ttf"
+  post_script_name: "Prompt-ExtraBoldItalic"
+  full_name: "Prompt ExtraBold Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "normal"
+  weight: 900
+  filename: "Prompt-Black.ttf"
+  post_script_name: "Prompt-Black"
+  full_name: "Prompt Black"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+fonts {
+  name: "Prompt"
+  style: "italic"
+  weight: 900
+  filename: "Prompt-BlackItalic.ttf"
+  post_script_name: "Prompt-BlackItalic"
+  full_name: "Prompt Black Italic"
+  copyright: "Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)"
+}
+subsets: "latin"
+subsets: "latin-ext"
+subsets: "menu"
+subsets: "thai"
+subsets: "vietnamese"
diff --git a/src/static/fonts/prompt/OFL.txt b/src/static/fonts/prompt/OFL.txt
new file mode 100644 (file)
index 0000000..c760a31
--- /dev/null
@@ -0,0 +1,93 @@
+Copyright (c) 2015, Cadson Demak (info@cadsondemak.com)\r
+\r
+This Font Software is licensed under the SIL Open Font License, Version 1.1.\r
+This license is copied below, and is also available with a FAQ at:\r
+http://scripts.sil.org/OFL\r
+\r
+\r
+-----------------------------------------------------------\r
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\r
+-----------------------------------------------------------\r
+\r
+PREAMBLE\r
+The goals of the Open Font License (OFL) are to stimulate worldwide\r
+development of collaborative font projects, to support the font creation\r
+efforts of academic and linguistic communities, and to provide a free and\r
+open framework in which fonts may be shared and improved in partnership\r
+with others.\r
+\r
+The OFL allows the licensed fonts to be used, studied, modified and\r
+redistributed freely as long as they are not sold by themselves. The\r
+fonts, including any derivative works, can be bundled, embedded, \r
+redistributed and/or sold with any software provided that any reserved\r
+names are not used by derivative works. The fonts and derivatives,\r
+however, cannot be released under any other type of license. The\r
+requirement for fonts to remain under this license does not apply\r
+to any document created using the fonts or their derivatives.\r
+\r
+DEFINITIONS\r
+"Font Software" refers to the set of files released by the Copyright\r
+Holder(s) under this license and clearly marked as such. This may\r
+include source files, build scripts and documentation.\r
+\r
+"Reserved Font Name" refers to any names specified as such after the\r
+copyright statement(s).\r
+\r
+"Original Version" refers to the collection of Font Software components as\r
+distributed by the Copyright Holder(s).\r
+\r
+"Modified Version" refers to any derivative made by adding to, deleting,\r
+or substituting -- in part or in whole -- any of the components of the\r
+Original Version, by changing formats or by porting the Font Software to a\r
+new environment.\r
+\r
+"Author" refers to any designer, engineer, programmer, technical\r
+writer or other person who contributed to the Font Software.\r
+\r
+PERMISSION & CONDITIONS\r
+Permission is hereby granted, free of charge, to any person obtaining\r
+a copy of the Font Software, to use, study, copy, merge, embed, modify,\r
+redistribute, and sell modified and unmodified copies of the Font\r
+Software, subject to the following conditions:\r
+\r
+1) Neither the Font Software nor any of its individual components,\r
+in Original or Modified Versions, may be sold by itself.\r
+\r
+2) Original or Modified Versions of the Font Software may be bundled,\r
+redistributed and/or sold with any software, provided that each copy\r
+contains the above copyright notice and this license. These can be\r
+included either as stand-alone text files, human-readable headers or\r
+in the appropriate machine-readable metadata fields within text or\r
+binary files as long as those fields can be easily viewed by the user.\r
+\r
+3) No Modified Version of the Font Software may use the Reserved Font\r
+Name(s) unless explicit written permission is granted by the corresponding\r
+Copyright Holder. This restriction only applies to the primary font name as\r
+presented to the users.\r
+\r
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\r
+Software shall not be used to promote, endorse or advertise any\r
+Modified Version, except to acknowledge the contribution(s) of the\r
+Copyright Holder(s) and the Author(s) or with their explicit written\r
+permission.\r
+\r
+5) The Font Software, modified or unmodified, in part or in whole,\r
+must be distributed entirely under this license, and must not be\r
+distributed under any other license. The requirement for fonts to\r
+remain under this license does not apply to any document created\r
+using the Font Software.\r
+\r
+TERMINATION\r
+This license becomes null and void if any of the above conditions are\r
+not met.\r
+\r
+DISCLAIMER\r
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,\r
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\r
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\r
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\r
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\r
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\r
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\r
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\r
+OTHER DEALINGS IN THE FONT SOFTWARE.\r
diff --git a/src/static/fonts/prompt/Prompt-Black.ttf b/src/static/fonts/prompt/Prompt-Black.ttf
new file mode 100644 (file)
index 0000000..624fe85
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Black.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-BlackItalic.ttf b/src/static/fonts/prompt/Prompt-BlackItalic.ttf
new file mode 100644 (file)
index 0000000..49ebb16
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-BlackItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-Bold.ttf b/src/static/fonts/prompt/Prompt-Bold.ttf
new file mode 100644 (file)
index 0000000..6cdd89b
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Bold.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-BoldItalic.ttf b/src/static/fonts/prompt/Prompt-BoldItalic.ttf
new file mode 100644 (file)
index 0000000..a9effd7
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-BoldItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-ExtraBold.ttf b/src/static/fonts/prompt/Prompt-ExtraBold.ttf
new file mode 100644 (file)
index 0000000..ded930f
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-ExtraBold.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-ExtraBoldItalic.ttf b/src/static/fonts/prompt/Prompt-ExtraBoldItalic.ttf
new file mode 100644 (file)
index 0000000..6c076ed
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-ExtraBoldItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-ExtraLight.ttf b/src/static/fonts/prompt/Prompt-ExtraLight.ttf
new file mode 100644 (file)
index 0000000..7f94676
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-ExtraLight.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-ExtraLightItalic.ttf b/src/static/fonts/prompt/Prompt-ExtraLightItalic.ttf
new file mode 100644 (file)
index 0000000..4ca72e3
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-ExtraLightItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-Italic.ttf b/src/static/fonts/prompt/Prompt-Italic.ttf
new file mode 100644 (file)
index 0000000..0545734
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Italic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-Light.ttf b/src/static/fonts/prompt/Prompt-Light.ttf
new file mode 100644 (file)
index 0000000..0c5d1eb
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Light.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-LightItalic.ttf b/src/static/fonts/prompt/Prompt-LightItalic.ttf
new file mode 100644 (file)
index 0000000..8bb339a
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-LightItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-Medium.ttf b/src/static/fonts/prompt/Prompt-Medium.ttf
new file mode 100644 (file)
index 0000000..adf72c9
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Medium.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-MediumItalic.ttf b/src/static/fonts/prompt/Prompt-MediumItalic.ttf
new file mode 100644 (file)
index 0000000..7b87fdb
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-MediumItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-Regular.ttf b/src/static/fonts/prompt/Prompt-Regular.ttf
new file mode 100644 (file)
index 0000000..2a736db
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Regular.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-SemiBold.ttf b/src/static/fonts/prompt/Prompt-SemiBold.ttf
new file mode 100644 (file)
index 0000000..011a050
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-SemiBold.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-SemiBoldItalic.ttf b/src/static/fonts/prompt/Prompt-SemiBoldItalic.ttf
new file mode 100644 (file)
index 0000000..3bec3f7
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-SemiBoldItalic.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-Thin.ttf b/src/static/fonts/prompt/Prompt-Thin.ttf
new file mode 100644 (file)
index 0000000..a3b80b4
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-Thin.ttf differ
diff --git a/src/static/fonts/prompt/Prompt-ThinItalic.ttf b/src/static/fonts/prompt/Prompt-ThinItalic.ttf
new file mode 100644 (file)
index 0000000..f64319a
Binary files /dev/null and b/src/static/fonts/prompt/Prompt-ThinItalic.ttf differ
diff --git a/src/static/img/auth/join.jpg b/src/static/img/auth/join.jpg
new file mode 100644 (file)
index 0000000..d422069
Binary files /dev/null and b/src/static/img/auth/join.jpg differ
diff --git a/src/static/img/downloads/cloud/aws.svg b/src/static/img/downloads/cloud/aws.svg
new file mode 100644 (file)
index 0000000..214e141
--- /dev/null
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+
+<svg
+   version="1.1"
+   id="Layer_1"
+   x="0px"
+   y="0px"
+   viewBox="0 0 500 500"
+   xml:space="preserve"
+   sodipodi:docname="Amazon_Web_Services_Logo.svg"
+   width="500"
+   height="500"
+   inkscape:version="1.1.1 (c3084ef, 2021-09-22)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+   id="defs17" /><sodipodi:namedview
+   id="namedview15"
+   pagecolor="#ffffff"
+   bordercolor="#666666"
+   borderopacity="1.0"
+   inkscape:pageshadow="2"
+   inkscape:pageopacity="0.0"
+   inkscape:pagecheckerboard="0"
+   showgrid="false"
+   inkscape:zoom="1.0997491"
+   inkscape:cx="260.51398"
+   inkscape:cy="256.87678"
+   inkscape:window-width="1296"
+   inkscape:window-height="789"
+   inkscape:window-x="0"
+   inkscape:window-y="25"
+   inkscape:window-maximized="0"
+   inkscape:current-layer="Layer_1" />
+<style
+   type="text/css"
+   id="style2">
+       .st0{fill:#252F3E;}
+       .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FF9900;}
+</style>
+<g
+   id="g12"
+   transform="translate(97.996539,159)">
+       <path
+   class="st0"
+   d="m 86.4,66.4 c 0,3.7 0.4,6.7 1.1,8.9 0.8,2.2 1.8,4.6 3.2,7.2 0.5,0.8 0.7,1.6 0.7,2.3 0,1 -0.6,2 -1.9,3 L 83.2,92 c -0.9,0.6 -1.8,0.9 -2.6,0.9 -1,0 -2,-0.5 -3,-1.4 C 76.2,90 75,88.4 74,86.8 73,85.1 72,83.2 70.9,80.9 63.1,90.1 53.3,94.7 41.5,94.7 c -8.4,0 -15.1,-2.4 -20,-7.2 -4.9,-4.8 -7.4,-11.2 -7.4,-19.2 0,-8.5 3,-15.4 9.1,-20.6 6.1,-5.2 14.2,-7.8 24.5,-7.8 3.4,0 6.9,0.3 10.6,0.8 3.7,0.5 7.5,1.3 11.5,2.2 V 35.6 C 69.8,28 68.2,22.7 65.1,19.6 61.9,16.5 56.5,15 48.8,15 c -3.5,0 -7.1,0.4 -10.8,1.3 -3.7,0.9 -7.3,2 -10.8,3.4 -1.6,0.7 -2.8,1.1 -3.5,1.3 -0.7,0.2 -1.2,0.3 -1.6,0.3 -1.4,0 -2.1,-1 -2.1,-3.1 V 13.3 C 20,11.7 20.2,10.5 20.7,9.8 21.2,9.1 22.1,8.4 23.5,7.7 27,5.9 31.2,4.4 36.1,3.2 41,1.9 46.2,1.3 51.7,1.3 63.6,1.3 72.3,4 77.9,9.4 83.4,14.8 86.2,23 86.2,34 V 66.4 Z M 45.8,81.6 c 3.3,0 6.7,-0.6 10.3,-1.8 3.6,-1.2 6.8,-3.4 9.5,-6.4 1.6,-1.9 2.8,-4 3.4,-6.4 0.6,-2.4 1,-5.3 1,-8.7 v -4.2 c -2.9,-0.7 -6,-1.3 -9.2,-1.7 -3.2,-0.4 -6.3,-0.6 -9.4,-0.6 -6.7,0 -11.6,1.3 -14.9,4 -3.3,2.7 -4.9,6.5 -4.9,11.5 0,4.7 1.2,8.2 3.7,10.6 2.4,2.5 5.9,3.7 10.5,3.7 z m 80.3,10.8 c -1.8,0 -3,-0.3 -3.8,-1 -0.8,-0.6 -1.5,-2 -2.1,-3.9 L 96.7,10.2 c -0.6,-2 -0.9,-3.3 -0.9,-4 0,-1.6 0.8,-2.5 2.4,-2.5 h 9.8 c 1.9,0 3.2,0.3 3.9,1 0.8,0.6 1.4,2 2,3.9 l 16.8,66.2 15.6,-66.2 c 0.5,-2 1.1,-3.3 1.9,-3.9 0.8,-0.6 2.2,-1 4,-1 h 8 c 1.9,0 3.2,0.3 4,1 0.8,0.6 1.5,2 1.9,3.9 l 15.8,67 17.3,-67 c 0.6,-2 1.3,-3.3 2,-3.9 0.8,-0.6 2.1,-1 3.9,-1 h 9.3 c 1.6,0 2.5,0.8 2.5,2.5 0,0.5 -0.1,1 -0.2,1.6 -0.1,0.6 -0.3,1.4 -0.7,2.5 l -24.1,77.3 c -0.6,2 -1.3,3.3 -2.1,3.9 -0.8,0.6 -2.1,1 -3.8,1 h -8.6 c -1.9,0 -3.2,-0.3 -4,-1 -0.8,-0.7 -1.5,-2 -1.9,-4 L 156,23 140.6,87.4 c -0.5,2 -1.1,3.3 -1.9,4 -0.8,0.7 -2.2,1 -4,1 z m 128.5,2.7 c -5.2,0 -10.4,-0.6 -15.4,-1.8 -5,-1.2 -8.9,-2.5 -11.5,-4 -1.6,-0.9 -2.7,-1.9 -3.1,-2.8 -0.4,-0.9 -0.6,-1.9 -0.6,-2.8 v -5.1 c 0,-2.1 0.8,-3.1 2.3,-3.1 0.6,0 1.2,0.1 1.8,0.3 0.6,0.2 1.5,0.6 2.5,1 3.4,1.5 7.1,2.7 11,3.5 4,0.8 7.9,1.2 11.9,1.2 6.3,0 11.2,-1.1 14.6,-3.3 3.4,-2.2 5.2,-5.4 5.2,-9.5 0,-2.8 -0.9,-5.1 -2.7,-7 -1.8,-1.9 -5.2,-3.6 -10.1,-5.2 L 246,52 c -7.3,-2.3 -12.7,-5.7 -16,-10.2 -3.3,-4.4 -5,-9.3 -5,-14.5 0,-4.2 0.9,-7.9 2.7,-11.1 1.8,-3.2 4.2,-6 7.2,-8.2 3,-2.3 6.4,-4 10.4,-5.2 4,-1.2 8.2,-1.7 12.6,-1.7 2.2,0 4.5,0.1 6.7,0.4 2.3,0.3 4.4,0.7 6.5,1.1 2,0.5 3.9,1 5.7,1.6 1.8,0.6 3.2,1.2 4.2,1.8 1.4,0.8 2.4,1.6 3,2.5 0.6,0.8 0.9,1.9 0.9,3.3 v 4.7 c 0,2.1 -0.8,3.2 -2.3,3.2 -0.8,0 -2.1,-0.4 -3.8,-1.2 -5.7,-2.6 -12.1,-3.9 -19.2,-3.9 -5.7,0 -10.2,0.9 -13.3,2.8 -3.1,1.9 -4.7,4.8 -4.7,8.9 0,2.8 1,5.2 3,7.1 2,1.9 5.7,3.8 11,5.5 l 14.2,4.5 c 7.2,2.3 12.4,5.5 15.5,9.6 3.1,4.1 4.6,8.8 4.6,14 0,4.3 -0.9,8.2 -2.6,11.6 -1.8,3.4 -4.2,6.4 -7.3,8.8 -3.1,2.5 -6.8,4.3 -11.1,5.6 -4.5,1.4 -9.2,2.1 -14.3,2.1 z"
+   id="path4" />
+       <g
+   id="g10">
+               <path
+   class="st1"
+   d="M 273.5,143.7 C 240.6,168 192.8,180.9 151.7,180.9 94.1,180.9 42.2,159.6 3,124.2 c -3.1,-2.8 -0.3,-6.6 3.4,-4.4 42.4,24.6 94.7,39.5 148.8,39.5 36.5,0 76.6,-7.6 113.5,-23.2 5.5,-2.5 10.2,3.6 4.8,7.6 z"
+   id="path6" />
+               <path
+   class="st1"
+   d="m 287.2,128.1 c -4.2,-5.4 -27.8,-2.6 -38.5,-1.3 -3.2,0.4 -3.7,-2.4 -0.8,-4.5 18.8,-13.2 49.7,-9.4 53.3,-5 3.6,4.5 -1,35.4 -18.6,50.2 -2.7,2.3 -5.3,1.1 -4.1,-1.9 4,-9.9 12.9,-32.2 8.7,-37.5 z"
+   id="path8" />
+       </g>
+</g>
+</svg>
diff --git a/src/static/img/downloads/cloud/exoscale.svg b/src/static/img/downloads/cloud/exoscale.svg
new file mode 100644 (file)
index 0000000..1a35327
--- /dev/null
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 500.00002 500"
+   version="1.1"
+   id="svg6"
+   sodipodi:docname="logo-3.svg"
+   width="500"
+   height="500"
+   inkscape:version="1.1.1 (c3084ef, 2021-09-22)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs10" />
+  <sodipodi:namedview
+     id="namedview8"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     showgrid="false"
+     width="500px"
+     inkscape:zoom="1.0490268"
+     inkscape:cx="223.54052"
+     inkscape:cy="252.61508"
+     inkscape:window-width="1296"
+     inkscape:window-height="790"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg6" />
+  <g
+     id="g14"
+     transform="matrix(1.2483383,0,0,1.2483383,70.925871,200.06647)">
+    <path
+       fill="#da291c"
+       d="M 30,21.9 9.1,58.1 H 12 l 18,-31 v 5.1 L 15,58.1 h 3 L 30,37.4 v 5.2 l -9,15.5 h 3 l 6,-10.3 v 5.1 l -3,5.2 h 23.9 z"
+       id="path2" />
+    <path
+       d="m 59.9,27.1 h 19.2 v 4.1 H 64.5 v 6.7 h 13 V 42 h -13 v 6.9 H 79.3 V 53 H 59.9 Z m 48.7,0 -9,12.6 9.4,13.2 h -5.4 L 96.8,43 90,52.9 H 84.8 L 94.1,39.7 85.1,27 h 5.4 l 6.4,9.3 6.5,-9.3 h 5.2 z m 4.1,13 c 0,-7.4 5.6,-13.4 13.6,-13.4 7.9,0 13.5,6 13.5,13.3 0,7.3 -5.6,13.4 -13.6,13.4 -7.9,0 -13.5,-6 -13.5,-13.3 z m 22.3,0 c 0,-5.1 -3.7,-9.2 -8.8,-9.2 -5.1,0 -8.7,4.1 -8.7,9.1 0,5 3.7,9.2 8.8,9.2 5.1,0 8.7,-4.1 8.7,-9.1 z m 10.4,9.1 2.7,-3.3 c 2.5,2.1 5,3.4 8.2,3.4 2.8,0 4.6,-1.3 4.6,-3.3 v -0.1 c 0,-1.8 -1,-2.8 -5.8,-4 -5.5,-1.3 -8.6,-3 -8.6,-7.7 v -0.1 c 0,-4.4 3.7,-7.5 8.8,-7.5 3.8,0 6.8,1.1 9.4,3.3 l -2.4,3.4 c -2.3,-1.7 -4.7,-2.7 -7,-2.7 -2.7,0 -4.2,1.4 -4.2,3.1 v 0.1 c 0,2 1.2,2.9 6.2,4.1 5.5,1.3 8.3,3.3 8.3,7.6 v 0.1 c 0,4.8 -3.8,7.7 -9.2,7.7 -4.1,0 -7.9,-1.4 -11,-4.1 z m 26.3,-9.1 c 0,-7.4 5.5,-13.4 13.3,-13.4 4.8,0 7.7,1.7 10.2,4 l -2.9,3.4 c -2.1,-1.9 -4.3,-3.2 -7.3,-3.2 -4.9,0 -8.5,4 -8.5,9.1 0,5.1 3.6,9.2 8.5,9.2 3.2,0 5.2,-1.3 7.4,-3.3 l 2.9,3 c -2.7,2.8 -5.7,4.6 -10.5,4.6 -7.5,-0.1 -13.1,-6 -13.1,-13.4 z m 39.7,-13.2 h 4.2 l 11.4,26 h -4.8 l -2.6,-6.2 h -12.2 l -2.7,6.2 H 200 Z m 6.5,15.8 -4.5,-10.3 -4.4,10.3 z m 15.6,-15.6 h 4.5 v 21.7 h 13.6 v 4.1 h -18.1 z m 24.9,0 h 19.2 v 4.1 H 263 v 6.7 h 13 V 42 h -13 v 6.9 h 14.8 V 53 h -19.4 z"
+       id="path4" />
+  </g>
+</svg>
diff --git a/src/static/img/downloads/cloud/hetzner.svg b/src/static/img/downloads/cloud/hetzner.svg
new file mode 100644 (file)
index 0000000..97bf32c
--- /dev/null
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 181.42 22.24">
+  <defs>
+    <style type="text/css">
+      .cls-1{fill:#d50c2d}
+    </style>
+  </defs>
+  <title>
+    Element 1
+  </title>
+  <g id="Ebene_2">
+           <path class="cls-1" d="M174.05,14.12a10.22,10.22,0,0,0,4.53-2l0,0a6.15,6.15,0,0,0,1.68-4.78,7.71,7.71,0,0,0-1.14-4.06A6.47,6.47,0,0,0,173.84.09l-1.09,0L170.2,0,158.66,0c-.7,0-1,.29-1,1V21.22c0,.7.29,1,1,1h3c.7,0,1-.29,1-1v-6.7h3.67a3.48,3.48,0,0,1,2.17.91l5.82,5.85a3.08,3.08,0,0,0,2,.92h4.47c.7,0,.87-.41.38-.91Zm-.76-4.3H162.64V4.72h10.65a2.13,2.13,0,0,1,1.87,2.15v.79A2.14,2.14,0,0,1,173.29,9.82Z"/>
+          <path class="cls-1" d="M153,17.52H136.47V13.35h13.19c.7,0,1-.29,1-1V9.92c0-.7-.29-1-1-1h-13.2V4.76H153c.7,0,1-.29,1-1V1c0-.7-.29-1-1-1H132.38c-.7,0-1,.29-1,1V21.24c0,.7.29,1,1,1H153c.7,0,1-.29,1-1V18.51C154,17.81,153.67,17.52,153,17.52Z"/>
+          <path class="cls-1" d="M127.73,7.3a7.25,7.25,0,0,0-1.13-4A6.61,6.61,0,0,0,121.24,0L106.08,0c-.71,0-1,.29-1,1V21.22c0,.7.29,1,1,1h3.26c.7,0,1-.28,1-1V4.73l8.78,0c1.87,0,3.69,1.24,3.69,3.11V21.24c0,.7.29,1,1,1h2.95c.71,0,1-.29,1-1Z"/>
+          <path class="cls-1" d="M100.47,17.39l-14.25,0L100.5,4.84a2.57,2.57,0,0,0,1-1.84V1c0-.7-.3-1-1-1H79.83c-.7,0-1,.29-1,1V3.77c0,.7.29,1,1,1H93.08L79.79,17.24a2.62,2.62,0,0,0-1,1.84v2.17c0,.7.29,1,1,1l20.65,0c.7,0,1-.29,1-1V18.38C101.46,17.68,101.17,17.39,100.47,17.39Z"/>
+          <path class="cls-1" d="M74.19,0H53.55c-.71,0-1,.28-1,1V3.76c0,.7.28,1,1,1h7.78V21.24c0,.7.29,1,1,1h3.3c.7,0,1-.29,1-1V4.75h7.57c.7,0,1-.29,1-1V1C75.18.32,74.89,0,74.19,0Z"/>
+          <path class="cls-1" d="M47.91,17.52H31.41V13.35H44.6c.7,0,1-.29,1-1V9.92c0-.7-.28-1-1-1H31.41V4.76h16.5c.7,0,1-.29,1-1V1c0-.7-.29-1-1-1H27.33c-.7,0-1,.29-1,1V21.24c0,.7.29,1,1,1H47.91c.7,0,1-.29,1-1V18.51C48.9,17.81,48.61,17.52,47.91,17.52Z"/>
+          <path class="cls-1" d="M21.63,0H18.52c-.7,0-1,.29-1,1V8.87H5.13V1c0-.7-.29-1-1-1H1C.29,0,0,.29,0,1V21.25c0,.71.29,1,1,1H4.13c.7,0,1-.28,1-1v-8h12.4v8c0,.7.29,1,1,1h3.11c.7,0,1-.29,1-1V1C22.62.32,22.33,0,21.63,0Z"/>
+     </g>
+</svg>
diff --git a/src/static/img/fdroid-logo.svg b/src/static/img/fdroid-logo.svg
new file mode 100644 (file)
index 0000000..ca7cc29
--- /dev/null
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="48" height="48" viewBox="0 0 48.000001 48.000001" id="svg4230" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="fdroid-logo.svg">
+  <defs id="defs4232">
+    <linearGradient inkscape:collect="always" id="linearGradient5212">
+      <stop style="stop-color:#ffffff;stop-opacity:0.09803922" offset="0" id="stop5214"/>
+      <stop style="stop-color:#ffffff;stop-opacity:0" offset="1" id="stop5216"/>
+    </linearGradient>
+    <radialGradient inkscape:collect="always" xlink:href="#linearGradient5212" id="radialGradient5220" cx="-98.23381" cy="3.4695871" fx="-98.23381" fy="3.4695871" r="22.671185" gradientTransform="matrix(0,1.9747624,-2.117225,3.9784049e-8,8.677247,1199.588)" gradientUnits="userSpaceOnUse"/>
+    <filter inkscape:collect="always" style="color-interpolation-filters:sRGB" id="filter4175" x="-0.023846937" width="1.0476939" y="-0.02415504" height="1.0483101">
+      <feGaussianBlur inkscape:collect="always" stdDeviation="0.45053152" id="feGaussianBlur4177"/>
+    </filter>
+  </defs>
+  <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="11.313708" inkscape:cx="6.4184057" inkscape:cy="25.737489" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" units="px" inkscape:window-width="1920" inkscape:window-height="1009" inkscape:window-x="0" inkscape:window-y="34" inkscape:window-maximized="1" gridtolerance="10000"/>
+  <metadata id="metadata4235">
+    <rdf:RDF>
+      <cc:Work rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+        <dc:title/>
+        <cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/"/>
+      </cc:Work>
+      <cc:License rdf:about="http://creativecommons.org/licenses/by-sa/3.0/">
+        <cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
+        <cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
+        <cc:requires rdf:resource="http://creativecommons.org/ns#Notice"/>
+        <cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"/>
+        <cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
+        <cc:requires rdf:resource="http://creativecommons.org/ns#ShareAlike"/>
+      </cc:License>
+    </rdf:RDF>
+  </metadata>
+  <g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-1004.3622)">
+    <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.4;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4175);color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 2.613462,1006.3488 a 1.250125,1.250125 0 0 0 -1.01172,2.0293 l 3.60351,4.6641 c -0.12699,0.3331 -0.20312,0.6915 -0.20312,1.0703 l 0,4 0,2.8652 0,0.1348 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-4 0,-2.8652 0,-0.1348 c 0,-0.3803 -0.0771,-0.74 -0.20508,-1.0742 l 3.60156,-4.6602 a 1.250125,1.250125 0 0 0 -1.04882,-2.0273 1.250125,1.250125 0 0 0 -0.92969,0.498 l -3.43164,4.4414 c -0.31022,-0.1079 -0.63841,-0.1777 -0.98633,-0.1777 l -32,0 c -0.34857,0 -0.67757,0.069 -0.98828,0.1777 l -3.4336,-4.4414 a 1.250125,1.250125 0 0 0 -0.96679,-0.5 z m 5.38867,18.7637 c -0.20775,0 -0.40983,0.021 -0.60547,0.061 -1.36951,0.2761 -2.39453,1.4698 -2.39453,2.9101 l 0,0.029 0,19.7793 0,0.029 0,0.1914 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-20 0,-0.029 c 0,-1.4403 -1.02502,-2.634 -2.39453,-2.9101 -0.19565,-0.039 -0.39772,-0.061 -0.60547,-0.061 l -32,0 z" id="path4192" inkscape:connector-curvature="0"/>
+    <g id="g5012">
+      <g id="g4179" transform="matrix(-1,0,0,1,47.999779,0)">
+        <path style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 2.5889342,1006.8622 4.25,5.5" id="path4181" inkscape:connector-curvature="0" sodipodi:nodetypes="cc"/>
+        <path sodipodi:nodetypes="cccccc" inkscape:connector-curvature="0" id="path4183" d="m 2.6113281,1005.6094 c -0.4534623,0.012 -0.7616975,0.189 -0.9807462,0.4486 2.0269314,2.4089 2.368401,2.7916 5.1354735,6.2214 1.0195329,1.319 2.0816026,0.6373 1.0620696,-0.6817 l -4.25,-5.5 c -0.2289894,-0.3056 -0.5850813,-0.478 -0.9667969,-0.4883 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.29803923;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
+        <path sodipodi:nodetypes="ccccc" inkscape:connector-curvature="0" id="path4185" d="m 1.6220992,1006.0705 c -0.1238933,0.1479 -0.561176,0.8046 -0.02249,1.5562 l 4.25,5.5 c 1.0195329,1.319 1.1498748,-0.6123 1.1498748,-0.6123 0,0 -3.7344514,-4.51 -5.3773848,-6.4439 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
+        <path sodipodi:nodetypes="cscccc" inkscape:connector-curvature="0" id="path4187" d="m 2.3378905,1005.8443 c -0.438175,0 -0.959862,0.1416 -0.8242183,0.7986 0.103561,0.5016 4.6608262,6.0744 4.6608262,6.0744 1.0195329,1.319 2.4934721,0.6763 1.4739391,-0.6425 l -4.234375,-5.4727 c -0.2602394,-0.29 -0.6085188,-0.7436 -1.076172,-0.7578 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
+      </g>
+      <g id="g4955">
+        <path sodipodi:nodetypes="cc" inkscape:connector-curvature="0" id="path4945" d="m 2.5889342,1006.8622 4.25,5.5" style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
+        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.29803923;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 2.6113281,1005.6094 c -0.4534623,0.012 -0.7616975,0.189 -0.9807462,0.4486 2.0269314,2.4089 2.368401,2.7916 5.1354735,6.2214 1.0195329,1.319 2.0816026,0.6373 1.0620696,-0.6817 l -4.25,-5.5 c -0.2289894,-0.3056 -0.5850813,-0.478 -0.9667969,-0.4883 z" id="path4947" inkscape:connector-curvature="0" sodipodi:nodetypes="cccccc"/>
+        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 1.6220992,1006.0705 c -0.1238933,0.1479 -0.561176,0.8046 -0.02249,1.5562 l 4.25,5.5 c 1.0195329,1.319 1.1498748,-0.6123 1.1498748,-0.6123 0,0 -3.7344514,-4.51 -5.3773848,-6.4439 z" id="path4951" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccc"/>
+        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 2.3378905,1005.8443 c -0.438175,0 -0.959862,0.1416 -0.8242183,0.7986 0.103561,0.5016 4.6608262,6.0744 4.6608262,6.0744 1.0195329,1.319 2.4934721,0.6763 1.4739391,-0.6425 l -4.234375,-5.4727 c -0.2602394,-0.29 -0.6085188,-0.7436 -1.076172,-0.7578 z" id="path4925" inkscape:connector-curvature="0" sodipodi:nodetypes="cscccc"/>
+      </g>
+      <g transform="translate(42,0)" id="g4967">
+        <rect style="opacity:1;fill:#aeea00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="rect4144" width="38" height="13" x="-37" y="1010.3622" rx="3" ry="3"/>
+        <rect ry="3" rx="3" y="1013.3622" x="-37" height="10" width="38" id="rect4961" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
+        <rect ry="3" rx="3" y="1010.3622" x="-37" height="10" width="38" id="rect4963" style="opacity:1;fill:#ffffff;fill-opacity:0.29803923;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
+        <rect ry="2.5384617" rx="3" y="1011.3622" x="-37" height="11" width="38" id="rect4965" style="opacity:1;fill:#aeea00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
+      </g>
+      <g id="g4979">
+        <rect style="opacity:1;fill:#1976d2;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="rect4146" width="38" height="26" x="5" y="1024.3622" rx="3" ry="3"/>
+        <rect ry="3" rx="3" y="1037.3622" x="5" height="13" width="38" id="rect4973" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
+        <rect ry="3" rx="3" y="1024.3622" x="5" height="13" width="38" id="rect4975" style="opacity:1;fill:#ffffff;fill-opacity:0.2;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
+        <rect ry="2.7692308" rx="3" y="1025.3622" x="5" height="24" width="38" id="rect4977" style="opacity:1;fill:#1976d2;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
+      </g>
+      <g transform="translate(0,1013.3622)" id="g4211">
+        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#0d47a1;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 24,17.75 c -2.880662,0 -5.319789,1.984685 -6.033203,4.650391 l 3.212891,0 C 21.734004,21.415044 22.774798,20.75 24,20.75 c 1.812692,0 3.25,1.437308 3.25,3.25 0,1.812693 -1.437308,3.25 -3.25,3.25 -1.307381,0 -2.411251,-0.75269 -2.929688,-1.849609 l -3.154296,0 C 18.558263,28.166146 21.04791,30.25 24,30.25 c 3.434013,0 6.25,-2.815987 6.25,-6.25 0,-3.434012 -2.815987,-6.25 -6.25,-6.25 z" id="path4161" inkscape:connector-curvature="0"/>
+        <circle style="opacity:1;fill:none;fill-opacity:0.40392157;stroke:#0d47a1;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="path4209" cx="24" cy="24" r="9.5500002"/>
+      </g>
+      <g id="g4989" transform="translate(0,0.50001738)">
+        <ellipse cy="1016.4872" cx="14.375" id="circle4985" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117" rx="3.375" ry="3.875"/>
+        <circle style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117" id="path4859" cx="14.375" cy="1016.9872" r="3.375"/>
+      </g>
+      <g transform="translate(19.5,0.50001738)" id="g4171">
+        <ellipse ry="3.875" rx="3.375" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117" id="ellipse4175" cx="14.375" cy="1016.4872"/>
+        <circle r="3.375" cy="1016.9872" cx="14.375" id="circle4177" style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117"/>
+      </g>
+    </g>
+    <path inkscape:connector-curvature="0" id="path5128" d="m 2.613462,1005.5987 a 1.250125,1.250125 0 0 0 -1.01172,2.0293 l 3.60351,4.6641 c -0.12699,0.3331 -0.20312,0.6915 -0.20312,1.0703 l 0,4 0,2.8652 0,0.1348 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-4 0,-2.8652 0,-0.1348 c 0,-0.3803 -0.0771,-0.74 -0.20508,-1.0742 l 3.60156,-4.6602 a 1.250125,1.250125 0 0 0 -1.04882,-2.0273 1.250125,1.250125 0 0 0 -0.92969,0.498 l -3.43164,4.4414 c -0.31022,-0.1079 -0.63841,-0.1777 -0.98633,-0.1777 l -32,0 c -0.34857,0 -0.67757,0.069 -0.98828,0.1777 l -3.4336,-4.4414 a 1.250125,1.250125 0 0 0 -0.96679,-0.5 z m 5.38867,18.7637 c -0.20775,0 -0.40983,0.021 -0.60547,0.061 -1.36951,0.2761 -2.39453,1.4698 -2.39453,2.9101 l 0,0.029 0,19.7793 0,0.029 0,0.1914 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-20 0,-0.029 c 0,-1.4403 -1.02502,-2.634 -2.39453,-2.9101 -0.19565,-0.039 -0.39772,-0.061 -0.60547,-0.061 l -32,0 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#radialGradient5220);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
+  </g>
+</svg>
\ No newline at end of file
diff --git a/src/static/img/kyberio-logo.svg b/src/static/img/kyberio-logo.svg
new file mode 100644 (file)
index 0000000..e858548
--- /dev/null
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg id="Ebene_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 181.24 43.45"><defs><style>.cls-1{fill:#323246;}.cls-1,.cls-2{stroke-width:0px;}.cls-2{fill:#00ff81;}</style></defs><g id="Ebene_1-2"><polygon class="cls-1" points="22.68 9.29 14.21 9.29 7.38 19.4 7.38 0 0 0 0 34.43 7.38 34.43 7.38 23.23 15.03 34.43 23.5 34.43 14.21 21.31 22.68 9.29"/><path class="cls-1" d="m36.34,24.86l-5.46-15.58h-7.92l9.56,24.05c-.4,1.09-.98,1.96-1.75,2.6-.77.64-1.91.96-3.44.96v6.28c.25.07.53.13.82.16.26.04.52.06.79.08.27.02.55.03.85.03,2.33,0,4.23-.72,5.71-2.16,1.47-1.44,2.85-3.73,4.13-6.86l10.11-25.14h-7.92l-5.47,15.58Z"/><rect class="cls-1" x="128.97" y="9.29" width="7.38" height="25.14"/><path class="cls-1" d="m123.86,9.07c-.27-.04-.57-.05-.9-.05-.95,0-1.8.16-2.54.47-.75.31-1.38.66-1.89,1.07-.62.44-1.15.93-1.59,1.48l-1.09-2.73h-5.46v25.14h7.38v-12.84c0-1.64.43-2.91,1.28-3.83.86-.91,1.98-1.37,3.36-1.37.4,0,.76.03,1.09.08.33.05.62.12.88.19.29.11.55.2.77.27v-7.65c-.18-.03-.38-.07-.6-.11-.18-.04-.41-.07-.68-.11Z"/><path class="cls-1" d="m102.71,12.32c-1.11-1.15-2.41-2.03-3.91-2.65-1.49-.62-3.1-.93-4.81-.93s-3.45.34-5,1.01c-1.55.67-2.91,1.6-4.07,2.79-1.17,1.18-2.09,2.57-2.76,4.15-.67,1.58-1.01,3.31-1.01,5.16s.34,3.58,1.01,5.16c.67,1.58,1.59,2.97,2.73,4.15,1.15,1.18,2.48,2.11,3.99,2.79,1.51.67,3.12,1.01,4.84,1.01s3.1-.22,4.4-.66c1.29-.44,2.43-1.03,3.41-1.78.98-.75,1.8-1.59,2.46-2.54.65-.95,1.15-1.93,1.47-2.95l-7.65-.55c-.22.33-.51.6-.87.82-.29.22-.68.41-1.18.57s-1.08.25-1.78.25c-1.38,0-2.52-.4-3.42-1.2-.89-.8-1.49-1.67-1.78-2.62h17.21c.04-.26.07-.53.11-.82.04-.25.07-.52.11-.79s.05-.55.05-.85c0-1.97-.32-3.76-.96-5.38-.64-1.62-1.51-3.01-2.62-4.15Zm-13.91,7.35c.29-1.09.88-2.05,1.78-2.87.89-.82,2.03-1.23,3.42-1.23s2.52.41,3.42,1.23c.89.82,1.48,1.78,1.78,2.87h-10.38Z"/><circle class="cls-2" cx="176.1" cy="28.86" r="5.14" transform="translate(31.17 132.97) rotate(-45)"/><path class="cls-1" d="m152.58,9.21c-7.17,0-13,5.83-13,13s5.83,13,13,13,13-5.83,13-13-5.83-13-13-13Zm0,19c-3.31,0-6-2.69-6-6s2.69-6,6-6,6,2.69,6,6-2.69,6-6,6Z"/><path class="cls-1" d="m65,9.21c-2.16,0-4.2.54-6,1.48V0h-7v22.2s0,0,0,0,0,0,0,0v12.79h7v-1.27c1.8.94,3.84,1.48,6,1.48,7.17,0,13-5.83,13-13s-5.83-13-13-13Zm0,19c-3.31,0-6-2.69-6-6h0c0-3.31,2.69-6,6-6s6,2.69,6,6-2.69,6-6,6Z"/></g></svg>
\ No newline at end of file
diff --git a/src/static/js/jquery-3.3.1.min.js b/src/static/js/jquery-3.3.1.min.js
deleted file mode 100644 (file)
index 4d9b3a2..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-/*! jQuery v3.3.1 | (c) JS Foundation and other contributors | jquery.org/license */
-!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(e,t){"use strict";var n=[],r=e.document,i=Object.getPrototypeOf,o=n.slice,a=n.concat,s=n.push,u=n.indexOf,l={},c=l.toString,f=l.hasOwnProperty,p=f.toString,d=p.call(Object),h={},g=function e(t){return"function"==typeof t&&"number"!=typeof t.nodeType},y=function e(t){return null!=t&&t===t.window},v={type:!0,src:!0,noModule:!0};function m(e,t,n){var i,o=(t=t||r).createElement("script");if(o.text=e,n)for(i in v)n[i]&&(o[i]=n[i]);t.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[c.call(e)]||"object":typeof e}var b="3.3.1",w=function(e,t){return new w.fn.init(e,t)},T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;w.fn=w.prototype={jquery:"3.3.1",constructor:w,length:0,toArray:function(){return o.call(this)},get:function(e){return null==e?o.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=w.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return w.each(this,e)},map:function(e){return this.pushStack(w.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(o.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:s,sort:n.sort,splice:n.splice},w.extend=w.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||g(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)n=a[t],a!==(r=e[t])&&(l&&r&&(w.isPlainObject(r)||(i=Array.isArray(r)))?(i?(i=!1,o=n&&Array.isArray(n)?n:[]):o=n&&w.isPlainObject(n)?n:{},a[t]=w.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},w.extend({expando:"jQuery"+("3.3.1"+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==c.call(e))&&(!(t=i(e))||"function"==typeof(n=f.call(t,"constructor")&&t.constructor)&&p.call(n)===d)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e){m(e)},each:function(e,t){var n,r=0;if(C(e)){for(n=e.length;r<n;r++)if(!1===t.call(e[r],r,e[r]))break}else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},trim:function(e){return null==e?"":(e+"").replace(T,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(C(Object(e))?w.merge(n,"string"==typeof e?[e]:e):s.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:u.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r,i=[],o=0,a=e.length,s=!n;o<a;o++)(r=!t(e[o],o))!==s&&i.push(e[o]);return i},map:function(e,t,n){var r,i,o=0,s=[];if(C(e))for(r=e.length;o<r;o++)null!=(i=t(e[o],o,n))&&s.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&s.push(i);return a.apply([],s)},guid:1,support:h}),"function"==typeof Symbol&&(w.fn[Symbol.iterator]=n[Symbol.iterator]),w.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function C(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!g(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&t>0&&t-1 in e)}var E=function(e){var t,n,r,i,o,a,s,u,l,c,f,p,d,h,g,y,v,m,x,b="sizzle"+1*new Date,w=e.document,T=0,C=0,E=ae(),k=ae(),S=ae(),D=function(e,t){return e===t&&(f=!0),0},N={}.hasOwnProperty,A=[],j=A.pop,q=A.push,L=A.push,H=A.slice,O=function(e,t){for(var n=0,r=e.length;n<r;n++)if(e[n]===t)return n;return-1},P="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",R="(?:\\\\.|[\\w-]|[^\0-\\xa0])+",I="\\["+M+"*("+R+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+R+"))|)"+M+"*\\]",W=":("+R+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+I+")*)|.*)\\)|)",$=new RegExp(M+"+","g"),B=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),F=new RegExp("^"+M+"*,"+M+"*"),_=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),z=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),X=new RegExp(W),U=new RegExp("^"+R+"$"),V={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+I),PSEUDO:new RegExp("^"+W),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+P+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},G=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Q=/^[^{]+\{\s*\[native \w/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,K=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ee=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},te=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ne=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},re=function(){p()},ie=me(function(e){return!0===e.disabled&&("form"in e||"label"in e)},{dir:"parentNode",next:"legend"});try{L.apply(A=H.call(w.childNodes),w.childNodes),A[w.childNodes.length].nodeType}catch(e){L={apply:A.length?function(e,t){q.apply(e,H.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function oe(e,t,r,i){var o,s,l,c,f,h,v,m=t&&t.ownerDocument,T=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==T&&9!==T&&11!==T)return r;if(!i&&((t?t.ownerDocument||t:w)!==d&&p(t),t=t||d,g)){if(11!==T&&(f=J.exec(e)))if(o=f[1]){if(9===T){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(m&&(l=m.getElementById(o))&&x(t,l)&&l.id===o)return r.push(l),r}else{if(f[2])return L.apply(r,t.getElementsByTagName(e)),r;if((o=f[3])&&n.getElementsByClassName&&t.getElementsByClassName)return L.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!S[e+" "]&&(!y||!y.test(e))){if(1!==T)m=t,v=e;else if("object"!==t.nodeName.toLowerCase()){(c=t.getAttribute("id"))?c=c.replace(te,ne):t.setAttribute("id",c=b),s=(h=a(e)).length;while(s--)h[s]="#"+c+" "+ve(h[s]);v=h.join(","),m=K.test(e)&&ge(t.parentNode)||t}if(v)try{return L.apply(r,m.querySelectorAll(v)),r}catch(e){}finally{c===b&&t.removeAttribute("id")}}}return u(e.replace(B,"$1"),t,r,i)}function ae(){var e=[];function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}return t}function se(e){return e[b]=!0,e}function ue(e){var t=d.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function le(e,t){var n=e.split("|"),i=n.length;while(i--)r.attrHandle[n[i]]=t}function ce(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function fe(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function pe(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function de(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&ie(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return se(function(t){return t=+t,se(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function ge(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}n=oe.support={},o=oe.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},p=oe.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==d&&9===a.nodeType&&a.documentElement?(d=a,h=d.documentElement,g=!o(d),w!==d&&(i=d.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=ue(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=ue(function(e){return e.appendChild(d.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=Q.test(d.getElementsByClassName),n.getById=ue(function(e){return h.appendChild(e).id=b,!d.getElementsByName||!d.getElementsByName(b).length}),n.getById?(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){return e.getAttribute("id")===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}}):(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){var n="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),r.find.TAG=n.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):n.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&g)return t.getElementsByClassName(e)},v=[],y=[],(n.qsa=Q.test(d.querySelectorAll))&&(ue(function(e){h.appendChild(e).innerHTML="<a id='"+b+"'></a><select id='"+b+"-\r\\' msallowcapture=''><option selected=''></option></select>",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+P+")"),e.querySelectorAll("[id~="+b+"-]").length||y.push("~="),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+b+"+*").length||y.push(".#.+[+~]")}),ue(function(e){e.innerHTML="<a href='' disabled='disabled'></a><select disabled='disabled'><option/></select>";var t=d.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),h.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(n.matchesSelector=Q.test(m=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&ue(function(e){n.disconnectedMatch=m.call(e,"*"),m.call(e,"[s!='']:x"),v.push("!=",W)}),y=y.length&&new RegExp(y.join("|")),v=v.length&&new RegExp(v.join("|")),t=Q.test(h.compareDocumentPosition),x=t||Q.test(h.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===d||e.ownerDocument===w&&x(w,e)?-1:t===d||t.ownerDocument===w&&x(w,t)?1:c?O(c,e)-O(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===d?-1:t===d?1:i?-1:o?1:c?O(c,e)-O(c,t):0;if(i===o)return ce(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?ce(a[r],s[r]):a[r]===w?-1:s[r]===w?1:0},d):d},oe.matches=function(e,t){return oe(e,null,null,t)},oe.matchesSelector=function(e,t){if((e.ownerDocument||e)!==d&&p(e),t=t.replace(z,"='$1']"),n.matchesSelector&&g&&!S[t+" "]&&(!v||!v.test(t))&&(!y||!y.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return oe(t,d,null,[e]).length>0},oe.contains=function(e,t){return(e.ownerDocument||e)!==d&&p(e),x(e,t)},oe.attr=function(e,t){(e.ownerDocument||e)!==d&&p(e);var i=r.attrHandle[t.toLowerCase()],o=i&&N.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},oe.escape=function(e){return(e+"").replace(te,ne)},oe.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},oe.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(D),f){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return c=null,e},i=oe.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else while(t=e[r++])n+=i(t);return n},(r=oe.selectors={cacheLength:50,createPseudo:se,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Z,ee),e[3]=(e[3]||e[4]||e[5]||"").replace(Z,ee),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||oe.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&oe.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return V.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=a(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Z,ee).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=oe.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace($," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h,g=o!==a?"nextSibling":"previousSibling",y=t.parentNode,v=s&&t.nodeName.toLowerCase(),m=!u&&!s,x=!1;if(y){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===v:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?y.firstChild:y.lastChild],a&&m){x=(d=(l=(c=(f=(p=y)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1])&&l[2],p=d&&y.childNodes[d];while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if(1===p.nodeType&&++x&&p===t){c[e]=[T,d,x];break}}else if(m&&(x=d=(l=(c=(f=(p=t)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1]),!1===x)while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===v:1===p.nodeType)&&++x&&(m&&((c=(f=p[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]=[T,x]),p===t))break;return(x-=i)===r||x%r==0&&x/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||oe.error("unsupported pseudo: "+e);return i[b]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?se(function(e,n){var r,o=i(e,t),a=o.length;while(a--)e[r=O(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:se(function(e){var t=[],n=[],r=s(e.replace(B,"$1"));return r[b]?se(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:se(function(e){return function(t){return oe(e,t).length>0}}),contains:se(function(e){return e=e.replace(Z,ee),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:se(function(e){return U.test(e||"")||oe.error("unsupported lang: "+e),e=e.replace(Z,ee).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===d.activeElement&&(!d.hasFocus||d.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:de(!1),disabled:de(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return Y.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he(function(){return[0]}),last:he(function(e,t){return[t-1]}),eq:he(function(e,t,n){return[n<0?n+t:n]}),even:he(function(e,t){for(var n=0;n<t;n+=2)e.push(n);return e}),odd:he(function(e,t){for(var n=1;n<t;n+=2)e.push(n);return e}),lt:he(function(e,t,n){for(var r=n<0?n+t:n;--r>=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r<t;)e.push(r);return e})}}).pseudos.nth=r.pseudos.eq;for(t in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})r.pseudos[t]=fe(t);for(t in{submit:!0,reset:!0})r.pseudos[t]=pe(t);function ye(){}ye.prototype=r.filters=r.pseudos,r.setFilters=new ye,a=oe.tokenize=function(e,t){var n,i,o,a,s,u,l,c=k[e+" "];if(c)return t?0:c.slice(0);s=e,u=[],l=r.preFilter;while(s){n&&!(i=F.exec(s))||(i&&(s=s.slice(i[0].length)||s),u.push(o=[])),n=!1,(i=_.exec(s))&&(n=i.shift(),o.push({value:n,type:i[0].replace(B," ")}),s=s.slice(n.length));for(a in r.filter)!(i=V[a].exec(s))||l[a]&&!(i=l[a](i))||(n=i.shift(),o.push({value:n,type:a,matches:i}),s=s.slice(n.length));if(!n)break}return t?s.length:s?oe.error(e):k(e,u).slice(0)};function ve(e){for(var t=0,n=e.length,r="";t<n;t++)r+=e[t].value;return r}function me(e,t,n){var r=t.dir,i=t.next,o=i||r,a=n&&"parentNode"===o,s=C++;return t.first?function(t,n,i){while(t=t[r])if(1===t.nodeType||a)return e(t,n,i);return!1}:function(t,n,u){var l,c,f,p=[T,s];if(u){while(t=t[r])if((1===t.nodeType||a)&&e(t,n,u))return!0}else while(t=t[r])if(1===t.nodeType||a)if(f=t[b]||(t[b]={}),c=f[t.uniqueID]||(f[t.uniqueID]={}),i&&i===t.nodeName.toLowerCase())t=t[r]||t;else{if((l=c[o])&&l[0]===T&&l[1]===s)return p[2]=l[2];if(c[o]=p,p[2]=e(t,n,u))return!0}return!1}}function xe(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function be(e,t,n){for(var r=0,i=t.length;r<i;r++)oe(e,t[r],n);return n}function we(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;s<u;s++)(o=e[s])&&(n&&!n(o,r,i)||(a.push(o),l&&t.push(s)));return a}function Te(e,t,n,r,i,o){return r&&!r[b]&&(r=Te(r)),i&&!i[b]&&(i=Te(i,o)),se(function(o,a,s,u){var l,c,f,p=[],d=[],h=a.length,g=o||be(t||"*",s.nodeType?[s]:s,[]),y=!e||!o&&t?g:we(g,p,e,s,u),v=n?i||(o?e:h||r)?[]:a:y;if(n&&n(y,v,s,u),r){l=we(v,d),r(l,[],s,u),c=l.length;while(c--)(f=l[c])&&(v[d[c]]=!(y[d[c]]=f))}if(o){if(i||e){if(i){l=[],c=v.length;while(c--)(f=v[c])&&l.push(y[c]=f);i(null,v=[],l,u)}c=v.length;while(c--)(f=v[c])&&(l=i?O(o,f):p[c])>-1&&(o[l]=!(a[l]=f))}}else v=we(v===a?v.splice(h,v.length):v),i?i(null,a,v,u):L.apply(a,v)})}function Ce(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],s=a||r.relative[" "],u=a?1:0,c=me(function(e){return e===t},s,!0),f=me(function(e){return O(t,e)>-1},s,!0),p=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];u<o;u++)if(n=r.relative[e[u].type])p=[me(xe(p),n)];else{if((n=r.filter[e[u].type].apply(null,e[u].matches))[b]){for(i=++u;i<o;i++)if(r.relative[e[i].type])break;return Te(u>1&&xe(p),u>1&&ve(e.slice(0,u-1).concat({value:" "===e[u-2].type?"*":""})).replace(B,"$1"),n,u<i&&Ce(e.slice(u,i)),i<o&&Ce(e=e.slice(i)),i<o&&ve(e))}p.push(n)}return xe(p)}function Ee(e,t){var n=t.length>0,i=e.length>0,o=function(o,a,s,u,c){var f,h,y,v=0,m="0",x=o&&[],b=[],w=l,C=o||i&&r.find.TAG("*",c),E=T+=null==w?1:Math.random()||.1,k=C.length;for(c&&(l=a===d||a||c);m!==k&&null!=(f=C[m]);m++){if(i&&f){h=0,a||f.ownerDocument===d||(p(f),s=!g);while(y=e[h++])if(y(f,a||d,s)){u.push(f);break}c&&(T=E)}n&&((f=!y&&f)&&v--,o&&x.push(f))}if(v+=m,n&&m!==v){h=0;while(y=t[h++])y(x,b,a,s);if(o){if(v>0)while(m--)x[m]||b[m]||(b[m]=j.call(u));b=we(b)}L.apply(u,b),c&&!o&&b.length>0&&v+t.length>1&&oe.uniqueSort(u)}return c&&(T=E,l=w),x};return n?se(o):o}return s=oe.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=a(e)),n=t.length;while(n--)(o=Ce(t[n]))[b]?r.push(o):i.push(o);(o=S(e,Ee(i,r))).selector=e}return o},u=oe.select=function(e,t,n,i){var o,u,l,c,f,p="function"==typeof e&&e,d=!i&&a(e=p.selector||e);if(n=n||[],1===d.length){if((u=d[0]=d[0].slice(0)).length>2&&"ID"===(l=u[0]).type&&9===t.nodeType&&g&&r.relative[u[1].type]){if(!(t=(r.find.ID(l.matches[0].replace(Z,ee),t)||[])[0]))return n;p&&(t=t.parentNode),e=e.slice(u.shift().value.length)}o=V.needsContext.test(e)?0:u.length;while(o--){if(l=u[o],r.relative[c=l.type])break;if((f=r.find[c])&&(i=f(l.matches[0].replace(Z,ee),K.test(u[0].type)&&ge(t.parentNode)||t))){if(u.splice(o,1),!(e=i.length&&ve(u)))return L.apply(n,i),n;break}}}return(p||s(e,d))(i,t,!g,n,!t||K.test(e)&&ge(t.parentNode)||t),n},n.sortStable=b.split("").sort(D).join("")===b,n.detectDuplicates=!!f,p(),n.sortDetached=ue(function(e){return 1&e.compareDocumentPosition(d.createElement("fieldset"))}),ue(function(e){return e.innerHTML="<a href='#'></a>","#"===e.firstChild.getAttribute("href")})||le("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&ue(function(e){return e.innerHTML="<input/>",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||le("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),ue(function(e){return null==e.getAttribute("disabled")})||le(P,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),oe}(e);w.find=E,w.expr=E.selectors,w.expr[":"]=w.expr.pseudos,w.uniqueSort=w.unique=E.uniqueSort,w.text=E.getText,w.isXMLDoc=E.isXML,w.contains=E.contains,w.escapeSelector=E.escape;var k=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&w(e).is(n))break;r.push(e)}return r},S=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},D=w.expr.match.needsContext;function N(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var A=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,t,n){return g(t)?w.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?w.grep(e,function(e){return e===t!==n}):"string"!=typeof t?w.grep(e,function(e){return u.call(t,e)>-1!==n}):w.filter(t,e,n)}w.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?w.find.matchesSelector(r,e)?[r]:[]:w.find.matches(e,w.grep(t,function(e){return 1===e.nodeType}))},w.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(w(e).filter(function(){for(t=0;t<r;t++)if(w.contains(i[t],this))return!0}));for(n=this.pushStack([]),t=0;t<r;t++)w.find(e,i[t],n);return r>1?w.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&D.test(e)?w(e):e||[],!1).length}});var q,L=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(w.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:L.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof w?t[0]:t,w.merge(this,w.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),A.test(i[1])&&w.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(o=r.getElementById(i[2]))&&(this[0]=o,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==n.ready?n.ready(e):e(w):w.makeArray(e,this)}).prototype=w.fn,q=w(r);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};w.fn.extend({has:function(e){var t=w(e,this),n=t.length;return this.filter(function(){for(var e=0;e<n;e++)if(w.contains(this,t[e]))return!0})},closest:function(e,t){var n,r=0,i=this.length,o=[],a="string"!=typeof e&&w(e);if(!D.test(e))for(;r<i;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(n.nodeType<11&&(a?a.index(n)>-1:1===n.nodeType&&w.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?w.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?u.call(w(e),this[0]):u.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(w.uniqueSort(w.merge(this.get(),w(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}w.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return k(e,"parentNode")},parentsUntil:function(e,t,n){return k(e,"parentNode",n)},next:function(e){return P(e,"nextSibling")},prev:function(e){return P(e,"previousSibling")},nextAll:function(e){return k(e,"nextSibling")},prevAll:function(e){return k(e,"previousSibling")},nextUntil:function(e,t,n){return k(e,"nextSibling",n)},prevUntil:function(e,t,n){return k(e,"previousSibling",n)},siblings:function(e){return S((e.parentNode||{}).firstChild,e)},children:function(e){return S(e.firstChild)},contents:function(e){return N(e,"iframe")?e.contentDocument:(N(e,"template")&&(e=e.content||e),w.merge([],e.childNodes))}},function(e,t){w.fn[e]=function(n,r){var i=w.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=w.filter(r,i)),this.length>1&&(O[e]||w.uniqueSort(i),H.test(e)&&i.reverse()),this.pushStack(i)}});var M=/[^\x20\t\r\n\f]+/g;function R(e){var t={};return w.each(e.match(M)||[],function(e,n){t[n]=!0}),t}w.Callbacks=function(e){e="string"==typeof e?R(e):w.extend({},e);var t,n,r,i,o=[],a=[],s=-1,u=function(){for(i=i||e.once,r=t=!0;a.length;s=-1){n=a.shift();while(++s<o.length)!1===o[s].apply(n[0],n[1])&&e.stopOnFalse&&(s=o.length,n=!1)}e.memory||(n=!1),t=!1,i&&(o=n?[]:"")},l={add:function(){return o&&(n&&!t&&(s=o.length-1,a.push(n)),function t(n){w.each(n,function(n,r){g(r)?e.unique&&l.has(r)||o.push(r):r&&r.length&&"string"!==x(r)&&t(r)})}(arguments),n&&!t&&u()),this},remove:function(){return w.each(arguments,function(e,t){var n;while((n=w.inArray(t,o,n))>-1)o.splice(n,1),n<=s&&s--}),this},has:function(e){return e?w.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||t||(o=n=""),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||u()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l};function I(e){return e}function W(e){throw e}function $(e,t,n,r){var i;try{e&&g(i=e.promise)?i.call(e).done(t).fail(n):e&&g(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}w.extend({Deferred:function(t){var n=[["notify","progress",w.Callbacks("memory"),w.Callbacks("memory"),2],["resolve","done",w.Callbacks("once memory"),w.Callbacks("once memory"),0,"resolved"],["reject","fail",w.Callbacks("once memory"),w.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},"catch":function(e){return i.then(null,e)},pipe:function(){var e=arguments;return w.Deferred(function(t){w.each(n,function(n,r){var i=g(e[r[4]])&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&g(e.promise)?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var s=this,u=arguments,l=function(){var e,l;if(!(t<o)){if((e=r.apply(s,u))===n.promise())throw new TypeError("Thenable self-resolution");l=e&&("object"==typeof e||"function"==typeof e)&&e.then,g(l)?i?l.call(e,a(o,n,I,i),a(o,n,W,i)):(o++,l.call(e,a(o,n,I,i),a(o,n,W,i),a(o,n,I,n.notifyWith))):(r!==I&&(s=void 0,u=[e]),(i||n.resolveWith)(s,u))}},c=i?l:function(){try{l()}catch(e){w.Deferred.exceptionHook&&w.Deferred.exceptionHook(e,c.stackTrace),t+1>=o&&(r!==W&&(s=void 0,u=[e]),n.rejectWith(s,u))}};t?c():(w.Deferred.getStackHook&&(c.stackTrace=w.Deferred.getStackHook()),e.setTimeout(c))}}return w.Deferred(function(e){n[0][3].add(a(0,e,g(i)?i:I,e.notifyWith)),n[1][3].add(a(0,e,g(t)?t:I)),n[2][3].add(a(0,e,g(r)?r:W))}).promise()},promise:function(e){return null!=e?w.extend(e,i):i}},o={};return w.each(n,function(e,t){var a=t[2],s=t[5];i[t[1]]=a.add,s&&a.add(function(){r=s},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),i=o.call(arguments),a=w.Deferred(),s=function(e){return function(n){r[e]=this,i[e]=arguments.length>1?o.call(arguments):n,--t||a.resolveWith(r,i)}};if(t<=1&&($(e,a.done(s(n)).resolve,a.reject,!t),"pending"===a.state()||g(i[n]&&i[n].then)))return a.then();while(n--)$(i[n],s(n),a.reject);return a.promise()}});var B=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;w.Deferred.exceptionHook=function(t,n){e.console&&e.console.warn&&t&&B.test(t.name)&&e.console.warn("jQuery.Deferred exception: "+t.message,t.stack,n)},w.readyException=function(t){e.setTimeout(function(){throw t})};var F=w.Deferred();w.fn.ready=function(e){return F.then(e)["catch"](function(e){w.readyException(e)}),this},w.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--w.readyWait:w.isReady)||(w.isReady=!0,!0!==e&&--w.readyWait>0||F.resolveWith(r,[w]))}}),w.ready.then=F.then;function _(){r.removeEventListener("DOMContentLoaded",_),e.removeEventListener("load",_),w.ready()}"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll?e.setTimeout(w.ready):(r.addEventListener("DOMContentLoaded",_),e.addEventListener("load",_));var z=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===x(n)){i=!0;for(s in n)z(e,t,s,n[s],!0,o,a)}else if(void 0!==r&&(i=!0,g(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(w(e),n)})),t))for(;s<u;s++)t(e[s],n,a?r:r.call(e[s],s,t(e[s],n)));return i?e:l?t.call(e):u?t(e[0],n):o},X=/^-ms-/,U=/-([a-z])/g;function V(e,t){return t.toUpperCase()}function G(e){return e.replace(X,"ms-").replace(U,V)}var Y=function(e){return 1===e.nodeType||9===e.nodeType||!+e.nodeType};function Q(){this.expando=w.expando+Q.uid++}Q.uid=1,Q.prototype={cache:function(e){var t=e[this.expando];return t||(t={},Y(e)&&(e.nodeType?e[this.expando]=t:Object.defineProperty(e,this.expando,{value:t,configurable:!0}))),t},set:function(e,t,n){var r,i=this.cache(e);if("string"==typeof t)i[G(t)]=n;else for(r in t)i[G(r)]=t[r];return i},get:function(e,t){return void 0===t?this.cache(e):e[this.expando]&&e[this.expando][G(t)]},access:function(e,t,n){return void 0===t||t&&"string"==typeof t&&void 0===n?this.get(e,t):(this.set(e,t,n),void 0!==n?n:t)},remove:function(e,t){var n,r=e[this.expando];if(void 0!==r){if(void 0!==t){n=(t=Array.isArray(t)?t.map(G):(t=G(t))in r?[t]:t.match(M)||[]).length;while(n--)delete r[t[n]]}(void 0===t||w.isEmptyObject(r))&&(e.nodeType?e[this.expando]=void 0:delete e[this.expando])}},hasData:function(e){var t=e[this.expando];return void 0!==t&&!w.isEmptyObject(t)}};var J=new Q,K=new Q,Z=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,ee=/[A-Z]/g;function te(e){return"true"===e||"false"!==e&&("null"===e?null:e===+e+""?+e:Z.test(e)?JSON.parse(e):e)}function ne(e,t,n){var r;if(void 0===n&&1===e.nodeType)if(r="data-"+t.replace(ee,"-$&").toLowerCase(),"string"==typeof(n=e.getAttribute(r))){try{n=te(n)}catch(e){}K.set(e,t,n)}else n=void 0;return n}w.extend({hasData:function(e){return K.hasData(e)||J.hasData(e)},data:function(e,t,n){return K.access(e,t,n)},removeData:function(e,t){K.remove(e,t)},_data:function(e,t,n){return J.access(e,t,n)},_removeData:function(e,t){J.remove(e,t)}}),w.fn.extend({data:function(e,t){var n,r,i,o=this[0],a=o&&o.attributes;if(void 0===e){if(this.length&&(i=K.get(o),1===o.nodeType&&!J.get(o,"hasDataAttrs"))){n=a.length;while(n--)a[n]&&0===(r=a[n].name).indexOf("data-")&&(r=G(r.slice(5)),ne(o,r,i[r]));J.set(o,"hasDataAttrs",!0)}return i}return"object"==typeof e?this.each(function(){K.set(this,e)}):z(this,function(t){var n;if(o&&void 0===t){if(void 0!==(n=K.get(o,e)))return n;if(void 0!==(n=ne(o,e)))return n}else this.each(function(){K.set(this,e,t)})},null,t,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){K.remove(this,e)})}}),w.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=J.get(e,t),n&&(!r||Array.isArray(n)?r=J.access(e,t,w.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=w.queue(e,t),r=n.length,i=n.shift(),o=w._queueHooks(e,t),a=function(){w.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return J.get(e,n)||J.access(e,n,{empty:w.Callbacks("once memory").add(function(){J.remove(e,[t+"queue",n])})})}}),w.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length<n?w.queue(this[0],e):void 0===t?this:this.each(function(){var n=w.queue(this,e,t);w._queueHooks(this,e),"fx"===e&&"inprogress"!==n[0]&&w.dequeue(this,e)})},dequeue:function(e){return this.each(function(){w.dequeue(this,e)})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,t){var n,r=1,i=w.Deferred(),o=this,a=this.length,s=function(){--r||i.resolveWith(o,[o])};"string"!=typeof e&&(t=e,e=void 0),e=e||"fx";while(a--)(n=J.get(o[a],e+"queueHooks"))&&n.empty&&(r++,n.empty.add(s));return s(),i.promise(t)}});var re=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,ie=new RegExp("^(?:([+-])=|)("+re+")([a-z%]*)$","i"),oe=["Top","Right","Bottom","Left"],ae=function(e,t){return"none"===(e=t||e).style.display||""===e.style.display&&w.contains(e.ownerDocument,e)&&"none"===w.css(e,"display")},se=function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i};function ue(e,t,n,r){var i,o,a=20,s=r?function(){return r.cur()}:function(){return w.css(e,t,"")},u=s(),l=n&&n[3]||(w.cssNumber[t]?"":"px"),c=(w.cssNumber[t]||"px"!==l&&+u)&&ie.exec(w.css(e,t));if(c&&c[3]!==l){u/=2,l=l||c[3],c=+u||1;while(a--)w.style(e,t,c+l),(1-o)*(1-(o=s()/u||.5))<=0&&(a=0),c/=o;c*=2,w.style(e,t,c+l),n=n||[]}return n&&(c=+c||+u||0,i=n[1]?c+(n[1]+1)*n[2]:+n[2],r&&(r.unit=l,r.start=c,r.end=i)),i}var le={};function ce(e){var t,n=e.ownerDocument,r=e.nodeName,i=le[r];return i||(t=n.body.appendChild(n.createElement(r)),i=w.css(t,"display"),t.parentNode.removeChild(t),"none"===i&&(i="block"),le[r]=i,i)}function fe(e,t){for(var n,r,i=[],o=0,a=e.length;o<a;o++)(r=e[o]).style&&(n=r.style.display,t?("none"===n&&(i[o]=J.get(r,"display")||null,i[o]||(r.style.display="")),""===r.style.display&&ae(r)&&(i[o]=ce(r))):"none"!==n&&(i[o]="none",J.set(r,"display",n)));for(o=0;o<a;o++)null!=i[o]&&(e[o].style.display=i[o]);return e}w.fn.extend({show:function(){return fe(this,!0)},hide:function(){return fe(this)},toggle:function(e){return"boolean"==typeof e?e?this.show():this.hide():this.each(function(){ae(this)?w(this).show():w(this).hide()})}});var pe=/^(?:checkbox|radio)$/i,de=/<([a-z][^\/\0>\x20\t\r\n\f]+)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,"<select multiple='multiple'>","</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&N(e,t)?w.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n<r;n++)J.set(e[n],"globalEval",!t||J.get(t[n],"globalEval"))}var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d<h;d++)if((o=e[d])||0===o)if("object"===x(o))w.merge(p,o.nodeType?[o]:o);else if(me.test(o)){a=a||f.appendChild(t.createElement("div")),s=(de.exec(o)||["",""])[1].toLowerCase(),u=ge[s]||ge._default,a.innerHTML=u[1]+w.htmlPrefilter(o)+u[2],c=u[0];while(c--)a=a.lastChild;w.merge(p,a.childNodes),(a=f.firstChild).textContent=""}else p.push(t.createTextNode(o));f.textContent="",d=0;while(o=p[d++])if(r&&w.inArray(o,r)>-1)i&&i.push(o);else if(l=w.contains(o.ownerDocument,o),a=ye(f.appendChild(o),"script"),l&&ve(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}!function(){var e=r.createDocumentFragment().appendChild(r.createElement("div")),t=r.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),h.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="<textarea>x</textarea>",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var be=r.documentElement,we=/^key/,Te=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ce=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function ke(){return!1}function Se(){try{return r.activeElement}catch(e){}}function De(e,t,n,r,i,o){var a,s;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(s in t)De(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=ke;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.get(e);if(y){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(be,i),n.guid||(n.guid=w.guid++),(u=y.events)||(u=y.events={}),(a=y.handle)||(a=y.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(M)||[""]).length;while(l--)d=g=(s=Ce.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=w.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=w.event.special[d]||{},c=w.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(d,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),w.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.hasData(e)&&J.get(e);if(y&&(u=y.events)){l=(t=(t||"").match(M)||[""]).length;while(l--)if(s=Ce.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){f=w.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,y.handle)||w.removeEvent(e,d,y.handle),delete u[d])}else for(d in u)w.event.remove(e,d+t[l],n,r,!0);w.isEmptyObject(u)&&J.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,s,u=new Array(arguments.length),l=(J.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(u[0]=t,n=1;n<arguments.length;n++)u[n]=arguments[n];if(t.delegateTarget=this,!c.preDispatch||!1!==c.preDispatch.call(this,t)){s=w.event.handlers.call(this,t,l),n=0;while((o=s[n++])&&!t.isPropagationStopped()){t.currentTarget=o.elem,r=0;while((a=o.handlers[r++])&&!t.isImmediatePropagationStopped())t.rnamespace&&!t.rnamespace.test(a.namespace)||(t.handleObj=a,t.data=a.data,void 0!==(i=((w.event.special[a.origType]||{}).handle||a.handler).apply(o.elem,u))&&!1===(t.result=i)&&(t.preventDefault(),t.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,t),t.result}},handlers:function(e,t){var n,r,i,o,a,s=[],u=t.delegateCount,l=e.target;if(u&&l.nodeType&&!("click"===e.type&&e.button>=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n<u;n++)void 0===a[i=(r=t[n]).selector+" "]&&(a[i]=r.needsContext?w(i,this).index(l)>-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}return l=this,u<t.length&&s.push({elem:l,handlers:t.slice(u)}),s},addProp:function(e,t){Object.defineProperty(w.Event.prototype,e,{enumerable:!0,configurable:!0,get:g(t)?function(){if(this.originalEvent)return t(this.originalEvent)}:function(){if(this.originalEvent)return this.originalEvent[e]},set:function(t){Object.defineProperty(this,e,{enumerable:!0,configurable:!0,writable:!0,value:t})}})},fix:function(e){return e[w.expando]?e:new w.Event(e)},special:{load:{noBubble:!0},focus:{trigger:function(){if(this!==Se()&&this.focus)return this.focus(),!1},delegateType:"focusin"},blur:{trigger:function(){if(this===Se()&&this.blur)return this.blur(),!1},delegateType:"focusout"},click:{trigger:function(){if("checkbox"===this.type&&this.click&&N(this,"input"))return this.click(),!1},_default:function(e){return N(e.target,"a")}},beforeunload:{postDispatch:function(e){void 0!==e.result&&e.originalEvent&&(e.originalEvent.returnValue=e.result)}}}},w.removeEvent=function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n)},w.Event=function(e,t){if(!(this instanceof w.Event))return new w.Event(e,t);e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||void 0===e.defaultPrevented&&!1===e.returnValue?Ee:ke,this.target=e.target&&3===e.target.nodeType?e.target.parentNode:e.target,this.currentTarget=e.currentTarget,this.relatedTarget=e.relatedTarget):this.type=e,t&&w.extend(this,t),this.timeStamp=e&&e.timeStamp||Date.now(),this[w.expando]=!0},w.Event.prototype={constructor:w.Event,isDefaultPrevented:ke,isPropagationStopped:ke,isImmediatePropagationStopped:ke,isSimulated:!1,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=Ee,e&&!this.isSimulated&&e.preventDefault()},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=Ee,e&&!this.isSimulated&&e.stopPropagation()},stopImmediatePropagation:function(){var e=this.originalEvent;this.isImmediatePropagationStopped=Ee,e&&!this.isSimulated&&e.stopImmediatePropagation(),this.stopPropagation()}},w.each({altKey:!0,bubbles:!0,cancelable:!0,changedTouches:!0,ctrlKey:!0,detail:!0,eventPhase:!0,metaKey:!0,pageX:!0,pageY:!0,shiftKey:!0,view:!0,"char":!0,charCode:!0,key:!0,keyCode:!0,button:!0,buttons:!0,clientX:!0,clientY:!0,offsetX:!0,offsetY:!0,pointerId:!0,pointerType:!0,screenX:!0,screenY:!0,targetTouches:!0,toElement:!0,touches:!0,which:function(e){var t=e.button;return null==e.which&&we.test(e.type)?null!=e.charCode?e.charCode:e.keyCode:!e.which&&void 0!==t&&Te.test(e.type)?1&t?1:2&t?3:4&t?2:0:e.which}},w.event.addProp),w.each({mouseenter:"mouseover",mouseleave:"mouseout",pointerenter:"pointerover",pointerleave:"pointerout"},function(e,t){w.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return i&&(i===r||w.contains(r,i))||(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),w.fn.extend({on:function(e,t,n,r){return De(this,e,t,n,r)},one:function(e,t,n,r){return De(this,e,t,n,r,1)},off:function(e,t,n){var r,i;if(e&&e.preventDefault&&e.handleObj)return r=e.handleObj,w(e.delegateTarget).off(r.namespace?r.origType+"."+r.namespace:r.origType,r.selector,r.handler),this;if("object"==typeof e){for(i in e)this.off(i,t,e[i]);return this}return!1!==t&&"function"!=typeof t||(n=t,t=void 0),!1===n&&(n=ke),this.each(function(){w.event.remove(this,e,n,t)})}});var Ne=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi,Ae=/<script|<style|<link/i,je=/checked\s*(?:[^=]|=\s*.checked.)/i,qe=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g;function Le(e,t){return N(e,"table")&&N(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function He(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Oe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Pe(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(J.hasData(e)&&(o=J.access(e),a=J.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n<r;n++)w.event.add(t,i,l[i][n])}K.hasData(e)&&(s=K.access(e),u=w.extend({},s),K.set(t,u))}}function Me(e,t){var n=t.nodeName.toLowerCase();"input"===n&&pe.test(e.type)?t.checked=e.checked:"input"!==n&&"textarea"!==n||(t.defaultValue=e.defaultValue)}function Re(e,t,n,r){t=a.apply([],t);var i,o,s,u,l,c,f=0,p=e.length,d=p-1,y=t[0],v=g(y);if(v||p>1&&"string"==typeof y&&!h.checkClone&&je.test(y))return e.each(function(i){var o=e.eq(i);v&&(t[0]=y.call(this,i,o.html())),Re(o,t,n,r)});if(p&&(i=xe(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(u=(s=w.map(ye(i,"script"),He)).length;f<p;f++)l=i,f!==d&&(l=w.clone(l,!0,!0),u&&w.merge(s,ye(l,"script"))),n.call(e[f],l,f);if(u)for(c=s[s.length-1].ownerDocument,w.map(s,Oe),f=0;f<u;f++)l=s[f],he.test(l.type||"")&&!J.access(l,"globalEval")&&w.contains(c,l)&&(l.src&&"module"!==(l.type||"").toLowerCase()?w._evalUrl&&w._evalUrl(l.src):m(l.textContent.replace(qe,""),c,l))}return e}function Ie(e,t,n){for(var r,i=t?w.filter(t,e):e,o=0;null!=(r=i[o]);o++)n||1!==r.nodeType||w.cleanData(ye(r)),r.parentNode&&(n&&w.contains(r.ownerDocument,r)&&ve(ye(r,"script")),r.parentNode.removeChild(r));return e}w.extend({htmlPrefilter:function(e){return e.replace(Ne,"<$1></$2>")},clone:function(e,t,n){var r,i,o,a,s=e.cloneNode(!0),u=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ye(s),r=0,i=(o=ye(e)).length;r<i;r++)Me(o[r],a[r]);if(t)if(n)for(o=o||ye(e),a=a||ye(s),r=0,i=o.length;r<i;r++)Pe(o[r],a[r]);else Pe(e,s);return(a=ye(s,"script")).length>0&&ve(a,!u&&ye(e,"script")),s},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[J.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[J.expando]=void 0}n[K.expando]&&(n[K.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Ie(this,e,!0)},remove:function(e){return Ie(this,e)},text:function(e){return z(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Le(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Le(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ye(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return z(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Ae.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n<r;n++)1===(t=this[n]||{}).nodeType&&(w.cleanData(ye(t,!1)),t.innerHTML=e);t=0}catch(e){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var e=[];return Re(this,arguments,function(t){var n=this.parentNode;w.inArray(this,e)<0&&(w.cleanData(ye(this)),n&&n.replaceChild(t,this))},e)}}),w.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){w.fn[e]=function(e){for(var n,r=[],i=w(e),o=i.length-1,a=0;a<=o;a++)n=a===o?this:this.clone(!0),w(i[a])[t](n),s.apply(r,n.get());return this.pushStack(r)}});var We=new RegExp("^("+re+")(?!px)[a-z%]+$","i"),$e=function(t){var n=t.ownerDocument.defaultView;return n&&n.opener||(n=e),n.getComputedStyle(t)},Be=new RegExp(oe.join("|"),"i");!function(){function t(){if(c){l.style.cssText="position:absolute;left:-11111px;width:60px;margin-top:1px;padding:0;border:0",c.style.cssText="position:relative;display:block;box-sizing:border-box;overflow:scroll;margin:auto;border:1px;padding:1px;width:60%;top:1%",be.appendChild(l).appendChild(c);var t=e.getComputedStyle(c);i="1%"!==t.top,u=12===n(t.marginLeft),c.style.right="60%",s=36===n(t.right),o=36===n(t.width),c.style.position="absolute",a=36===c.offsetWidth||"absolute",be.removeChild(l),c=null}}function n(e){return Math.round(parseFloat(e))}var i,o,a,s,u,l=r.createElement("div"),c=r.createElement("div");c.style&&(c.style.backgroundClip="content-box",c.cloneNode(!0).style.backgroundClip="",h.clearCloneStyle="content-box"===c.style.backgroundClip,w.extend(h,{boxSizingReliable:function(){return t(),o},pixelBoxStyles:function(){return t(),s},pixelPosition:function(){return t(),i},reliableMarginLeft:function(){return t(),u},scrollboxSize:function(){return t(),a}}))}();function Fe(e,t,n){var r,i,o,a,s=e.style;return(n=n||$e(e))&&(""!==(a=n.getPropertyValue(t)||n[t])||w.contains(e.ownerDocument,e)||(a=w.style(e,t)),!h.pixelBoxStyles()&&We.test(a)&&Be.test(t)&&(r=s.width,i=s.minWidth,o=s.maxWidth,s.minWidth=s.maxWidth=s.width=a,a=n.width,s.width=r,s.minWidth=i,s.maxWidth=o)),void 0!==a?a+"":a}function _e(e,t){return{get:function(){if(!e())return(this.get=t).apply(this,arguments);delete this.get}}}var ze=/^(none|table(?!-c[ea]).+)/,Xe=/^--/,Ue={position:"absolute",visibility:"hidden",display:"block"},Ve={letterSpacing:"0",fontWeight:"400"},Ge=["Webkit","Moz","ms"],Ye=r.createElement("div").style;function Qe(e){if(e in Ye)return e;var t=e[0].toUpperCase()+e.slice(1),n=Ge.length;while(n--)if((e=Ge[n]+t)in Ye)return e}function Je(e){var t=w.cssProps[e];return t||(t=w.cssProps[e]=Qe(e)||e),t}function Ke(e,t,n){var r=ie.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||"px"):t}function Ze(e,t,n,r,i,o){var a="width"===t?1:0,s=0,u=0;if(n===(r?"border":"content"))return 0;for(;a<4;a+=2)"margin"===n&&(u+=w.css(e,n+oe[a],!0,i)),r?("content"===n&&(u-=w.css(e,"padding"+oe[a],!0,i)),"margin"!==n&&(u-=w.css(e,"border"+oe[a]+"Width",!0,i))):(u+=w.css(e,"padding"+oe[a],!0,i),"padding"!==n?u+=w.css(e,"border"+oe[a]+"Width",!0,i):s+=w.css(e,"border"+oe[a]+"Width",!0,i));return!r&&o>=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))),u}function et(e,t,n){var r=$e(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(We.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=G(t),u=Xe.test(t),l=e.style;if(u||(t=Je(s)),a=w.cssHooks[t]||w.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=ue(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[s]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=G(t);return Xe.test(t)||(t=Je(s)),(a=w.cssHooks[t]||w.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Ve&&(i=Ve[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!ze.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):se(e,Ue,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=$e(e),a="border-box"===w.css(e,"boxSizing",!1,o),s=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(s-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),s&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Ke(e,n,s)}}}),w.cssHooks.marginLeft=_e(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-se(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Ke)}),w.fn.extend({css:function(e,t){return z(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=$e(e),i=t.length;a<i;a++)o[t[a]]=w.css(e,t[a],!1,r);return o}return void 0!==n?w.style(e,t,n):w.css(e,t)},e,t,arguments.length>1)}});function tt(e,t,n,r,i){return new tt.prototype.init(e,t,n,r,i)}w.Tween=tt,tt.prototype={constructor:tt,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||w.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(w.cssNumber[n]?"":"px")},cur:function(){var e=tt.propHooks[this.prop];return e&&e.get?e.get(this):tt.propHooks._default.get(this)},run:function(e){var t,n=tt.propHooks[this.prop];return this.options.duration?this.pos=t=w.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):tt.propHooks._default.set(this),this}},tt.prototype.init.prototype=tt.prototype,tt.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=w.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){w.fx.step[e.prop]?w.fx.step[e.prop](e):1!==e.elem.nodeType||null==e.elem.style[w.cssProps[e.prop]]&&!w.cssHooks[e.prop]?e.elem[e.prop]=e.now:w.style(e.elem,e.prop,e.now+e.unit)}}},tt.propHooks.scrollTop=tt.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},w.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},w.fx=tt.prototype.init,w.fx.step={};var nt,rt,it=/^(?:toggle|show|hide)$/,ot=/queueHooks$/;function at(){rt&&(!1===r.hidden&&e.requestAnimationFrame?e.requestAnimationFrame(at):e.setTimeout(at,w.fx.interval),w.fx.tick())}function st(){return e.setTimeout(function(){nt=void 0}),nt=Date.now()}function ut(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i["margin"+(n=oe[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function lt(e,t,n){for(var r,i=(pt.tweeners[t]||[]).concat(pt.tweeners["*"]),o=0,a=i.length;o<a;o++)if(r=i[o].call(n,t,e))return r}function ct(e,t,n){var r,i,o,a,s,u,l,c,f="width"in t||"height"in t,p=this,d={},h=e.style,g=e.nodeType&&ae(e),y=J.get(e,"fxshow");n.queue||(null==(a=w._queueHooks(e,"fx")).unqueued&&(a.unqueued=0,s=a.empty.fire,a.empty.fire=function(){a.unqueued||s()}),a.unqueued++,p.always(function(){p.always(function(){a.unqueued--,w.queue(e,"fx").length||a.empty.fire()})}));for(r in t)if(i=t[r],it.test(i)){if(delete t[r],o=o||"toggle"===i,i===(g?"hide":"show")){if("show"!==i||!y||void 0===y[r])continue;g=!0}d[r]=y&&y[r]||w.style(e,r)}if((u=!w.isEmptyObject(t))||!w.isEmptyObject(d)){f&&1===e.nodeType&&(n.overflow=[h.overflow,h.overflowX,h.overflowY],null==(l=y&&y.display)&&(l=J.get(e,"display")),"none"===(c=w.css(e,"display"))&&(l?c=l:(fe([e],!0),l=e.style.display||l,c=w.css(e,"display"),fe([e]))),("inline"===c||"inline-block"===c&&null!=l)&&"none"===w.css(e,"float")&&(u||(p.done(function(){h.display=l}),null==l&&(c=h.display,l="none"===c?"":c)),h.display="inline-block")),n.overflow&&(h.overflow="hidden",p.always(function(){h.overflow=n.overflow[0],h.overflowX=n.overflow[1],h.overflowY=n.overflow[2]})),u=!1;for(r in d)u||(y?"hidden"in y&&(g=y.hidden):y=J.access(e,"fxshow",{display:l}),o&&(y.hidden=!g),g&&fe([e],!0),p.done(function(){g||fe([e]),J.remove(e,"fxshow");for(r in d)w.style(e,r,d[r])})),u=lt(g?y[r]:0,r,p),r in y||(y[r]=u.start,g&&(u.end=u.start,u.start=0))}}function ft(e,t){var n,r,i,o,a;for(n in e)if(r=G(n),i=t[r],o=e[n],Array.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),(a=w.cssHooks[r])&&"expand"in a){o=a.expand(o),delete e[r];for(n in o)n in e||(e[n]=o[n],t[n]=i)}else t[r]=i}function pt(e,t,n){var r,i,o=0,a=pt.prefilters.length,s=w.Deferred().always(function(){delete u.elem}),u=function(){if(i)return!1;for(var t=nt||st(),n=Math.max(0,l.startTime+l.duration-t),r=1-(n/l.duration||0),o=0,a=l.tweens.length;o<a;o++)l.tweens[o].run(r);return s.notifyWith(e,[l,r,n]),r<1&&a?n:(a||s.notifyWith(e,[l,1,0]),s.resolveWith(e,[l]),!1)},l=s.promise({elem:e,props:w.extend({},t),opts:w.extend(!0,{specialEasing:{},easing:w.easing._default},n),originalProperties:t,originalOptions:n,startTime:nt||st(),duration:n.duration,tweens:[],createTween:function(t,n){var r=w.Tween(e,l.opts,t,n,l.opts.specialEasing[t]||l.opts.easing);return l.tweens.push(r),r},stop:function(t){var n=0,r=t?l.tweens.length:0;if(i)return this;for(i=!0;n<r;n++)l.tweens[n].run(1);return t?(s.notifyWith(e,[l,1,0]),s.resolveWith(e,[l,t])):s.rejectWith(e,[l,t]),this}}),c=l.props;for(ft(c,l.opts.specialEasing);o<a;o++)if(r=pt.prefilters[o].call(l,e,c,l.opts))return g(r.stop)&&(w._queueHooks(l.elem,l.opts.queue).stop=r.stop.bind(r)),r;return w.map(c,lt,l),g(l.opts.start)&&l.opts.start.call(e,l),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always),w.fx.timer(w.extend(u,{elem:e,anim:l,queue:l.opts.queue})),l}w.Animation=w.extend(pt,{tweeners:{"*":[function(e,t){var n=this.createTween(e,t);return ue(n.elem,e,ie.exec(t),n),n}]},tweener:function(e,t){g(e)?(t=e,e=["*"]):e=e.match(M);for(var n,r=0,i=e.length;r<i;r++)n=e[r],pt.tweeners[n]=pt.tweeners[n]||[],pt.tweeners[n].unshift(t)},prefilters:[ct],prefilter:function(e,t){t?pt.prefilters.unshift(e):pt.prefilters.push(e)}}),w.speed=function(e,t,n){var r=e&&"object"==typeof e?w.extend({},e):{complete:n||!n&&t||g(e)&&e,duration:e,easing:n&&t||t&&!g(t)&&t};return w.fx.off?r.duration=0:"number"!=typeof r.duration&&(r.duration in w.fx.speeds?r.duration=w.fx.speeds[r.duration]:r.duration=w.fx.speeds._default),null!=r.queue&&!0!==r.queue||(r.queue="fx"),r.old=r.complete,r.complete=function(){g(r.old)&&r.old.call(this),r.queue&&w.dequeue(this,r.queue)},r},w.fn.extend({fadeTo:function(e,t,n,r){return this.filter(ae).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(e,t,n,r){var i=w.isEmptyObject(e),o=w.speed(t,n,r),a=function(){var t=pt(this,w.extend({},e),o);(i||J.get(this,"finish"))&&t.stop(!0)};return a.finish=a,i||!1===o.queue?this.each(a):this.queue(o.queue,a)},stop:function(e,t,n){var r=function(e){var t=e.stop;delete e.stop,t(n)};return"string"!=typeof e&&(n=t,t=e,e=void 0),t&&!1!==e&&this.queue(e||"fx",[]),this.each(function(){var t=!0,i=null!=e&&e+"queueHooks",o=w.timers,a=J.get(this);if(i)a[i]&&a[i].stop&&r(a[i]);else for(i in a)a[i]&&a[i].stop&&ot.test(i)&&r(a[i]);for(i=o.length;i--;)o[i].elem!==this||null!=e&&o[i].queue!==e||(o[i].anim.stop(n),t=!1,o.splice(i,1));!t&&n||w.dequeue(this,e)})},finish:function(e){return!1!==e&&(e=e||"fx"),this.each(function(){var t,n=J.get(this),r=n[e+"queue"],i=n[e+"queueHooks"],o=w.timers,a=r?r.length:0;for(n.finish=!0,w.queue(this,e,[]),i&&i.stop&&i.stop.call(this,!0),t=o.length;t--;)o[t].elem===this&&o[t].queue===e&&(o[t].anim.stop(!0),o.splice(t,1));for(t=0;t<a;t++)r[t]&&r[t].finish&&r[t].finish.call(this);delete n.finish})}}),w.each(["toggle","show","hide"],function(e,t){var n=w.fn[t];w.fn[t]=function(e,r,i){return null==e||"boolean"==typeof e?n.apply(this,arguments):this.animate(ut(t,!0),e,r,i)}}),w.each({slideDown:ut("show"),slideUp:ut("hide"),slideToggle:ut("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,t){w.fn[e]=function(e,n,r){return this.animate(t,e,n,r)}}),w.timers=[],w.fx.tick=function(){var e,t=0,n=w.timers;for(nt=Date.now();t<n.length;t++)(e=n[t])()||n[t]!==e||n.splice(t--,1);n.length||w.fx.stop(),nt=void 0},w.fx.timer=function(e){w.timers.push(e),w.fx.start()},w.fx.interval=13,w.fx.start=function(){rt||(rt=!0,at())},w.fx.stop=function(){rt=null},w.fx.speeds={slow:600,fast:200,_default:400},w.fn.delay=function(t,n){return t=w.fx?w.fx.speeds[t]||t:t,n=n||"fx",this.queue(n,function(n,r){var i=e.setTimeout(n,t);r.stop=function(){e.clearTimeout(i)}})},function(){var e=r.createElement("input"),t=r.createElement("select").appendChild(r.createElement("option"));e.type="checkbox",h.checkOn=""!==e.value,h.optSelected=t.selected,(e=r.createElement("input")).value="t",e.type="radio",h.radioValue="t"===e.value}();var dt,ht=w.expr.attrHandle;w.fn.extend({attr:function(e,t){return z(this,w.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?dt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&N(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(M);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),dt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=ht[t]||w.find.attr;ht[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=ht[a],ht[a]=i,i=null!=n(e,t,r)?a:null,ht[a]=o),i}});var gt=/^(?:input|select|textarea|button)$/i,yt=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return z(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):gt.test(e.nodeName)||yt.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function vt(e){return(e.match(M)||[]).join(" ")}function mt(e){return e.getAttribute&&e.getAttribute("class")||""}function xt(e){return Array.isArray(e)?e:"string"==typeof e?e.match(M)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,mt(this)))});if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},removeClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,mt(this)))});if(!arguments.length)return this.attr("class","");if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,mt(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=xt(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=mt(this))&&J.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":J.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+vt(mt(n))+" ").indexOf(t)>-1)return!0;return!1}});var bt=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(bt,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:vt(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r<u;r++)if(((n=i[r]).selected||r===o)&&!n.disabled&&(!n.parentNode.disabled||!N(n.parentNode,"optgroup"))){if(t=w(n).val(),a)return t;s.push(t)}return s},set:function(e,t){var n,r,i=e.options,o=w.makeArray(t),a=i.length;while(a--)((r=i[a]).selected=w.inArray(w.valHooks.option.get(r),o)>-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var wt=/^(?:focusinfocus|focusoutblur)$/,Tt=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,s,u,l,c,p,d,h,v=[i||r],m=f.call(t,"type")?t.type:t,x=f.call(t,"namespace")?t.namespace.split("."):[];if(s=h=u=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!wt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(x=m.split(".")).shift(),x.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=x.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+x.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),d=w.event.special[m]||{},o||!d.trigger||!1!==d.trigger.apply(i,n))){if(!o&&!d.noBubble&&!y(i)){for(l=d.delegateType||m,wt.test(l+m)||(s=s.parentNode);s;s=s.parentNode)v.push(s),u=s;u===(i.ownerDocument||r)&&v.push(u.defaultView||u.parentWindow||e)}a=0;while((s=v[a++])&&!t.isPropagationStopped())h=s,t.type=a>1?l:d.bindType||m,(p=(J.get(s,"events")||{})[t.type]&&J.get(s,"handle"))&&p.apply(s,n),(p=c&&s[c])&&p.apply&&Y(s)&&(t.result=p.apply(s,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||d._default&&!1!==d._default.apply(v.pop(),n)||!Y(i)||c&&g(i[m])&&!y(i)&&((u=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,Tt),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,Tt),w.event.triggered=void 0,u&&(i[c]=u)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=J.access(r,t);i||r.addEventListener(e,n,!0),J.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=J.access(r,t)-1;i?J.access(r,t,i):(r.removeEventListener(e,n,!0),J.remove(r,t))}}});var Ct=e.location,Et=Date.now(),kt=/\?/;w.parseXML=function(t){var n;if(!t||"string"!=typeof t)return null;try{n=(new e.DOMParser).parseFromString(t,"text/xml")}catch(e){n=void 0}return n&&!n.getElementsByTagName("parsererror").length||w.error("Invalid XML: "+t),n};var St=/\[\]$/,Dt=/\r?\n/g,Nt=/^(?:submit|button|image|reset|file)$/i,At=/^(?:input|select|textarea|keygen)/i;function jt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||St.test(e)?r(e,i):jt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==x(t))r(e,t);else for(i in t)jt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)jt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&At.test(this.nodeName)&&!Nt.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(Dt,"\r\n")}}):{name:t.name,value:n.replace(Dt,"\r\n")}}).get()}});var qt=/%20/g,Lt=/#.*$/,Ht=/([?&])_=[^&]*/,Ot=/^(.*?):[ \t]*([^\r\n]*)$/gm,Pt=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Mt=/^(?:GET|HEAD)$/,Rt=/^\/\//,It={},Wt={},$t="*/".concat("*"),Bt=r.createElement("a");Bt.href=Ct.href;function Ft(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(M)||[];if(g(n))while(r=o[i++])"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function _t(e,t,n,r){var i={},o=e===Wt;function a(s){var u;return i[s]=!0,w.each(e[s]||[],function(e,s){var l=s(t,n,r);return"string"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)}),u}return a(t.dataTypes[0])||!i["*"]&&a("*")}function zt(e,t){var n,r,i=w.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&w.extend(!0,e,r),e}function Xt(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}function Ut(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}w.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Ct.href,type:"GET",isLocal:Pt.test(Ct.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":$t,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":w.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,w.ajaxSettings),t):zt(w.ajaxSettings,e)},ajaxPrefilter:Ft(It),ajaxTransport:Ft(Wt),ajax:function(t,n){"object"==typeof t&&(n=t,t=void 0),n=n||{};var i,o,a,s,u,l,c,f,p,d,h=w.ajaxSetup({},n),g=h.context||h,y=h.context&&(g.nodeType||g.jquery)?w(g):w.event,v=w.Deferred(),m=w.Callbacks("once memory"),x=h.statusCode||{},b={},T={},C="canceled",E={readyState:0,getResponseHeader:function(e){var t;if(c){if(!s){s={};while(t=Ot.exec(a))s[t[1].toLowerCase()]=t[2]}t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return c?a:null},setRequestHeader:function(e,t){return null==c&&(e=T[e.toLowerCase()]=T[e.toLowerCase()]||e,b[e]=t),this},overrideMimeType:function(e){return null==c&&(h.mimeType=e),this},statusCode:function(e){var t;if(e)if(c)E.always(e[E.status]);else for(t in e)x[t]=[x[t],e[t]];return this},abort:function(e){var t=e||C;return i&&i.abort(t),k(0,t),this}};if(v.promise(E),h.url=((t||h.url||Ct.href)+"").replace(Rt,Ct.protocol+"//"),h.type=n.method||n.type||h.method||h.type,h.dataTypes=(h.dataType||"*").toLowerCase().match(M)||[""],null==h.crossDomain){l=r.createElement("a");try{l.href=h.url,l.href=l.href,h.crossDomain=Bt.protocol+"//"+Bt.host!=l.protocol+"//"+l.host}catch(e){h.crossDomain=!0}}if(h.data&&h.processData&&"string"!=typeof h.data&&(h.data=w.param(h.data,h.traditional)),_t(It,h,n,E),c)return E;(f=w.event&&h.global)&&0==w.active++&&w.event.trigger("ajaxStart"),h.type=h.type.toUpperCase(),h.hasContent=!Mt.test(h.type),o=h.url.replace(Lt,""),h.hasContent?h.data&&h.processData&&0===(h.contentType||"").indexOf("application/x-www-form-urlencoded")&&(h.data=h.data.replace(qt,"+")):(d=h.url.slice(o.length),h.data&&(h.processData||"string"==typeof h.data)&&(o+=(kt.test(o)?"&":"?")+h.data,delete h.data),!1===h.cache&&(o=o.replace(Ht,"$1"),d=(kt.test(o)?"&":"?")+"_="+Et+++d),h.url=o+d),h.ifModified&&(w.lastModified[o]&&E.setRequestHeader("If-Modified-Since",w.lastModified[o]),w.etag[o]&&E.setRequestHeader("If-None-Match",w.etag[o])),(h.data&&h.hasContent&&!1!==h.contentType||n.contentType)&&E.setRequestHeader("Content-Type",h.contentType),E.setRequestHeader("Accept",h.dataTypes[0]&&h.accepts[h.dataTypes[0]]?h.accepts[h.dataTypes[0]]+("*"!==h.dataTypes[0]?", "+$t+"; q=0.01":""):h.accepts["*"]);for(p in h.headers)E.setRequestHeader(p,h.headers[p]);if(h.beforeSend&&(!1===h.beforeSend.call(g,E,h)||c))return E.abort();if(C="abort",m.add(h.complete),E.done(h.success),E.fail(h.error),i=_t(Wt,h,n,E)){if(E.readyState=1,f&&y.trigger("ajaxSend",[E,h]),c)return E;h.async&&h.timeout>0&&(u=e.setTimeout(function(){E.abort("timeout")},h.timeout));try{c=!1,i.send(b,k)}catch(e){if(c)throw e;k(-1,e)}}else k(-1,"No Transport");function k(t,n,r,s){var l,p,d,b,T,C=n;c||(c=!0,u&&e.clearTimeout(u),i=void 0,a=s||"",E.readyState=t>0?4:0,l=t>=200&&t<300||304===t,r&&(b=Xt(h,E,r)),b=Ut(h,b,E,l),l?(h.ifModified&&((T=E.getResponseHeader("Last-Modified"))&&(w.lastModified[o]=T),(T=E.getResponseHeader("etag"))&&(w.etag[o]=T)),204===t||"HEAD"===h.type?C="nocontent":304===t?C="notmodified":(C=b.state,p=b.data,l=!(d=b.error))):(d=C,!t&&C||(C="error",t<0&&(t=0))),E.status=t,E.statusText=(n||C)+"",l?v.resolveWith(g,[p,C,E]):v.rejectWith(g,[E,C,d]),E.statusCode(x),x=void 0,f&&y.trigger(l?"ajaxSuccess":"ajaxError",[E,h,l?p:d]),m.fireWith(g,[E,C]),f&&(y.trigger("ajaxComplete",[E,h]),--w.active||w.event.trigger("ajaxStop")))}return E},getJSON:function(e,t,n){return w.get(e,t,n,"json")},getScript:function(e,t){return w.get(e,void 0,t,"script")}}),w.each(["get","post"],function(e,t){w[t]=function(e,n,r,i){return g(n)&&(i=i||r,r=n,n=void 0),w.ajax(w.extend({url:e,type:t,dataType:i,data:n,success:r},w.isPlainObject(e)&&e))}}),w._evalUrl=function(e){return w.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},w.ajaxSettings.xhr=function(){try{return new e.XMLHttpRequest}catch(e){}};var Vt={0:200,1223:204},Gt=w.ajaxSettings.xhr();h.cors=!!Gt&&"withCredentials"in Gt,h.ajax=Gt=!!Gt,w.ajaxTransport(function(t){var n,r;if(h.cors||Gt&&!t.crossDomain)return{send:function(i,o){var a,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(a in t.xhrFields)s[a]=t.xhrFields[a];t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");for(a in i)s.setRequestHeader(a,i[a]);n=function(e){return function(){n&&(n=r=s.onload=s.onerror=s.onabort=s.ontimeout=s.onreadystatechange=null,"abort"===e?s.abort():"error"===e?"number"!=typeof s.status?o(0,"error"):o(s.status,s.statusText):o(Vt[s.status]||s.status,s.statusText,"text"!==(s.responseType||"text")||"string"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=n(),r=s.onerror=s.ontimeout=n("error"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&e.setTimeout(function(){n&&r()})},n=n("abort");try{s.send(t.hasContent&&t.data||null)}catch(e){if(n)throw e}},abort:function(){n&&n()}}}),w.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),w.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return w.globalEval(e),e}}}),w.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),w.ajaxTransport("script",function(e){if(e.crossDomain){var t,n;return{send:function(i,o){t=w("<script>").prop({charset:e.scriptCharset,src:e.url}).on("load error",n=function(e){t.remove(),n=null,e&&o("error"===e.type?404:200,e.type)}),r.head.appendChild(t[0])},abort:function(){n&&n()}}}});var Yt=[],Qt=/(=)\?(?=&|$)|\?\?/;w.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Yt.pop()||w.expando+"_"+Et++;return this[e]=!0,e}}),w.ajaxPrefilter("json jsonp",function(t,n,r){var i,o,a,s=!1!==t.jsonp&&(Qt.test(t.url)?"url":"string"==typeof t.data&&0===(t.contentType||"").indexOf("application/x-www-form-urlencoded")&&Qt.test(t.data)&&"data");if(s||"jsonp"===t.dataTypes[0])return i=t.jsonpCallback=g(t.jsonpCallback)?t.jsonpCallback():t.jsonpCallback,s?t[s]=t[s].replace(Qt,"$1"+i):!1!==t.jsonp&&(t.url+=(kt.test(t.url)?"&":"?")+t.jsonp+"="+i),t.converters["script json"]=function(){return a||w.error(i+" was not called"),a[0]},t.dataTypes[0]="json",o=e[i],e[i]=function(){a=arguments},r.always(function(){void 0===o?w(e).removeProp(i):e[i]=o,t[i]&&(t.jsonpCallback=n.jsonpCallback,Yt.push(i)),a&&g(o)&&o(a[0]),a=o=void 0}),"script"}),h.createHTMLDocument=function(){var e=r.implementation.createHTMLDocument("").body;return e.innerHTML="<form></form><form></form>",2===e.childNodes.length}(),w.parseHTML=function(e,t,n){if("string"!=typeof e)return[];"boolean"==typeof t&&(n=t,t=!1);var i,o,a;return t||(h.createHTMLDocument?((i=(t=r.implementation.createHTMLDocument("")).createElement("base")).href=r.location.href,t.head.appendChild(i)):t=r),o=A.exec(e),a=!n&&[],o?[t.createElement(o[1])]:(o=xe([e],t,a),a&&a.length&&w(a).remove(),w.merge([],o.childNodes))},w.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return s>-1&&(r=vt(e.slice(s)),e=e.slice(0,s)),g(t)?(n=t,t=void 0):t&&"object"==typeof t&&(i="POST"),a.length>0&&w.ajax({url:e,type:i||"GET",dataType:"html",data:t}).done(function(e){o=arguments,a.html(r?w("<div>").append(w.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},w.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){w.fn[t]=function(e){return this.on(t,e)}}),w.expr.pseudos.animated=function(e){return w.grep(w.timers,function(t){return e===t.elem}).length},w.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l,c=w.css(e,"position"),f=w(e),p={};"static"===c&&(e.style.position="relative"),s=f.offset(),o=w.css(e,"top"),u=w.css(e,"left"),(l=("absolute"===c||"fixed"===c)&&(o+u).indexOf("auto")>-1)?(a=(r=f.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),g(t)&&(t=t.call(e,n,w.extend({},s))),null!=t.top&&(p.top=t.top-s.top+a),null!=t.left&&(p.left=t.left-s.left+i),"using"in t?t.using.call(e,p):f.css(p)}},w.fn.extend({offset:function(e){if(arguments.length)return void 0===e?this:this.each(function(t){w.offset.setOffset(this,e,t)});var t,n,r=this[0];if(r)return r.getClientRects().length?(t=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:t.top+n.pageYOffset,left:t.left+n.pageXOffset}):{top:0,left:0}},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===w.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===w.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=w(e).offset()).top+=w.css(e,"borderTopWidth",!0),i.left+=w.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-w.css(r,"marginTop",!0),left:t.left-i.left-w.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===w.css(e,"position"))e=e.offsetParent;return e||be})}}),w.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,t){var n="pageYOffset"===t;w.fn[e]=function(r){return z(this,function(e,r,i){var o;if(y(e)?o=e:9===e.nodeType&&(o=e.defaultView),void 0===i)return o?o[t]:e[r];o?o.scrollTo(n?o.pageXOffset:i,n?i:o.pageYOffset):e[r]=i},e,r,arguments.length)}}),w.each(["top","left"],function(e,t){w.cssHooks[t]=_e(h.pixelPosition,function(e,n){if(n)return n=Fe(e,t),We.test(n)?w(e).position()[t]+"px":n})}),w.each({Height:"height",Width:"width"},function(e,t){w.each({padding:"inner"+e,content:t,"":"outer"+e},function(n,r){w.fn[r]=function(i,o){var a=arguments.length&&(n||"boolean"!=typeof i),s=n||(!0===i||!0===o?"margin":"border");return z(this,function(t,n,i){var o;return y(t)?0===r.indexOf("outer")?t["inner"+e]:t.document.documentElement["client"+e]:9===t.nodeType?(o=t.documentElement,Math.max(t.body["scroll"+e],o["scroll"+e],t.body["offset"+e],o["offset"+e],o["client"+e])):void 0===i?w.css(t,n,s):w.style(t,n,i,s)},t,a?i:void 0,a)}})}),w.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,t){w.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),w.fn.extend({hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),w.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)}}),w.proxy=function(e,t){var n,r,i;if("string"==typeof t&&(n=e[t],t=e,e=n),g(e))return r=o.call(arguments,2),i=function(){return e.apply(t||this,r.concat(o.call(arguments)))},i.guid=e.guid=e.guid||w.guid++,i},w.holdReady=function(e){e?w.readyWait++:w.ready(!0)},w.isArray=Array.isArray,w.parseJSON=JSON.parse,w.nodeName=N,w.isFunction=g,w.isWindow=y,w.camelCase=G,w.type=x,w.now=Date.now,w.isNumeric=function(e){var t=w.type(e);return("number"===t||"string"===t)&&!isNaN(e-parseFloat(e))},"function"==typeof define&&define.amd&&define("jquery",[],function(){return w});var Jt=e.jQuery,Kt=e.$;return w.noConflict=function(t){return e.$===w&&(e.$=Kt),t&&e.jQuery===w&&(e.jQuery=Jt),w},t||(e.jQuery=e.$=w),w});
diff --git a/src/static/js/jquery-3.6.0.min.js b/src/static/js/jquery-3.6.0.min.js
new file mode 100644 (file)
index 0000000..c4c6022
--- /dev/null
@@ -0,0 +1,2 @@
+/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */
+!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0<t&&t-1 in e)}S.fn=S.prototype={jquery:f,constructor:S,length:0,toArray:function(){return s.call(this)},get:function(e){return null==e?s.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=S.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return S.each(this,e)},map:function(n){return this.pushStack(S.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(s.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(S.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(S.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(0<=n&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:u,sort:t.sort,splice:t.splice},S.extend=S.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||m(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(S.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||S.isPlainObject(n)?n:{},i=!1,a[t]=S.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},S.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==o.call(e))&&(!(t=r(e))||"function"==typeof(n=v.call(t,"constructor")&&t.constructor)&&a.call(n)===l)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t,n){b(e,{nonce:t&&t.nonce},n)},each:function(e,t){var n,r=0;if(p(e)){for(n=e.length;r<n;r++)if(!1===t.call(e[r],r,e[r]))break}else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},makeArray:function(e,t){var n=t||[];return null!=e&&(p(Object(e))?S.merge(n,"string"==typeof e?[e]:e):u.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:i.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;i<o;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(p(e))for(r=e.length;o<r;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g(a)},guid:1,support:y}),"function"==typeof Symbol&&(S.fn[Symbol.iterator]=t[Symbol.iterator]),S.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var d=function(n){var e,d,b,o,i,h,f,g,w,u,l,T,C,a,E,v,s,c,y,S="sizzle"+1*new Date,p=n.document,k=0,r=0,m=ue(),x=ue(),A=ue(),N=ue(),j=function(e,t){return e===t&&(l=!0),0},D={}.hasOwnProperty,t=[],q=t.pop,L=t.push,H=t.push,O=t.slice,P=function(e,t){for(var n=0,r=e.length;n<r;n++)if(e[n]===t)return n;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",I="(?:\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+",W="\\["+M+"*("+I+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+I+"))|)"+M+"*\\]",F=":("+I+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+W+")*)|.*)\\)|)",B=new RegExp(M+"+","g"),$=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=new RegExp("^"+M+"*,"+M+"*"),z=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="<a id='"+S+"'></a><select id='"+S+"-\r\\' msallowcapture=''><option selected=''></option></select>",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="<a href='' disabled='disabled'></a><select disabled='disabled'><option/></select>";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0<se(t,C,null,[e]).length},se.contains=function(e,t){return(e.ownerDocument||e)!=C&&T(e),y(e,t)},se.attr=function(e,t){(e.ownerDocument||e)!=C&&T(e);var n=b.attrHandle[t.toLowerCase()],r=n&&D.call(b.attrHandle,t.toLowerCase())?n(e,t,!E):void 0;return void 0!==r?r:d.attributes||!E?e.getAttribute(t):(r=e.getAttributeNode(t))&&r.specified?r.value:null},se.escape=function(e){return(e+"").replace(re,ie)},se.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},se.uniqueSort=function(e){var t,n=[],r=0,i=0;if(l=!d.detectDuplicates,u=!d.sortStable&&e.slice(0),e.sort(j),l){while(t=e[i++])t===e[i]&&(r=n.push(i));while(r--)e.splice(n[r],1)}return u=null,e},o=se.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else while(t=e[r++])n+=o(t);return n},(b=se.selectors={cacheLength:50,createPseudo:le,match:G,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1<t.indexOf(i):"$="===r?i&&t.slice(-i.length)===i:"~="===r?-1<(" "+t.replace(B," ")+" ").indexOf(i):"|="===r&&(t===i||t.slice(0,i.length+1)===i+"-"))}},CHILD:function(h,e,t,g,v){var y="nth"!==h.slice(0,3),m="last"!==h.slice(-4),x="of-type"===e;return 1===g&&0===v?function(e){return!!e.parentNode}:function(e,t,n){var r,i,o,a,s,u,l=y!==m?"nextSibling":"previousSibling",c=e.parentNode,f=x&&e.nodeName.toLowerCase(),p=!n&&!x,d=!1;if(c){if(y){while(l){a=e;while(a=a[l])if(x?a.nodeName.toLowerCase()===f:1===a.nodeType)return!1;u=l="only"===h&&!u&&"nextSibling"}return!0}if(u=[m?c.firstChild:c.lastChild],m&&p){d=(s=(r=(i=(o=(a=c)[S]||(a[S]={}))[a.uniqueID]||(o[a.uniqueID]={}))[h]||[])[0]===k&&r[1])&&r[2],a=s&&c.childNodes[s];while(a=++s&&a&&a[l]||(d=s=0)||u.pop())if(1===a.nodeType&&++d&&a===e){i[h]=[k,s,d];break}}else if(p&&(d=s=(r=(i=(o=(a=e)[S]||(a[S]={}))[a.uniqueID]||(o[a.uniqueID]={}))[h]||[])[0]===k&&r[1]),!1===d)while(a=++s&&a&&a[l]||(d=s=0)||u.pop())if((x?a.nodeName.toLowerCase()===f:1===a.nodeType)&&++d&&(p&&((i=(o=a[S]||(a[S]={}))[a.uniqueID]||(o[a.uniqueID]={}))[h]=[k,d]),a===e))break;return(d-=v)===g||d%g==0&&0<=d/g}}},PSEUDO:function(e,o){var t,a=b.pseudos[e]||b.setFilters[e.toLowerCase()]||se.error("unsupported pseudo: "+e);return a[S]?a(o):1<a.length?(t=[e,e,"",o],b.setFilters.hasOwnProperty(e.toLowerCase())?le(function(e,t){var n,r=a(e,o),i=r.length;while(i--)e[n=P(e,r[i])]=!(t[n]=r[i])}):function(e){return a(e,0,t)}):a}},pseudos:{not:le(function(e){var r=[],i=[],s=f(e.replace($,"$1"));return s[S]?le(function(e,t,n,r){var i,o=s(e,null,r,[]),a=e.length;while(a--)(i=o[a])&&(e[a]=!(t[a]=i))}):function(e,t,n){return r[0]=e,s(r,null,n,i),r[0]=null,!i.pop()}}),has:le(function(t){return function(e){return 0<se(t,e).length}}),contains:le(function(t){return t=t.replace(te,ne),function(e){return-1<(e.textContent||o(e)).indexOf(t)}}),lang:le(function(n){return V.test(n||"")||se.error("unsupported lang: "+n),n=n.replace(te,ne).toLowerCase(),function(e){var t;do{if(t=E?e.lang:e.getAttribute("xml:lang")||e.getAttribute("lang"))return(t=t.toLowerCase())===n||0===t.indexOf(n+"-")}while((e=e.parentNode)&&1===e.nodeType);return!1}}),target:function(e){var t=n.location&&n.location.hash;return t&&t.slice(1)===e.id},root:function(e){return e===a},focus:function(e){return e===C.activeElement&&(!C.hasFocus||C.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:ge(!1),disabled:ge(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!b.pseudos.empty(e)},header:function(e){return J.test(e.nodeName)},input:function(e){return Q.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:ve(function(){return[0]}),last:ve(function(e,t){return[t-1]}),eq:ve(function(e,t,n){return[n<0?n+t:n]}),even:ve(function(e,t){for(var n=0;n<t;n+=2)e.push(n);return e}),odd:ve(function(e,t){for(var n=1;n<t;n+=2)e.push(n);return e}),lt:ve(function(e,t,n){for(var r=n<0?n+t:t<n?t:n;0<=--r;)e.push(r);return e}),gt:ve(function(e,t,n){for(var r=n<0?n+t:n;++r<t;)e.push(r);return e})}}).pseudos.nth=b.pseudos.eq,{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})b.pseudos[e]=de(e);for(e in{submit:!0,reset:!0})b.pseudos[e]=he(e);function me(){}function xe(e){for(var t=0,n=e.length,r="";t<n;t++)r+=e[t].value;return r}function be(s,e,t){var u=e.dir,l=e.next,c=l||u,f=t&&"parentNode"===c,p=r++;return e.first?function(e,t,n){while(e=e[u])if(1===e.nodeType||f)return s(e,t,n);return!1}:function(e,t,n){var r,i,o,a=[k,p];if(n){while(e=e[u])if((1===e.nodeType||f)&&s(e,t,n))return!0}else while(e=e[u])if(1===e.nodeType||f)if(i=(o=e[S]||(e[S]={}))[e.uniqueID]||(o[e.uniqueID]={}),l&&l===e.nodeName.toLowerCase())e=e[u]||e;else{if((r=i[c])&&r[0]===k&&r[1]===p)return a[2]=r[2];if((i[c]=a)[2]=s(e,t,n))return!0}return!1}}function we(i){return 1<i.length?function(e,t,n){var r=i.length;while(r--)if(!i[r](e,t,n))return!1;return!0}:i[0]}function Te(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;s<u;s++)(o=e[s])&&(n&&!n(o,r,i)||(a.push(o),l&&t.push(s)));return a}function Ce(d,h,g,v,y,e){return v&&!v[S]&&(v=Ce(v)),y&&!y[S]&&(y=Ce(y,e)),le(function(e,t,n,r){var i,o,a,s=[],u=[],l=t.length,c=e||function(e,t,n){for(var r=0,i=t.length;r<i;r++)se(e,t[r],n);return n}(h||"*",n.nodeType?[n]:n,[]),f=!d||!e&&h?c:Te(c,s,d,n,r),p=g?y||(e?d:l||v)?[]:t:f;if(g&&g(f,p,n,r),v){i=Te(p,u),v(i,[],n,r),o=i.length;while(o--)(a=i[o])&&(p[u[o]]=!(f[u[o]]=a))}if(e){if(y||d){if(y){i=[],o=p.length;while(o--)(a=p[o])&&i.push(f[o]=a);y(null,p=[],i,r)}o=p.length;while(o--)(a=p[o])&&-1<(i=y?P(e,a):s[o])&&(e[i]=!(t[i]=a))}}else p=Te(p===t?p.splice(l,p.length):p),y?y(null,t,p,r):H.apply(t,p)})}function Ee(e){for(var i,t,n,r=e.length,o=b.relative[e[0].type],a=o||b.relative[" "],s=o?1:0,u=be(function(e){return e===i},a,!0),l=be(function(e){return-1<P(i,e)},a,!0),c=[function(e,t,n){var r=!o&&(n||t!==w)||((i=t).nodeType?u(e,t,n):l(e,t,n));return i=null,r}];s<r;s++)if(t=b.relative[e[s].type])c=[be(we(c),t)];else{if((t=b.filter[e[s].type].apply(null,e[s].matches))[S]){for(n=++s;n<r;n++)if(b.relative[e[n].type])break;return Ce(1<s&&we(c),1<s&&xe(e.slice(0,s-1).concat({value:" "===e[s-2].type?"*":""})).replace($,"$1"),t,s<n&&Ee(e.slice(s,n)),n<r&&Ee(e=e.slice(n)),n<r&&xe(e))}c.push(t)}return we(c)}return me.prototype=b.filters=b.pseudos,b.setFilters=new me,h=se.tokenize=function(e,t){var n,r,i,o,a,s,u,l=x[e+" "];if(l)return t?0:l.slice(0);a=e,s=[],u=b.preFilter;while(a){for(o in n&&!(r=_.exec(a))||(r&&(a=a.slice(r[0].length)||a),s.push(i=[])),n=!1,(r=z.exec(a))&&(n=r.shift(),i.push({value:n,type:r[0].replace($," ")}),a=a.slice(n.length)),b.filter)!(r=G[o].exec(a))||u[o]&&!(r=u[o](r))||(n=r.shift(),i.push({value:n,type:o,matches:r}),a=a.slice(n.length));if(!n)break}return t?a.length:a?se.error(e):x(e,s).slice(0)},f=se.compile=function(e,t){var n,v,y,m,x,r,i=[],o=[],a=A[e+" "];if(!a){t||(t=h(e)),n=t.length;while(n--)(a=Ee(t[n]))[S]?i.push(a):o.push(a);(a=A(e,(v=o,m=0<(y=i).length,x=0<v.length,r=function(e,t,n,r,i){var o,a,s,u=0,l="0",c=e&&[],f=[],p=w,d=e||x&&b.find.TAG("*",i),h=k+=null==p?1:Math.random()||.1,g=d.length;for(i&&(w=t==C||t||i);l!==g&&null!=(o=d[l]);l++){if(x&&o){a=0,t||o.ownerDocument==C||(T(o),n=!E);while(s=v[a++])if(s(o,t||C,n)){r.push(o);break}i&&(k=h)}m&&((o=!s&&o)&&u--,e&&c.push(o))}if(u+=l,m&&l!==u){a=0;while(s=y[a++])s(c,f,t,n);if(e){if(0<u)while(l--)c[l]||f[l]||(f[l]=q.call(r));f=Te(f)}H.apply(r,f),i&&!e&&0<f.length&&1<u+y.length&&se.uniqueSort(r)}return i&&(k=h,w=p),c},m?le(r):r))).selector=e}return a},g=se.select=function(e,t,n,r){var i,o,a,s,u,l="function"==typeof e&&e,c=!r&&h(e=l.selector||e);if(n=n||[],1===c.length){if(2<(o=c[0]=c[0].slice(0)).length&&"ID"===(a=o[0]).type&&9===t.nodeType&&E&&b.relative[o[1].type]){if(!(t=(b.find.ID(a.matches[0].replace(te,ne),t)||[])[0]))return n;l&&(t=t.parentNode),e=e.slice(o.shift().value.length)}i=G.needsContext.test(e)?0:o.length;while(i--){if(a=o[i],b.relative[s=a.type])break;if((u=b.find[s])&&(r=u(a.matches[0].replace(te,ne),ee.test(o[0].type)&&ye(t.parentNode)||t))){if(o.splice(i,1),!(e=r.length&&xe(o)))return H.apply(n,r),n;break}}}return(l||f(e,c))(r,t,!E,n,!t||ee.test(e)&&ye(t.parentNode)||t),n},d.sortStable=S.split("").sort(j).join("")===S,d.detectDuplicates=!!l,T(),d.sortDetached=ce(function(e){return 1&e.compareDocumentPosition(C.createElement("fieldset"))}),ce(function(e){return e.innerHTML="<a href='#'></a>","#"===e.firstChild.getAttribute("href")})||fe("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),d.attributes&&ce(function(e){return e.innerHTML="<input/>",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||fe("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),ce(function(e){return null==e.getAttribute("disabled")})||fe(R,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),se}(C);S.find=d,S.expr=d.selectors,S.expr[":"]=S.expr.pseudos,S.uniqueSort=S.unique=d.uniqueSort,S.text=d.getText,S.isXMLDoc=d.isXML,S.contains=d.contains,S.escapeSelector=d.escape;var h=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&S(e).is(n))break;r.push(e)}return r},T=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},k=S.expr.match.needsContext;function A(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var N=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1<i.call(n,e)!==r}):S.filter(n,e,r)}S.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?S.find.matchesSelector(r,e)?[r]:[]:S.find.matches(e,S.grep(t,function(e){return 1===e.nodeType}))},S.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(S(e).filter(function(){for(t=0;t<r;t++)if(S.contains(i[t],this))return!0}));for(n=this.pushStack([]),t=0;t<r;t++)S.find(e,i[t],n);return 1<r?S.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&k.test(e)?S(e):e||[],!1).length}});var D,q=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e<n;e++)if(S.contains(this,t[e]))return!0})},closest:function(e,t){var n,r=0,i=this.length,o=[],a="string"!=typeof e&&S(e);if(!k.test(e))for(;r<i;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(n.nodeType<11&&(a?-1<a.index(n):1===n.nodeType&&S.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(1<o.length?S.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?i.call(S(e),this[0]):i.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(S.uniqueSort(S.merge(this.get(),S(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),S.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return h(e,"parentNode")},parentsUntil:function(e,t,n){return h(e,"parentNode",n)},next:function(e){return O(e,"nextSibling")},prev:function(e){return O(e,"previousSibling")},nextAll:function(e){return h(e,"nextSibling")},prevAll:function(e){return h(e,"previousSibling")},nextUntil:function(e,t,n){return h(e,"nextSibling",n)},prevUntil:function(e,t,n){return h(e,"previousSibling",n)},siblings:function(e){return T((e.parentNode||{}).firstChild,e)},children:function(e){return T(e.firstChild)},contents:function(e){return null!=e.contentDocument&&r(e.contentDocument)?e.contentDocument:(A(e,"template")&&(e=e.content||e),S.merge([],e.childNodes))}},function(r,i){S.fn[r]=function(e,t){var n=S.map(this,i,e);return"Until"!==r.slice(-5)&&(t=e),t&&"string"==typeof t&&(n=S.filter(t,n)),1<this.length&&(H[r]||S.uniqueSort(n),L.test(r)&&n.reverse()),this.pushStack(n)}});var P=/[^\x20\t\r\n\f]+/g;function R(e){return e}function M(e){throw e}function I(e,t,n,r){var i;try{e&&m(i=e.promise)?i.call(e).done(t).fail(n):e&&m(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}S.Callbacks=function(r){var e,n;r="string"==typeof r?(e=r,n={},S.each(e.match(P)||[],function(e,t){n[t]=!0}),n):S.extend({},r);var i,t,o,a,s=[],u=[],l=-1,c=function(){for(a=a||r.once,o=i=!0;u.length;l=-1){t=u.shift();while(++l<s.length)!1===s[l].apply(t[0],t[1])&&r.stopOnFalse&&(l=s.length,t=!1)}r.memory||(t=!1),i=!1,a&&(s=t?[]:"")},f={add:function(){return s&&(t&&!i&&(l=s.length-1,u.push(t)),function n(e){S.each(e,function(e,t){m(t)?r.unique&&f.has(t)||s.push(t):t&&t.length&&"string"!==w(t)&&n(t)})}(arguments),t&&!i&&c()),this},remove:function(){return S.each(arguments,function(e,t){var n;while(-1<(n=S.inArray(t,s,n)))s.splice(n,1),n<=l&&l--}),this},has:function(e){return e?-1<S.inArray(e,s):0<s.length},empty:function(){return s&&(s=[]),this},disable:function(){return a=u=[],s=t="",this},disabled:function(){return!s},lock:function(){return a=u=[],t||i||(s=t=""),this},locked:function(){return!!a},fireWith:function(e,t){return a||(t=[e,(t=t||[]).slice?t.slice():t],u.push(t),i||c()),this},fire:function(){return f.fireWith(this,arguments),this},fired:function(){return!!o}};return f},S.extend({Deferred:function(e){var o=[["notify","progress",S.Callbacks("memory"),S.Callbacks("memory"),2],["resolve","done",S.Callbacks("once memory"),S.Callbacks("once memory"),0,"resolved"],["reject","fail",S.Callbacks("once memory"),S.Callbacks("once memory"),1,"rejected"]],i="pending",a={state:function(){return i},always:function(){return s.done(arguments).fail(arguments),this},"catch":function(e){return a.then(null,e)},pipe:function(){var i=arguments;return S.Deferred(function(r){S.each(o,function(e,t){var n=m(i[t[4]])&&i[t[4]];s[t[1]](function(){var e=n&&n.apply(this,arguments);e&&m(e.promise)?e.promise().progress(r.notify).done(r.resolve).fail(r.reject):r[t[0]+"With"](this,n?[e]:arguments)})}),i=null}).promise()},then:function(t,n,r){var u=0;function l(i,o,a,s){return function(){var n=this,r=arguments,e=function(){var e,t;if(!(i<u)){if((e=a.apply(n,r))===o.promise())throw new TypeError("Thenable self-resolution");t=e&&("object"==typeof e||"function"==typeof e)&&e.then,m(t)?s?t.call(e,l(u,o,R,s),l(u,o,M,s)):(u++,t.call(e,l(u,o,R,s),l(u,o,M,s),l(u,o,R,o.notifyWith))):(a!==R&&(n=void 0,r=[e]),(s||o.resolveWith)(n,r))}},t=s?e:function(){try{e()}catch(e){S.Deferred.exceptionHook&&S.Deferred.exceptionHook(e,t.stackTrace),u<=i+1&&(a!==M&&(n=void 0,r=[e]),o.rejectWith(n,r))}};i?t():(S.Deferred.getStackHook&&(t.stackTrace=S.Deferred.getStackHook()),C.setTimeout(t))}}return S.Deferred(function(e){o[0][3].add(l(0,e,m(r)?r:R,e.notifyWith)),o[1][3].add(l(0,e,m(t)?t:R)),o[2][3].add(l(0,e,m(n)?n:M))}).promise()},promise:function(e){return null!=e?S.extend(e,a):a}},s={};return S.each(o,function(e,t){var n=t[2],r=t[5];a[t[1]]=n.add,r&&n.add(function(){i=r},o[3-e][2].disable,o[3-e][3].disable,o[0][2].lock,o[0][3].lock),n.add(t[3].fire),s[t[0]]=function(){return s[t[0]+"With"](this===s?void 0:this,arguments),this},s[t[0]+"With"]=n.fireWith}),a.promise(s),e&&e.call(s,s),s},when:function(e){var n=arguments.length,t=n,r=Array(t),i=s.call(arguments),o=S.Deferred(),a=function(t){return function(e){r[t]=this,i[t]=1<arguments.length?s.call(arguments):e,--n||o.resolveWith(r,i)}};if(n<=1&&(I(e,o.done(a(t)).resolve,o.reject,!n),"pending"===o.state()||m(i[t]&&i[t].then)))return o.then();while(t--)I(i[t],a(t),o.reject);return o.promise()}});var W=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;S.Deferred.exceptionHook=function(e,t){C.console&&C.console.warn&&e&&W.test(e.name)&&C.console.warn("jQuery.Deferred exception: "+e.message,e.stack,t)},S.readyException=function(e){C.setTimeout(function(){throw e})};var F=S.Deferred();function B(){E.removeEventListener("DOMContentLoaded",B),C.removeEventListener("load",B),S.ready()}S.fn.ready=function(e){return F.then(e)["catch"](function(e){S.readyException(e)}),this},S.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--S.readyWait:S.isReady)||(S.isReady=!0)!==e&&0<--S.readyWait||F.resolveWith(E,[S])}}),S.ready.then=F.then,"complete"===E.readyState||"loading"!==E.readyState&&!E.documentElement.doScroll?C.setTimeout(S.ready):(E.addEventListener("DOMContentLoaded",B),C.addEventListener("load",B));var $=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===w(n))for(s in i=!0,n)$(e,t,s,n[s],!0,o,a);else if(void 0!==r&&(i=!0,m(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(S(e),n)})),t))for(;s<u;s++)t(e[s],n,a?r:r.call(e[s],s,t(e[s],n)));return i?e:l?t.call(e):u?t(e[0],n):o},_=/^-ms-/,z=/-([a-z])/g;function U(e,t){return t.toUpperCase()}function X(e){return e.replace(_,"ms-").replace(z,U)}var V=function(e){return 1===e.nodeType||9===e.nodeType||!+e.nodeType};function G(){this.expando=S.expando+G.uid++}G.uid=1,G.prototype={cache:function(e){var t=e[this.expando];return t||(t={},V(e)&&(e.nodeType?e[this.expando]=t:Object.defineProperty(e,this.expando,{value:t,configurable:!0}))),t},set:function(e,t,n){var r,i=this.cache(e);if("string"==typeof t)i[X(t)]=n;else for(r in t)i[X(r)]=t[r];return i},get:function(e,t){return void 0===t?this.cache(e):e[this.expando]&&e[this.expando][X(t)]},access:function(e,t,n){return void 0===t||t&&"string"==typeof t&&void 0===n?this.get(e,t):(this.set(e,t,n),void 0!==n?n:t)},remove:function(e,t){var n,r=e[this.expando];if(void 0!==r){if(void 0!==t){n=(t=Array.isArray(t)?t.map(X):(t=X(t))in r?[t]:t.match(P)||[]).length;while(n--)delete r[t[n]]}(void 0===t||S.isEmptyObject(r))&&(e.nodeType?e[this.expando]=void 0:delete e[this.expando])}},hasData:function(e){var t=e[this.expando];return void 0!==t&&!S.isEmptyObject(t)}};var Y=new G,Q=new G,J=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,K=/[A-Z]/g;function Z(e,t,n){var r,i;if(void 0===n&&1===e.nodeType)if(r="data-"+t.replace(K,"-$&").toLowerCase(),"string"==typeof(n=e.getAttribute(r))){try{n="true"===(i=n)||"false"!==i&&("null"===i?null:i===+i+""?+i:J.test(i)?JSON.parse(i):i)}catch(e){}Q.set(e,t,n)}else n=void 0;return n}S.extend({hasData:function(e){return Q.hasData(e)||Y.hasData(e)},data:function(e,t,n){return Q.access(e,t,n)},removeData:function(e,t){Q.remove(e,t)},_data:function(e,t,n){return Y.access(e,t,n)},_removeData:function(e,t){Y.remove(e,t)}}),S.fn.extend({data:function(n,e){var t,r,i,o=this[0],a=o&&o.attributes;if(void 0===n){if(this.length&&(i=Q.get(o),1===o.nodeType&&!Y.get(o,"hasDataAttrs"))){t=a.length;while(t--)a[t]&&0===(r=a[t].name).indexOf("data-")&&(r=X(r.slice(5)),Z(o,r,i[r]));Y.set(o,"hasDataAttrs",!0)}return i}return"object"==typeof n?this.each(function(){Q.set(this,n)}):$(this,function(e){var t;if(o&&void 0===e)return void 0!==(t=Q.get(o,n))?t:void 0!==(t=Z(o,n))?t:void 0;this.each(function(){Q.set(this,n,e)})},null,e,1<arguments.length,null,!0)},removeData:function(e){return this.each(function(){Q.remove(this,e)})}}),S.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=Y.get(e,t),n&&(!r||Array.isArray(n)?r=Y.access(e,t,S.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=S.queue(e,t),r=n.length,i=n.shift(),o=S._queueHooks(e,t);"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,function(){S.dequeue(e,t)},o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return Y.get(e,n)||Y.access(e,n,{empty:S.Callbacks("once memory").add(function(){Y.remove(e,[t+"queue",n])})})}}),S.fn.extend({queue:function(t,n){var e=2;return"string"!=typeof t&&(n=t,t="fx",e--),arguments.length<e?S.queue(this[0],t):void 0===n?this:this.each(function(){var e=S.queue(this,t,n);S._queueHooks(this,t),"fx"===t&&"inprogress"!==e[0]&&S.dequeue(this,t)})},dequeue:function(e){return this.each(function(){S.dequeue(this,e)})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,t){var n,r=1,i=S.Deferred(),o=this,a=this.length,s=function(){--r||i.resolveWith(o,[o])};"string"!=typeof e&&(t=e,e=void 0),e=e||"fx";while(a--)(n=Y.get(o[a],e+"queueHooks"))&&n.empty&&(r++,n.empty.add(s));return s(),i.promise(t)}});var ee=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,te=new RegExp("^(?:([+-])=|)("+ee+")([a-z%]*)$","i"),ne=["Top","Right","Bottom","Left"],re=E.documentElement,ie=function(e){return S.contains(e.ownerDocument,e)},oe={composed:!0};re.getRootNode&&(ie=function(e){return S.contains(e.ownerDocument,e)||e.getRootNode(oe)===e.ownerDocument});var ae=function(e,t){return"none"===(e=t||e).style.display||""===e.style.display&&ie(e)&&"none"===S.css(e,"display")};function se(e,t,n,r){var i,o,a=20,s=r?function(){return r.cur()}:function(){return S.css(e,t,"")},u=s(),l=n&&n[3]||(S.cssNumber[t]?"":"px"),c=e.nodeType&&(S.cssNumber[t]||"px"!==l&&+u)&&te.exec(S.css(e,t));if(c&&c[3]!==l){u/=2,l=l||c[3],c=+u||1;while(a--)S.style(e,t,c+l),(1-o)*(1-(o=s()/u||.5))<=0&&(a=0),c/=o;c*=2,S.style(e,t,c+l),n=n||[]}return n&&(c=+c||+u||0,i=n[1]?c+(n[1]+1)*n[2]:+n[2],r&&(r.unit=l,r.start=c,r.end=i)),i}var ue={};function le(e,t){for(var n,r,i,o,a,s,u,l=[],c=0,f=e.length;c<f;c++)(r=e[c]).style&&(n=r.style.display,t?("none"===n&&(l[c]=Y.get(r,"display")||null,l[c]||(r.style.display="")),""===r.style.display&&ae(r)&&(l[c]=(u=a=o=void 0,a=(i=r).ownerDocument,s=i.nodeName,(u=ue[s])||(o=a.body.appendChild(a.createElement(s)),u=S.css(o,"display"),o.parentNode.removeChild(o),"none"===u&&(u="block"),ue[s]=u)))):"none"!==n&&(l[c]="none",Y.set(r,"display",n)));for(c=0;c<f;c++)null!=l[c]&&(e[c].style.display=l[c]);return e}S.fn.extend({show:function(){return le(this,!0)},hide:function(){return le(this)},toggle:function(e){return"boolean"==typeof e?e?this.show():this.hide():this.each(function(){ae(this)?S(this).show():S(this).hide()})}});var ce,fe,pe=/^(?:checkbox|radio)$/i,de=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="<textarea>x</textarea>",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="<option></option>",y.option=!!ce.lastChild;var ge={thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n<r;n++)Y.set(e[n],"globalEval",!t||Y.get(t[n],"globalEval"))}ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td,y.option||(ge.optgroup=ge.option=[1,"<select multiple='multiple'>","</select>"]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d<h;d++)if((o=e[d])||0===o)if("object"===w(o))S.merge(p,o.nodeType?[o]:o);else if(me.test(o)){a=a||f.appendChild(t.createElement("div")),s=(de.exec(o)||["",""])[1].toLowerCase(),u=ge[s]||ge._default,a.innerHTML=u[1]+S.htmlPrefilter(o)+u[2],c=u[0];while(c--)a=a.lastChild;S.merge(p,a.childNodes),(a=f.firstChild).textContent=""}else p.push(t.createTextNode(o));f.textContent="",d=0;while(o=p[d++])if(r&&-1<S.inArray(o,r))i&&i.push(o);else if(l=ie(o),a=ve(f.appendChild(o),"script"),l&&ye(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}var be=/^([^.]*)(?:\.(.+)|)/;function we(){return!0}function Te(){return!1}function Ce(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ee(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ee(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Te;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return S().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=S.guid++)),e.each(function(){S.event.add(this,t,i,r,n)})}function Se(e,i,o){o?(Y.set(e,i,!1),S.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Y.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(S.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Y.set(this,i,r),t=o(this,i),this[i](),r!==(n=Y.get(this,i))||t?Y.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n&&n.value}else r.length&&(Y.set(this,i,{value:S.event.trigger(S.extend(r[0],S.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Y.get(e,i)&&S.event.add(e,i,we)}S.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Y.get(t);if(V(t)){n.handler&&(n=(o=n).handler,i=o.selector),i&&S.find.matchesSelector(re,i),n.guid||(n.guid=S.guid++),(u=v.events)||(u=v.events=Object.create(null)),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof S&&S.event.triggered!==e.type?S.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(P)||[""]).length;while(l--)d=g=(s=be.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=S.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=S.event.special[d]||{},c=S.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&S.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),S.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Y.hasData(e)&&Y.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(P)||[""]).length;while(l--)if(d=g=(s=be.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=S.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||S.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)S.event.remove(e,d+t[l],n,r,!0);S.isEmptyObject(u)&&Y.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=new Array(arguments.length),u=S.event.fix(e),l=(Y.get(this,"events")||Object.create(null))[u.type]||[],c=S.event.special[u.type]||{};for(s[0]=u,t=1;t<arguments.length;t++)s[t]=arguments[t];if(u.delegateTarget=this,!c.preDispatch||!1!==c.preDispatch.call(this,u)){a=S.event.handlers.call(this,u,l),t=0;while((i=a[t++])&&!u.isPropagationStopped()){u.currentTarget=i.elem,n=0;while((o=i.handlers[n++])&&!u.isImmediatePropagationStopped())u.rnamespace&&!1!==o.namespace&&!u.rnamespace.test(o.namespace)||(u.handleObj=o,u.data=o.data,void 0!==(r=((S.event.special[o.origType]||{}).handle||o.handler).apply(i.elem,s))&&!1===(u.result=r)&&(u.preventDefault(),u.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,u),u.result}},handlers:function(e,t){var n,r,i,o,a,s=[],u=t.delegateCount,l=e.target;if(u&&l.nodeType&&!("click"===e.type&&1<=e.button))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n<u;n++)void 0===a[i=(r=t[n]).selector+" "]&&(a[i]=r.needsContext?-1<S(i,this).index(l):S.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}return l=this,u<t.length&&s.push({elem:l,handlers:t.slice(u)}),s},addProp:function(t,e){Object.defineProperty(S.Event.prototype,t,{enumerable:!0,configurable:!0,get:m(e)?function(){if(this.originalEvent)return e(this.originalEvent)}:function(){if(this.originalEvent)return this.originalEvent[t]},set:function(e){Object.defineProperty(this,t,{enumerable:!0,configurable:!0,writable:!0,value:e})}})},fix:function(e){return e[S.expando]?e:new S.Event(e)},special:{load:{noBubble:!0},click:{setup:function(e){var t=this||e;return pe.test(t.type)&&t.click&&A(t,"input")&&Se(t,"click",we),!1},trigger:function(e){var t=this||e;return pe.test(t.type)&&t.click&&A(t,"input")&&Se(t,"click"),!0},_default:function(e){var t=e.target;return pe.test(t.type)&&t.click&&A(t,"input")&&Y.get(t,"click")||A(t,"a")}},beforeunload:{postDispatch:function(e){void 0!==e.result&&e.originalEvent&&(e.originalEvent.returnValue=e.result)}}}},S.removeEvent=function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n)},S.Event=function(e,t){if(!(this instanceof S.Event))return new S.Event(e,t);e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||void 0===e.defaultPrevented&&!1===e.returnValue?we:Te,this.target=e.target&&3===e.target.nodeType?e.target.parentNode:e.target,this.currentTarget=e.currentTarget,this.relatedTarget=e.relatedTarget):this.type=e,t&&S.extend(this,t),this.timeStamp=e&&e.timeStamp||Date.now(),this[S.expando]=!0},S.Event.prototype={constructor:S.Event,isDefaultPrevented:Te,isPropagationStopped:Te,isImmediatePropagationStopped:Te,isSimulated:!1,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=we,e&&!this.isSimulated&&e.preventDefault()},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=we,e&&!this.isSimulated&&e.stopPropagation()},stopImmediatePropagation:function(){var e=this.originalEvent;this.isImmediatePropagationStopped=we,e&&!this.isSimulated&&e.stopImmediatePropagation(),this.stopPropagation()}},S.each({altKey:!0,bubbles:!0,cancelable:!0,changedTouches:!0,ctrlKey:!0,detail:!0,eventPhase:!0,metaKey:!0,pageX:!0,pageY:!0,shiftKey:!0,view:!0,"char":!0,code:!0,charCode:!0,key:!0,keyCode:!0,button:!0,buttons:!0,clientX:!0,clientY:!0,offsetX:!0,offsetY:!0,pointerId:!0,pointerType:!0,screenX:!0,screenY:!0,targetTouches:!0,toElement:!0,touches:!0,which:!0},S.event.addProp),S.each({focus:"focusin",blur:"focusout"},function(e,t){S.event.special[e]={setup:function(){return Se(this,e,Ce),!1},trigger:function(){return Se(this,e),!0},_default:function(){return!0},delegateType:t}}),S.each({mouseenter:"mouseover",mouseleave:"mouseout",pointerenter:"pointerover",pointerleave:"pointerout"},function(e,i){S.event.special[e]={delegateType:i,bindType:i,handle:function(e){var t,n=e.relatedTarget,r=e.handleObj;return n&&(n===this||S.contains(this,n))||(e.type=r.origType,t=r.handler.apply(this,arguments),e.type=i),t}}}),S.fn.extend({on:function(e,t,n,r){return Ee(this,e,t,n,r)},one:function(e,t,n,r){return Ee(this,e,t,n,r,1)},off:function(e,t,n){var r,i;if(e&&e.preventDefault&&e.handleObj)return r=e.handleObj,S(e.delegateTarget).off(r.namespace?r.origType+"."+r.namespace:r.origType,r.selector,r.handler),this;if("object"==typeof e){for(i in e)this.off(i,t,e[i]);return this}return!1!==t&&"function"!=typeof t||(n=t,t=void 0),!1===n&&(n=Te),this.each(function(){S.event.remove(this,e,n,t)})}});var ke=/<script|<style|<link/i,Ae=/checked\s*(?:[^=]|=\s*.checked.)/i,Ne=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n<r;n++)S.event.add(t,i,s[i][n]);Q.hasData(e)&&(o=Q.access(e),a=S.extend({},o),Q.set(t,a))}}function He(n,r,i,o){r=g(r);var e,t,a,s,u,l,c=0,f=n.length,p=f-1,d=r[0],h=m(d);if(h||1<f&&"string"==typeof d&&!y.checkClone&&Ae.test(d))return n.each(function(e){var t=n.eq(e);h&&(r[0]=d.call(this,e,t.html())),He(t,r,i,o)});if(f&&(t=(e=xe(r,n[0].ownerDocument,!1,n,o)).firstChild,1===e.childNodes.length&&(e=t),t||o)){for(s=(a=S.map(ve(e,"script"),De)).length;c<f;c++)u=e,c!==p&&(u=S.clone(u,!0,!0),s&&S.merge(a,ve(u,"script"))),i.call(n[c],u,c);if(s)for(l=a[a.length-1].ownerDocument,S.map(a,qe),c=0;c<s;c++)u=a[c],he.test(u.type||"")&&!Y.access(u,"globalEval")&&S.contains(l,u)&&(u.src&&"module"!==(u.type||"").toLowerCase()?S._evalUrl&&!u.noModule&&S._evalUrl(u.src,{nonce:u.nonce||u.getAttribute("nonce")},l):b(u.textContent.replace(Ne,""),u,l))}return n}function Oe(e,t,n){for(var r,i=t?S.filter(t,e):e,o=0;null!=(r=i[o]);o++)n||1!==r.nodeType||S.cleanData(ve(r)),r.parentNode&&(n&&ie(r)&&ye(ve(r,"script")),r.parentNode.removeChild(r));return e}S.extend({htmlPrefilter:function(e){return e},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=ie(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||S.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r<i;r++)s=o[r],u=a[r],void 0,"input"===(l=u.nodeName.toLowerCase())&&pe.test(s.type)?u.checked=s.checked:"input"!==l&&"textarea"!==l||(u.defaultValue=s.defaultValue);if(t)if(n)for(o=o||ve(e),a=a||ve(c),r=0,i=o.length;r<i;r++)Le(o[r],a[r]);else Le(e,c);return 0<(a=ve(c,"script")).length&&ye(a,!f&&ve(e,"script")),c},cleanData:function(e){for(var t,n,r,i=S.event.special,o=0;void 0!==(n=e[o]);o++)if(V(n)){if(t=n[Y.expando]){if(t.events)for(r in t.events)i[r]?S.event.remove(n,r):S.removeEvent(n,r,t.handle);n[Y.expando]=void 0}n[Q.expando]&&(n[Q.expando]=void 0)}}}),S.fn.extend({detach:function(e){return Oe(this,e,!0)},remove:function(e){return Oe(this,e)},text:function(e){return $(this,function(e){return void 0===e?S.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return He(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||je(this,e).appendChild(e)})},prepend:function(){return He(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=je(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return He(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return He(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(S.cleanData(ve(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return S.clone(this,e,t)})},html:function(e){return $(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!ke.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=S.htmlPrefilter(e);try{for(;n<r;n++)1===(t=this[n]||{}).nodeType&&(S.cleanData(ve(t,!1)),t.innerHTML=e);t=0}catch(e){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var n=[];return He(this,arguments,function(e){var t=this.parentNode;S.inArray(this,n)<0&&(S.cleanData(ve(this)),t&&t.replaceChild(e,this))},n)}}),S.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,a){S.fn[e]=function(e){for(var t,n=[],r=S(e),i=r.length-1,o=0;o<=i;o++)t=o===i?this:this.clone(!0),S(r[o])[a](t),u.apply(n,t.get());return this.pushStack(n)}});var Pe=new RegExp("^("+ee+")(?!px)[a-z%]+$","i"),Re=function(e){var t=e.ownerDocument.defaultView;return t&&t.opener||(t=C),t.getComputedStyle(e)},Me=function(e,t,n){var r,i,o={};for(i in t)o[i]=e.style[i],e.style[i]=t[i];for(i in r=n.call(e),t)e.style[i]=o[i];return r},Ie=new RegExp(ne.join("|"),"i");function We(e,t,n){var r,i,o,a,s=e.style;return(n=n||Re(e))&&(""!==(a=n.getPropertyValue(t)||n[t])||ie(e)||(a=S.style(e,t)),!y.pixelBoxStyles()&&Pe.test(a)&&Ie.test(t)&&(r=s.width,i=s.minWidth,o=s.maxWidth,s.minWidth=s.maxWidth=s.width=a,a=n.width,s.width=r,s.minWidth=i,s.maxWidth=o)),void 0!==a?a+"":a}function Fe(e,t){return{get:function(){if(!e())return(this.get=t).apply(this,arguments);delete this.get}}}!function(){function e(){if(l){u.style.cssText="position:absolute;left:-11111px;width:60px;margin-top:1px;padding:0;border:0",l.style.cssText="position:relative;display:block;box-sizing:border-box;overflow:scroll;margin:auto;border:1px;padding:1px;width:60%;top:1%",re.appendChild(u).appendChild(l);var e=C.getComputedStyle(l);n="1%"!==e.top,s=12===t(e.marginLeft),l.style.right="60%",o=36===t(e.right),r=36===t(e.width),l.style.position="absolute",i=12===t(l.offsetWidth/3),re.removeChild(u),l=null}}function t(e){return Math.round(parseFloat(e))}var n,r,i,o,a,s,u=E.createElement("div"),l=E.createElement("div");l.style&&(l.style.backgroundClip="content-box",l.cloneNode(!0).style.backgroundClip="",y.clearCloneStyle="content-box"===l.style.backgroundClip,S.extend(y,{boxSizingReliable:function(){return e(),r},pixelBoxStyles:function(){return e(),o},pixelPosition:function(){return e(),n},reliableMarginLeft:function(){return e(),s},scrollboxSize:function(){return e(),i},reliableTrDimensions:function(){var e,t,n,r;return null==a&&(e=E.createElement("table"),t=E.createElement("tr"),n=E.createElement("div"),e.style.cssText="position:absolute;left:-11111px;border-collapse:separate",t.style.cssText="border:1px solid",t.style.height="1px",n.style.height="9px",n.style.display="block",re.appendChild(e).appendChild(t).appendChild(n),r=C.getComputedStyle(t),a=parseInt(r.height,10)+parseInt(r.borderTopWidth,10)+parseInt(r.borderBottomWidth,10)===t.offsetHeight,re.removeChild(e)),a}}))}();var Be=["Webkit","Moz","ms"],$e=E.createElement("div").style,_e={};function ze(e){var t=S.cssProps[e]||_e[e];return t||(e in $e?e:_e[e]=function(e){var t=e[0].toUpperCase()+e.slice(1),n=Be.length;while(n--)if((e=Be[n]+t)in $e)return e}(e)||e)}var Ue=/^(none|table(?!-c[ea]).+)/,Xe=/^--/,Ve={position:"absolute",visibility:"hidden",display:"block"},Ge={letterSpacing:"0",fontWeight:"400"};function Ye(e,t,n){var r=te.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||"px"):t}function Qe(e,t,n,r,i,o){var a="width"===t?1:0,s=0,u=0;if(n===(r?"border":"content"))return 0;for(;a<4;a+=2)"margin"===n&&(u+=S.css(e,n+ne[a],!0,i)),r?("content"===n&&(u-=S.css(e,"padding"+ne[a],!0,i)),"margin"!==n&&(u-=S.css(e,"border"+ne[a]+"Width",!0,i))):(u+=S.css(e,"padding"+ne[a],!0,i),"padding"!==n?u+=S.css(e,"border"+ne[a]+"Width",!0,i):s+=S.css(e,"border"+ne[a]+"Width",!0,i));return!r&&0<=o&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))||0),u}function Je(e,t,n){var r=Re(e),i=(!y.boxSizingReliable()||n)&&"border-box"===S.css(e,"boxSizing",!1,r),o=i,a=We(e,t,r),s="offset"+t[0].toUpperCase()+t.slice(1);if(Pe.test(a)){if(!n)return a;a="auto"}return(!y.boxSizingReliable()&&i||!y.reliableTrDimensions()&&A(e,"tr")||"auto"===a||!parseFloat(a)&&"inline"===S.css(e,"display",!1,r))&&e.getClientRects().length&&(i="border-box"===S.css(e,"boxSizing",!1,r),(o=s in e)&&(a=e[s])),(a=parseFloat(a)||0)+Qe(e,t,n||(i?"border":"content"),o,r,a)+"px"}function Ke(e,t,n,r,i){return new Ke.prototype.init(e,t,n,r,i)}S.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=We(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=X(t),u=Xe.test(t),l=e.style;if(u||(t=ze(s)),a=S.cssHooks[t]||S.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"===(o=typeof n)&&(i=te.exec(n))&&i[1]&&(n=se(e,t,i),o="number"),null!=n&&n==n&&("number"!==o||u||(n+=i&&i[3]||(S.cssNumber[s]?"":"px")),y.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=X(t);return Xe.test(t)||(t=ze(s)),(a=S.cssHooks[t]||S.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=We(e,t,r)),"normal"===i&&t in Ge&&(i=Ge[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),S.each(["height","width"],function(e,u){S.cssHooks[u]={get:function(e,t,n){if(t)return!Ue.test(S.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?Je(e,u,n):Me(e,Ve,function(){return Je(e,u,n)})},set:function(e,t,n){var r,i=Re(e),o=!y.scrollboxSize()&&"absolute"===i.position,a=(o||n)&&"border-box"===S.css(e,"boxSizing",!1,i),s=n?Qe(e,u,n,a,i):0;return a&&o&&(s-=Math.ceil(e["offset"+u[0].toUpperCase()+u.slice(1)]-parseFloat(i[u])-Qe(e,u,"border",!1,i)-.5)),s&&(r=te.exec(t))&&"px"!==(r[3]||"px")&&(e.style[u]=t,t=S.css(e,u)),Ye(0,t,s)}}}),S.cssHooks.marginLeft=Fe(y.reliableMarginLeft,function(e,t){if(t)return(parseFloat(We(e,"marginLeft"))||e.getBoundingClientRect().left-Me(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),S.each({margin:"",padding:"",border:"Width"},function(i,o){S.cssHooks[i+o]={expand:function(e){for(var t=0,n={},r="string"==typeof e?e.split(" "):[e];t<4;t++)n[i+ne[t]+o]=r[t]||r[t-2]||r[0];return n}},"margin"!==i&&(S.cssHooks[i+o].set=Ye)}),S.fn.extend({css:function(e,t){return $(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=Re(e),i=t.length;a<i;a++)o[t[a]]=S.css(e,t[a],!1,r);return o}return void 0!==n?S.style(e,t,n):S.css(e,t)},e,t,1<arguments.length)}}),((S.Tween=Ke).prototype={constructor:Ke,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||S.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(S.cssNumber[n]?"":"px")},cur:function(){var e=Ke.propHooks[this.prop];return e&&e.get?e.get(this):Ke.propHooks._default.get(this)},run:function(e){var t,n=Ke.propHooks[this.prop];return this.options.duration?this.pos=t=S.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):Ke.propHooks._default.set(this),this}}).init.prototype=Ke.prototype,(Ke.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=S.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){S.fx.step[e.prop]?S.fx.step[e.prop](e):1!==e.elem.nodeType||!S.cssHooks[e.prop]&&null==e.elem.style[ze(e.prop)]?e.elem[e.prop]=e.now:S.style(e.elem,e.prop,e.now+e.unit)}}}).scrollTop=Ke.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},S.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},S.fx=Ke.prototype.init,S.fx.step={};var Ze,et,tt,nt,rt=/^(?:toggle|show|hide)$/,it=/queueHooks$/;function ot(){et&&(!1===E.hidden&&C.requestAnimationFrame?C.requestAnimationFrame(ot):C.setTimeout(ot,S.fx.interval),S.fx.tick())}function at(){return C.setTimeout(function(){Ze=void 0}),Ze=Date.now()}function st(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i["margin"+(n=ne[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function ut(e,t,n){for(var r,i=(lt.tweeners[t]||[]).concat(lt.tweeners["*"]),o=0,a=i.length;o<a;o++)if(r=i[o].call(n,t,e))return r}function lt(o,e,t){var n,a,r=0,i=lt.prefilters.length,s=S.Deferred().always(function(){delete u.elem}),u=function(){if(a)return!1;for(var e=Ze||at(),t=Math.max(0,l.startTime+l.duration-e),n=1-(t/l.duration||0),r=0,i=l.tweens.length;r<i;r++)l.tweens[r].run(n);return s.notifyWith(o,[l,n,t]),n<1&&i?t:(i||s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l]),!1)},l=s.promise({elem:o,props:S.extend({},e),opts:S.extend(!0,{specialEasing:{},easing:S.easing._default},t),originalProperties:e,originalOptions:t,startTime:Ze||at(),duration:t.duration,tweens:[],createTween:function(e,t){var n=S.Tween(o,l.opts,e,t,l.opts.specialEasing[e]||l.opts.easing);return l.tweens.push(n),n},stop:function(e){var t=0,n=e?l.tweens.length:0;if(a)return this;for(a=!0;t<n;t++)l.tweens[t].run(1);return e?(s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l,e])):s.rejectWith(o,[l,e]),this}}),c=l.props;for(!function(e,t){var n,r,i,o,a;for(n in e)if(i=t[r=X(n)],o=e[n],Array.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),(a=S.cssHooks[r])&&"expand"in a)for(n in o=a.expand(o),delete e[r],o)n in e||(e[n]=o[n],t[n]=i);else t[r]=i}(c,l.opts.specialEasing);r<i;r++)if(n=lt.prefilters[r].call(l,o,c,l.opts))return m(n.stop)&&(S._queueHooks(l.elem,l.opts.queue).stop=n.stop.bind(n)),n;return S.map(c,ut,l),m(l.opts.start)&&l.opts.start.call(o,l),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always),S.fx.timer(S.extend(u,{elem:o,anim:l,queue:l.opts.queue})),l}S.Animation=S.extend(lt,{tweeners:{"*":[function(e,t){var n=this.createTween(e,t);return se(n.elem,e,te.exec(t),n),n}]},tweener:function(e,t){m(e)?(t=e,e=["*"]):e=e.match(P);for(var n,r=0,i=e.length;r<i;r++)n=e[r],lt.tweeners[n]=lt.tweeners[n]||[],lt.tweeners[n].unshift(t)},prefilters:[function(e,t,n){var r,i,o,a,s,u,l,c,f="width"in t||"height"in t,p=this,d={},h=e.style,g=e.nodeType&&ae(e),v=Y.get(e,"fxshow");for(r in n.queue||(null==(a=S._queueHooks(e,"fx")).unqueued&&(a.unqueued=0,s=a.empty.fire,a.empty.fire=function(){a.unqueued||s()}),a.unqueued++,p.always(function(){p.always(function(){a.unqueued--,S.queue(e,"fx").length||a.empty.fire()})})),t)if(i=t[r],rt.test(i)){if(delete t[r],o=o||"toggle"===i,i===(g?"hide":"show")){if("show"!==i||!v||void 0===v[r])continue;g=!0}d[r]=v&&v[r]||S.style(e,r)}if((u=!S.isEmptyObject(t))||!S.isEmptyObject(d))for(r in f&&1===e.nodeType&&(n.overflow=[h.overflow,h.overflowX,h.overflowY],null==(l=v&&v.display)&&(l=Y.get(e,"display")),"none"===(c=S.css(e,"display"))&&(l?c=l:(le([e],!0),l=e.style.display||l,c=S.css(e,"display"),le([e]))),("inline"===c||"inline-block"===c&&null!=l)&&"none"===S.css(e,"float")&&(u||(p.done(function(){h.display=l}),null==l&&(c=h.display,l="none"===c?"":c)),h.display="inline-block")),n.overflow&&(h.overflow="hidden",p.always(function(){h.overflow=n.overflow[0],h.overflowX=n.overflow[1],h.overflowY=n.overflow[2]})),u=!1,d)u||(v?"hidden"in v&&(g=v.hidden):v=Y.access(e,"fxshow",{display:l}),o&&(v.hidden=!g),g&&le([e],!0),p.done(function(){for(r in g||le([e]),Y.remove(e,"fxshow"),d)S.style(e,r,d[r])})),u=ut(g?v[r]:0,r,p),r in v||(v[r]=u.start,g&&(u.end=u.start,u.start=0))}],prefilter:function(e,t){t?lt.prefilters.unshift(e):lt.prefilters.push(e)}}),S.speed=function(e,t,n){var r=e&&"object"==typeof e?S.extend({},e):{complete:n||!n&&t||m(e)&&e,duration:e,easing:n&&t||t&&!m(t)&&t};return S.fx.off?r.duration=0:"number"!=typeof r.duration&&(r.duration in S.fx.speeds?r.duration=S.fx.speeds[r.duration]:r.duration=S.fx.speeds._default),null!=r.queue&&!0!==r.queue||(r.queue="fx"),r.old=r.complete,r.complete=function(){m(r.old)&&r.old.call(this),r.queue&&S.dequeue(this,r.queue)},r},S.fn.extend({fadeTo:function(e,t,n,r){return this.filter(ae).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(t,e,n,r){var i=S.isEmptyObject(t),o=S.speed(e,n,r),a=function(){var e=lt(this,S.extend({},t),o);(i||Y.get(this,"finish"))&&e.stop(!0)};return a.finish=a,i||!1===o.queue?this.each(a):this.queue(o.queue,a)},stop:function(i,e,o){var a=function(e){var t=e.stop;delete e.stop,t(o)};return"string"!=typeof i&&(o=e,e=i,i=void 0),e&&this.queue(i||"fx",[]),this.each(function(){var e=!0,t=null!=i&&i+"queueHooks",n=S.timers,r=Y.get(this);if(t)r[t]&&r[t].stop&&a(r[t]);else for(t in r)r[t]&&r[t].stop&&it.test(t)&&a(r[t]);for(t=n.length;t--;)n[t].elem!==this||null!=i&&n[t].queue!==i||(n[t].anim.stop(o),e=!1,n.splice(t,1));!e&&o||S.dequeue(this,i)})},finish:function(a){return!1!==a&&(a=a||"fx"),this.each(function(){var e,t=Y.get(this),n=t[a+"queue"],r=t[a+"queueHooks"],i=S.timers,o=n?n.length:0;for(t.finish=!0,S.queue(this,a,[]),r&&r.stop&&r.stop.call(this,!0),e=i.length;e--;)i[e].elem===this&&i[e].queue===a&&(i[e].anim.stop(!0),i.splice(e,1));for(e=0;e<o;e++)n[e]&&n[e].finish&&n[e].finish.call(this);delete t.finish})}}),S.each(["toggle","show","hide"],function(e,r){var i=S.fn[r];S.fn[r]=function(e,t,n){return null==e||"boolean"==typeof e?i.apply(this,arguments):this.animate(st(r,!0),e,t,n)}}),S.each({slideDown:st("show"),slideUp:st("hide"),slideToggle:st("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,r){S.fn[e]=function(e,t,n){return this.animate(r,e,t,n)}}),S.timers=[],S.fx.tick=function(){var e,t=0,n=S.timers;for(Ze=Date.now();t<n.length;t++)(e=n[t])()||n[t]!==e||n.splice(t--,1);n.length||S.fx.stop(),Ze=void 0},S.fx.timer=function(e){S.timers.push(e),S.fx.start()},S.fx.interval=13,S.fx.start=function(){et||(et=!0,ot())},S.fx.stop=function(){et=null},S.fx.speeds={slow:600,fast:200,_default:400},S.fn.delay=function(r,e){return r=S.fx&&S.fx.speeds[r]||r,e=e||"fx",this.queue(e,function(e,t){var n=C.setTimeout(e,r);t.stop=function(){C.clearTimeout(n)}})},tt=E.createElement("input"),nt=E.createElement("select").appendChild(E.createElement("option")),tt.type="checkbox",y.checkOn=""!==tt.value,y.optSelected=nt.selected,(tt=E.createElement("input")).value="t",tt.type="radio",y.radioValue="t"===tt.value;var ct,ft=S.expr.attrHandle;S.fn.extend({attr:function(e,t){return $(this,S.attr,e,t,1<arguments.length)},removeAttr:function(e){return this.each(function(){S.removeAttr(this,e)})}}),S.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?S.prop(e,t,n):(1===o&&S.isXMLDoc(e)||(i=S.attrHooks[t.toLowerCase()]||(S.expr.match.bool.test(t)?ct:void 0)),void 0!==n?null===n?void S.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=S.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!y.radioValue&&"radio"===t&&A(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(P);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),ct={set:function(e,t,n){return!1===t?S.removeAttr(e,n):e.setAttribute(n,n),n}},S.each(S.expr.match.bool.source.match(/\w+/g),function(e,t){var a=ft[t]||S.find.attr;ft[t]=function(e,t,n){var r,i,o=t.toLowerCase();return n||(i=ft[o],ft[o]=r,r=null!=a(e,t,n)?o:null,ft[o]=i),r}});var pt=/^(?:input|select|textarea|button)$/i,dt=/^(?:a|area)$/i;function ht(e){return(e.match(P)||[]).join(" ")}function gt(e){return e.getAttribute&&e.getAttribute("class")||""}function vt(e){return Array.isArray(e)?e:"string"==typeof e&&e.match(P)||[]}S.fn.extend({prop:function(e,t){return $(this,S.prop,e,t,1<arguments.length)},removeProp:function(e){return this.each(function(){delete this[S.propFix[e]||e]})}}),S.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&S.isXMLDoc(e)||(t=S.propFix[t]||t,i=S.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=S.find.attr(e,"tabindex");return t?parseInt(t,10):pt.test(e.nodeName)||dt.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),y.optSelected||(S.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),S.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){S.propFix[this.toLowerCase()]=this}),S.fn.extend({addClass:function(t){var e,n,r,i,o,a,s,u=0;if(m(t))return this.each(function(e){S(this).addClass(t.call(this,e,gt(this)))});if((e=vt(t)).length)while(n=this[u++])if(i=gt(n),r=1===n.nodeType&&" "+ht(i)+" "){a=0;while(o=e[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=ht(r))&&n.setAttribute("class",s)}return this},removeClass:function(t){var e,n,r,i,o,a,s,u=0;if(m(t))return this.each(function(e){S(this).removeClass(t.call(this,e,gt(this)))});if(!arguments.length)return this.attr("class","");if((e=vt(t)).length)while(n=this[u++])if(i=gt(n),r=1===n.nodeType&&" "+ht(i)+" "){a=0;while(o=e[a++])while(-1<r.indexOf(" "+o+" "))r=r.replace(" "+o+" "," ");i!==(s=ht(r))&&n.setAttribute("class",s)}return this},toggleClass:function(i,t){var o=typeof i,a="string"===o||Array.isArray(i);return"boolean"==typeof t&&a?t?this.addClass(i):this.removeClass(i):m(i)?this.each(function(e){S(this).toggleClass(i.call(this,e,gt(this),t),t)}):this.each(function(){var e,t,n,r;if(a){t=0,n=S(this),r=vt(i);while(e=r[t++])n.hasClass(e)?n.removeClass(e):n.addClass(e)}else void 0!==i&&"boolean"!==o||((e=gt(this))&&Y.set(this,"__className__",e),this.setAttribute&&this.setAttribute("class",e||!1===i?"":Y.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&-1<(" "+ht(gt(n))+" ").indexOf(t))return!0;return!1}});var yt=/\r/g;S.fn.extend({val:function(n){var r,e,i,t=this[0];return arguments.length?(i=m(n),this.each(function(e){var t;1===this.nodeType&&(null==(t=i?n.call(this,e,S(this).val()):n)?t="":"number"==typeof t?t+="":Array.isArray(t)&&(t=S.map(t,function(e){return null==e?"":e+""})),(r=S.valHooks[this.type]||S.valHooks[this.nodeName.toLowerCase()])&&"set"in r&&void 0!==r.set(this,t,"value")||(this.value=t))})):t?(r=S.valHooks[t.type]||S.valHooks[t.nodeName.toLowerCase()])&&"get"in r&&void 0!==(e=r.get(t,"value"))?e:"string"==typeof(e=t.value)?e.replace(yt,""):null==e?"":e:void 0}}),S.extend({valHooks:{option:{get:function(e){var t=S.find.attr(e,"value");return null!=t?t:ht(S.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r<u;r++)if(((n=i[r]).selected||r===o)&&!n.disabled&&(!n.parentNode.disabled||!A(n.parentNode,"optgroup"))){if(t=S(n).val(),a)return t;s.push(t)}return s},set:function(e,t){var n,r,i=e.options,o=S.makeArray(t),a=i.length;while(a--)((r=i[a]).selected=-1<S.inArray(S.valHooks.option.get(r),o))&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),S.each(["radio","checkbox"],function(){S.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=-1<S.inArray(S(e).val(),t)}},y.checkOn||(S.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),y.focusin="onfocusin"in C;var mt=/^(?:focusinfocus|focusoutblur)$/,xt=function(e){e.stopPropagation()};S.extend(S.event,{trigger:function(e,t,n,r){var i,o,a,s,u,l,c,f,p=[n||E],d=v.call(e,"type")?e.type:e,h=v.call(e,"namespace")?e.namespace.split("."):[];if(o=f=a=n=n||E,3!==n.nodeType&&8!==n.nodeType&&!mt.test(d+S.event.triggered)&&(-1<d.indexOf(".")&&(d=(h=d.split(".")).shift(),h.sort()),u=d.indexOf(":")<0&&"on"+d,(e=e[S.expando]?e:new S.Event(d,"object"==typeof e&&e)).isTrigger=r?2:3,e.namespace=h.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=n),t=null==t?[e]:S.makeArray(t,[e]),c=S.event.special[d]||{},r||!c.trigger||!1!==c.trigger.apply(n,t))){if(!r&&!c.noBubble&&!x(n)){for(s=c.delegateType||d,mt.test(s+d)||(o=o.parentNode);o;o=o.parentNode)p.push(o),a=o;a===(n.ownerDocument||E)&&p.push(a.defaultView||a.parentWindow||C)}i=0;while((o=p[i++])&&!e.isPropagationStopped())f=o,e.type=1<i?s:c.bindType||d,(l=(Y.get(o,"events")||Object.create(null))[e.type]&&Y.get(o,"handle"))&&l.apply(o,t),(l=u&&o[u])&&l.apply&&V(o)&&(e.result=l.apply(o,t),!1===e.result&&e.preventDefault());return e.type=d,r||e.isDefaultPrevented()||c._default&&!1!==c._default.apply(p.pop(),t)||!V(n)||u&&m(n[d])&&!x(n)&&((a=n[u])&&(n[u]=null),S.event.triggered=d,e.isPropagationStopped()&&f.addEventListener(d,xt),n[d](),e.isPropagationStopped()&&f.removeEventListener(d,xt),S.event.triggered=void 0,a&&(n[u]=a)),e.result}},simulate:function(e,t,n){var r=S.extend(new S.Event,n,{type:e,isSimulated:!0});S.event.trigger(r,null,t)}}),S.fn.extend({trigger:function(e,t){return this.each(function(){S.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return S.event.trigger(e,t,n,!0)}}),y.focusin||S.each({focus:"focusin",blur:"focusout"},function(n,r){var i=function(e){S.event.simulate(r,e.target,S.event.fix(e))};S.event.special[r]={setup:function(){var e=this.ownerDocument||this.document||this,t=Y.access(e,r);t||e.addEventListener(n,i,!0),Y.access(e,r,(t||0)+1)},teardown:function(){var e=this.ownerDocument||this.document||this,t=Y.access(e,r)-1;t?Y.access(e,r,t):(e.removeEventListener(n,i,!0),Y.remove(e,r))}}});var bt=C.location,wt={guid:Date.now()},Tt=/\?/;S.parseXML=function(e){var t,n;if(!e||"string"!=typeof e)return null;try{t=(new C.DOMParser).parseFromString(e,"text/xml")}catch(e){}return n=t&&t.getElementsByTagName("parsererror")[0],t&&!n||S.error("Invalid XML: "+(n?S.map(n.childNodes,function(e){return e.textContent}).join("\n"):e)),t};var Ct=/\[\]$/,Et=/\r?\n/g,St=/^(?:submit|button|image|reset|file)$/i,kt=/^(?:input|select|textarea|keygen)/i;function At(n,e,r,i){var t;if(Array.isArray(e))S.each(e,function(e,t){r||Ct.test(n)?i(n,t):At(n+"["+("object"==typeof t&&null!=t?e:"")+"]",t,r,i)});else if(r||"object"!==w(e))i(n,e);else for(t in e)At(n+"["+t+"]",e[t],r,i)}S.param=function(e,t){var n,r=[],i=function(e,t){var n=m(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(null==e)return"";if(Array.isArray(e)||e.jquery&&!S.isPlainObject(e))S.each(e,function(){i(this.name,this.value)});else for(n in e)At(n,e[n],t,i);return r.join("&")},S.fn.extend({serialize:function(){return S.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=S.prop(this,"elements");return e?S.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!S(this).is(":disabled")&&kt.test(this.nodeName)&&!St.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=S(this).val();return null==n?null:Array.isArray(n)?S.map(n,function(e){return{name:t.name,value:e.replace(Et,"\r\n")}}):{name:t.name,value:n.replace(Et,"\r\n")}}).get()}});var Nt=/%20/g,jt=/#.*$/,Dt=/([?&])_=[^&]*/,qt=/^(.*?):[ \t]*([^\r\n]*)$/gm,Lt=/^(?:GET|HEAD)$/,Ht=/^\/\//,Ot={},Pt={},Rt="*/".concat("*"),Mt=E.createElement("a");function It(o){return function(e,t){"string"!=typeof e&&(t=e,e="*");var n,r=0,i=e.toLowerCase().match(P)||[];if(m(t))while(n=i[r++])"+"===n[0]?(n=n.slice(1)||"*",(o[n]=o[n]||[]).unshift(t)):(o[n]=o[n]||[]).push(t)}}function Wt(t,i,o,a){var s={},u=t===Pt;function l(e){var r;return s[e]=!0,S.each(t[e]||[],function(e,t){var n=t(i,o,a);return"string"!=typeof n||u||s[n]?u?!(r=n):void 0:(i.dataTypes.unshift(n),l(n),!1)}),r}return l(i.dataTypes[0])||!s["*"]&&l("*")}function Ft(e,t){var n,r,i=S.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&S.extend(!0,e,r),e}Mt.href=bt.href,S.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:bt.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(bt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Rt,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":S.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?Ft(Ft(e,S.ajaxSettings),t):Ft(S.ajaxSettings,e)},ajaxPrefilter:It(Ot),ajaxTransport:It(Pt),ajax:function(e,t){"object"==typeof e&&(t=e,e=void 0),t=t||{};var c,f,p,n,d,r,h,g,i,o,v=S.ajaxSetup({},t),y=v.context||v,m=v.context&&(y.nodeType||y.jquery)?S(y):S.event,x=S.Deferred(),b=S.Callbacks("once memory"),w=v.statusCode||{},a={},s={},u="canceled",T={readyState:0,getResponseHeader:function(e){var t;if(h){if(!n){n={};while(t=qt.exec(p))n[t[1].toLowerCase()+" "]=(n[t[1].toLowerCase()+" "]||[]).concat(t[2])}t=n[e.toLowerCase()+" "]}return null==t?null:t.join(", ")},getAllResponseHeaders:function(){return h?p:null},setRequestHeader:function(e,t){return null==h&&(e=s[e.toLowerCase()]=s[e.toLowerCase()]||e,a[e]=t),this},overrideMimeType:function(e){return null==h&&(v.mimeType=e),this},statusCode:function(e){var t;if(e)if(h)T.always(e[T.status]);else for(t in e)w[t]=[w[t],e[t]];return this},abort:function(e){var t=e||u;return c&&c.abort(t),l(0,t),this}};if(x.promise(T),v.url=((e||v.url||bt.href)+"").replace(Ht,bt.protocol+"//"),v.type=t.method||t.type||v.method||v.type,v.dataTypes=(v.dataType||"*").toLowerCase().match(P)||[""],null==v.crossDomain){r=E.createElement("a");try{r.href=v.url,r.href=r.href,v.crossDomain=Mt.protocol+"//"+Mt.host!=r.protocol+"//"+r.host}catch(e){v.crossDomain=!0}}if(v.data&&v.processData&&"string"!=typeof v.data&&(v.data=S.param(v.data,v.traditional)),Wt(Ot,v,t,T),h)return T;for(i in(g=S.event&&v.global)&&0==S.active++&&S.event.trigger("ajaxStart"),v.type=v.type.toUpperCase(),v.hasContent=!Lt.test(v.type),f=v.url.replace(jt,""),v.hasContent?v.data&&v.processData&&0===(v.contentType||"").indexOf("application/x-www-form-urlencoded")&&(v.data=v.data.replace(Nt,"+")):(o=v.url.slice(f.length),v.data&&(v.processData||"string"==typeof v.data)&&(f+=(Tt.test(f)?"&":"?")+v.data,delete v.data),!1===v.cache&&(f=f.replace(Dt,"$1"),o=(Tt.test(f)?"&":"?")+"_="+wt.guid+++o),v.url=f+o),v.ifModified&&(S.lastModified[f]&&T.setRequestHeader("If-Modified-Since",S.lastModified[f]),S.etag[f]&&T.setRequestHeader("If-None-Match",S.etag[f])),(v.data&&v.hasContent&&!1!==v.contentType||t.contentType)&&T.setRequestHeader("Content-Type",v.contentType),T.setRequestHeader("Accept",v.dataTypes[0]&&v.accepts[v.dataTypes[0]]?v.accepts[v.dataTypes[0]]+("*"!==v.dataTypes[0]?", "+Rt+"; q=0.01":""):v.accepts["*"]),v.headers)T.setRequestHeader(i,v.headers[i]);if(v.beforeSend&&(!1===v.beforeSend.call(y,T,v)||h))return T.abort();if(u="abort",b.add(v.complete),T.done(v.success),T.fail(v.error),c=Wt(Pt,v,t,T)){if(T.readyState=1,g&&m.trigger("ajaxSend",[T,v]),h)return T;v.async&&0<v.timeout&&(d=C.setTimeout(function(){T.abort("timeout")},v.timeout));try{h=!1,c.send(a,l)}catch(e){if(h)throw e;l(-1,e)}}else l(-1,"No Transport");function l(e,t,n,r){var i,o,a,s,u,l=t;h||(h=!0,d&&C.clearTimeout(d),c=void 0,p=r||"",T.readyState=0<e?4:0,i=200<=e&&e<300||304===e,n&&(s=function(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}(v,T,n)),!i&&-1<S.inArray("script",v.dataTypes)&&S.inArray("json",v.dataTypes)<0&&(v.converters["text script"]=function(){}),s=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}(v,s,T,i),i?(v.ifModified&&((u=T.getResponseHeader("Last-Modified"))&&(S.lastModified[f]=u),(u=T.getResponseHeader("etag"))&&(S.etag[f]=u)),204===e||"HEAD"===v.type?l="nocontent":304===e?l="notmodified":(l=s.state,o=s.data,i=!(a=s.error))):(a=l,!e&&l||(l="error",e<0&&(e=0))),T.status=e,T.statusText=(t||l)+"",i?x.resolveWith(y,[o,l,T]):x.rejectWith(y,[T,l,a]),T.statusCode(w),w=void 0,g&&m.trigger(i?"ajaxSuccess":"ajaxError",[T,v,i?o:a]),b.fireWith(y,[T,l]),g&&(m.trigger("ajaxComplete",[T,v]),--S.active||S.event.trigger("ajaxStop")))}return T},getJSON:function(e,t,n){return S.get(e,t,n,"json")},getScript:function(e,t){return S.get(e,void 0,t,"script")}}),S.each(["get","post"],function(e,i){S[i]=function(e,t,n,r){return m(t)&&(r=r||n,n=t,t=void 0),S.ajax(S.extend({url:e,type:i,dataType:r,data:t,success:n},S.isPlainObject(e)&&e))}}),S.ajaxPrefilter(function(e){var t;for(t in e.headers)"content-type"===t.toLowerCase()&&(e.contentType=e.headers[t]||"")}),S._evalUrl=function(e,t,n){return S.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(e){S.globalEval(e,t,n)}})},S.fn.extend({wrapAll:function(e){var t;return this[0]&&(m(e)&&(e=e.call(this[0])),t=S(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(n){return m(n)?this.each(function(e){S(this).wrapInner(n.call(this,e))}):this.each(function(){var e=S(this),t=e.contents();t.length?t.wrapAll(n):e.append(n)})},wrap:function(t){var n=m(t);return this.each(function(e){S(this).wrapAll(n?t.call(this,e):t)})},unwrap:function(e){return this.parent(e).not("body").each(function(){S(this).replaceWith(this.childNodes)}),this}}),S.expr.pseudos.hidden=function(e){return!S.expr.pseudos.visible(e)},S.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},S.ajaxSettings.xhr=function(){try{return new C.XMLHttpRequest}catch(e){}};var Bt={0:200,1223:204},$t=S.ajaxSettings.xhr();y.cors=!!$t&&"withCredentials"in $t,y.ajax=$t=!!$t,S.ajaxTransport(function(i){var o,a;if(y.cors||$t&&!i.crossDomain)return{send:function(e,t){var n,r=i.xhr();if(r.open(i.type,i.url,i.async,i.username,i.password),i.xhrFields)for(n in i.xhrFields)r[n]=i.xhrFields[n];for(n in i.mimeType&&r.overrideMimeType&&r.overrideMimeType(i.mimeType),i.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest"),e)r.setRequestHeader(n,e[n]);o=function(e){return function(){o&&(o=a=r.onload=r.onerror=r.onabort=r.ontimeout=r.onreadystatechange=null,"abort"===e?r.abort():"error"===e?"number"!=typeof r.status?t(0,"error"):t(r.status,r.statusText):t(Bt[r.status]||r.status,r.statusText,"text"!==(r.responseType||"text")||"string"!=typeof r.responseText?{binary:r.response}:{text:r.responseText},r.getAllResponseHeaders()))}},r.onload=o(),a=r.onerror=r.ontimeout=o("error"),void 0!==r.onabort?r.onabort=a:r.onreadystatechange=function(){4===r.readyState&&C.setTimeout(function(){o&&a()})},o=o("abort");try{r.send(i.hasContent&&i.data||null)}catch(e){if(o)throw e}},abort:function(){o&&o()}}}),S.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),S.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return S.globalEval(e),e}}}),S.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),S.ajaxTransport("script",function(n){var r,i;if(n.crossDomain||n.scriptAttrs)return{send:function(e,t){r=S("<script>").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="<form></form><form></form>",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1<s&&(r=ht(e.slice(s)),e=e.slice(0,s)),m(t)?(n=t,t=void 0):t&&"object"==typeof t&&(i="POST"),0<a.length&&S.ajax({url:e,type:i||"GET",dataType:"html",data:t}).done(function(e){o=arguments,a.html(r?S("<div>").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0<arguments.length?this.on(n,null,e,t):this.trigger(n)}});var Xt=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;S.proxy=function(e,t){var n,r,i;if("string"==typeof t&&(n=e[t],t=e,e=n),m(e))return r=s.call(arguments,2),(i=function(){return e.apply(t||this,r.concat(s.call(arguments)))}).guid=e.guid=e.guid||S.guid++,i},S.holdReady=function(e){e?S.readyWait++:S.ready(!0)},S.isArray=Array.isArray,S.parseJSON=JSON.parse,S.nodeName=A,S.isFunction=m,S.isWindow=x,S.camelCase=X,S.type=w,S.now=Date.now,S.isNumeric=function(e){var t=S.type(e);return("number"===t||"string"===t)&&!isNaN(e-parseFloat(e))},S.trim=function(e){return null==e?"":(e+"").replace(Xt,"")},"function"==typeof define&&define.amd&&define("jquery",[],function(){return S});var Vt=C.jQuery,Gt=C.$;return S.noConflict=function(e){return C.$===S&&(C.$=Gt),e&&C.jQuery===S&&(C.jQuery=Vt),S},"undefined"==typeof e&&(C.jQuery=C.$=S),S});
index 94b8ff32c1d38e602b76de05d51a57a72d842555..82a2520036e07f94f95fdafaa21bbb4b7f402aea 100644 (file)
@@ -3,7 +3,7 @@ $(function() {
                var map = L.map(this);
 
                // Add the map provider
-               var layer = L.tileLayer("https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png", {
+               var layer = L.tileLayer("https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", {
                        attribution: "&copy; <a href=\"https://osm.org/copyright\">OpenStreetMap</a> contributors"
                }).addTo(map);
 
diff --git a/src/static/js/popper.min.js b/src/static/js/popper.min.js
deleted file mode 100644 (file)
index 3776e3b..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-/*
- Copyright (C) Federico Zivolo 2019
- Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT).
- */var e='undefined'!=typeof window&&'undefined'!=typeof document;const t=['Edge','Trident','Firefox'];let o=0;for(let n=0;n<t.length;n+=1)if(e&&0<=navigator.userAgent.indexOf(t[n])){o=1;break}function n(e){let t=!1;return()=>{t||(t=!0,window.Promise.resolve().then(()=>{t=!1,e()}))}}function i(e){let t=!1;return()=>{t||(t=!0,setTimeout(()=>{t=!1,e()},o))}}const r=e&&window.Promise;var p=r?n:i;function d(e){return e&&'[object Function]'==={}.toString.call(e)}function s(e,t){if(1!==e.nodeType)return[];const o=e.ownerDocument.defaultView,n=o.getComputedStyle(e,null);return t?n[t]:n}function f(e){return'HTML'===e.nodeName?e:e.parentNode||e.host}function a(e){if(!e)return document.body;switch(e.nodeName){case'HTML':case'BODY':return e.ownerDocument.body;case'#document':return e.body;}const{overflow:t,overflowX:o,overflowY:n}=s(e);return /(auto|scroll|overlay)/.test(t+n+o)?e:a(f(e))}const l=e&&!!(window.MSInputMethodContext&&document.documentMode),m=e&&/MSIE 10/.test(navigator.userAgent);function h(e){return 11===e?l:10===e?m:l||m}function c(e){if(!e)return document.documentElement;const t=h(10)?document.body:null;let o=e.offsetParent||null;for(;o===t&&e.nextElementSibling;)o=(e=e.nextElementSibling).offsetParent;const n=o&&o.nodeName;return n&&'BODY'!==n&&'HTML'!==n?-1!==['TH','TD','TABLE'].indexOf(o.nodeName)&&'static'===s(o,'position')?c(o):o:e?e.ownerDocument.documentElement:document.documentElement}function u(e){const{nodeName:t}=e;return'BODY'!==t&&('HTML'===t||c(e.firstElementChild)===e)}function g(e){return null===e.parentNode?e:g(e.parentNode)}function b(e,t){if(!e||!e.nodeType||!t||!t.nodeType)return document.documentElement;const o=e.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_FOLLOWING,n=o?e:t,i=o?t:e,r=document.createRange();r.setStart(n,0),r.setEnd(i,0);const{commonAncestorContainer:p}=r;if(e!==p&&t!==p||n.contains(i))return u(p)?p:c(p);const d=g(e);return d.host?b(d.host,t):b(e,g(t).host)}function w(e,t='top'){const o='top'===t?'scrollTop':'scrollLeft',n=e.nodeName;if('BODY'===n||'HTML'===n){const t=e.ownerDocument.documentElement,n=e.ownerDocument.scrollingElement||t;return n[o]}return e[o]}function y(e,t,o=!1){const n=w(t,'top'),i=w(t,'left'),r=o?-1:1;return e.top+=n*r,e.bottom+=n*r,e.left+=i*r,e.right+=i*r,e}function E(e,t){const o='x'===t?'Left':'Top',n='Left'==o?'Right':'Bottom';return parseFloat(e[`border${o}Width`],10)+parseFloat(e[`border${n}Width`],10)}function x(e,t,o,n){return Math.max(t[`offset${e}`],t[`scroll${e}`],o[`client${e}`],o[`offset${e}`],o[`scroll${e}`],h(10)?parseInt(o[`offset${e}`])+parseInt(n[`margin${'Height'===e?'Top':'Left'}`])+parseInt(n[`margin${'Height'===e?'Bottom':'Right'}`]):0)}function v(e){const t=e.body,o=e.documentElement,n=h(10)&&getComputedStyle(o);return{height:x('Height',t,o,n),width:x('Width',t,o,n)}}var O=Object.assign||function(e){for(var t,o=1;o<arguments.length;o++)for(var n in t=arguments[o],t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n]);return e};function L(e){return O({},e,{right:e.left+e.width,bottom:e.top+e.height})}function S(e){let t={};try{if(h(10)){t=e.getBoundingClientRect();const o=w(e,'top'),n=w(e,'left');t.top+=o,t.left+=n,t.bottom+=o,t.right+=n}else t=e.getBoundingClientRect()}catch(t){}const o={left:t.left,top:t.top,width:t.right-t.left,height:t.bottom-t.top},n='HTML'===e.nodeName?v(e.ownerDocument):{},i=n.width||e.clientWidth||o.right-o.left,r=n.height||e.clientHeight||o.bottom-o.top;let p=e.offsetWidth-i,d=e.offsetHeight-r;if(p||d){const t=s(e);p-=E(t,'x'),d-=E(t,'y'),o.width-=p,o.height-=d}return L(o)}function T(e,t,o=!1){var n=Math.max;const i=h(10),r='HTML'===t.nodeName,p=S(e),d=S(t),f=a(e),l=s(t),m=parseFloat(l.borderTopWidth,10),c=parseFloat(l.borderLeftWidth,10);o&&r&&(d.top=n(d.top,0),d.left=n(d.left,0));let u=L({top:p.top-d.top-m,left:p.left-d.left-c,width:p.width,height:p.height});if(u.marginTop=0,u.marginLeft=0,!i&&r){const e=parseFloat(l.marginTop,10),t=parseFloat(l.marginLeft,10);u.top-=m-e,u.bottom-=m-e,u.left-=c-t,u.right-=c-t,u.marginTop=e,u.marginLeft=t}return(i&&!o?t.contains(f):t===f&&'BODY'!==f.nodeName)&&(u=y(u,t)),u}function D(e,t=!1){var o=Math.max;const n=e.ownerDocument.documentElement,i=T(e,n),r=o(n.clientWidth,window.innerWidth||0),p=o(n.clientHeight,window.innerHeight||0),d=t?0:w(n),s=t?0:w(n,'left'),f={top:d-i.top+i.marginTop,left:s-i.left+i.marginLeft,width:r,height:p};return L(f)}function C(e){const t=e.nodeName;if('BODY'===t||'HTML'===t)return!1;if('fixed'===s(e,'position'))return!0;const o=f(e);return!!o&&C(o)}function N(e){if(!e||!e.parentElement||h())return document.documentElement;let t=e.parentElement;for(;t&&'none'===s(t,'transform');)t=t.parentElement;return t||document.documentElement}function P(e,t,o,n,i=!1){let r={top:0,left:0};const p=i?N(e):b(e,t);if('viewport'===n)r=D(p,i);else{let o;'scrollParent'===n?(o=a(f(t)),'BODY'===o.nodeName&&(o=e.ownerDocument.documentElement)):'window'===n?o=e.ownerDocument.documentElement:o=n;const d=T(o,p,i);if('HTML'===o.nodeName&&!C(p)){const{height:t,width:o}=v(e.ownerDocument);r.top+=d.top-d.marginTop,r.bottom=t+d.top,r.left+=d.left-d.marginLeft,r.right=o+d.left}else r=d}o=o||0;const d='number'==typeof o;return r.left+=d?o:o.left||0,r.top+=d?o:o.top||0,r.right-=d?o:o.right||0,r.bottom-=d?o:o.bottom||0,r}function B({width:e,height:t}){return e*t}function H(e,t,o,n,i,r=0){if(-1===e.indexOf('auto'))return e;const p=P(o,n,r,i),d={top:{width:p.width,height:t.top-p.top},right:{width:p.right-t.right,height:p.height},bottom:{width:p.width,height:p.bottom-t.bottom},left:{width:t.left-p.left,height:p.height}},s=Object.keys(d).map((e)=>O({key:e},d[e],{area:B(d[e])})).sort((e,t)=>t.area-e.area),f=s.filter(({width:e,height:t})=>e>=o.clientWidth&&t>=o.clientHeight),a=0<f.length?f[0].key:s[0].key,l=e.split('-')[1];return a+(l?`-${l}`:'')}function W(e,t,o,n=null){const i=n?N(t):b(t,o);return T(o,i,n)}function k(e){const t=e.ownerDocument.defaultView,o=t.getComputedStyle(e),n=parseFloat(o.marginTop||0)+parseFloat(o.marginBottom||0),i=parseFloat(o.marginLeft||0)+parseFloat(o.marginRight||0),r={width:e.offsetWidth+i,height:e.offsetHeight+n};return r}function A(e){const t={left:'right',right:'left',bottom:'top',top:'bottom'};return e.replace(/left|right|bottom|top/g,(e)=>t[e])}function M(e,t,o){o=o.split('-')[0];const n=k(e),i={width:n.width,height:n.height},r=-1!==['right','left'].indexOf(o),p=r?'top':'left',d=r?'left':'top',s=r?'height':'width',f=r?'width':'height';return i[p]=t[p]+t[s]/2-n[s]/2,i[d]=o===d?t[d]-n[f]:t[A(d)],i}function F(e,t){return Array.prototype.find?e.find(t):e.filter(t)[0]}function I(e,t,o){if(Array.prototype.findIndex)return e.findIndex((e)=>e[t]===o);const n=F(e,(e)=>e[t]===o);return e.indexOf(n)}function R(e,t,o){const n=void 0===o?e:e.slice(0,I(e,'name',o));return n.forEach((e)=>{e['function']&&console.warn('`modifier.function` is deprecated, use `modifier.fn`!');const o=e['function']||e.fn;e.enabled&&d(o)&&(t.offsets.popper=L(t.offsets.popper),t.offsets.reference=L(t.offsets.reference),t=o(t,e))}),t}function U(){if(this.state.isDestroyed)return;let e={instance:this,styles:{},arrowStyles:{},attributes:{},flipped:!1,offsets:{}};e.offsets.reference=W(this.state,this.popper,this.reference,this.options.positionFixed),e.placement=H(this.options.placement,e.offsets.reference,this.popper,this.reference,this.options.modifiers.flip.boundariesElement,this.options.modifiers.flip.padding),e.originalPlacement=e.placement,e.positionFixed=this.options.positionFixed,e.offsets.popper=M(this.popper,e.offsets.reference,e.placement),e.offsets.popper.position=this.options.positionFixed?'fixed':'absolute',e=R(this.modifiers,e),this.state.isCreated?this.options.onUpdate(e):(this.state.isCreated=!0,this.options.onCreate(e))}function Y(e,t){return e.some(({name:e,enabled:o})=>o&&e===t)}function V(e){const t=[!1,'ms','Webkit','Moz','O'],o=e.charAt(0).toUpperCase()+e.slice(1);for(let n=0;n<t.length;n++){const i=t[n],r=i?`${i}${o}`:e;if('undefined'!=typeof document.body.style[r])return r}return null}function j(){return this.state.isDestroyed=!0,Y(this.modifiers,'applyStyle')&&(this.popper.removeAttribute('x-placement'),this.popper.style.position='',this.popper.style.top='',this.popper.style.left='',this.popper.style.right='',this.popper.style.bottom='',this.popper.style.willChange='',this.popper.style[V('transform')]=''),this.disableEventListeners(),this.options.removeOnDestroy&&this.popper.parentNode.removeChild(this.popper),this}function K(e){const t=e.ownerDocument;return t?t.defaultView:window}function q(e,t,o,n){const i='BODY'===e.nodeName,r=i?e.ownerDocument.defaultView:e;r.addEventListener(t,o,{passive:!0}),i||q(a(r.parentNode),t,o,n),n.push(r)}function z(e,t,o,n){o.updateBound=n,K(e).addEventListener('resize',o.updateBound,{passive:!0});const i=a(e);return q(i,'scroll',o.updateBound,o.scrollParents),o.scrollElement=i,o.eventsEnabled=!0,o}function G(){this.state.eventsEnabled||(this.state=z(this.reference,this.options,this.state,this.scheduleUpdate))}function _(e,t){return K(e).removeEventListener('resize',t.updateBound),t.scrollParents.forEach((e)=>{e.removeEventListener('scroll',t.updateBound)}),t.updateBound=null,t.scrollParents=[],t.scrollElement=null,t.eventsEnabled=!1,t}function X(){this.state.eventsEnabled&&(cancelAnimationFrame(this.scheduleUpdate),this.state=_(this.reference,this.state))}function J(e){return''!==e&&!isNaN(parseFloat(e))&&isFinite(e)}function Q(e,t){Object.keys(t).forEach((o)=>{let n='';-1!==['width','height','top','right','bottom','left'].indexOf(o)&&J(t[o])&&(n='px'),e.style[o]=t[o]+n})}function Z(e,t){Object.keys(t).forEach(function(o){const n=t[o];!1===n?e.removeAttribute(o):e.setAttribute(o,t[o])})}function $(e){return Q(e.instance.popper,e.styles),Z(e.instance.popper,e.attributes),e.arrowElement&&Object.keys(e.arrowStyles).length&&Q(e.arrowElement,e.arrowStyles),e}function ee(e,t,o,n,i){const r=W(i,t,e,o.positionFixed),p=H(o.placement,r,t,e,o.modifiers.flip.boundariesElement,o.modifiers.flip.padding);return t.setAttribute('x-placement',p),Q(t,{position:o.positionFixed?'fixed':'absolute'}),o}function te(e,t){const{popper:o,reference:n}=e.offsets,{round:i,floor:r}=Math,p=(e)=>e,d=i(n.width),s=i(o.width),f=-1!==['left','right'].indexOf(e.placement),a=-1!==e.placement.indexOf('-'),l=t?f||a||d%2==s%2?i:r:p,m=t?i:p;return{left:l(1==d%2&&1==s%2&&!a&&t?o.left-1:o.left),top:m(o.top),bottom:m(o.bottom),right:l(o.right)}}const oe=e&&/Firefox/i.test(navigator.userAgent);function ne(e,t){const{x:o,y:n}=t,{popper:i}=e.offsets,r=F(e.instance.modifiers,(e)=>'applyStyle'===e.name).gpuAcceleration;void 0!==r&&console.warn('WARNING: `gpuAcceleration` option moved to `computeStyle` modifier and will not be supported in future versions of Popper.js!');const p=void 0===r?t.gpuAcceleration:r,d=c(e.instance.popper),s=S(d),f={position:i.position},a=te(e,2>window.devicePixelRatio||!oe),l='bottom'===o?'top':'bottom',m='right'===n?'left':'right',h=V('transform');let u,g;if(g='bottom'==l?'HTML'===d.nodeName?-d.clientHeight+a.bottom:-s.height+a.bottom:a.top,u='right'==m?'HTML'===d.nodeName?-d.clientWidth+a.right:-s.width+a.right:a.left,p&&h)f[h]=`translate3d(${u}px, ${g}px, 0)`,f[l]=0,f[m]=0,f.willChange='transform';else{const e='bottom'==l?-1:1,t='right'==m?-1:1;f[l]=g*e,f[m]=u*t,f.willChange=`${l}, ${m}`}const b={"x-placement":e.placement};return e.attributes=O({},b,e.attributes),e.styles=O({},f,e.styles),e.arrowStyles=O({},e.offsets.arrow,e.arrowStyles),e}function ie(e,t,o){const n=F(e,({name:e})=>e===t),i=!!n&&e.some((e)=>e.name===o&&e.enabled&&e.order<n.order);if(!i){const e=`\`${t}\``,n=`\`${o}\``;console.warn(`${n} modifier is required by ${e} modifier in order to work, be sure to include it before ${e}!`)}return i}function re(e,t){if(!ie(e.instance.modifiers,'arrow','keepTogether'))return e;let o=t.element;if('string'==typeof o){if(o=e.instance.popper.querySelector(o),!o)return e;}else if(!e.instance.popper.contains(o))return console.warn('WARNING: `arrow.element` must be child of its popper element!'),e;const n=e.placement.split('-')[0],{popper:i,reference:r}=e.offsets,p=-1!==['left','right'].indexOf(n),d=p?'height':'width',f=p?'Top':'Left',a=f.toLowerCase(),l=p?'left':'top',m=p?'bottom':'right',h=k(o)[d];r[m]-h<i[a]&&(e.offsets.popper[a]-=i[a]-(r[m]-h)),r[a]+h>i[m]&&(e.offsets.popper[a]+=r[a]+h-i[m]),e.offsets.popper=L(e.offsets.popper);const c=r[a]+r[d]/2-h/2,u=s(e.instance.popper),g=parseFloat(u[`margin${f}`],10),b=parseFloat(u[`border${f}Width`],10);let w=c-e.offsets.popper[a]-g-b;return w=Math.max(Math.min(i[d]-h,w),0),e.arrowElement=o,e.offsets.arrow={[a]:Math.round(w),[l]:''},e}function pe(e){if('end'===e)return'start';return'start'===e?'end':e}var de=['auto-start','auto','auto-end','top-start','top','top-end','right-start','right','right-end','bottom-end','bottom','bottom-start','left-end','left','left-start'];const se=de.slice(3);function fe(e,t=!1){const o=se.indexOf(e),n=se.slice(o+1).concat(se.slice(0,o));return t?n.reverse():n}const ae={FLIP:'flip',CLOCKWISE:'clockwise',COUNTERCLOCKWISE:'counterclockwise'};function le(e,t){if(Y(e.instance.modifiers,'inner'))return e;if(e.flipped&&e.placement===e.originalPlacement)return e;const o=P(e.instance.popper,e.instance.reference,t.padding,t.boundariesElement,e.positionFixed);let n=e.placement.split('-')[0],i=A(n),r=e.placement.split('-')[1]||'',p=[];switch(t.behavior){case ae.FLIP:p=[n,i];break;case ae.CLOCKWISE:p=fe(n);break;case ae.COUNTERCLOCKWISE:p=fe(n,!0);break;default:p=t.behavior;}return p.forEach((d,s)=>{if(n!==d||p.length===s+1)return e;n=e.placement.split('-')[0],i=A(n);const f=e.offsets.popper,a=e.offsets.reference,l=Math.floor,m='left'===n&&l(f.right)>l(a.left)||'right'===n&&l(f.left)<l(a.right)||'top'===n&&l(f.bottom)>l(a.top)||'bottom'===n&&l(f.top)<l(a.bottom),h=l(f.left)<l(o.left),c=l(f.right)>l(o.right),u=l(f.top)<l(o.top),g=l(f.bottom)>l(o.bottom),b='left'===n&&h||'right'===n&&c||'top'===n&&u||'bottom'===n&&g,w=-1!==['top','bottom'].indexOf(n),y=!!t.flipVariations&&(w&&'start'===r&&h||w&&'end'===r&&c||!w&&'start'===r&&u||!w&&'end'===r&&g),E=!!t.flipVariationsByContent&&(w&&'start'===r&&c||w&&'end'===r&&h||!w&&'start'===r&&g||!w&&'end'===r&&u),x=y||E;(m||b||x)&&(e.flipped=!0,(m||b)&&(n=p[s+1]),x&&(r=pe(r)),e.placement=n+(r?'-'+r:''),e.offsets.popper=O({},e.offsets.popper,M(e.instance.popper,e.offsets.reference,e.placement)),e=R(e.instance.modifiers,e,'flip'))}),e}function me(e){const{popper:t,reference:o}=e.offsets,n=e.placement.split('-')[0],i=Math.floor,r=-1!==['top','bottom'].indexOf(n),p=r?'right':'bottom',d=r?'left':'top',s=r?'width':'height';return t[p]<i(o[d])&&(e.offsets.popper[d]=i(o[d])-t[s]),t[d]>i(o[p])&&(e.offsets.popper[d]=i(o[p])),e}function he(e,t,o,n){var i=Math.max;const r=e.match(/((?:\-|\+)?\d*\.?\d*)(.*)/),p=+r[1],d=r[2];if(!p)return e;if(0===d.indexOf('%')){let e;switch(d){case'%p':e=o;break;case'%':case'%r':default:e=n;}const i=L(e);return i[t]/100*p}if('vh'===d||'vw'===d){let e;return e='vh'===d?i(document.documentElement.clientHeight,window.innerHeight||0):i(document.documentElement.clientWidth,window.innerWidth||0),e/100*p}return p}function ce(e,t,o,n){const i=[0,0],r=-1!==['right','left'].indexOf(n),p=e.split(/(\+|\-)/).map((e)=>e.trim()),d=p.indexOf(F(p,(e)=>-1!==e.search(/,|\s/)));p[d]&&-1===p[d].indexOf(',')&&console.warn('Offsets separated by white space(s) are deprecated, use a comma (,) instead.');const s=/\s*,\s*|\s+/;let f=-1===d?[p]:[p.slice(0,d).concat([p[d].split(s)[0]]),[p[d].split(s)[1]].concat(p.slice(d+1))];return f=f.map((e,n)=>{const i=(1===n?!r:r)?'height':'width';let p=!1;return e.reduce((e,t)=>''===e[e.length-1]&&-1!==['+','-'].indexOf(t)?(e[e.length-1]=t,p=!0,e):p?(e[e.length-1]+=t,p=!1,e):e.concat(t),[]).map((e)=>he(e,i,t,o))}),f.forEach((e,t)=>{e.forEach((o,n)=>{J(o)&&(i[t]+=o*('-'===e[n-1]?-1:1))})}),i}function ue(e,{offset:t}){const{placement:o,offsets:{popper:n,reference:i}}=e,r=o.split('-')[0];let p;return p=J(+t)?[+t,0]:ce(t,n,i,r),'left'===r?(n.top+=p[0],n.left-=p[1]):'right'===r?(n.top+=p[0],n.left+=p[1]):'top'===r?(n.left+=p[0],n.top-=p[1]):'bottom'===r&&(n.left+=p[0],n.top+=p[1]),e.popper=n,e}function ge(e,t){let o=t.boundariesElement||c(e.instance.popper);e.instance.reference===o&&(o=c(o));const n=V('transform'),i=e.instance.popper.style,{top:r,left:p,[n]:d}=i;i.top='',i.left='',i[n]='';const s=P(e.instance.popper,e.instance.reference,t.padding,o,e.positionFixed);i.top=r,i.left=p,i[n]=d,t.boundaries=s;const f=t.priority;let a=e.offsets.popper;const l={primary(e){let o=a[e];return a[e]<s[e]&&!t.escapeWithReference&&(o=Math.max(a[e],s[e])),{[e]:o}},secondary(e){const o='right'===e?'left':'top';let n=a[o];return a[e]>s[e]&&!t.escapeWithReference&&(n=Math.min(a[o],s[e]-('right'===e?a.width:a.height))),{[o]:n}}};return f.forEach((e)=>{const t=-1===['left','top'].indexOf(e)?'secondary':'primary';a=O({},a,l[t](e))}),e.offsets.popper=a,e}function be(e){const t=e.placement,o=t.split('-')[0],n=t.split('-')[1];if(n){const{reference:t,popper:i}=e.offsets,r=-1!==['bottom','top'].indexOf(o),p=r?'left':'top',d=r?'width':'height',s={start:{[p]:t[p]},end:{[p]:t[p]+t[d]-i[d]}};e.offsets.popper=O({},i,s[n])}return e}function we(e){if(!ie(e.instance.modifiers,'hide','preventOverflow'))return e;const t=e.offsets.reference,o=F(e.instance.modifiers,(e)=>'preventOverflow'===e.name).boundaries;if(t.bottom<o.top||t.left>o.right||t.top>o.bottom||t.right<o.left){if(!0===e.hide)return e;e.hide=!0,e.attributes['x-out-of-boundaries']=''}else{if(!1===e.hide)return e;e.hide=!1,e.attributes['x-out-of-boundaries']=!1}return e}function ye(e){const t=e.placement,o=t.split('-')[0],{popper:n,reference:i}=e.offsets,r=-1!==['left','right'].indexOf(o),p=-1===['top','left'].indexOf(o);return n[r?'left':'top']=i[o]-(p?n[r?'width':'height']:0),e.placement=A(t),e.offsets.popper=L(n),e}var Ee={shift:{order:100,enabled:!0,fn:be},offset:{order:200,enabled:!0,fn:ue,offset:0},preventOverflow:{order:300,enabled:!0,fn:ge,priority:['left','right','top','bottom'],padding:5,boundariesElement:'scrollParent'},keepTogether:{order:400,enabled:!0,fn:me},arrow:{order:500,enabled:!0,fn:re,element:'[x-arrow]'},flip:{order:600,enabled:!0,fn:le,behavior:'flip',padding:5,boundariesElement:'viewport',flipVariations:!1,flipVariationsByContent:!1},inner:{order:700,enabled:!1,fn:ye},hide:{order:800,enabled:!0,fn:we},computeStyle:{order:850,enabled:!0,fn:ne,gpuAcceleration:!0,x:'bottom',y:'right'},applyStyle:{order:900,enabled:!0,fn:$,onLoad:ee,gpuAcceleration:void 0}},xe={placement:'bottom',positionFixed:!1,eventsEnabled:!0,removeOnDestroy:!1,onCreate:()=>{},onUpdate:()=>{},modifiers:Ee};class ve{constructor(e,t,o={}){this.scheduleUpdate=()=>requestAnimationFrame(this.update),this.update=p(this.update.bind(this)),this.options=O({},ve.Defaults,o),this.state={isDestroyed:!1,isCreated:!1,scrollParents:[]},this.reference=e&&e.jquery?e[0]:e,this.popper=t&&t.jquery?t[0]:t,this.options.modifiers={},Object.keys(O({},ve.Defaults.modifiers,o.modifiers)).forEach((e)=>{this.options.modifiers[e]=O({},ve.Defaults.modifiers[e]||{},o.modifiers?o.modifiers[e]:{})}),this.modifiers=Object.keys(this.options.modifiers).map((e)=>O({name:e},this.options.modifiers[e])).sort((e,t)=>e.order-t.order),this.modifiers.forEach((e)=>{e.enabled&&d(e.onLoad)&&e.onLoad(this.reference,this.popper,this.options,e,this.state)}),this.update();const n=this.options.eventsEnabled;n&&this.enableEventListeners(),this.state.eventsEnabled=n}update(){return U.call(this)}destroy(){return j.call(this)}enableEventListeners(){return G.call(this)}disableEventListeners(){return X.call(this)}}ve.Utils=('undefined'==typeof window?global:window).PopperUtils,ve.placements=de,ve.Defaults=xe;export default ve;
-//# sourceMappingURL=popper.min.js.map
diff --git a/src/static/js/popper.min.js.map b/src/static/js/popper.min.js.map
deleted file mode 100644 (file)
index 78fea50..0000000
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"popper.min.js","sources":["../src/utils/isBrowser.js","../src/utils/debounce.js","../src/utils/isFunction.js","../src/utils/getStyleComputedProperty.js","../src/utils/getParentNode.js","../src/utils/getScrollParent.js","../src/utils/isIE.js","../src/utils/getOffsetParent.js","../src/utils/isOffsetContainer.js","../src/utils/getRoot.js","../src/utils/findCommonOffsetParent.js","../src/utils/getScroll.js","../src/utils/includeScroll.js","../src/utils/getBordersSize.js","../src/utils/getWindowSizes.js","../src/utils/getClientRect.js","../src/utils/getBoundingClientRect.js","../src/utils/getOffsetRectRelativeToArbitraryNode.js","../src/utils/getViewportOffsetRectRelativeToArtbitraryNode.js","../src/utils/isFixed.js","../src/utils/getFixedPositionOffsetParent.js","../src/utils/getBoundaries.js","../src/utils/computeAutoPlacement.js","../src/utils/getReferenceOffsets.js","../src/utils/getOuterSizes.js","../src/utils/getOppositePlacement.js","../src/utils/getPopperOffsets.js","../src/utils/find.js","../src/utils/findIndex.js","../src/utils/runModifiers.js","../src/methods/update.js","../src/utils/isModifierEnabled.js","../src/utils/getSupportedPropertyName.js","../src/methods/destroy.js","../src/utils/getWindow.js","../src/utils/setupEventListeners.js","../src/methods/enableEventListeners.js","../src/utils/removeEventListeners.js","../src/methods/disableEventListeners.js","../src/utils/isNumeric.js","../src/utils/setStyles.js","../src/utils/setAttributes.js","../src/modifiers/applyStyle.js","../src/utils/getRoundedOffsets.js","../src/modifiers/computeStyle.js","../src/utils/isModifierRequired.js","../src/modifiers/arrow.js","../src/utils/getOppositeVariation.js","../src/methods/placements.js","../src/utils/clockwise.js","../src/modifiers/flip.js","../src/modifiers/keepTogether.js","../src/modifiers/offset.js","../src/modifiers/preventOverflow.js","../src/modifiers/shift.js","../src/modifiers/hide.js","../src/modifiers/inner.js","../src/modifiers/index.js","../src/methods/defaults.js","../src/index.js"],"sourcesContent":["export default typeof window !== 'undefined' && typeof document !== 'undefined';\n","import isBrowser from './isBrowser';\n\nconst longerTimeoutBrowsers = ['Edge', 'Trident', 'Firefox'];\nlet timeoutDuration = 0;\nfor (let i = 0; i < longerTimeoutBrowsers.length; i += 1) {\n  if (isBrowser && navigator.userAgent.indexOf(longerTimeoutBrowsers[i]) >= 0) {\n    timeoutDuration = 1;\n    break;\n  }\n}\n\nexport function microtaskDebounce(fn) {\n  let called = false\n  return () => {\n    if (called) {\n      return\n    }\n    called = true\n    window.Promise.resolve().then(() => {\n      called = false\n      fn()\n    })\n  }\n}\n\nexport function taskDebounce(fn) {\n  let scheduled = false;\n  return () => {\n    if (!scheduled) {\n      scheduled = true;\n      setTimeout(() => {\n        scheduled = false;\n        fn();\n      }, timeoutDuration);\n    }\n  };\n}\n\nconst supportsMicroTasks = isBrowser && window.Promise\n\n\n/**\n* Create a debounced version of a method, that's asynchronously deferred\n* but called in the minimum time possible.\n*\n* @method\n* @memberof Popper.Utils\n* @argument {Function} fn\n* @returns {Function}\n*/\nexport default (supportsMicroTasks\n  ? microtaskDebounce\n  : taskDebounce);\n","/**\n * Check if the given variable is a function\n * @method\n * @memberof Popper.Utils\n * @argument {Any} functionToCheck - variable to check\n * @returns {Boolean} answer to: is a function?\n */\nexport default function isFunction(functionToCheck) {\n  const getType = {};\n  return (\n    functionToCheck &&\n    getType.toString.call(functionToCheck) === '[object Function]'\n  );\n}\n","/**\n * Get CSS computed property of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Eement} element\n * @argument {String} property\n */\nexport default function getStyleComputedProperty(element, property) {\n  if (element.nodeType !== 1) {\n    return [];\n  }\n  // NOTE: 1 DOM access here\n  const window = element.ownerDocument.defaultView;\n  const css = window.getComputedStyle(element, null);\n  return property ? css[property] : css;\n}\n","/**\n * Returns the parentNode or the host of the element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} parent\n */\nexport default function getParentNode(element) {\n  if (element.nodeName === 'HTML') {\n    return element;\n  }\n  return element.parentNode || element.host;\n}\n","import getStyleComputedProperty from './getStyleComputedProperty';\nimport getParentNode from './getParentNode';\n\n/**\n * Returns the scrolling parent of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} scroll parent\n */\nexport default function getScrollParent(element) {\n  // Return body, `getScroll` will take care to get the correct `scrollTop` from it\n  if (!element) {\n    return document.body\n  }\n\n  switch (element.nodeName) {\n    case 'HTML':\n    case 'BODY':\n      return element.ownerDocument.body\n    case '#document':\n      return element.body\n  }\n\n  // Firefox want us to check `-x` and `-y` variations as well\n  const { overflow, overflowX, overflowY } = getStyleComputedProperty(element);\n  if (/(auto|scroll|overlay)/.test(overflow + overflowY + overflowX)) {\n    return element;\n  }\n\n  return getScrollParent(getParentNode(element));\n}\n","import isBrowser from './isBrowser';\n\nconst isIE11 = isBrowser && !!(window.MSInputMethodContext && document.documentMode);\nconst isIE10 = isBrowser && /MSIE 10/.test(navigator.userAgent);\n\n/**\n * Determines if the browser is Internet Explorer\n * @method\n * @memberof Popper.Utils\n * @param {Number} version to check\n * @returns {Boolean} isIE\n */\nexport default function isIE(version) {\n  if (version === 11) {\n    return isIE11;\n  }\n  if (version === 10) {\n    return isIE10;\n  }\n  return isIE11 || isIE10;\n}\n","import getStyleComputedProperty from './getStyleComputedProperty';\nimport isIE from './isIE';\n/**\n * Returns the offset parent of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} offset parent\n */\nexport default function getOffsetParent(element) {\n  if (!element) {\n    return document.documentElement;\n  }\n\n  const noOffsetParent = isIE(10) ? document.body : null;\n\n  // NOTE: 1 DOM access here\n  let offsetParent = element.offsetParent || null;\n  // Skip hidden elements which don't have an offsetParent\n  while (offsetParent === noOffsetParent && element.nextElementSibling) {\n    offsetParent = (element = element.nextElementSibling).offsetParent;\n  }\n\n  const nodeName = offsetParent && offsetParent.nodeName;\n\n  if (!nodeName || nodeName === 'BODY' || nodeName === 'HTML') {\n    return element ? element.ownerDocument.documentElement : document.documentElement;\n  }\n\n  // .offsetParent will return the closest TH, TD or TABLE in case\n  // no offsetParent is present, I hate this job...\n  if (\n    ['TH', 'TD', 'TABLE'].indexOf(offsetParent.nodeName) !== -1 &&\n    getStyleComputedProperty(offsetParent, 'position') === 'static'\n  ) {\n    return getOffsetParent(offsetParent);\n  }\n\n  return offsetParent;\n}\n","import getOffsetParent from './getOffsetParent';\n\nexport default function isOffsetContainer(element) {\n  const { nodeName } = element;\n  if (nodeName === 'BODY') {\n    return false;\n  }\n  return (\n    nodeName === 'HTML' || getOffsetParent(element.firstElementChild) === element\n  );\n}\n","/**\n * Finds the root node (document, shadowDOM root) of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} node\n * @returns {Element} root node\n */\nexport default function getRoot(node) {\n  if (node.parentNode !== null) {\n    return getRoot(node.parentNode);\n  }\n\n  return node;\n}\n","import isOffsetContainer from './isOffsetContainer';\nimport getRoot from './getRoot';\nimport getOffsetParent from './getOffsetParent';\n\n/**\n * Finds the offset parent common to the two provided nodes\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element1\n * @argument {Element} element2\n * @returns {Element} common offset parent\n */\nexport default function findCommonOffsetParent(element1, element2) {\n  // This check is needed to avoid errors in case one of the elements isn't defined for any reason\n  if (!element1 || !element1.nodeType || !element2 || !element2.nodeType) {\n    return document.documentElement;\n  }\n\n  // Here we make sure to give as \"start\" the element that comes first in the DOM\n  const order =\n    element1.compareDocumentPosition(element2) &\n    Node.DOCUMENT_POSITION_FOLLOWING;\n  const start = order ? element1 : element2;\n  const end = order ? element2 : element1;\n\n  // Get common ancestor container\n  const range = document.createRange();\n  range.setStart(start, 0);\n  range.setEnd(end, 0);\n  const { commonAncestorContainer } = range;\n\n  // Both nodes are inside #document\n  if (\n    (element1 !== commonAncestorContainer &&\n      element2 !== commonAncestorContainer) ||\n    start.contains(end)\n  ) {\n    if (isOffsetContainer(commonAncestorContainer)) {\n      return commonAncestorContainer;\n    }\n\n    return getOffsetParent(commonAncestorContainer);\n  }\n\n  // one of the nodes is inside shadowDOM, find which one\n  const element1root = getRoot(element1);\n  if (element1root.host) {\n    return findCommonOffsetParent(element1root.host, element2);\n  } else {\n    return findCommonOffsetParent(element1, getRoot(element2).host);\n  }\n}\n","/**\n * Gets the scroll value of the given element in the given side (top and left)\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @argument {String} side `top` or `left`\n * @returns {number} amount of scrolled pixels\n */\nexport default function getScroll(element, side = 'top') {\n  const upperSide = side === 'top' ? 'scrollTop' : 'scrollLeft';\n  const nodeName = element.nodeName;\n\n  if (nodeName === 'BODY' || nodeName === 'HTML') {\n    const html = element.ownerDocument.documentElement;\n    const scrollingElement = element.ownerDocument.scrollingElement || html;\n    return scrollingElement[upperSide];\n  }\n\n  return element[upperSide];\n}\n","import getScroll from './getScroll';\n\n/*\n * Sum or subtract the element scroll values (left and top) from a given rect object\n * @method\n * @memberof Popper.Utils\n * @param {Object} rect - Rect object you want to change\n * @param {HTMLElement} element - The element from the function reads the scroll values\n * @param {Boolean} subtract - set to true if you want to subtract the scroll values\n * @return {Object} rect - The modifier rect object\n */\nexport default function includeScroll(rect, element, subtract = false) {\n  const scrollTop = getScroll(element, 'top');\n  const scrollLeft = getScroll(element, 'left');\n  const modifier = subtract ? -1 : 1;\n  rect.top += scrollTop * modifier;\n  rect.bottom += scrollTop * modifier;\n  rect.left += scrollLeft * modifier;\n  rect.right += scrollLeft * modifier;\n  return rect;\n}\n","/*\n * Helper to detect borders of a given element\n * @method\n * @memberof Popper.Utils\n * @param {CSSStyleDeclaration} styles\n * Result of `getStyleComputedProperty` on the given element\n * @param {String} axis - `x` or `y`\n * @return {number} borders - The borders size of the given axis\n */\n\nexport default function getBordersSize(styles, axis) {\n  const sideA = axis === 'x' ? 'Left' : 'Top';\n  const sideB = sideA === 'Left' ? 'Right' : 'Bottom';\n\n  return (\n    parseFloat(styles[`border${sideA}Width`], 10) +\n    parseFloat(styles[`border${sideB}Width`], 10)\n  );\n}\n","import isIE from './isIE';\n\nfunction getSize(axis, body, html, computedStyle) {\n  return Math.max(\n    body[`offset${axis}`],\n    body[`scroll${axis}`],\n    html[`client${axis}`],\n    html[`offset${axis}`],\n    html[`scroll${axis}`],\n    isIE(10)\n      ? (parseInt(html[`offset${axis}`]) + \n      parseInt(computedStyle[`margin${axis === 'Height' ? 'Top' : 'Left'}`]) + \n      parseInt(computedStyle[`margin${axis === 'Height' ? 'Bottom' : 'Right'}`]))\n    : 0 \n  );\n}\n\nexport default function getWindowSizes(document) {\n  const body = document.body;\n  const html = document.documentElement;\n  const computedStyle = isIE(10) && getComputedStyle(html);\n\n  return {\n    height: getSize('Height', body, html, computedStyle),\n    width: getSize('Width', body, html, computedStyle),\n  };\n}\n","/**\n * Given element offsets, generate an output similar to getBoundingClientRect\n * @method\n * @memberof Popper.Utils\n * @argument {Object} offsets\n * @returns {Object} ClientRect like output\n */\nexport default function getClientRect(offsets) {\n  return {\n    ...offsets,\n    right: offsets.left + offsets.width,\n    bottom: offsets.top + offsets.height,\n  };\n}\n","import getStyleComputedProperty from './getStyleComputedProperty';\nimport getBordersSize from './getBordersSize';\nimport getWindowSizes from './getWindowSizes';\nimport getScroll from './getScroll';\nimport getClientRect from './getClientRect';\nimport isIE from './isIE';\n\n/**\n * Get bounding client rect of given element\n * @method\n * @memberof Popper.Utils\n * @param {HTMLElement} element\n * @return {Object} client rect\n */\nexport default function getBoundingClientRect(element) {\n  let rect = {};\n\n  // IE10 10 FIX: Please, don't ask, the element isn't\n  // considered in DOM in some circumstances...\n  // This isn't reproducible in IE10 compatibility mode of IE11\n  try {\n    if (isIE(10)) {\n      rect = element.getBoundingClientRect();\n      const scrollTop = getScroll(element, 'top');\n      const scrollLeft = getScroll(element, 'left');\n      rect.top += scrollTop;\n      rect.left += scrollLeft;\n      rect.bottom += scrollTop;\n      rect.right += scrollLeft;\n    }\n    else {\n      rect = element.getBoundingClientRect();\n    }\n  }\n  catch(e){}\n\n  const result = {\n    left: rect.left,\n    top: rect.top,\n    width: rect.right - rect.left,\n    height: rect.bottom - rect.top,\n  };\n\n  // subtract scrollbar size from sizes\n  const sizes = element.nodeName === 'HTML' ? getWindowSizes(element.ownerDocument) : {};\n  const width =\n    sizes.width || element.clientWidth || result.right - result.left;\n  const height =\n    sizes.height || element.clientHeight || result.bottom - result.top;\n\n  let horizScrollbar = element.offsetWidth - width;\n  let vertScrollbar = element.offsetHeight - height;\n\n  // if an hypothetical scrollbar is detected, we must be sure it's not a `border`\n  // we make this check conditional for performance reasons\n  if (horizScrollbar || vertScrollbar) {\n    const styles = getStyleComputedProperty(element);\n    horizScrollbar -= getBordersSize(styles, 'x');\n    vertScrollbar -= getBordersSize(styles, 'y');\n\n    result.width -= horizScrollbar;\n    result.height -= vertScrollbar;\n  }\n\n  return getClientRect(result);\n}\n","import getStyleComputedProperty from './getStyleComputedProperty';\nimport includeScroll from './includeScroll';\nimport getScrollParent from './getScrollParent';\nimport getBoundingClientRect from './getBoundingClientRect';\nimport runIsIE from './isIE';\nimport getClientRect from './getClientRect';\n\nexport default function getOffsetRectRelativeToArbitraryNode(children, parent, fixedPosition = false) {\n  const isIE10 = runIsIE(10);\n  const isHTML = parent.nodeName === 'HTML';\n  const childrenRect = getBoundingClientRect(children);\n  const parentRect = getBoundingClientRect(parent);\n  const scrollParent = getScrollParent(children);\n\n  const styles = getStyleComputedProperty(parent);\n  const borderTopWidth = parseFloat(styles.borderTopWidth, 10);\n  const borderLeftWidth = parseFloat(styles.borderLeftWidth, 10);\n\n  // In cases where the parent is fixed, we must ignore negative scroll in offset calc\n  if(fixedPosition && isHTML) {\n    parentRect.top = Math.max(parentRect.top, 0);\n    parentRect.left = Math.max(parentRect.left, 0);\n  }\n  let offsets = getClientRect({\n    top: childrenRect.top - parentRect.top - borderTopWidth,\n    left: childrenRect.left - parentRect.left - borderLeftWidth,\n    width: childrenRect.width,\n    height: childrenRect.height,\n  });\n  offsets.marginTop = 0;\n  offsets.marginLeft = 0;\n\n  // Subtract margins of documentElement in case it's being used as parent\n  // we do this only on HTML because it's the only element that behaves\n  // differently when margins are applied to it. The margins are included in\n  // the box of the documentElement, in the other cases not.\n  if (!isIE10 && isHTML) {\n    const marginTop = parseFloat(styles.marginTop, 10);\n    const marginLeft = parseFloat(styles.marginLeft, 10);\n\n    offsets.top -= borderTopWidth - marginTop;\n    offsets.bottom -= borderTopWidth - marginTop;\n    offsets.left -= borderLeftWidth - marginLeft;\n    offsets.right -= borderLeftWidth - marginLeft;\n\n    // Attach marginTop and marginLeft because in some circumstances we may need them\n    offsets.marginTop = marginTop;\n    offsets.marginLeft = marginLeft;\n  }\n\n  if (\n    isIE10 && !fixedPosition\n      ? parent.contains(scrollParent)\n      : parent === scrollParent && scrollParent.nodeName !== 'BODY'\n  ) {\n    offsets = includeScroll(offsets, parent);\n  }\n\n  return offsets;\n}\n","import getOffsetRectRelativeToArbitraryNode from './getOffsetRectRelativeToArbitraryNode';\nimport getScroll from './getScroll';\nimport getClientRect from './getClientRect';\n\nexport default function getViewportOffsetRectRelativeToArtbitraryNode(element, excludeScroll = false) {\n  const html = element.ownerDocument.documentElement;\n  const relativeOffset = getOffsetRectRelativeToArbitraryNode(element, html);\n  const width = Math.max(html.clientWidth, window.innerWidth || 0);\n  const height = Math.max(html.clientHeight, window.innerHeight || 0);\n\n  const scrollTop = !excludeScroll ? getScroll(html) : 0;\n  const scrollLeft = !excludeScroll ? getScroll(html, 'left') : 0;\n\n  const offset = {\n    top: scrollTop - relativeOffset.top + relativeOffset.marginTop,\n    left: scrollLeft - relativeOffset.left + relativeOffset.marginLeft,\n    width,\n    height,\n  };\n\n  return getClientRect(offset);\n}\n","import getStyleComputedProperty from './getStyleComputedProperty';\nimport getParentNode from './getParentNode';\n\n/**\n * Check if the given element is fixed or is inside a fixed parent\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @argument {Element} customContainer\n * @returns {Boolean} answer to \"isFixed?\"\n */\nexport default function isFixed(element) {\n  const nodeName = element.nodeName;\n  if (nodeName === 'BODY' || nodeName === 'HTML') {\n    return false;\n  }\n  if (getStyleComputedProperty(element, 'position') === 'fixed') {\n    return true;\n  }\n  const parentNode = getParentNode(element);\n  if (!parentNode) {\n    return false;\n  }\n  return isFixed(parentNode);\n}\n","import getStyleComputedProperty from './getStyleComputedProperty';\nimport isIE from './isIE';\n/**\n * Finds the first parent of an element that has a transformed property defined\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} first transformed parent or documentElement\n */\n\nexport default function getFixedPositionOffsetParent(element) {\n  // This check is needed to avoid errors in case one of the elements isn't defined for any reason\n   if (!element || !element.parentElement || isIE()) {\n    return document.documentElement;\n  }\n  let el = element.parentElement;\n  while (el && getStyleComputedProperty(el, 'transform') === 'none') {\n    el = el.parentElement;\n  }\n  return el || document.documentElement;\n\n}\n","import getScrollParent from './getScrollParent';\nimport getParentNode from './getParentNode';\nimport findCommonOffsetParent from './findCommonOffsetParent';\nimport getOffsetRectRelativeToArbitraryNode from './getOffsetRectRelativeToArbitraryNode';\nimport getViewportOffsetRectRelativeToArtbitraryNode from './getViewportOffsetRectRelativeToArtbitraryNode';\nimport getWindowSizes from './getWindowSizes';\nimport isFixed from './isFixed';\nimport getFixedPositionOffsetParent from './getFixedPositionOffsetParent';\n\n/**\n * Computed the boundaries limits and return them\n * @method\n * @memberof Popper.Utils\n * @param {HTMLElement} popper\n * @param {HTMLElement} reference\n * @param {number} padding\n * @param {HTMLElement} boundariesElement - Element used to define the boundaries\n * @param {Boolean} fixedPosition - Is in fixed position mode\n * @returns {Object} Coordinates of the boundaries\n */\nexport default function getBoundaries(\n  popper,\n  reference,\n  padding,\n  boundariesElement,\n  fixedPosition = false\n) {\n  // NOTE: 1 DOM access here\n\n  let boundaries = { top: 0, left: 0 };\n  const offsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference);\n\n  // Handle viewport case\n  if (boundariesElement === 'viewport' ) {\n    boundaries = getViewportOffsetRectRelativeToArtbitraryNode(offsetParent, fixedPosition);\n  }\n\n  else {\n    // Handle other cases based on DOM element used as boundaries\n    let boundariesNode;\n    if (boundariesElement === 'scrollParent') {\n      boundariesNode = getScrollParent(getParentNode(reference));\n      if (boundariesNode.nodeName === 'BODY') {\n        boundariesNode = popper.ownerDocument.documentElement;\n      }\n    } else if (boundariesElement === 'window') {\n      boundariesNode = popper.ownerDocument.documentElement;\n    } else {\n      boundariesNode = boundariesElement;\n    }\n\n    const offsets = getOffsetRectRelativeToArbitraryNode(\n      boundariesNode,\n      offsetParent,\n      fixedPosition\n    );\n\n    // In case of HTML, we need a different computation\n    if (boundariesNode.nodeName === 'HTML' && !isFixed(offsetParent)) {\n      const { height, width } = getWindowSizes(popper.ownerDocument);\n      boundaries.top += offsets.top - offsets.marginTop;\n      boundaries.bottom = height + offsets.top;\n      boundaries.left += offsets.left - offsets.marginLeft;\n      boundaries.right = width + offsets.left;\n    } else {\n      // for all the other DOM elements, this one is good\n      boundaries = offsets;\n    }\n  }\n\n  // Add paddings\n  padding = padding || 0;\n  const isPaddingNumber = typeof padding === 'number';\n  boundaries.left += isPaddingNumber ? padding : padding.left || 0; \n  boundaries.top += isPaddingNumber ? padding : padding.top || 0; \n  boundaries.right -= isPaddingNumber ? padding : padding.right || 0; \n  boundaries.bottom -= isPaddingNumber ? padding : padding.bottom || 0; \n\n  return boundaries;\n}\n","import getBoundaries from '../utils/getBoundaries';\n\nfunction getArea({ width, height }) {\n  return width * height;\n}\n\n/**\n * Utility used to transform the `auto` placement to the placement with more\n * available space.\n * @method\n * @memberof Popper.Utils\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function computeAutoPlacement(\n  placement,\n  refRect,\n  popper,\n  reference,\n  boundariesElement,\n  padding = 0\n) {\n  if (placement.indexOf('auto') === -1) {\n    return placement;\n  }\n\n  const boundaries = getBoundaries(\n    popper,\n    reference,\n    padding,\n    boundariesElement\n  );\n\n  const rects = {\n    top: {\n      width: boundaries.width,\n      height: refRect.top - boundaries.top,\n    },\n    right: {\n      width: boundaries.right - refRect.right,\n      height: boundaries.height,\n    },\n    bottom: {\n      width: boundaries.width,\n      height: boundaries.bottom - refRect.bottom,\n    },\n    left: {\n      width: refRect.left - boundaries.left,\n      height: boundaries.height,\n    },\n  };\n\n  const sortedAreas = Object.keys(rects)\n    .map(key => ({\n      key,\n      ...rects[key],\n      area: getArea(rects[key]),\n    }))\n    .sort((a, b) => b.area - a.area);\n\n  const filteredAreas = sortedAreas.filter(\n    ({ width, height }) =>\n      width >= popper.clientWidth && height >= popper.clientHeight\n  );\n\n  const computedPlacement = filteredAreas.length > 0\n    ? filteredAreas[0].key\n    : sortedAreas[0].key;\n\n  const variation = placement.split('-')[1];\n\n  return computedPlacement + (variation ? `-${variation}` : '');\n}\n","import findCommonOffsetParent from './findCommonOffsetParent';\nimport getOffsetRectRelativeToArbitraryNode from './getOffsetRectRelativeToArbitraryNode';\nimport getFixedPositionOffsetParent from './getFixedPositionOffsetParent';\n\n/**\n * Get offsets to the reference element\n * @method\n * @memberof Popper.Utils\n * @param {Object} state\n * @param {Element} popper - the popper element\n * @param {Element} reference - the reference element (the popper will be relative to this)\n * @param {Element} fixedPosition - is in fixed position mode\n * @returns {Object} An object containing the offsets which will be applied to the popper\n */\nexport default function getReferenceOffsets(state, popper, reference, fixedPosition = null) {\n  const commonOffsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference);\n  return getOffsetRectRelativeToArbitraryNode(reference, commonOffsetParent, fixedPosition);\n}\n","/**\n * Get the outer sizes of the given element (offset size + margins)\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Object} object containing width and height properties\n */\nexport default function getOuterSizes(element) {\n  const window = element.ownerDocument.defaultView;\n  const styles = window.getComputedStyle(element);\n  const x = parseFloat(styles.marginTop || 0) + parseFloat(styles.marginBottom || 0);\n  const y = parseFloat(styles.marginLeft || 0) + parseFloat(styles.marginRight || 0);\n  const result = {\n    width: element.offsetWidth + y,\n    height: element.offsetHeight + x,\n  };\n  return result;\n}\n","/**\n * Get the opposite placement of the given one\n * @method\n * @memberof Popper.Utils\n * @argument {String} placement\n * @returns {String} flipped placement\n */\nexport default function getOppositePlacement(placement) {\n  const hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' };\n  return placement.replace(/left|right|bottom|top/g, matched => hash[matched]);\n}\n","import getOuterSizes from './getOuterSizes';\nimport getOppositePlacement from './getOppositePlacement';\n\n/**\n * Get offsets to the popper\n * @method\n * @memberof Popper.Utils\n * @param {Object} position - CSS position the Popper will get applied\n * @param {HTMLElement} popper - the popper element\n * @param {Object} referenceOffsets - the reference offsets (the popper will be relative to this)\n * @param {String} placement - one of the valid placement options\n * @returns {Object} popperOffsets - An object containing the offsets which will be applied to the popper\n */\nexport default function getPopperOffsets(popper, referenceOffsets, placement) {\n  placement = placement.split('-')[0];\n\n  // Get popper node sizes\n  const popperRect = getOuterSizes(popper);\n\n  // Add position, width and height to our offsets object\n  const popperOffsets = {\n    width: popperRect.width,\n    height: popperRect.height,\n  };\n\n  // depending by the popper placement we have to compute its offsets slightly differently\n  const isHoriz = ['right', 'left'].indexOf(placement) !== -1;\n  const mainSide = isHoriz ? 'top' : 'left';\n  const secondarySide = isHoriz ? 'left' : 'top';\n  const measurement = isHoriz ? 'height' : 'width';\n  const secondaryMeasurement = !isHoriz ? 'height' : 'width';\n\n  popperOffsets[mainSide] =\n    referenceOffsets[mainSide] +\n    referenceOffsets[measurement] / 2 -\n    popperRect[measurement] / 2;\n  if (placement === secondarySide) {\n    popperOffsets[secondarySide] =\n      referenceOffsets[secondarySide] - popperRect[secondaryMeasurement];\n  } else {\n    popperOffsets[secondarySide] =\n      referenceOffsets[getOppositePlacement(secondarySide)];\n  }\n\n  return popperOffsets;\n}\n","/**\n * Mimics the `find` method of Array\n * @method\n * @memberof Popper.Utils\n * @argument {Array} arr\n * @argument prop\n * @argument value\n * @returns index or -1\n */\nexport default function find(arr, check) {\n  // use native find if supported\n  if (Array.prototype.find) {\n    return arr.find(check);\n  }\n\n  // use `filter` to obtain the same behavior of `find`\n  return arr.filter(check)[0];\n}\n","import find from './find';\n\n/**\n * Return the index of the matching object\n * @method\n * @memberof Popper.Utils\n * @argument {Array} arr\n * @argument prop\n * @argument value\n * @returns index or -1\n */\nexport default function findIndex(arr, prop, value) {\n  // use native findIndex if supported\n  if (Array.prototype.findIndex) {\n    return arr.findIndex(cur => cur[prop] === value);\n  }\n\n  // use `find` + `indexOf` if `findIndex` isn't supported\n  const match = find(arr, obj => obj[prop] === value);\n  return arr.indexOf(match);\n}\n","import isFunction from './isFunction';\nimport findIndex from './findIndex';\nimport getClientRect from '../utils/getClientRect';\n\n/**\n * Loop trough the list of modifiers and run them in order,\n * each of them will then edit the data object.\n * @method\n * @memberof Popper.Utils\n * @param {dataObject} data\n * @param {Array} modifiers\n * @param {String} ends - Optional modifier name used as stopper\n * @returns {dataObject}\n */\nexport default function runModifiers(modifiers, data, ends) {\n  const modifiersToRun = ends === undefined\n    ? modifiers\n    : modifiers.slice(0, findIndex(modifiers, 'name', ends));\n\n  modifiersToRun.forEach(modifier => {\n    if (modifier['function']) { // eslint-disable-line dot-notation\n      console.warn('`modifier.function` is deprecated, use `modifier.fn`!');\n    }\n    const fn = modifier['function'] || modifier.fn; // eslint-disable-line dot-notation\n    if (modifier.enabled && isFunction(fn)) {\n      // Add properties to offsets to make them a complete clientRect object\n      // we do this before each modifier to make sure the previous one doesn't\n      // mess with these values\n      data.offsets.popper = getClientRect(data.offsets.popper);\n      data.offsets.reference = getClientRect(data.offsets.reference);\n\n      data = fn(data, modifier);\n    }\n  });\n\n  return data;\n}\n","import computeAutoPlacement from '../utils/computeAutoPlacement';\nimport getReferenceOffsets from '../utils/getReferenceOffsets';\nimport getPopperOffsets from '../utils/getPopperOffsets';\nimport runModifiers from '../utils/runModifiers';\n\n/**\n * Updates the position of the popper, computing the new offsets and applying\n * the new style.<br />\n * Prefer `scheduleUpdate` over `update` because of performance reasons.\n * @method\n * @memberof Popper\n */\nexport default function update() {\n  // if popper is destroyed, don't perform any further update\n  if (this.state.isDestroyed) {\n    return;\n  }\n\n  let data = {\n    instance: this,\n    styles: {},\n    arrowStyles: {},\n    attributes: {},\n    flipped: false,\n    offsets: {},\n  };\n\n  // compute reference element offsets\n  data.offsets.reference = getReferenceOffsets(\n    this.state,\n    this.popper,\n    this.reference,\n    this.options.positionFixed\n  );\n\n  // compute auto placement, store placement inside the data object,\n  // modifiers will be able to edit `placement` if needed\n  // and refer to originalPlacement to know the original value\n  data.placement = computeAutoPlacement(\n    this.options.placement,\n    data.offsets.reference,\n    this.popper,\n    this.reference,\n    this.options.modifiers.flip.boundariesElement,\n    this.options.modifiers.flip.padding\n  );\n\n  // store the computed placement inside `originalPlacement`\n  data.originalPlacement = data.placement;\n\n  data.positionFixed = this.options.positionFixed;\n\n  // compute the popper offsets\n  data.offsets.popper = getPopperOffsets(\n    this.popper,\n    data.offsets.reference,\n    data.placement\n  );\n\n  data.offsets.popper.position = this.options.positionFixed\n    ? 'fixed'\n    : 'absolute';\n\n  // run the modifiers\n  data = runModifiers(this.modifiers, data);\n\n  // the first `update` will call `onCreate` callback\n  // the other ones will call `onUpdate` callback\n  if (!this.state.isCreated) {\n    this.state.isCreated = true;\n    this.options.onCreate(data);\n  } else {\n    this.options.onUpdate(data);\n  }\n}\n","/**\n * Helper used to know if the given modifier is enabled.\n * @method\n * @memberof Popper.Utils\n * @returns {Boolean}\n */\nexport default function isModifierEnabled(modifiers, modifierName) {\n  return modifiers.some(\n    ({ name, enabled }) => enabled && name === modifierName\n  );\n}\n","/**\n * Get the prefixed supported property name\n * @method\n * @memberof Popper.Utils\n * @argument {String} property (camelCase)\n * @returns {String} prefixed property (camelCase or PascalCase, depending on the vendor prefix)\n */\nexport default function getSupportedPropertyName(property) {\n  const prefixes = [false, 'ms', 'Webkit', 'Moz', 'O'];\n  const upperProp = property.charAt(0).toUpperCase() + property.slice(1);\n\n  for (let i = 0; i < prefixes.length; i++) {\n    const prefix = prefixes[i];\n    const toCheck = prefix ? `${prefix}${upperProp}` : property;\n    if (typeof document.body.style[toCheck] !== 'undefined') {\n      return toCheck;\n    }\n  }\n  return null;\n}\n","import isModifierEnabled from '../utils/isModifierEnabled';\nimport getSupportedPropertyName from '../utils/getSupportedPropertyName';\n\n/**\n * Destroys the popper.\n * @method\n * @memberof Popper\n */\nexport default function destroy() {\n  this.state.isDestroyed = true;\n\n  // touch DOM only if `applyStyle` modifier is enabled\n  if (isModifierEnabled(this.modifiers, 'applyStyle')) {\n    this.popper.removeAttribute('x-placement');\n    this.popper.style.position = '';\n    this.popper.style.top = '';\n    this.popper.style.left = '';\n    this.popper.style.right = '';\n    this.popper.style.bottom = '';\n    this.popper.style.willChange = '';\n    this.popper.style[getSupportedPropertyName('transform')] = '';\n  }\n\n  this.disableEventListeners();\n\n  // remove the popper if user explicity asked for the deletion on destroy\n  // do not use `remove` because IE11 doesn't support it\n  if (this.options.removeOnDestroy) {\n    this.popper.parentNode.removeChild(this.popper);\n  }\n  return this;\n}\n","/**\n * Get the window associated with the element\n * @argument {Element} element\n * @returns {Window}\n */\nexport default function getWindow(element) {\n  const ownerDocument = element.ownerDocument;\n  return ownerDocument ? ownerDocument.defaultView : window;\n}\n","import getScrollParent from './getScrollParent';\nimport getWindow from './getWindow';\n\nfunction attachToScrollParents(scrollParent, event, callback, scrollParents) {\n  const isBody = scrollParent.nodeName === 'BODY';\n  const target = isBody ? scrollParent.ownerDocument.defaultView : scrollParent;\n  target.addEventListener(event, callback, { passive: true });\n\n  if (!isBody) {\n    attachToScrollParents(\n      getScrollParent(target.parentNode),\n      event,\n      callback,\n      scrollParents\n    );\n  }\n  scrollParents.push(target);\n}\n\n/**\n * Setup needed event listeners used to update the popper position\n * @method\n * @memberof Popper.Utils\n * @private\n */\nexport default function setupEventListeners(\n  reference,\n  options,\n  state,\n  updateBound\n) {\n  // Resize event listener on window\n  state.updateBound = updateBound;\n  getWindow(reference).addEventListener('resize', state.updateBound, { passive: true });\n\n  // Scroll event listener on scroll parents\n  const scrollElement = getScrollParent(reference);\n  attachToScrollParents(\n    scrollElement,\n    'scroll',\n    state.updateBound,\n    state.scrollParents\n  );\n  state.scrollElement = scrollElement;\n  state.eventsEnabled = true;\n\n  return state;\n}\n","import setupEventListeners from '../utils/setupEventListeners';\n\n/**\n * It will add resize/scroll events and start recalculating\n * position of the popper element when they are triggered.\n * @method\n * @memberof Popper\n */\nexport default function enableEventListeners() {\n  if (!this.state.eventsEnabled) {\n    this.state = setupEventListeners(\n      this.reference,\n      this.options,\n      this.state,\n      this.scheduleUpdate\n    );\n  }\n}\n","import getWindow from './getWindow';\n\n/**\n * Remove event listeners used to update the popper position\n * @method\n * @memberof Popper.Utils\n * @private\n */\nexport default function removeEventListeners(reference, state) {\n  // Remove resize event listener on window\n  getWindow(reference).removeEventListener('resize', state.updateBound);\n\n  // Remove scroll event listener on scroll parents\n  state.scrollParents.forEach(target => {\n    target.removeEventListener('scroll', state.updateBound);\n  });\n\n  // Reset state\n  state.updateBound = null;\n  state.scrollParents = [];\n  state.scrollElement = null;\n  state.eventsEnabled = false;\n  return state;\n}\n","import removeEventListeners from '../utils/removeEventListeners';\n\n/**\n * It will remove resize/scroll events and won't recalculate popper position\n * when they are triggered. It also won't trigger `onUpdate` callback anymore,\n * unless you call `update` method manually.\n * @method\n * @memberof Popper\n */\nexport default function disableEventListeners() {\n  if (this.state.eventsEnabled) {\n    cancelAnimationFrame(this.scheduleUpdate);\n    this.state = removeEventListeners(this.reference, this.state);\n  }\n}\n","/**\n * Tells if a given input is a number\n * @method\n * @memberof Popper.Utils\n * @param {*} input to check\n * @return {Boolean}\n */\nexport default function isNumeric(n) {\n  return n !== '' && !isNaN(parseFloat(n)) && isFinite(n);\n}\n","import isNumeric from './isNumeric';\n\n/**\n * Set the style to the given popper\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element - Element to apply the style to\n * @argument {Object} styles\n * Object with a list of properties and values which will be applied to the element\n */\nexport default function setStyles(element, styles) {\n  Object.keys(styles).forEach(prop => {\n    let unit = '';\n    // add unit if the value is numeric and is one of the following\n    if (\n      ['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) !==\n        -1 &&\n      isNumeric(styles[prop])\n    ) {\n      unit = 'px';\n    }\n    element.style[prop] = styles[prop] + unit;\n  });\n}\n","/**\n * Set the attributes to the given popper\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element - Element to apply the attributes to\n * @argument {Object} styles\n * Object with a list of properties and values which will be applied to the element\n */\nexport default function setAttributes(element, attributes) {\n  Object.keys(attributes).forEach(function(prop) {\n    const value = attributes[prop];\n    if (value !== false) {\n      element.setAttribute(prop, attributes[prop]);\n    } else {\n      element.removeAttribute(prop);\n    }\n  });\n}\n","import setStyles from '../utils/setStyles';\nimport setAttributes from '../utils/setAttributes';\nimport getReferenceOffsets from '../utils/getReferenceOffsets';\nimport computeAutoPlacement from '../utils/computeAutoPlacement';\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} data.styles - List of style properties - values to apply to popper element\n * @argument {Object} data.attributes - List of attribute properties - values to apply to popper element\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The same data object\n */\nexport default function applyStyle(data) {\n  // any property present in `data.styles` will be applied to the popper,\n  // in this way we can make the 3rd party modifiers add custom styles to it\n  // Be aware, modifiers could override the properties defined in the previous\n  // lines of this modifier!\n  setStyles(data.instance.popper, data.styles);\n\n  // any property present in `data.attributes` will be applied to the popper,\n  // they will be set as HTML attributes of the element\n  setAttributes(data.instance.popper, data.attributes);\n\n  // if arrowElement is defined and arrowStyles has some properties\n  if (data.arrowElement && Object.keys(data.arrowStyles).length) {\n    setStyles(data.arrowElement, data.arrowStyles);\n  }\n\n  return data;\n}\n\n/**\n * Set the x-placement attribute before everything else because it could be used\n * to add margins to the popper margins needs to be calculated to get the\n * correct popper offsets.\n * @method\n * @memberof Popper.modifiers\n * @param {HTMLElement} reference - The reference element used to position the popper\n * @param {HTMLElement} popper - The HTML element used as popper\n * @param {Object} options - Popper.js options\n */\nexport function applyStyleOnLoad(\n  reference,\n  popper,\n  options,\n  modifierOptions,\n  state\n) {\n  // compute reference element offsets\n  const referenceOffsets = getReferenceOffsets(state, popper, reference, options.positionFixed);\n\n  // compute auto placement, store placement inside the data object,\n  // modifiers will be able to edit `placement` if needed\n  // and refer to originalPlacement to know the original value\n  const placement = computeAutoPlacement(\n    options.placement,\n    referenceOffsets,\n    popper,\n    reference,\n    options.modifiers.flip.boundariesElement,\n    options.modifiers.flip.padding\n  );\n\n  popper.setAttribute('x-placement', placement);\n\n  // Apply `position` to popper before anything else because\n  // without the position applied we can't guarantee correct computations\n  setStyles(popper, { position: options.positionFixed ? 'fixed' : 'absolute' });\n\n  return options;\n}\n","/**\n * @function\n * @memberof Popper.Utils\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Boolean} shouldRound - If the offsets should be rounded at all\n * @returns {Object} The popper's position offsets rounded\n *\n * The tale of pixel-perfect positioning. It's still not 100% perfect, but as\n * good as it can be within reason.\n * Discussion here: https://github.com/FezVrasta/popper.js/pull/715\n *\n * Low DPI screens cause a popper to be blurry if not using full pixels (Safari\n * as well on High DPI screens).\n *\n * Firefox prefers no rounding for positioning and does not have blurriness on\n * high DPI screens.\n *\n * Only horizontal placement and left/right values need to be considered.\n */\nexport default function getRoundedOffsets(data, shouldRound) {\n  const { popper, reference } = data.offsets;\n  const { round, floor } = Math;\n  const noRound = v => v;\n  \n  const referenceWidth = round(reference.width);\n  const popperWidth = round(popper.width);\n  \n  const isVertical = ['left', 'right'].indexOf(data.placement) !== -1;\n  const isVariation = data.placement.indexOf('-') !== -1;\n  const sameWidthParity = referenceWidth % 2 === popperWidth % 2;\n  const bothOddWidth = referenceWidth % 2 === 1 && popperWidth % 2 === 1;\n\n  const horizontalToInteger = !shouldRound\n    ? noRound\n    : isVertical || isVariation || sameWidthParity\n    ? round\n    : floor;\n  const verticalToInteger = !shouldRound ? noRound : round;\n\n  return {\n    left: horizontalToInteger(\n      bothOddWidth && !isVariation && shouldRound\n        ? popper.left - 1\n        : popper.left\n    ),\n    top: verticalToInteger(popper.top),\n    bottom: verticalToInteger(popper.bottom),\n    right: horizontalToInteger(popper.right),\n  };\n}\n","import getSupportedPropertyName from '../utils/getSupportedPropertyName';\nimport find from '../utils/find';\nimport getOffsetParent from '../utils/getOffsetParent';\nimport getBoundingClientRect from '../utils/getBoundingClientRect';\nimport getRoundedOffsets from '../utils/getRoundedOffsets';\nimport isBrowser from '../utils/isBrowser';\n\nconst isFirefox = isBrowser && /Firefox/i.test(navigator.userAgent);\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function computeStyle(data, options) {\n  const { x, y } = options;\n  const { popper } = data.offsets;\n\n  // Remove this legacy support in Popper.js v2\n  const legacyGpuAccelerationOption = find(\n    data.instance.modifiers,\n    modifier => modifier.name === 'applyStyle'\n  ).gpuAcceleration;\n  if (legacyGpuAccelerationOption !== undefined) {\n    console.warn(\n      'WARNING: `gpuAcceleration` option moved to `computeStyle` modifier and will not be supported in future versions of Popper.js!'\n    );\n  }\n  const gpuAcceleration =\n    legacyGpuAccelerationOption !== undefined\n      ? legacyGpuAccelerationOption\n      : options.gpuAcceleration;\n\n  const offsetParent = getOffsetParent(data.instance.popper);\n  const offsetParentRect = getBoundingClientRect(offsetParent);\n\n  // Styles\n  const styles = {\n    position: popper.position,\n  };\n\n  const offsets = getRoundedOffsets(\n    data,\n    window.devicePixelRatio < 2 || !isFirefox\n  );\n\n  const sideA = x === 'bottom' ? 'top' : 'bottom';\n  const sideB = y === 'right' ? 'left' : 'right';\n\n  // if gpuAcceleration is set to `true` and transform is supported,\n  //  we use `translate3d` to apply the position to the popper we\n  // automatically use the supported prefixed version if needed\n  const prefixedProperty = getSupportedPropertyName('transform');\n\n  // now, let's make a step back and look at this code closely (wtf?)\n  // If the content of the popper grows once it's been positioned, it\n  // may happen that the popper gets misplaced because of the new content\n  // overflowing its reference element\n  // To avoid this problem, we provide two options (x and y), which allow\n  // the consumer to define the offset origin.\n  // If we position a popper on top of a reference element, we can set\n  // `x` to `top` to make the popper grow towards its top instead of\n  // its bottom.\n  let left, top;\n  if (sideA === 'bottom') {\n    // when offsetParent is <html> the positioning is relative to the bottom of the screen (excluding the scrollbar)\n    // and not the bottom of the html element\n    if (offsetParent.nodeName === 'HTML') {\n      top = -offsetParent.clientHeight + offsets.bottom;\n    } else {\n      top = -offsetParentRect.height + offsets.bottom;\n    }\n  } else {\n    top = offsets.top;\n  }\n  if (sideB === 'right') {\n    if (offsetParent.nodeName === 'HTML') {\n      left = -offsetParent.clientWidth + offsets.right;\n    } else {\n      left = -offsetParentRect.width + offsets.right;\n    }\n  } else {\n    left = offsets.left;\n  }\n  if (gpuAcceleration && prefixedProperty) {\n    styles[prefixedProperty] = `translate3d(${left}px, ${top}px, 0)`;\n    styles[sideA] = 0;\n    styles[sideB] = 0;\n    styles.willChange = 'transform';\n  } else {\n    // othwerise, we use the standard `top`, `left`, `bottom` and `right` properties\n    const invertTop = sideA === 'bottom' ? -1 : 1;\n    const invertLeft = sideB === 'right' ? -1 : 1;\n    styles[sideA] = top * invertTop;\n    styles[sideB] = left * invertLeft;\n    styles.willChange = `${sideA}, ${sideB}`;\n  }\n\n  // Attributes\n  const attributes = {\n    'x-placement': data.placement,\n  };\n\n  // Update `data` attributes, styles and arrowStyles\n  data.attributes = { ...attributes, ...data.attributes };\n  data.styles = { ...styles, ...data.styles };\n  data.arrowStyles = { ...data.offsets.arrow, ...data.arrowStyles };\n\n  return data;\n}\n","import find from './find';\n\n/**\n * Helper used to know if the given modifier depends from another one.<br />\n * It checks if the needed modifier is listed and enabled.\n * @method\n * @memberof Popper.Utils\n * @param {Array} modifiers - list of modifiers\n * @param {String} requestingName - name of requesting modifier\n * @param {String} requestedName - name of requested modifier\n * @returns {Boolean}\n */\nexport default function isModifierRequired(\n  modifiers,\n  requestingName,\n  requestedName\n) {\n  const requesting = find(modifiers, ({ name }) => name === requestingName);\n\n  const isRequired =\n    !!requesting &&\n    modifiers.some(modifier => {\n      return (\n        modifier.name === requestedName &&\n        modifier.enabled &&\n        modifier.order < requesting.order\n      );\n    });\n\n  if (!isRequired) {\n    const requesting = `\\`${requestingName}\\``;\n    const requested = `\\`${requestedName}\\``;\n    console.warn(\n      `${requested} modifier is required by ${requesting} modifier in order to work, be sure to include it before ${requesting}!`\n    );\n  }\n  return isRequired;\n}\n","import getClientRect from '../utils/getClientRect';\nimport getOuterSizes from '../utils/getOuterSizes';\nimport isModifierRequired from '../utils/isModifierRequired';\nimport getStyleComputedProperty from '../utils/getStyleComputedProperty';\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function arrow(data, options) {\n  // arrow depends on keepTogether in order to work\n  if (!isModifierRequired(data.instance.modifiers, 'arrow', 'keepTogether')) {\n    return data;\n  }\n\n  let arrowElement = options.element;\n\n  // if arrowElement is a string, suppose it's a CSS selector\n  if (typeof arrowElement === 'string') {\n    arrowElement = data.instance.popper.querySelector(arrowElement);\n\n    // if arrowElement is not found, don't run the modifier\n    if (!arrowElement) {\n      return data;\n    }\n  } else {\n    // if the arrowElement isn't a query selector we must check that the\n    // provided DOM node is child of its popper node\n    if (!data.instance.popper.contains(arrowElement)) {\n      console.warn(\n        'WARNING: `arrow.element` must be child of its popper element!'\n      );\n      return data;\n    }\n  }\n\n  const placement = data.placement.split('-')[0];\n  const { popper, reference } = data.offsets;\n  const isVertical = ['left', 'right'].indexOf(placement) !== -1;\n\n  const len = isVertical ? 'height' : 'width';\n  const sideCapitalized = isVertical ? 'Top' : 'Left';\n  const side = sideCapitalized.toLowerCase();\n  const altSide = isVertical ? 'left' : 'top';\n  const opSide = isVertical ? 'bottom' : 'right';\n  const arrowElementSize = getOuterSizes(arrowElement)[len];\n\n  //\n  // extends keepTogether behavior making sure the popper and its\n  // reference have enough pixels in conjunction\n  //\n\n  // top/left side\n  if (reference[opSide] - arrowElementSize < popper[side]) {\n    data.offsets.popper[side] -=\n      popper[side] - (reference[opSide] - arrowElementSize);\n  }\n  // bottom/right side\n  if (reference[side] + arrowElementSize > popper[opSide]) {\n    data.offsets.popper[side] +=\n      reference[side] + arrowElementSize - popper[opSide];\n  }\n  data.offsets.popper = getClientRect(data.offsets.popper);\n\n  // compute center of the popper\n  const center = reference[side] + reference[len] / 2 - arrowElementSize / 2;\n\n  // Compute the sideValue using the updated popper offsets\n  // take popper margin in account because we don't have this info available\n  const css = getStyleComputedProperty(data.instance.popper);\n  const popperMarginSide = parseFloat(css[`margin${sideCapitalized}`], 10);\n  const popperBorderSide = parseFloat(css[`border${sideCapitalized}Width`], 10);\n  let sideValue =\n    center - data.offsets.popper[side] - popperMarginSide - popperBorderSide;\n\n  // prevent arrowElement from being placed not contiguously to its popper\n  sideValue = Math.max(Math.min(popper[len] - arrowElementSize, sideValue), 0);\n\n  data.arrowElement = arrowElement;\n  data.offsets.arrow = {\n    [side]: Math.round(sideValue),\n    [altSide]: '', // make sure to unset any eventual altSide value from the DOM node\n  };\n\n  return data;\n}\n","/**\n * Get the opposite placement variation of the given one\n * @method\n * @memberof Popper.Utils\n * @argument {String} placement variation\n * @returns {String} flipped placement variation\n */\nexport default function getOppositeVariation(variation) {\n  if (variation === 'end') {\n    return 'start';\n  } else if (variation === 'start') {\n    return 'end';\n  }\n  return variation;\n}\n","/**\n * List of accepted placements to use as values of the `placement` option.<br />\n * Valid placements are:\n * - `auto`\n * - `top`\n * - `right`\n * - `bottom`\n * - `left`\n *\n * Each placement can have a variation from this list:\n * - `-start`\n * - `-end`\n *\n * Variations are interpreted easily if you think of them as the left to right\n * written languages. Horizontally (`top` and `bottom`), `start` is left and `end`\n * is right.<br />\n * Vertically (`left` and `right`), `start` is top and `end` is bottom.\n *\n * Some valid examples are:\n * - `top-end` (on top of reference, right aligned)\n * - `right-start` (on right of reference, top aligned)\n * - `bottom` (on bottom, centered)\n * - `auto-end` (on the side with more space available, alignment depends by placement)\n *\n * @static\n * @type {Array}\n * @enum {String}\n * @readonly\n * @method placements\n * @memberof Popper\n */\nexport default [\n  'auto-start',\n  'auto',\n  'auto-end',\n  'top-start',\n  'top',\n  'top-end',\n  'right-start',\n  'right',\n  'right-end',\n  'bottom-end',\n  'bottom',\n  'bottom-start',\n  'left-end',\n  'left',\n  'left-start',\n];\n","import placements from '../methods/placements';\n\n// Get rid of `auto` `auto-start` and `auto-end`\nconst validPlacements = placements.slice(3);\n\n/**\n * Given an initial placement, returns all the subsequent placements\n * clockwise (or counter-clockwise).\n *\n * @method\n * @memberof Popper.Utils\n * @argument {String} placement - A valid placement (it accepts variations)\n * @argument {Boolean} counter - Set to true to walk the placements counterclockwise\n * @returns {Array} placements including their variations\n */\nexport default function clockwise(placement, counter = false) {\n  const index = validPlacements.indexOf(placement);\n  const arr = validPlacements\n    .slice(index + 1)\n    .concat(validPlacements.slice(0, index));\n  return counter ? arr.reverse() : arr;\n}\n","import getOppositePlacement from '../utils/getOppositePlacement';\nimport getOppositeVariation from '../utils/getOppositeVariation';\nimport getPopperOffsets from '../utils/getPopperOffsets';\nimport runModifiers from '../utils/runModifiers';\nimport getBoundaries from '../utils/getBoundaries';\nimport isModifierEnabled from '../utils/isModifierEnabled';\nimport clockwise from '../utils/clockwise';\n\nconst BEHAVIORS = {\n  FLIP: 'flip',\n  CLOCKWISE: 'clockwise',\n  COUNTERCLOCKWISE: 'counterclockwise',\n};\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function flip(data, options) {\n  // if `inner` modifier is enabled, we can't use the `flip` modifier\n  if (isModifierEnabled(data.instance.modifiers, 'inner')) {\n    return data;\n  }\n\n  if (data.flipped && data.placement === data.originalPlacement) {\n    // seems like flip is trying to loop, probably there's not enough space on any of the flippable sides\n    return data;\n  }\n\n  const boundaries = getBoundaries(\n    data.instance.popper,\n    data.instance.reference,\n    options.padding,\n    options.boundariesElement,\n    data.positionFixed\n  );\n\n  let placement = data.placement.split('-')[0];\n  let placementOpposite = getOppositePlacement(placement);\n  let variation = data.placement.split('-')[1] || '';\n\n  let flipOrder = [];\n\n  switch (options.behavior) {\n    case BEHAVIORS.FLIP:\n      flipOrder = [placement, placementOpposite];\n      break;\n    case BEHAVIORS.CLOCKWISE:\n      flipOrder = clockwise(placement);\n      break;\n    case BEHAVIORS.COUNTERCLOCKWISE:\n      flipOrder = clockwise(placement, true);\n      break;\n    default:\n      flipOrder = options.behavior;\n  }\n\n  flipOrder.forEach((step, index) => {\n    if (placement !== step || flipOrder.length === index + 1) {\n      return data;\n    }\n\n    placement = data.placement.split('-')[0];\n    placementOpposite = getOppositePlacement(placement);\n\n    const popperOffsets = data.offsets.popper;\n    const refOffsets = data.offsets.reference;\n\n    // using floor because the reference offsets may contain decimals we are not going to consider here\n    const floor = Math.floor;\n    const overlapsRef =\n      (placement === 'left' &&\n        floor(popperOffsets.right) > floor(refOffsets.left)) ||\n      (placement === 'right' &&\n        floor(popperOffsets.left) < floor(refOffsets.right)) ||\n      (placement === 'top' &&\n        floor(popperOffsets.bottom) > floor(refOffsets.top)) ||\n      (placement === 'bottom' &&\n        floor(popperOffsets.top) < floor(refOffsets.bottom));\n\n    const overflowsLeft = floor(popperOffsets.left) < floor(boundaries.left);\n    const overflowsRight = floor(popperOffsets.right) > floor(boundaries.right);\n    const overflowsTop = floor(popperOffsets.top) < floor(boundaries.top);\n    const overflowsBottom =\n      floor(popperOffsets.bottom) > floor(boundaries.bottom);\n\n    const overflowsBoundaries =\n      (placement === 'left' && overflowsLeft) ||\n      (placement === 'right' && overflowsRight) ||\n      (placement === 'top' && overflowsTop) ||\n      (placement === 'bottom' && overflowsBottom);\n\n    // flip the variation if required\n    const isVertical = ['top', 'bottom'].indexOf(placement) !== -1;\n\n    // flips variation if reference element overflows boundaries\n    const flippedVariationByRef =\n      !!options.flipVariations &&\n      ((isVertical && variation === 'start' && overflowsLeft) ||\n        (isVertical && variation === 'end' && overflowsRight) ||\n        (!isVertical && variation === 'start' && overflowsTop) ||\n        (!isVertical && variation === 'end' && overflowsBottom));\n\n    // flips variation if popper content overflows boundaries\n    const flippedVariationByContent =\n      !!options.flipVariationsByContent &&\n      ((isVertical && variation === 'start' && overflowsRight) ||\n        (isVertical && variation === 'end' && overflowsLeft) ||\n        (!isVertical && variation === 'start' && overflowsBottom) ||\n        (!isVertical && variation === 'end' && overflowsTop));\n\n    const flippedVariation = flippedVariationByRef || flippedVariationByContent;\n\n    if (overlapsRef || overflowsBoundaries || flippedVariation) {\n      // this boolean to detect any flip loop\n      data.flipped = true;\n\n      if (overlapsRef || overflowsBoundaries) {\n        placement = flipOrder[index + 1];\n      }\n\n      if (flippedVariation) {\n        variation = getOppositeVariation(variation);\n      }\n\n      data.placement = placement + (variation ? '-' + variation : '');\n\n      // this object contains `position`, we want to preserve it along with\n      // any additional property we may add in the future\n      data.offsets.popper = {\n        ...data.offsets.popper,\n        ...getPopperOffsets(\n          data.instance.popper,\n          data.offsets.reference,\n          data.placement\n        ),\n      };\n\n      data = runModifiers(data.instance.modifiers, data, 'flip');\n    }\n  });\n  return data;\n}\n","/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function keepTogether(data) {\n  const { popper, reference } = data.offsets;\n  const placement = data.placement.split('-')[0];\n  const floor = Math.floor;\n  const isVertical = ['top', 'bottom'].indexOf(placement) !== -1;\n  const side = isVertical ? 'right' : 'bottom';\n  const opSide = isVertical ? 'left' : 'top';\n  const measurement = isVertical ? 'width' : 'height';\n\n  if (popper[side] < floor(reference[opSide])) {\n    data.offsets.popper[opSide] =\n      floor(reference[opSide]) - popper[measurement];\n  }\n  if (popper[opSide] > floor(reference[side])) {\n    data.offsets.popper[opSide] = floor(reference[side]);\n  }\n\n  return data;\n}\n","import isNumeric from '../utils/isNumeric';\nimport getClientRect from '../utils/getClientRect';\nimport find from '../utils/find';\n\n/**\n * Converts a string containing value + unit into a px value number\n * @function\n * @memberof {modifiers~offset}\n * @private\n * @argument {String} str - Value + unit string\n * @argument {String} measurement - `height` or `width`\n * @argument {Object} popperOffsets\n * @argument {Object} referenceOffsets\n * @returns {Number|String}\n * Value in pixels, or original string if no values were extracted\n */\nexport function toValue(str, measurement, popperOffsets, referenceOffsets) {\n  // separate value from unit\n  const split = str.match(/((?:\\-|\\+)?\\d*\\.?\\d*)(.*)/);\n  const value = +split[1];\n  const unit = split[2];\n\n  // If it's not a number it's an operator, I guess\n  if (!value) {\n    return str;\n  }\n\n  if (unit.indexOf('%') === 0) {\n    let element;\n    switch (unit) {\n      case '%p':\n        element = popperOffsets;\n        break;\n      case '%':\n      case '%r':\n      default:\n        element = referenceOffsets;\n    }\n\n    const rect = getClientRect(element);\n    return rect[measurement] / 100 * value;\n  } else if (unit === 'vh' || unit === 'vw') {\n    // if is a vh or vw, we calculate the size based on the viewport\n    let size;\n    if (unit === 'vh') {\n      size = Math.max(\n        document.documentElement.clientHeight,\n        window.innerHeight || 0\n      );\n    } else {\n      size = Math.max(\n        document.documentElement.clientWidth,\n        window.innerWidth || 0\n      );\n    }\n    return size / 100 * value;\n  } else {\n    // if is an explicit pixel unit, we get rid of the unit and keep the value\n    // if is an implicit unit, it's px, and we return just the value\n    return value;\n  }\n}\n\n/**\n * Parse an `offset` string to extrapolate `x` and `y` numeric offsets.\n * @function\n * @memberof {modifiers~offset}\n * @private\n * @argument {String} offset\n * @argument {Object} popperOffsets\n * @argument {Object} referenceOffsets\n * @argument {String} basePlacement\n * @returns {Array} a two cells array with x and y offsets in numbers\n */\nexport function parseOffset(\n  offset,\n  popperOffsets,\n  referenceOffsets,\n  basePlacement\n) {\n  const offsets = [0, 0];\n\n  // Use height if placement is left or right and index is 0 otherwise use width\n  // in this way the first offset will use an axis and the second one\n  // will use the other one\n  const useHeight = ['right', 'left'].indexOf(basePlacement) !== -1;\n\n  // Split the offset string to obtain a list of values and operands\n  // The regex addresses values with the plus or minus sign in front (+10, -20, etc)\n  const fragments = offset.split(/(\\+|\\-)/).map(frag => frag.trim());\n\n  // Detect if the offset string contains a pair of values or a single one\n  // they could be separated by comma or space\n  const divider = fragments.indexOf(\n    find(fragments, frag => frag.search(/,|\\s/) !== -1)\n  );\n\n  if (fragments[divider] && fragments[divider].indexOf(',') === -1) {\n    console.warn(\n      'Offsets separated by white space(s) are deprecated, use a comma (,) instead.'\n    );\n  }\n\n  // If divider is found, we divide the list of values and operands to divide\n  // them by ofset X and Y.\n  const splitRegex = /\\s*,\\s*|\\s+/;\n  let ops = divider !== -1\n    ? [\n        fragments\n          .slice(0, divider)\n          .concat([fragments[divider].split(splitRegex)[0]]),\n        [fragments[divider].split(splitRegex)[1]].concat(\n          fragments.slice(divider + 1)\n        ),\n      ]\n    : [fragments];\n\n  // Convert the values with units to absolute pixels to allow our computations\n  ops = ops.map((op, index) => {\n    // Most of the units rely on the orientation of the popper\n    const measurement = (index === 1 ? !useHeight : useHeight)\n      ? 'height'\n      : 'width';\n    let mergeWithPrevious = false;\n    return (\n      op\n        // This aggregates any `+` or `-` sign that aren't considered operators\n        // e.g.: 10 + +5 => [10, +, +5]\n        .reduce((a, b) => {\n          if (a[a.length - 1] === '' && ['+', '-'].indexOf(b) !== -1) {\n            a[a.length - 1] = b;\n            mergeWithPrevious = true;\n            return a;\n          } else if (mergeWithPrevious) {\n            a[a.length - 1] += b;\n            mergeWithPrevious = false;\n            return a;\n          } else {\n            return a.concat(b);\n          }\n        }, [])\n        // Here we convert the string values into number values (in px)\n        .map(str => toValue(str, measurement, popperOffsets, referenceOffsets))\n    );\n  });\n\n  // Loop trough the offsets arrays and execute the operations\n  ops.forEach((op, index) => {\n    op.forEach((frag, index2) => {\n      if (isNumeric(frag)) {\n        offsets[index] += frag * (op[index2 - 1] === '-' ? -1 : 1);\n      }\n    });\n  });\n  return offsets;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @argument {Number|String} options.offset=0\n * The offset value as described in the modifier description\n * @returns {Object} The data object, properly modified\n */\nexport default function offset(data, { offset }) {\n  const { placement, offsets: { popper, reference } } = data;\n  const basePlacement = placement.split('-')[0];\n\n  let offsets;\n  if (isNumeric(+offset)) {\n    offsets = [+offset, 0];\n  } else {\n    offsets = parseOffset(offset, popper, reference, basePlacement);\n  }\n\n  if (basePlacement === 'left') {\n    popper.top += offsets[0];\n    popper.left -= offsets[1];\n  } else if (basePlacement === 'right') {\n    popper.top += offsets[0];\n    popper.left += offsets[1];\n  } else if (basePlacement === 'top') {\n    popper.left += offsets[0];\n    popper.top -= offsets[1];\n  } else if (basePlacement === 'bottom') {\n    popper.left += offsets[0];\n    popper.top += offsets[1];\n  }\n\n  data.popper = popper;\n  return data;\n}\n","import getOffsetParent from '../utils/getOffsetParent';\nimport getBoundaries from '../utils/getBoundaries';\nimport getSupportedPropertyName from '../utils/getSupportedPropertyName';\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function preventOverflow(data, options) {\n  let boundariesElement =\n    options.boundariesElement || getOffsetParent(data.instance.popper);\n\n  // If offsetParent is the reference element, we really want to\n  // go one step up and use the next offsetParent as reference to\n  // avoid to make this modifier completely useless and look like broken\n  if (data.instance.reference === boundariesElement) {\n    boundariesElement = getOffsetParent(boundariesElement);\n  }\n\n  // NOTE: DOM access here\n  // resets the popper's position so that the document size can be calculated excluding\n  // the size of the popper element itself\n  const transformProp = getSupportedPropertyName('transform');\n  const popperStyles = data.instance.popper.style; // assignment to help minification\n  const { top, left, [transformProp]: transform } = popperStyles;\n  popperStyles.top = '';\n  popperStyles.left = '';\n  popperStyles[transformProp] = '';\n\n  const boundaries = getBoundaries(\n    data.instance.popper,\n    data.instance.reference,\n    options.padding,\n    boundariesElement,\n    data.positionFixed\n  );\n\n  // NOTE: DOM access here\n  // restores the original style properties after the offsets have been computed\n  popperStyles.top = top;\n  popperStyles.left = left;\n  popperStyles[transformProp] = transform;\n\n  options.boundaries = boundaries;\n\n  const order = options.priority;\n  let popper = data.offsets.popper;\n\n  const check = {\n    primary(placement) {\n      let value = popper[placement];\n      if (\n        popper[placement] < boundaries[placement] &&\n        !options.escapeWithReference\n      ) {\n        value = Math.max(popper[placement], boundaries[placement]);\n      }\n      return { [placement]: value };\n    },\n    secondary(placement) {\n      const mainSide = placement === 'right' ? 'left' : 'top';\n      let value = popper[mainSide];\n      if (\n        popper[placement] > boundaries[placement] &&\n        !options.escapeWithReference\n      ) {\n        value = Math.min(\n          popper[mainSide],\n          boundaries[placement] -\n            (placement === 'right' ? popper.width : popper.height)\n        );\n      }\n      return { [mainSide]: value };\n    },\n  };\n\n  order.forEach(placement => {\n    const side =\n      ['left', 'top'].indexOf(placement) !== -1 ? 'primary' : 'secondary';\n    popper = { ...popper, ...check[side](placement) };\n  });\n\n  data.offsets.popper = popper;\n\n  return data;\n}\n","/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function shift(data) {\n  const placement = data.placement;\n  const basePlacement = placement.split('-')[0];\n  const shiftvariation = placement.split('-')[1];\n\n  // if shift shiftvariation is specified, run the modifier\n  if (shiftvariation) {\n    const { reference, popper } = data.offsets;\n    const isVertical = ['bottom', 'top'].indexOf(basePlacement) !== -1;\n    const side = isVertical ? 'left' : 'top';\n    const measurement = isVertical ? 'width' : 'height';\n\n    const shiftOffsets = {\n      start: { [side]: reference[side] },\n      end: {\n        [side]: reference[side] + reference[measurement] - popper[measurement],\n      },\n    };\n\n    data.offsets.popper = { ...popper, ...shiftOffsets[shiftvariation] };\n  }\n\n  return data;\n}\n","import isModifierRequired from '../utils/isModifierRequired';\nimport find from '../utils/find';\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function hide(data) {\n  if (!isModifierRequired(data.instance.modifiers, 'hide', 'preventOverflow')) {\n    return data;\n  }\n\n  const refRect = data.offsets.reference;\n  const bound = find(\n    data.instance.modifiers,\n    modifier => modifier.name === 'preventOverflow'\n  ).boundaries;\n\n  if (\n    refRect.bottom < bound.top ||\n    refRect.left > bound.right ||\n    refRect.top > bound.bottom ||\n    refRect.right < bound.left\n  ) {\n    // Avoid unnecessary DOM access if visibility hasn't changed\n    if (data.hide === true) {\n      return data;\n    }\n\n    data.hide = true;\n    data.attributes['x-out-of-boundaries'] = '';\n  } else {\n    // Avoid unnecessary DOM access if visibility hasn't changed\n    if (data.hide === false) {\n      return data;\n    }\n\n    data.hide = false;\n    data.attributes['x-out-of-boundaries'] = false;\n  }\n\n  return data;\n}\n","import getClientRect from '../utils/getClientRect';\nimport getOppositePlacement from '../utils/getOppositePlacement';\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nexport default function inner(data) {\n  const placement = data.placement;\n  const basePlacement = placement.split('-')[0];\n  const { popper, reference } = data.offsets;\n  const isHoriz = ['left', 'right'].indexOf(basePlacement) !== -1;\n\n  const subtractLength = ['top', 'left'].indexOf(basePlacement) === -1;\n\n  popper[isHoriz ? 'left' : 'top'] =\n    reference[basePlacement] -\n    (subtractLength ? popper[isHoriz ? 'width' : 'height'] : 0);\n\n  data.placement = getOppositePlacement(placement);\n  data.offsets.popper = getClientRect(popper);\n\n  return data;\n}\n","import applyStyle, { applyStyleOnLoad } from './applyStyle';\nimport computeStyle from './computeStyle';\nimport arrow from './arrow';\nimport flip from './flip';\nimport keepTogether from './keepTogether';\nimport offset from './offset';\nimport preventOverflow from './preventOverflow';\nimport shift from './shift';\nimport hide from './hide';\nimport inner from './inner';\n\n/**\n * Modifier function, each modifier can have a function of this type assigned\n * to its `fn` property.<br />\n * These functions will be called on each update, this means that you must\n * make sure they are performant enough to avoid performance bottlenecks.\n *\n * @function ModifierFn\n * @argument {dataObject} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {dataObject} The data object, properly modified\n */\n\n/**\n * Modifiers are plugins used to alter the behavior of your poppers.<br />\n * Popper.js uses a set of 9 modifiers to provide all the basic functionalities\n * needed by the library.\n *\n * Usually you don't want to override the `order`, `fn` and `onLoad` props.\n * All the other properties are configurations that could be tweaked.\n * @namespace modifiers\n */\nexport default {\n  /**\n   * Modifier used to shift the popper on the start or end of its reference\n   * element.<br />\n   * It will read the variation of the `placement` property.<br />\n   * It can be one either `-end` or `-start`.\n   * @memberof modifiers\n   * @inner\n   */\n  shift: {\n    /** @prop {number} order=100 - Index used to define the order of execution */\n    order: 100,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: shift,\n  },\n\n  /**\n   * The `offset` modifier can shift your popper on both its axis.\n   *\n   * It accepts the following units:\n   * - `px` or unit-less, interpreted as pixels\n   * - `%` or `%r`, percentage relative to the length of the reference element\n   * - `%p`, percentage relative to the length of the popper element\n   * - `vw`, CSS viewport width unit\n   * - `vh`, CSS viewport height unit\n   *\n   * For length is intended the main axis relative to the placement of the popper.<br />\n   * This means that if the placement is `top` or `bottom`, the length will be the\n   * `width`. In case of `left` or `right`, it will be the `height`.\n   *\n   * You can provide a single value (as `Number` or `String`), or a pair of values\n   * as `String` divided by a comma or one (or more) white spaces.<br />\n   * The latter is a deprecated method because it leads to confusion and will be\n   * removed in v2.<br />\n   * Additionally, it accepts additions and subtractions between different units.\n   * Note that multiplications and divisions aren't supported.\n   *\n   * Valid examples are:\n   * ```\n   * 10\n   * '10%'\n   * '10, 10'\n   * '10%, 10'\n   * '10 + 10%'\n   * '10 - 5vh + 3%'\n   * '-10px + 5vh, 5px - 6%'\n   * ```\n   * > **NB**: If you desire to apply offsets to your poppers in a way that may make them overlap\n   * > with their reference element, unfortunately, you will have to disable the `flip` modifier.\n   * > You can read more on this at this [issue](https://github.com/FezVrasta/popper.js/issues/373).\n   *\n   * @memberof modifiers\n   * @inner\n   */\n  offset: {\n    /** @prop {number} order=200 - Index used to define the order of execution */\n    order: 200,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: offset,\n    /** @prop {Number|String} offset=0\n     * The offset value as described in the modifier description\n     */\n    offset: 0,\n  },\n\n  /**\n   * Modifier used to prevent the popper from being positioned outside the boundary.\n   *\n   * A scenario exists where the reference itself is not within the boundaries.<br />\n   * We can say it has \"escaped the boundaries\" — or just \"escaped\".<br />\n   * In this case we need to decide whether the popper should either:\n   *\n   * - detach from the reference and remain \"trapped\" in the boundaries, or\n   * - if it should ignore the boundary and \"escape with its reference\"\n   *\n   * When `escapeWithReference` is set to`true` and reference is completely\n   * outside its boundaries, the popper will overflow (or completely leave)\n   * the boundaries in order to remain attached to the edge of the reference.\n   *\n   * @memberof modifiers\n   * @inner\n   */\n  preventOverflow: {\n    /** @prop {number} order=300 - Index used to define the order of execution */\n    order: 300,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: preventOverflow,\n    /**\n     * @prop {Array} [priority=['left','right','top','bottom']]\n     * Popper will try to prevent overflow following these priorities by default,\n     * then, it could overflow on the left and on top of the `boundariesElement`\n     */\n    priority: ['left', 'right', 'top', 'bottom'],\n    /**\n     * @prop {number} padding=5\n     * Amount of pixel used to define a minimum distance between the boundaries\n     * and the popper. This makes sure the popper always has a little padding\n     * between the edges of its container\n     */\n    padding: 5,\n    /**\n     * @prop {String|HTMLElement} boundariesElement='scrollParent'\n     * Boundaries used by the modifier. Can be `scrollParent`, `window`,\n     * `viewport` or any DOM element.\n     */\n    boundariesElement: 'scrollParent',\n  },\n\n  /**\n   * Modifier used to make sure the reference and its popper stay near each other\n   * without leaving any gap between the two. Especially useful when the arrow is\n   * enabled and you want to ensure that it points to its reference element.\n   * It cares only about the first axis. You can still have poppers with margin\n   * between the popper and its reference element.\n   * @memberof modifiers\n   * @inner\n   */\n  keepTogether: {\n    /** @prop {number} order=400 - Index used to define the order of execution */\n    order: 400,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: keepTogether,\n  },\n\n  /**\n   * This modifier is used to move the `arrowElement` of the popper to make\n   * sure it is positioned between the reference element and its popper element.\n   * It will read the outer size of the `arrowElement` node to detect how many\n   * pixels of conjunction are needed.\n   *\n   * It has no effect if no `arrowElement` is provided.\n   * @memberof modifiers\n   * @inner\n   */\n  arrow: {\n    /** @prop {number} order=500 - Index used to define the order of execution */\n    order: 500,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: arrow,\n    /** @prop {String|HTMLElement} element='[x-arrow]' - Selector or node used as arrow */\n    element: '[x-arrow]',\n  },\n\n  /**\n   * Modifier used to flip the popper's placement when it starts to overlap its\n   * reference element.\n   *\n   * Requires the `preventOverflow` modifier before it in order to work.\n   *\n   * **NOTE:** this modifier will interrupt the current update cycle and will\n   * restart it if it detects the need to flip the placement.\n   * @memberof modifiers\n   * @inner\n   */\n  flip: {\n    /** @prop {number} order=600 - Index used to define the order of execution */\n    order: 600,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: flip,\n    /**\n     * @prop {String|Array} behavior='flip'\n     * The behavior used to change the popper's placement. It can be one of\n     * `flip`, `clockwise`, `counterclockwise` or an array with a list of valid\n     * placements (with optional variations)\n     */\n    behavior: 'flip',\n    /**\n     * @prop {number} padding=5\n     * The popper will flip if it hits the edges of the `boundariesElement`\n     */\n    padding: 5,\n    /**\n     * @prop {String|HTMLElement} boundariesElement='viewport'\n     * The element which will define the boundaries of the popper position.\n     * The popper will never be placed outside of the defined boundaries\n     * (except if `keepTogether` is enabled)\n     */\n    boundariesElement: 'viewport',\n    /**\n     * @prop {Boolean} flipVariations=false\n     * The popper will switch placement variation between `-start` and `-end` when\n     * the reference element overlaps its boundaries.\n     *\n     * The original placement should have a set variation.\n     */\n    flipVariations: false,\n    /**\n     * @prop {Boolean} flipVariationsByContent=false\n     * The popper will switch placement variation between `-start` and `-end` when\n     * the popper element overlaps its reference boundaries.\n     *\n     * The original placement should have a set variation.\n     */\n    flipVariationsByContent: false,\n  },\n\n  /**\n   * Modifier used to make the popper flow toward the inner of the reference element.\n   * By default, when this modifier is disabled, the popper will be placed outside\n   * the reference element.\n   * @memberof modifiers\n   * @inner\n   */\n  inner: {\n    /** @prop {number} order=700 - Index used to define the order of execution */\n    order: 700,\n    /** @prop {Boolean} enabled=false - Whether the modifier is enabled or not */\n    enabled: false,\n    /** @prop {ModifierFn} */\n    fn: inner,\n  },\n\n  /**\n   * Modifier used to hide the popper when its reference element is outside of the\n   * popper boundaries. It will set a `x-out-of-boundaries` attribute which can\n   * be used to hide with a CSS selector the popper when its reference is\n   * out of boundaries.\n   *\n   * Requires the `preventOverflow` modifier before it in order to work.\n   * @memberof modifiers\n   * @inner\n   */\n  hide: {\n    /** @prop {number} order=800 - Index used to define the order of execution */\n    order: 800,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: hide,\n  },\n\n  /**\n   * Computes the style that will be applied to the popper element to gets\n   * properly positioned.\n   *\n   * Note that this modifier will not touch the DOM, it just prepares the styles\n   * so that `applyStyle` modifier can apply it. This separation is useful\n   * in case you need to replace `applyStyle` with a custom implementation.\n   *\n   * This modifier has `850` as `order` value to maintain backward compatibility\n   * with previous versions of Popper.js. Expect the modifiers ordering method\n   * to change in future major versions of the library.\n   *\n   * @memberof modifiers\n   * @inner\n   */\n  computeStyle: {\n    /** @prop {number} order=850 - Index used to define the order of execution */\n    order: 850,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: computeStyle,\n    /**\n     * @prop {Boolean} gpuAcceleration=true\n     * If true, it uses the CSS 3D transformation to position the popper.\n     * Otherwise, it will use the `top` and `left` properties\n     */\n    gpuAcceleration: true,\n    /**\n     * @prop {string} [x='bottom']\n     * Where to anchor the X axis (`bottom` or `top`). AKA X offset origin.\n     * Change this if your popper should grow in a direction different from `bottom`\n     */\n    x: 'bottom',\n    /**\n     * @prop {string} [x='left']\n     * Where to anchor the Y axis (`left` or `right`). AKA Y offset origin.\n     * Change this if your popper should grow in a direction different from `right`\n     */\n    y: 'right',\n  },\n\n  /**\n   * Applies the computed styles to the popper element.\n   *\n   * All the DOM manipulations are limited to this modifier. This is useful in case\n   * you want to integrate Popper.js inside a framework or view library and you\n   * want to delegate all the DOM manipulations to it.\n   *\n   * Note that if you disable this modifier, you must make sure the popper element\n   * has its position set to `absolute` before Popper.js can do its work!\n   *\n   * Just disable this modifier and define your own to achieve the desired effect.\n   *\n   * @memberof modifiers\n   * @inner\n   */\n  applyStyle: {\n    /** @prop {number} order=900 - Index used to define the order of execution */\n    order: 900,\n    /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n    enabled: true,\n    /** @prop {ModifierFn} */\n    fn: applyStyle,\n    /** @prop {Function} */\n    onLoad: applyStyleOnLoad,\n    /**\n     * @deprecated since version 1.10.0, the property moved to `computeStyle` modifier\n     * @prop {Boolean} gpuAcceleration=true\n     * If true, it uses the CSS 3D transformation to position the popper.\n     * Otherwise, it will use the `top` and `left` properties\n     */\n    gpuAcceleration: undefined,\n  },\n};\n\n/**\n * The `dataObject` is an object containing all the information used by Popper.js.\n * This object is passed to modifiers and to the `onCreate` and `onUpdate` callbacks.\n * @name dataObject\n * @property {Object} data.instance The Popper.js instance\n * @property {String} data.placement Placement applied to popper\n * @property {String} data.originalPlacement Placement originally defined on init\n * @property {Boolean} data.flipped True if popper has been flipped by flip modifier\n * @property {Boolean} data.hide True if the reference element is out of boundaries, useful to know when to hide the popper\n * @property {HTMLElement} data.arrowElement Node used as arrow by arrow modifier\n * @property {Object} data.styles Any CSS property defined here will be applied to the popper. It expects the JavaScript nomenclature (eg. `marginBottom`)\n * @property {Object} data.arrowStyles Any CSS property defined here will be applied to the popper arrow. It expects the JavaScript nomenclature (eg. `marginBottom`)\n * @property {Object} data.boundaries Offsets of the popper boundaries\n * @property {Object} data.offsets The measurements of popper, reference and arrow elements\n * @property {Object} data.offsets.popper `top`, `left`, `width`, `height` values\n * @property {Object} data.offsets.reference `top`, `left`, `width`, `height` values\n * @property {Object} data.offsets.arrow] `top` and `left` offsets, only one of them will be different from 0\n */\n","import modifiers from '../modifiers/index';\n\n/**\n * Default options provided to Popper.js constructor.<br />\n * These can be overridden using the `options` argument of Popper.js.<br />\n * To override an option, simply pass an object with the same\n * structure of the `options` object, as the 3rd argument. For example:\n * ```\n * new Popper(ref, pop, {\n *   modifiers: {\n *     preventOverflow: { enabled: false }\n *   }\n * })\n * ```\n * @type {Object}\n * @static\n * @memberof Popper\n */\nexport default {\n  /**\n   * Popper's placement.\n   * @prop {Popper.placements} placement='bottom'\n   */\n  placement: 'bottom',\n\n  /**\n   * Set this to true if you want popper to position it self in 'fixed' mode\n   * @prop {Boolean} positionFixed=false\n   */\n  positionFixed: false,\n\n  /**\n   * Whether events (resize, scroll) are initially enabled.\n   * @prop {Boolean} eventsEnabled=true\n   */\n  eventsEnabled: true,\n\n  /**\n   * Set to true if you want to automatically remove the popper when\n   * you call the `destroy` method.\n   * @prop {Boolean} removeOnDestroy=false\n   */\n  removeOnDestroy: false,\n\n  /**\n   * Callback called when the popper is created.<br />\n   * By default, it is set to no-op.<br />\n   * Access Popper.js instance with `data.instance`.\n   * @prop {onCreate}\n   */\n  onCreate: () => {},\n\n  /**\n   * Callback called when the popper is updated. This callback is not called\n   * on the initialization/creation of the popper, but only on subsequent\n   * updates.<br />\n   * By default, it is set to no-op.<br />\n   * Access Popper.js instance with `data.instance`.\n   * @prop {onUpdate}\n   */\n  onUpdate: () => {},\n\n  /**\n   * List of modifiers used to modify the offsets before they are applied to the popper.\n   * They provide most of the functionalities of Popper.js.\n   * @prop {modifiers}\n   */\n  modifiers,\n};\n\n/**\n * @callback onCreate\n * @param {dataObject} data\n */\n\n/**\n * @callback onUpdate\n * @param {dataObject} data\n */\n","// Utils\nimport debounce from './utils/debounce';\nimport isFunction from './utils/isFunction';\n\n// Methods\nimport update from './methods/update';\nimport destroy from './methods/destroy';\nimport enableEventListeners from './methods/enableEventListeners';\nimport disableEventListeners from './methods/disableEventListeners';\nimport Defaults from './methods/defaults';\nimport placements from './methods/placements';\n\nexport default class Popper {\n  /**\n   * Creates a new Popper.js instance.\n   * @class Popper\n   * @param {Element|referenceObject} reference - The reference element used to position the popper\n   * @param {Element} popper - The HTML / XML element used as the popper\n   * @param {Object} options - Your custom options to override the ones defined in [Defaults](#defaults)\n   * @return {Object} instance - The generated Popper.js instance\n   */\n  constructor(reference, popper, options = {}) {\n    // make update() debounced, so that it only runs at most once-per-tick\n    this.update = debounce(this.update.bind(this));\n\n    // with {} we create a new object with the options inside it\n    this.options = { ...Popper.Defaults, ...options };\n\n    // init state\n    this.state = {\n      isDestroyed: false,\n      isCreated: false,\n      scrollParents: [],\n    };\n\n    // get reference and popper elements (allow jQuery wrappers)\n    this.reference = reference && reference.jquery ? reference[0] : reference;\n    this.popper = popper && popper.jquery ? popper[0] : popper;\n\n    // Deep merge modifiers options\n    this.options.modifiers = {};\n    Object.keys({\n      ...Popper.Defaults.modifiers,\n      ...options.modifiers,\n    }).forEach(name => {\n      this.options.modifiers[name] = {\n        // If it's a built-in modifier, use it as base\n        ...(Popper.Defaults.modifiers[name] || {}),\n        // If there are custom options, override and merge with default ones\n        ...(options.modifiers ? options.modifiers[name] : {}),\n      };\n    });\n\n    // Refactoring modifiers' list (Object => Array)\n    this.modifiers = Object.keys(this.options.modifiers)\n      .map(name => ({\n        name,\n        ...this.options.modifiers[name],\n      }))\n      // sort the modifiers by order\n      .sort((a, b) => a.order - b.order);\n\n    // modifiers have the ability to execute arbitrary code when Popper.js get inited\n    // such code is executed in the same order of its modifier\n    // they could add new properties to their options configuration\n    // BE AWARE: don't add options to `options.modifiers.name` but to `modifierOptions`!\n    this.modifiers.forEach(modifierOptions => {\n      if (modifierOptions.enabled && isFunction(modifierOptions.onLoad)) {\n        modifierOptions.onLoad(\n          this.reference,\n          this.popper,\n          this.options,\n          modifierOptions,\n          this.state\n        );\n      }\n    });\n\n    // fire the first update to position the popper in the right place\n    this.update();\n\n    const eventsEnabled = this.options.eventsEnabled;\n    if (eventsEnabled) {\n      // setup event listeners, they will take care of update the position in specific situations\n      this.enableEventListeners();\n    }\n\n    this.state.eventsEnabled = eventsEnabled;\n  }\n\n  // We can't use class properties because they don't get listed in the\n  // class prototype and break stuff like Sinon stubs\n  update() {\n    return update.call(this);\n  }\n  destroy() {\n    return destroy.call(this);\n  }\n  enableEventListeners() {\n    return enableEventListeners.call(this);\n  }\n  disableEventListeners() {\n    return disableEventListeners.call(this);\n  }\n\n  /**\n   * Schedules an update. It will run on the next UI update available.\n   * @method scheduleUpdate\n   * @memberof Popper\n   */\n  scheduleUpdate = () => requestAnimationFrame(this.update);\n\n  /**\n   * Collection of utilities useful when writing custom modifiers.\n   * Starting from version 1.7, this method is available only if you\n   * include `popper-utils.js` before `popper.js`.\n   *\n   * **DEPRECATION**: This way to access PopperUtils is deprecated\n   * and will be removed in v2! Use the PopperUtils module directly instead.\n   * Due to the high instability of the methods contained in Utils, we can't\n   * guarantee them to follow semver. Use them at your own risk!\n   * @static\n   * @private\n   * @type {Object}\n   * @deprecated since version 1.8\n   * @member Utils\n   * @memberof Popper\n   */\n  static Utils = (typeof window !== 'undefined' ? window : global).PopperUtils;\n\n  static placements = placements;\n\n  static Defaults = Defaults;\n}\n\n/**\n * The `referenceObject` is an object that provides an interface compatible with Popper.js\n * and lets you use it as replacement of a real DOM node.<br />\n * You can use this method to position a popper relatively to a set of coordinates\n * in case you don't have a DOM node to use as reference.\n *\n * ```\n * new Popper(referenceObject, popperNode);\n * ```\n *\n * NB: This feature isn't supported in Internet Explorer 10.\n * @name referenceObject\n * @property {Function} data.getBoundingClientRect\n * A function that returns a set of coordinates compatible with the native `getBoundingClientRect` method.\n * @property {number} data.clientWidth\n * An ES6 getter that will return the width of the virtual reference element.\n * @property {number} data.clientHeight\n * An ES6 getter that will return the height of the virtual reference element.\n */\n"],"names":["window","document","timeoutDuration","i","longerTimeoutBrowsers","length","isBrowser","navigator","userAgent","indexOf","called","Promise","resolve","then","scheduled","supportsMicroTasks","functionToCheck","getType","toString","call","element","nodeType","ownerDocument","defaultView","css","getComputedStyle","property","nodeName","parentNode","host","body","overflow","overflowX","overflowY","getStyleComputedProperty","test","getScrollParent","getParentNode","isIE11","MSInputMethodContext","documentMode","isIE10","version","documentElement","noOffsetParent","isIE","offsetParent","nextElementSibling","getOffsetParent","firstElementChild","node","getRoot","element1","element2","order","compareDocumentPosition","Node","DOCUMENT_POSITION_FOLLOWING","start","end","range","createRange","setStart","setEnd","commonAncestorContainer","contains","isOffsetContainer","element1root","findCommonOffsetParent","side","upperSide","html","scrollingElement","subtract","scrollTop","getScroll","scrollLeft","modifier","top","bottom","left","right","sideA","axis","sideB","parseFloat","styles","Math","max","parseInt","computedStyle","getSize","offsets","width","height","rect","getBoundingClientRect","result","sizes","getWindowSizes","clientWidth","clientHeight","horizScrollbar","offsetWidth","vertScrollbar","offsetHeight","getBordersSize","getClientRect","fixedPosition","runIsIE","isHTML","parent","childrenRect","parentRect","scrollParent","borderTopWidth","borderLeftWidth","marginTop","marginLeft","includeScroll","excludeScroll","relativeOffset","getOffsetRectRelativeToArbitraryNode","innerWidth","innerHeight","offset","isFixed","parentElement","el","boundaries","getFixedPositionOffsetParent","boundariesElement","getViewportOffsetRectRelativeToArtbitraryNode","boundariesNode","popper","padding","isPaddingNumber","placement","getBoundaries","rects","refRect","sortedAreas","Object","keys","map","key","getArea","sort","b","area","a","filteredAreas","filter","computedPlacement","variation","split","commonOffsetParent","x","marginBottom","y","marginRight","hash","replace","matched","popperRect","getOuterSizes","popperOffsets","isHoriz","mainSide","secondarySide","measurement","secondaryMeasurement","referenceOffsets","getOppositePlacement","Array","prototype","find","arr","findIndex","cur","match","obj","modifiersToRun","ends","modifiers","slice","forEach","warn","fn","enabled","isFunction","data","reference","state","isDestroyed","getReferenceOffsets","options","positionFixed","computeAutoPlacement","flip","originalPlacement","getPopperOffsets","position","runModifiers","isCreated","onUpdate","onCreate","some","name","prefixes","upperProp","charAt","toUpperCase","prefix","toCheck","style","isModifierEnabled","removeAttribute","willChange","getSupportedPropertyName","disableEventListeners","removeOnDestroy","removeChild","isBody","target","addEventListener","passive","push","updateBound","scrollElement","scrollParents","eventsEnabled","setupEventListeners","scheduleUpdate","removeEventListener","removeEventListeners","n","isNaN","isFinite","prop","unit","isNumeric","value","attributes","setAttribute","instance","arrowElement","arrowStyles","round","floor","noRound","v","referenceWidth","popperWidth","isVertical","isVariation","horizontalToInteger","verticalToInteger","bothOddWidth","isFirefox","legacyGpuAccelerationOption","gpuAcceleration","offsetParentRect","getRoundedOffsets","devicePixelRatio","prefixedProperty","invertTop","invertLeft","arrow","requesting","isRequired","requested","isModifierRequired","querySelector","len","sideCapitalized","toLowerCase","altSide","opSide","arrowElementSize","center","popperMarginSide","popperBorderSide","sideValue","min","validPlacements","placements","counter","index","concat","reverse","BEHAVIORS","flipped","placementOpposite","flipOrder","behavior","FLIP","CLOCKWISE","clockwise","COUNTERCLOCKWISE","refOffsets","overlapsRef","overflowsLeft","overflowsRight","overflowsTop","overflowsBottom","overflowsBoundaries","flippedVariationByRef","flipVariations","flippedVariationByContent","flipVariationsByContent","flippedVariation","getOppositeVariation","str","size","useHeight","fragments","frag","trim","divider","search","splitRegex","ops","mergeWithPrevious","op","reduce","toValue","index2","basePlacement","parseOffset","transformProp","popperStyles","priority","check","escapeWithReference","shiftvariation","shiftOffsets","bound","hide","subtractLength","requestAnimationFrame","update","debounce","bind","Popper","Defaults","jquery","modifierOptions","onLoad","enableEventListeners","destroy","Utils","global","PopperUtils"],"mappings":";;;GAAA,MAAiC,WAAlB,QAAOA,OAAP,EAAqD,WAApB,QAAOC,SAAvD,sCCGA,GAAIC,GAAkB,CAAtB,CACA,IAAK,GAAIC,GAAI,CAAb,CAAgBA,EAAIC,EAAsBC,MAA1C,CAAkDF,GAAK,CAAvD,IACMG,GAAsE,CAAzDC,YAAUC,SAAVD,CAAoBE,OAApBF,CAA4BH,IAA5BG,EAA4D,GACzD,CADyD,OAM/E,aAAsC,IAChCG,YACG,IAAM,SAAA,QAKJC,QAAQC,UAAUC,KAAK,IAAM,KAAA,IAApC,EALW,CAAb,EAYF,aAAiC,IAC3BC,YACG,IAAM,SAAA,YAGE,IAAM,KAAA,IAAjB,IAHS,CAAb,EAWF,KAAMC,GAAqBT,GAAaN,OAAOW,OAA/C,CAYA,MAAgBI,KAAhB,CC3CA,aAAoD,OAGhDC,IAC2C,mBAA3CC,MAAQC,QAARD,CAAiBE,IAAjBF,ICJJ,eAAoE,IACzC,CAArBG,KAAQC,uBAINrB,GAASoB,EAAQE,aAARF,CAAsBG,YAC/BC,EAAMxB,EAAOyB,gBAAPzB,GAAiC,IAAjCA,QACL0B,GAAWF,IAAXE,GCPT,aAA+C,OACpB,MAArBN,KAAQO,QADiC,GAItCP,EAAQQ,UAARR,EAAsBA,EAAQS,KCDvC,aAAiD,IAE3C,SACK5B,UAAS6B,YAGVV,EAAQO,cACT,WACA,aACIP,GAAQE,aAARF,CAAsBU,SAC1B,kBACIV,GAAQU,WAIb,CAAEC,UAAF,CAAYC,WAAZ,CAAuBC,WAAvB,EAAqCC,KAfI,MAgB3C,yBAAwBC,IAAxB,CAA6BJ,KAA7B,CAhB2C,GAoBxCK,EAAgBC,IAAhBD,OC5BHE,GAAShC,GAAa,CAAC,EAAEN,OAAOuC,oBAAPvC,EAA+BC,SAASuC,YAA1C,EACvBC,EAASnC,GAAa,UAAU6B,IAAV,CAAe5B,UAAUC,SAAzB,EAS5B,aAAsC,OACpB,GAAZkC,IADgC,GAIpB,EAAZA,IAJgC,GAO7BJ,KCVT,aAAiD,IAC3C,SACKrC,UAAS0C,qBAGZC,GAAiBC,EAAK,EAALA,EAAW5C,SAAS6B,IAApBe,CAA2B,QAG9CC,GAAe1B,EAAQ0B,YAAR1B,EAAwB,KARI,KAUxC0B,OAAmC1B,EAAQ2B,kBAVH,IAW9B,CAAC3B,EAAUA,EAAQ2B,kBAAnB,EAAuCD,kBAGlDnB,GAAWmB,GAAgBA,EAAanB,SAdC,MAgB3C,IAA0B,MAAbA,IAAb,EAAiD,MAAbA,IAhBO,CAuBY,CAAC,CAA1D,uBAAsBlB,OAAtB,CAA8BqC,EAAanB,QAA3C,GACuD,QAAvDO,OAAuC,UAAvCA,CAxB6C,CA0BtCc,IA1BsC,GAiBtC5B,EAAUA,EAAQE,aAARF,CAAsBuB,eAAhCvB,CAAkDnB,SAAS0C,6BCxBnB,MAC3C,CAAEhB,UAAF,IAD2C,MAEhC,MAAbA,IAF6C,GAMlC,MAAbA,MAAuBqB,EAAgB5B,EAAQ6B,iBAAxBD,KANwB,ECKnD,aAAsC,OACZ,KAApBE,KAAKtB,UAD2B,GAE3BuB,EAAQD,EAAKtB,UAAbuB,ECGX,eAAmE,IAE7D,IAAa,CAACC,EAAS/B,QAAvB,EAAmC,EAAnC,EAAgD,CAACgC,EAAShC,eACrDpB,UAAS0C,qBAIZW,GACJF,EAASG,uBAATH,IACAI,KAAKC,4BACDC,EAAQJ,MACRK,EAAML,MAGNM,EAAQ3D,SAAS4D,WAAT5D,KACR6D,WAAgB,EAf2C,GAgB3DC,SAAY,EAhB+C,MAiB3D,CAAEC,yBAAF,OAIHZ,OACCC,KADDD,EAEDM,EAAMO,QAANP,UAEIQ,QAIGlB,UAIHmB,GAAehB,KAjC4C,MAkC7DgB,GAAatC,IAlCgD,CAmCxDuC,EAAuBD,EAAatC,IAApCuC,GAnCwD,CAqCxDA,IAAiCjB,KAAkBtB,IAAnDuC,ECzCX,aAA2CC,EAAO,KAAlD,CAAyD,MACjDC,GAAqB,KAATD,KAAiB,WAAjBA,CAA+B,aAC3C1C,EAAWP,EAAQO,YAER,MAAbA,MAAoC,MAAbA,KAAqB,MACxC4C,GAAOnD,EAAQE,aAARF,CAAsBuB,gBAC7B6B,EAAmBpD,EAAQE,aAARF,CAAsBoD,gBAAtBpD,UAClBoD,YAGFpD,MCPT,eAAqDqD,IAArD,CAAuE,MAC/DC,GAAYC,IAAmB,KAAnBA,EACZC,EAAaD,IAAmB,MAAnBA,EACbE,EAAWJ,EAAW,CAAC,CAAZA,CAAgB,WAC5BK,KAAOJ,MACPK,QAAUL,MACVM,MAAQJ,MACRK,OAASL,MCRhB,eAAqD,MAC7CM,GAAiB,GAATC,KAAe,MAAfA,CAAwB,MAChCC,EAAkB,MAAVF,IAAmB,OAAnBA,CAA6B,eAGzCG,YAAWC,WAAQ,QAARA,CAAXD,CAA0C,EAA1CA,EACAA,WAAWC,WAAQ,QAARA,CAAXD,CAA0C,EAA1CA,qBCd8C,OACzCE,MAAKC,GAALD,CACLzD,WAAM,GAANA,CADKyD,CAELzD,WAAM,GAANA,CAFKyD,CAGLhB,WAAM,GAANA,CAHKgB,CAILhB,WAAM,GAANA,CAJKgB,CAKLhB,WAAM,GAANA,CALKgB,CAML1C,EAAK,EAALA,EACK4C,SAASlB,WAAM,GAANA,CAATkB,EACHA,SAASC,WAAgC,QAATP,KAAoB,KAApBA,CAA4B,QAAnDO,CAATD,CADGA,CAEHA,SAASC,WAAgC,QAATP,KAAoB,QAApBA,CAA+B,SAAtDO,CAATD,CAHF5C,CAIE,CAVG0C,EAcT,aAAiD,MACzCzD,GAAO7B,EAAS6B,KAChByC,EAAOtE,EAAS0C,gBAChB+C,EAAgB7C,EAAK,EAALA,GAAYpB,0BAE3B,QACGkE,EAAQ,QAARA,OADH,OAEEA,EAAQ,OAARA,OAFF,uKCfT,aAA+C,sBAGpCC,EAAQZ,IAARY,CAAeA,EAAQC,aACtBD,EAAQd,GAARc,CAAcA,EAAQE,SCGlC,aAAuD,IACjDC,SAKA,IACElD,EAAK,EAALA,EAAU,GACLzB,EAAQ4E,qBAAR5E,EADK,MAENsD,GAAYC,IAAmB,KAAnBA,EACZC,EAAaD,IAAmB,MAAnBA,IACdG,MAJO,GAKPE,OALO,GAMPD,SANO,GAOPE,QAPP,QAUS7D,EAAQ4E,qBAAR5E,EAXX,CAcA,QAAQ,OAEF6E,GAAS,MACPF,EAAKf,IADE,KAERe,EAAKjB,GAFG,OAGNiB,EAAKd,KAALc,CAAaA,EAAKf,IAHZ,QAILe,EAAKhB,MAALgB,CAAcA,EAAKjB,GAJd,EAQToB,EAA6B,MAArB9E,KAAQO,QAARP,CAA8B+E,EAAe/E,EAAQE,aAAvB6E,CAA9B/E,IACRyE,EACJK,EAAML,KAANK,EAAe9E,EAAQgF,WAAvBF,EAAsCD,EAAOhB,KAAPgB,CAAeA,EAAOjB,KACxDc,EACJI,EAAMJ,MAANI,EAAgB9E,EAAQiF,YAAxBH,EAAwCD,EAAOlB,MAAPkB,CAAgBA,EAAOnB,OAE7DwB,GAAiBlF,EAAQmF,WAARnF,GACjBoF,EAAgBpF,EAAQqF,YAARrF,MAIhBkF,KAAiC,MAC7BhB,GAASpD,QACGwE,IAAuB,GAAvBA,CAFiB,IAGlBA,IAAuB,GAAvBA,CAHkB,GAK5Bb,QAL4B,GAM5BC,gBAGFa,qBCzDsEC,KAAuB,OAajFrB,KAAKC,GAb4E,MAC9F/C,GAASoE,EAAQ,EAARA,EACTC,EAA6B,MAApBC,KAAOpF,SAChBqF,EAAehB,KACfiB,EAAajB,KACbkB,EAAe9E,KAEfkD,EAASpD,KACTiF,EAAiB9B,WAAWC,EAAO6B,cAAlB9B,CAAkC,EAAlCA,EACjB+B,EAAkB/B,WAAWC,EAAO8B,eAAlB/B,CAAmC,EAAnCA,EAGrBuB,IAZiG,KAavF9B,IAAMS,EAAS0B,EAAWnC,GAApBS,CAAyB,CAAzBA,CAbiF,GAcvFP,KAAOO,EAAS0B,EAAWjC,IAApBO,CAA0B,CAA1BA,CAdgF,KAgBhGK,GAAUe,EAAc,KACrBK,EAAalC,GAAbkC,CAAmBC,EAAWnC,GAA9BkC,EADqB,MAEpBA,EAAahC,IAAbgC,CAAoBC,EAAWjC,IAA/BgC,EAFoB,OAGnBA,EAAanB,KAHM,QAIlBmB,EAAalB,MAJK,CAAda,OAMNU,UAAY,IACZC,WAAa,EAMjB,MAAmB,MACfD,GAAYhC,WAAWC,EAAO+B,SAAlBhC,CAA6B,EAA7BA,EACZiC,EAAajC,WAAWC,EAAOgC,UAAlBjC,CAA8B,EAA9BA,IAEXP,KAAOqC,GAJM,GAKbpC,QAAUoC,GALG,GAMbnC,MAAQoC,GANK,GAObnC,OAASmC,GAPI,GAUbC,WAVa,GAWbC,oBAIR7E,GAAU,EAAVA,CACIsE,EAAO9C,QAAP8C,GADJtE,CAEIsE,OAAqD,MAA1BG,KAAavF,cAElC4F,uBCnDiEC,KAAuB,OAGtFjC,KAAKC,GAHiF,MAC9FjB,GAAOnD,EAAQE,aAARF,CAAsBuB,gBAC7B8E,EAAiBC,OACjB7B,EAAQN,EAAShB,EAAK6B,WAAdb,CAA2BvF,OAAO2H,UAAP3H,EAAqB,CAAhDuF,EACRO,EAASP,EAAShB,EAAK8B,YAAdd,CAA4BvF,OAAO4H,WAAP5H,EAAsB,CAAlDuF,EAETb,EAAY,EAAmC,CAAnC,CAAiBC,KAC7BC,EAAa,EAA2C,CAA3C,CAAiBD,IAAgB,MAAhBA,EAE9BkD,EAAS,KACRnD,EAAY+C,EAAe3C,GAA3BJ,CAAiC+C,EAAeJ,SADxC,MAEPzC,EAAa6C,EAAezC,IAA5BJ,CAAmC6C,EAAeH,UAF3C,QAAA,SAAA,QAORX,MCTT,aAAyC,MACjChF,GAAWP,EAAQO,YACR,MAAbA,MAAoC,MAAbA,iBAG2B,OAAlDO,OAAkC,UAAlCA,gBAGEN,GAAaS,KARoB,WAYhCyF,KCbT,aAA8D,IAEvD,IAAY,CAAC1G,EAAQ2G,aAArB,EAAsClF,UAClC5C,UAAS0C,mBAEdqF,GAAK5G,EAAQ2G,cAL2C,KAMrDC,GAAoD,MAA9C9F,OAA6B,WAA7BA,CAN+C,IAOrD8F,EAAGD,oBAEHC,IAAM/H,SAAS0C,gBCCxB,mBAKEiE,IALF,CAME,IAGIqB,GAAa,CAAEnD,IAAK,CAAP,CAAUE,KAAM,CAAhB,OACXlC,GAAe8D,EAAgBsB,IAAhBtB,CAAuDxC,UAGlD,UAAtB+D,OACWC,WAGV,IAECC,GACsB,cAAtBF,IAHD,IAIgB/F,EAAgBC,IAAhBD,CAJhB,CAK+B,MAA5BiG,KAAe1G,QALlB,KAMkB2G,EAAOhH,aAAPgH,CAAqB3F,eANvC,GAQ8B,QAAtBwF,IARR,GASgBG,EAAOhH,aAAPgH,CAAqB3F,eATrC,IAAA,MAcGiD,GAAU8B,YAOgB,MAA5BW,KAAe1G,QAAf0G,EAAsC,CAACP,KAAuB,MAC1D,CAAEhC,QAAF,CAAUD,OAAV,EAAoBM,EAAemC,EAAOhH,aAAtB6E,IACfrB,KAAOc,EAAQd,GAARc,CAAcA,EAAQyB,SAFwB,GAGrDtC,OAASe,EAASF,EAAQd,GAH2B,GAIrDE,MAAQY,EAAQZ,IAARY,CAAeA,EAAQ0B,UAJsB,GAKrDrC,MAAQY,EAAQD,EAAQZ,IALrC,YAaQuD,GAAW,CA7CrB,MA8CMC,GAAqC,QAAnB,oBACbxD,MAAQwD,IAA4BD,EAAQvD,IAARuD,EAAgB,IACpDzD,KAAO0D,IAA4BD,EAAQzD,GAARyD,EAAe,IAClDtD,OAASuD,IAA4BD,EAAQtD,KAARsD,EAAiB,IACtDxD,QAAUyD,IAA4BD,EAAQxD,MAARwD,EAAkB,eC1EpD,CAAE1C,OAAF,CAASC,QAAT,EAAmB,OAC3BD,KAYT,qBAME0C,EAAU,CANZ,CAOE,IACkC,CAAC,CAA/BE,KAAUhI,OAAVgI,CAAkB,MAAlBA,gBAIER,GAAaS,WAObC,EAAQ,KACP,OACIV,EAAWpC,KADf,QAEK+C,EAAQ9D,GAAR8D,CAAcX,EAAWnD,GAF9B,CADO,OAKL,OACEmD,EAAWhD,KAAXgD,CAAmBW,EAAQ3D,KAD7B,QAEGgD,EAAWnC,MAFd,CALK,QASJ,OACCmC,EAAWpC,KADZ,QAEEoC,EAAWlD,MAAXkD,CAAoBW,EAAQ7D,MAF9B,CATI,MAaN,OACG6D,EAAQ5D,IAAR4D,CAAeX,EAAWjD,IAD7B,QAEIiD,EAAWnC,MAFf,CAbM,EAmBR+C,EAAcC,OAAOC,IAAPD,IACjBE,GADiBF,CACbG,eAEAN,WACGO,EAAQP,IAARO,GAJUJ,EAMjBK,IANiBL,CAMZ,OAAUM,EAAEC,IAAFD,CAASE,EAAED,IANTP,EAQdS,EAAgBV,EAAYW,MAAZX,CACpB,CAAC,CAAEhD,OAAF,CAASC,QAAT,CAAD,GACED,GAASyC,EAAOlC,WAAhBP,EAA+BC,GAAUwC,EAAOjC,YAF9BwC,EAKhBY,EAA2C,CAAvBF,GAAclJ,MAAdkJ,CACtBA,EAAc,CAAdA,EAAiBN,GADKM,CAEtBV,EAAY,CAAZA,EAAeI,IAEbS,EAAYjB,EAAUkB,KAAVlB,CAAgB,GAAhBA,EAAqB,CAArBA,QAEXgB,IAAqBC,MAAa,GAAbA,CAA8B,EAAnDD,EC1DT,iBAAsE7C,EAAgB,IAAtF,CAA4F,MACpFgD,GAAqBhD,EAAgBsB,IAAhBtB,CAAuDxC,aAC3EsD,UCTT,aAA+C,MACvC1H,GAASoB,EAAQE,aAARF,CAAsBG,YAC/B+D,EAAStF,EAAOyB,gBAAPzB,IACT6J,EAAIxE,WAAWC,EAAO+B,SAAP/B,EAAoB,CAA/BD,EAAoCA,WAAWC,EAAOwE,YAAPxE,EAAuB,CAAlCD,EACxC0E,EAAI1E,WAAWC,EAAOgC,UAAPhC,EAAqB,CAAhCD,EAAqCA,WAAWC,EAAO0E,WAAP1E,EAAsB,CAAjCD,EACzCY,EAAS,OACN7E,EAAQmF,WAARnF,EADM,QAELA,EAAQqF,YAARrF,EAFK,WCLjB,aAAwD,MAChD6I,GAAO,CAAEjF,KAAM,OAAR,CAAiBC,MAAO,MAAxB,CAAgCF,OAAQ,KAAxC,CAA+CD,IAAK,QAApD,QACN2D,GAAUyB,OAAVzB,CAAkB,wBAAlBA,CAA4C0B,KAAWF,IAAvDxB,ECIT,iBAA8E,GAChEA,EAAUkB,KAAVlB,CAAgB,GAAhBA,EAAqB,CAArBA,CADgE,MAItE2B,GAAaC,KAGbC,EAAgB,OACbF,EAAWvE,KADE,QAEZuE,EAAWtE,MAFC,EAMhByE,EAAmD,CAAC,CAA1C,oBAAkB9J,OAAlB,IACV+J,EAAWD,EAAU,KAAVA,CAAkB,OAC7BE,EAAgBF,EAAU,MAAVA,CAAmB,MACnCG,EAAcH,EAAU,QAAVA,CAAqB,QACnCI,EAAuB,EAAsB,OAAtB,CAAW,qBAGtCC,KACAA,KAAgC,CADhCA,CAEAR,KAA0B,OACxB3B,MAEAmC,KAAkCR,KAGlCQ,EAAiBC,IAAjBD,IChCN,eAAyC,OAEnCE,OAAMC,SAAND,CAAgBE,IAFmB,CAG9BC,EAAID,IAAJC,GAH8B,CAOhCA,EAAIzB,MAAJyB,IAAkB,CAAlBA,ECLT,iBAAoD,IAE9CH,MAAMC,SAAND,CAAgBI,gBACXD,GAAIC,SAAJD,CAAcE,KAAOA,QAArBF,OAIHG,GAAQJ,IAAUK,KAAOA,QAAjBL,QACPC,GAAIxK,OAAJwK,ICLT,iBAA4D,MACpDK,GAAiBC,aAEnBC,EAAUC,KAAVD,CAAgB,CAAhBA,CAAmBN,IAAqB,MAArBA,GAAnBM,WAEWE,QAAQ7G,KAAY,CAC7BA,EAAS,UAATA,CAD6B,UAEvB8G,KAAK,wDAFkB,MAI3BC,GAAK/G,EAAS,UAATA,GAAwBA,EAAS+G,GACxC/G,EAASgH,OAAThH,EAAoBiH,IALS,KAS1BlG,QAAQ0C,OAAS3B,EAAcoF,EAAKnG,OAALmG,CAAazD,MAA3B3B,CATS,GAU1Bf,QAAQoG,UAAYrF,EAAcoF,EAAKnG,OAALmG,CAAaC,SAA3BrF,CAVM,GAYxBiF,MAZwB,CAAnC,KCPF,YAAiC,IAE3B,KAAKK,KAAL,CAAWC,sBAIXH,GAAO,UACC,IADD,UAAA,eAAA,cAAA,WAAA,WAAA,IAUNnG,QAAQoG,UAAYG,EACvB,KAAKF,KADkBE,CAEvB,KAAK7D,MAFkB6D,CAGvB,KAAKH,SAHkBG,CAIvB,KAAKC,OAAL,CAAaC,aAJUF,CAhBM,GA0B1B1D,UAAY6D,EACf,KAAKF,OAAL,CAAa3D,SADE6D,CAEfP,EAAKnG,OAALmG,CAAaC,SAFEM,CAGf,KAAKhE,MAHUgE,CAIf,KAAKN,SAJUM,CAKf,KAAKF,OAAL,CAAaZ,SAAb,CAAuBe,IAAvB,CAA4BpE,iBALbmE,CAMf,KAAKF,OAAL,CAAaZ,SAAb,CAAuBe,IAAvB,CAA4BhE,OANb+D,CA1Bc,GAoC1BE,kBAAoBT,EAAKtD,SApCC,GAsC1B4D,cAAgB,KAAKD,OAAL,CAAaC,aAtCH,GAyC1BzG,QAAQ0C,OAASmE,EACpB,KAAKnE,MADemE,CAEpBV,EAAKnG,OAALmG,CAAaC,SAFOS,CAGpBV,EAAKtD,SAHegE,CAzCS,GA+C1B7G,QAAQ0C,OAAOoE,SAAW,KAAKN,OAAL,CAAaC,aAAb,CAC3B,OAD2B,CAE3B,UAjD2B,GAoDxBM,EAAa,KAAKnB,SAAlBmB,GApDwB,CAwD1B,KAAKV,KAAL,CAAWW,SAxDe,MA4DxBR,QAAQS,WA5DgB,OAyDxBZ,MAAMW,YAzDkB,MA0DxBR,QAAQU,WA1DgB,ECNjC,eAAmE,OAC1DtB,GAAUuB,IAAVvB,CACL,CAAC,CAAEwB,MAAF,CAAQnB,SAAR,CAAD,GAAuBA,GAAWmB,KAD7BxB,ECAT,aAA2D,MACnDyB,gCACAC,EAAYxL,EAASyL,MAATzL,CAAgB,CAAhBA,EAAmB0L,WAAnB1L,GAAmCA,EAAS+J,KAAT/J,CAAe,CAAfA,MAEhD,GAAIvB,GAAI,EAAGA,EAAI8M,EAAS5M,OAAQF,IAAK,MAClCkN,GAASJ,KACTK,EAAUD,KAAU,IAAA,GAAVA,MAC4B,WAAxC,QAAOpN,UAAS6B,IAAT7B,CAAcsN,KAAdtN,mBAIN,MCVT,YAAkC,aAC3BgM,MAAMC,eAGPsB,EAAkB,KAAKhC,SAAvBgC,CAAkC,YAAlCA,SACGlF,OAAOmF,gBAAgB,oBACvBnF,OAAOiF,MAAMb,SAAW,QACxBpE,OAAOiF,MAAMzI,IAAM,QACnBwD,OAAOiF,MAAMvI,KAAO,QACpBsD,OAAOiF,MAAMtI,MAAQ,QACrBqD,OAAOiF,MAAMxI,OAAS,QACtBuD,OAAOiF,MAAMG,WAAa,QAC1BpF,OAAOiF,MAAMI,EAAyB,WAAzBA,GAAyC,SAGxDC,wBAID,KAAKxB,OAAL,CAAayB,sBACVvF,OAAO1G,WAAWkM,YAAY,KAAKxF,QAEnC,KCzBT,aAA2C,MACnChH,GAAgBF,EAAQE,oBACvBA,GAAgBA,EAAcC,WAA9BD,CAA4CtB,0BCJwB,MACrE+N,GAAmC,MAA1B7G,KAAavF,SACtBqM,EAASD,EAAS7G,EAAa5F,aAAb4F,CAA2B3F,WAApCwM,KACRE,qBAAkC,CAAEC,UAAF,EAHkC,MAOvE9L,EAAgB4L,EAAOpM,UAAvBQ,QAPuE,GAa7D+L,QAShB,mBAKE,GAEMC,aAFN,MAGqBH,iBAAiB,SAAUhC,EAAMmC,YAAa,CAAEF,UAAF,EAHnE,MAMMG,GAAgBjM,gBAGpB,SACA6J,EAAMmC,YACNnC,EAAMqC,iBAEFD,kBACAE,mBCpCR,YAA+C,CACxC,KAAKtC,KAAL,CAAWsC,aAD6B,QAEtCtC,MAAQuC,EACX,KAAKxC,SADMwC,CAEX,KAAKpC,OAFMoC,CAGX,KAAKvC,KAHMuC,CAIX,KAAKC,cAJMD,CAF8B,ECA/C,eAA+D,aAExCE,oBAAoB,SAAUzC,EAAMmC,eAGnDE,cAAc5C,QAAQsC,KAAU,GAC7BU,oBAAoB,SAAUzC,EAAMmC,YAD7C,KAKMA,YAAc,OACdE,mBACAD,cAAgB,OAChBE,mBCZR,YAAgD,CAC1C,KAAKtC,KAAL,CAAWsC,aAD+B,wBAEvB,KAAKE,eAFkB,MAGvCxC,MAAQ0C,EAAqB,KAAK3C,SAA1B2C,CAAqC,KAAK1C,KAA1C0C,CAH+B,ECFhD,aAAqC,OACtB,EAANC,MAAY,CAACC,MAAMxJ,aAANwJ,CAAbD,EAAqCE,YCE9C,eAAmD,QAC1C/F,QAAa2C,QAAQqD,KAAQ,IAC9BC,GAAO,GAIP,CAAC,CADH,oDAAsDvO,OAAtD,KAEAwO,EAAU3J,IAAV2J,CANgC,KAQzB,IARyB,IAU1B1B,SAAcjI,MAVxB,GCHF,eAA2D,QAClDyD,QAAiB2C,QAAQ,WAAe,MACvCwD,GAAQC,KACVD,MAFyC,GAKnCzB,kBALmC,GAGnC2B,eAAmBD,KAH/B,GCKF,aAAyC,UAK7BpD,EAAKsD,QAALtD,CAAczD,OAAQyD,EAAKzG,UAIvByG,EAAKsD,QAALtD,CAAczD,OAAQyD,EAAKoD,YAGrCpD,EAAKuD,YAALvD,EAAqBjD,OAAOC,IAAPD,CAAYiD,EAAKwD,WAAjBzG,EAA8BzI,UAC3C0L,EAAKuD,aAAcvD,EAAKwD,eAgBtC,sBAME,MAEM3E,GAAmBuB,QAA8CC,EAAQC,aAAtDF,EAKnB1D,EAAY6D,EAChBF,EAAQ3D,SADQ6D,OAKhBF,EAAQZ,SAARY,CAAkBG,IAAlBH,CAAuBjE,iBALPmE,CAMhBF,EAAQZ,SAARY,CAAkBG,IAAlBH,CAAuB7D,OANP+D,WASX8C,aAAa,qBAIF,CAAE1C,SAAUN,EAAQC,aAARD,CAAwB,OAAxBA,CAAkC,UAA9C,KClDpB,gBAA6D,MACrD,CAAE9D,QAAF,CAAU0D,WAAV,EAAwBD,EAAKnG,QAC7B,CAAE4J,OAAF,CAASC,OAAT,EAAmBlK,KACnBmK,EAAUC,OAEVC,EAAiBJ,EAAMxD,EAAUnG,KAAhB2J,EACjBK,EAAcL,EAAMlH,EAAOzC,KAAb2J,EAEdM,EAA2D,CAAC,CAA/C,oBAAkBrP,OAAlB,CAA0BsL,EAAKtD,SAA/B,EACbsH,EAA8C,CAAC,CAAjChE,KAAKtD,SAALsD,CAAetL,OAAfsL,CAAuB,GAAvBA,EAIdiE,EAAsB,EAExBF,MALoBF,EAAiB,CAAjBA,EAAuBC,EAAc,CAKzDC,IAFwB,GAKtBG,EAAoB,YAEnB,MACCD,EAVoC,CAAvBJ,IAAiB,CAAjBA,EAAgD,CAApBC,IAAc,CAW3DK,EAAgB,EAAhBA,IACI5H,EAAOtD,IAAPsD,CAAc,CADlB4H,CAEI5H,EAAOtD,IAHPgL,CADD,KAMAC,EAAkB3H,EAAOxD,GAAzBmL,CANA,QAOGA,EAAkB3H,EAAOvD,MAAzBkL,CAPH,OAQED,EAAoB1H,EAAOrD,KAA3B+K,CARF,OChCHG,IAAY7P,GAAa,WAAW6B,IAAX,CAAgB5B,UAAUC,SAA1B,EAS/B,gBAAoD,MAC5C,CAAEqJ,GAAF,CAAKE,GAAL,IACA,CAAEzB,QAAF,EAAayD,EAAKnG,QAGlBwK,EAA8BpF,EAClCe,EAAKsD,QAALtD,CAAcP,SADoBR,CAElCnG,KAA8B,YAAlBA,KAASmI,IAFahC,EAGlCqF,gBACED,UAT8C,UAUxCzE,KACN,gIAX8C,MAc5C0E,GACJD,WAEIhE,EAAQiE,eAFZD,GAIItN,EAAeE,EAAgB+I,EAAKsD,QAALtD,CAAczD,MAA9BtF,EACfsN,EAAmBtK,KAGnBV,EAAS,UACHgD,EAAOoE,QADJ,EAIT9G,EAAU2K,KAEY,CAA1BvQ,QAAOwQ,gBAAPxQ,EAA+B,GAFjBuQ,EAKVrL,EAAc,QAAN2E,KAAiB,KAAjBA,CAAyB,SACjCzE,EAAc,OAAN2E,KAAgB,MAAhBA,CAAyB,QAKjC0G,EAAmB9C,EAAyB,WAAzBA,KAWrB3I,GAAMF,OACI,QAAVI,IAG4B,MAA1BpC,KAAanB,SACT,CAACmB,EAAauD,YAAd,CAA6BT,EAAQb,OAErC,CAACuL,EAAiBxK,MAAlB,CAA2BF,EAAQb,OAGrCa,EAAQd,MAEF,OAAVM,IAC4B,MAA1BtC,KAAanB,SACR,CAACmB,EAAasD,WAAd,CAA4BR,EAAQX,MAEpC,CAACqL,EAAiBzK,KAAlB,CAA0BD,EAAQX,MAGpCW,EAAQZ,KAEbqL,yBAC0B,QAAA,eACZ,OACA,IACT3C,WAAa,gBACf,MAECgD,GAAsB,QAAVxL,IAAqB,CAAC,CAAtBA,CAA0B,EACtCyL,EAAuB,OAAVvL,IAAoB,CAAC,CAArBA,CAAyB,OAC5BN,GAJX,MAKWE,GALX,GAME0I,cAAc,MAAA,SAIjByB,GAAa,eACFpD,EAAKtD,SADH,WAKd0G,kBAAiCpD,EAAKoD,cACtC7J,cAAyByG,EAAKzG,UAC9BiK,iBAAmBxD,EAAKnG,OAALmG,CAAa6E,MAAU7E,EAAKwD,eChGtD,kBAIE,MACMsB,GAAa7F,IAAgB,CAAC,CAAEgC,MAAF,CAAD,GAAcA,KAA9BhC,EAEb8F,EACJ,CAAC,EAAD,EACAtF,EAAUuB,IAAVvB,CAAe3G,KAEXA,EAASmI,IAATnI,MACAA,EAASgH,OADThH,EAEAA,EAASvB,KAATuB,CAAiBgM,EAAWvN,KAJhCkI,KAQE,GAAa,MACTqF,QAAc,MACdE,OAAa,cACXpF,QACL,6BAAA,6DAAA,eCrBP,gBAA6C,IAEvC,CAACqF,GAAmBjF,EAAKsD,QAALtD,CAAcP,SAAjCwF,CAA4C,OAA5CA,CAAqD,cAArDA,cAID1B,GAAelD,EAAQhL,WAGC,QAAxB,iBACa2K,EAAKsD,QAALtD,CAAczD,MAAdyD,CAAqBkF,aAArBlF,IAGX,qBAMA,CAACA,EAAKsD,QAALtD,CAAczD,MAAdyD,CAAqB9H,QAArB8H,mBACKJ,KACN,wEAMAlD,GAAYsD,EAAKtD,SAALsD,CAAepC,KAAfoC,CAAqB,GAArBA,EAA0B,CAA1BA,EACZ,CAAEzD,QAAF,CAAU0D,WAAV,EAAwBD,EAAKnG,QAC7BkK,EAAsD,CAAC,CAA1C,oBAAkBrP,OAAlB,IAEbyQ,EAAMpB,EAAa,QAAbA,CAAwB,QAC9BqB,EAAkBrB,EAAa,KAAbA,CAAqB,OACvCzL,EAAO8M,EAAgBC,WAAhBD,GACPE,EAAUvB,EAAa,MAAbA,CAAsB,MAChCwB,EAASxB,EAAa,QAAbA,CAAwB,QACjCyB,EAAmBlH,QAQrB2B,OAAuC1D,IA5CA,KA6CpC1C,QAAQ0C,WACXA,MAAgB0D,MAAhB1D,CA9CuC,EAiDvC0D,OAAqC1D,IAjDE,KAkDpC1C,QAAQ0C,WACX0D,OAAqC1D,IAnDE,IAqDtC1C,QAAQ0C,OAAS3B,EAAcoF,EAAKnG,OAALmG,CAAazD,MAA3B3B,CArDqB,MAwDrC6K,GAASxF,KAAkBA,KAAiB,CAAnCA,CAAuCuF,EAAmB,EAInE/P,EAAMU,EAAyB6J,EAAKsD,QAALtD,CAAczD,MAAvCpG,EACNuP,EAAmBpM,WAAW7D,WAAK,GAALA,CAAX6D,CAA4C,EAA5CA,EACnBqM,EAAmBrM,WAAW7D,WAAK,QAALA,CAAX6D,CAAiD,EAAjDA,KACrBsM,GACFH,EAASzF,EAAKnG,OAALmG,CAAazD,MAAbyD,GAATyF,cAGUjM,KAAKC,GAALD,CAASA,KAAKqM,GAALrM,CAAS+C,MAAT/C,GAATA,CAA8D,CAA9DA,IAEP+J,iBACA1J,QAAQgL,MAAQ,KACXrL,KAAKiK,KAALjK,GADW,KAER,EAFQ,IC3EvB,cAAwD,IACpC,KAAdmE,WACK,QAF6C,MAG7B,OAAdA,IAH2C,CAI7C,KAJ6C,GCwBxD,yKAAA,CC5BA,KAAMmI,IAAkBC,GAAWrG,KAAXqG,CAAiB,CAAjBA,CAAxB,CAYA,cAA6CC,IAA7C,CAA8D,MACtDC,GAAQH,GAAgBpR,OAAhBoR,IACR5G,EAAM4G,GACTpG,KADSoG,CACHG,EAAQ,CADLH,EAETI,MAFSJ,CAEFA,GAAgBpG,KAAhBoG,CAAsB,CAAtBA,GAFEA,QAGLE,GAAU9G,EAAIiH,OAAJjH,EAAV8G,QCZHI,IAAY,MACV,MADU,WAEL,WAFK,kBAGE,kBAHF,EAalB,gBAA4C,IAEtC3E,EAAkBzB,EAAKsD,QAALtD,CAAcP,SAAhCgC,CAA2C,OAA3CA,cAIAzB,EAAKqG,OAALrG,EAAgBA,EAAKtD,SAALsD,GAAmBA,EAAKS,gCAKtCvE,GAAaS,EACjBqD,EAAKsD,QAALtD,CAAczD,MADGI,CAEjBqD,EAAKsD,QAALtD,CAAcC,SAFGtD,CAGjB0D,EAAQ7D,OAHSG,CAIjB0D,EAAQjE,iBAJSO,CAKjBqD,EAAKM,aALY3D,KAQfD,GAAYsD,EAAKtD,SAALsD,CAAepC,KAAfoC,CAAqB,GAArBA,EAA0B,CAA1BA,EACZsG,EAAoBxH,KACpBnB,EAAYqC,EAAKtD,SAALsD,CAAepC,KAAfoC,CAAqB,GAArBA,EAA0B,CAA1BA,GAAgC,GAE5CuG,YAEIlG,EAAQmG,cACTJ,IAAUK,OACD,gBAETL,IAAUM,YACDC,gBAETP,IAAUQ,mBACDD,yBAGAtG,EAAQmG,mBAGd7G,QAAQ,OAAiB,IAC7BjD,OAAsB6J,EAAUjS,MAAViS,GAAqBN,EAAQ,aAI3CjG,EAAKtD,SAALsD,CAAepC,KAAfoC,CAAqB,GAArBA,EAA0B,CAA1BA,CALqB,GAMblB,IANa,MAQ3BP,GAAgByB,EAAKnG,OAALmG,CAAazD,OAC7BsK,EAAa7G,EAAKnG,OAALmG,CAAaC,UAG1ByD,EAAQlK,KAAKkK,MACboD,EACW,MAAdpK,MACCgH,EAAMnF,EAAcrF,KAApBwK,EAA6BA,EAAMmD,EAAW5N,IAAjByK,CAD9BhH,EAEc,OAAdA,MACCgH,EAAMnF,EAActF,IAApByK,EAA4BA,EAAMmD,EAAW3N,KAAjBwK,CAH7BhH,EAIc,KAAdA,MACCgH,EAAMnF,EAAcvF,MAApB0K,EAA8BA,EAAMmD,EAAW9N,GAAjB2K,CAL/BhH,EAMc,QAAdA,MACCgH,EAAMnF,EAAcxF,GAApB2K,EAA2BA,EAAMmD,EAAW7N,MAAjB0K,EAEzBqD,EAAgBrD,EAAMnF,EAActF,IAApByK,EAA4BA,EAAMxH,EAAWjD,IAAjByK,EAC5CsD,EAAiBtD,EAAMnF,EAAcrF,KAApBwK,EAA6BA,EAAMxH,EAAWhD,KAAjBwK,EAC9CuD,EAAevD,EAAMnF,EAAcxF,GAApB2K,EAA2BA,EAAMxH,EAAWnD,GAAjB2K,EAC1CwD,EACJxD,EAAMnF,EAAcvF,MAApB0K,EAA8BA,EAAMxH,EAAWlD,MAAjB0K,EAE1ByD,EACW,MAAdzK,SACc,OAAdA,OADAA,EAEc,KAAdA,OAFAA,EAGc,QAAdA,QAGGqH,EAAsD,CAAC,CAA1C,oBAAkBrP,OAAlB,IAGb0S,EACJ,CAAC,CAAC/G,EAAQgH,cAAV,GACEtD,GAA4B,OAAdpG,IAAdoG,KACCA,GAA4B,KAAdpG,IAAdoG,GADDA,EAEC,IAA6B,OAAdpG,IAAf,GAFDoG,EAGC,IAA6B,KAAdpG,IAAf,GAJH,EAOI2J,EACJ,CAAC,CAACjH,EAAQkH,uBAAV,GACExD,GAA4B,OAAdpG,IAAdoG,KACCA,GAA4B,KAAdpG,IAAdoG,GADDA,EAEC,IAA6B,OAAdpG,IAAf,GAFDoG,EAGC,IAA6B,KAAdpG,IAAf,GAJH,EAMI6J,EAAmBJ,KAtDQ,CAwD7BN,OAxD6B,MA0D1BT,UA1D0B,EA4D3BS,IA5D2B,MA6DjBP,EAAUN,EAAQ,CAAlBM,CA7DiB,QAiEjBkB,KAjEiB,IAoE1B/K,UAAYA,GAAaiB,EAAY,KAAZA,CAA8B,EAA3CjB,CApEc,GAwE1B7C,QAAQ0C,YACRyD,EAAKnG,OAALmG,CAAazD,OACbmE,EACDV,EAAKsD,QAALtD,CAAczD,MADbmE,CAEDV,EAAKnG,OAALmG,CAAaC,SAFZS,CAGDV,EAAKtD,SAHJgE,EA1E0B,GAiFxBE,EAAaZ,EAAKsD,QAALtD,CAAcP,SAA3BmB,GAA4C,MAA5CA,CAjFwB,CAAnC,KCrDF,cAA2C,MACnC,CAAErE,QAAF,CAAU0D,WAAV,EAAwBD,EAAKnG,QAC7B6C,EAAYsD,EAAKtD,SAALsD,CAAepC,KAAfoC,CAAqB,GAArBA,EAA0B,CAA1BA,EACZ0D,EAAQlK,KAAKkK,MACbK,EAAsD,CAAC,CAA1C,oBAAkBrP,OAAlB,IACb4D,EAAOyL,EAAa,OAAbA,CAAuB,SAC9BwB,EAASxB,EAAa,MAAbA,CAAsB,MAC/BpF,EAAcoF,EAAa,OAAbA,CAAuB,eAEvCxH,MAAemH,EAAMzD,IAANyD,MACZ7J,QAAQ0C,UACXmH,EAAMzD,IAANyD,EAA2BnH,MAE3BA,KAAiBmH,EAAMzD,IAANyD,MACd7J,QAAQ0C,UAAiBmH,EAAMzD,IAANyD,KCLlC,oBAA2E,OA6B9DlK,KAAKC,GA7ByD,MAEnEmE,GAAQ8J,EAAIrI,KAAJqI,CAAU,2BAAVA,EACRvE,EAAQ,CAACvF,EAAM,CAANA,EACTqF,EAAOrF,EAAM,CAANA,KAGT,eAIsB,CAAtBqF,KAAKvO,OAALuO,CAAa,GAAbA,EAAyB,IACvB5N,iBAEG,mBAGA,QACA,uBAKD2E,GAAOY,WACNZ,MAAoB,GAApBA,EAbT,CAcO,GAAa,IAATiJ,MAA0B,IAATA,IAArB,CAAoC,IAErC0E,YACS,IAAT1E,KACKzJ,EACLtF,SAAS0C,eAAT1C,CAAyBoG,YADpBd,CAELvF,OAAO4H,WAAP5H,EAAsB,CAFjBuF,EAKAA,EACLtF,SAAS0C,eAAT1C,CAAyBmG,WADpBb,CAELvF,OAAO2H,UAAP3H,EAAqB,CAFhBuF,EAKFmO,EAAO,GAAPA,EAdF,UAiCT,oBAKE,MACM9N,SAKA+N,EAAyD,CAAC,CAA9C,oBAAkBlT,OAAlB,IAIZmT,EAAY/L,EAAO8B,KAAP9B,CAAa,SAAbA,EAAwBmB,GAAxBnB,CAA4BgM,KAAQA,EAAKC,IAALD,EAApChM,EAIZkM,EAAUH,EAAUnT,OAAVmT,CACd5I,IAAgB6I,KAAgC,CAAC,CAAzBA,KAAKG,MAALH,CAAY,MAAZA,CAAxB7I,CADc4I,EAIZA,MAA0D,CAAC,CAArCA,QAAmBnT,OAAnBmT,CAA2B,GAA3BA,CAlB1B,UAmBUjI,KACN,+EApBJ,MA0BMsI,GAAa,iBACfC,GAAkB,CAAC,CAAbH,KASN,GATMA,CACN,CACEH,EACGnI,KADHmI,CACS,CADTA,IAEG3B,MAFH2B,CAEU,CAACA,KAAmBjK,KAAnBiK,IAAqC,CAArCA,CAAD,CAFVA,CADF,CAIE,CAACA,KAAmBjK,KAAnBiK,IAAqC,CAArCA,CAAD,EAA0C3B,MAA1C,CACE2B,EAAUnI,KAAVmI,CAAgBG,EAAU,CAA1BH,CADF,CAJF,WAWEM,EAAIlL,GAAJkL,CAAQ,OAAe,MAErBxJ,GAAc,CAAW,CAAVsH,KAAc,EAAdA,EAAD,EAChB,QADgB,CAEhB,WACAmC,YAEFC,GAGGC,MAHHD,CAGU,OACkB,EAApB9K,KAAEA,EAAEjJ,MAAFiJ,CAAW,CAAbA,GAAoD,CAAC,CAA3B,aAAW7I,OAAX,GADxB,IAEF6I,EAAEjJ,MAAFiJ,CAAW,IAFT,KAAA,SAMFA,EAAEjJ,MAAFiJ,CAAW,KANT,KAAA,IAUGA,EAAE2I,MAAF3I,GAbb8K,KAiBGpL,GAjBHoL,CAiBOX,KAAOa,WAjBdF,CAPE,CAAAF,IA6BFxI,QAAQ,OAAe,GACtBA,QAAQ,OAAkB,CACvBuD,IADuB,SAEP4E,GAA2B,GAAnBO,KAAGG,EAAS,CAAZH,EAAyB,CAAC,CAA1BA,CAA8B,CAAtCP,CAFO,CAA7B,EADF,KAmBF,cAAqC,CAAEhM,QAAF,CAArC,CAAiD,MACzC,CAAEY,WAAF,CAAa7C,QAAS,CAAE0C,QAAF,CAAU0D,WAAV,CAAtB,IACAwI,EAAgB/L,EAAUkB,KAAVlB,CAAgB,GAAhBA,EAAqB,CAArBA,KAElB7C,YACAqJ,EAAU,EAAVA,EACQ,CAAC,EAAD,CAAU,CAAV,EAEAwF,YAGU,MAAlBD,QACK1P,KAAOc,EAAQ,CAARA,IACPZ,MAAQY,EAAQ,CAARA,GACY,OAAlB4O,QACF1P,KAAOc,EAAQ,CAARA,IACPZ,MAAQY,EAAQ,CAARA,GACY,KAAlB4O,QACFxP,MAAQY,EAAQ,CAARA,IACRd,KAAOc,EAAQ,CAARA,GACa,QAAlB4O,SACFxP,MAAQY,EAAQ,CAARA,IACRd,KAAOc,EAAQ,CAARA,KAGX0C,WCpLP,gBAAuD,IACjDH,GACFiE,EAAQjE,iBAARiE,EAA6BpJ,EAAgB+I,EAAKsD,QAALtD,CAAczD,MAA9BtF,EAK3B+I,EAAKsD,QAALtD,CAAcC,SAAdD,IAPiD,KAQ/B/I,IAR+B,OAc/C0R,GAAgB/G,EAAyB,WAAzBA,EAChBgH,EAAe5I,EAAKsD,QAALtD,CAAczD,MAAdyD,CAAqBwB,MACpC,CAAEzI,KAAF,CAAOE,MAAP,CAAa,KAAb,MACOF,IAAM,EAjBkC,GAkBxCE,KAAO,EAlBiC,MAmBvB,EAnBuB,MAqB/CiD,GAAaS,EACjBqD,EAAKsD,QAALtD,CAAczD,MADGI,CAEjBqD,EAAKsD,QAALtD,CAAcC,SAFGtD,CAGjB0D,EAAQ7D,OAHSG,GAKjBqD,EAAKM,aALY3D,IAUN5D,KA/BwC,GAgCxCE,MAhCwC,OAAA,GAmC7CiD,YAnC6C,MAqC/C3E,GAAQ8I,EAAQwI,YAClBtM,GAASyD,EAAKnG,OAALmG,CAAazD,YAEpBuM,GAAQ,WACO,IACb3F,GAAQ5G,WAEVA,MAAoBL,IAApBK,EACA,CAAC8D,EAAQ0I,wBAEDvP,KAAKC,GAALD,CAAS+C,IAAT/C,CAA4B0C,IAA5B1C,GAEH,CAAE,KAAF,CATG,CAAA,aAWS,MACbiF,GAAyB,OAAd/B,KAAwB,MAAxBA,CAAiC,SAC9CyG,GAAQ5G,WAEVA,MAAoBL,IAApBK,EACA,CAAC8D,EAAQ0I,wBAEDvP,KAAKqM,GAALrM,CACN+C,IADM/C,CAEN0C,MACiB,OAAdQ,KAAwBH,EAAOzC,KAA/B4C,CAAuCH,EAAOxC,MADjDmC,CAFM1C,GAMH,CAAE,KAAF,EAxBG,WA4BRmG,QAAQjD,KAAa,MACnBpE,GACmC,CAAC,CAAxC,kBAAgB5D,OAAhB,IAAwD,WAAxD,CAA4C,mBACrBoU,QAH3B,KAMKjP,QAAQ0C,WC9Ef,cAAoC,MAC5BG,GAAYsD,EAAKtD,UACjB+L,EAAgB/L,EAAUkB,KAAVlB,CAAgB,GAAhBA,EAAqB,CAArBA,EAChBsM,EAAiBtM,EAAUkB,KAAVlB,CAAgB,GAAhBA,EAAqB,CAArBA,OAGH,MACZ,CAAEuD,WAAF,CAAa1D,QAAb,EAAwByD,EAAKnG,QAC7BkK,EAA0D,CAAC,CAA9C,oBAAkBrP,OAAlB,IACb4D,EAAOyL,EAAa,MAAbA,CAAsB,MAC7BpF,EAAcoF,EAAa,OAAbA,CAAuB,SAErCkF,EAAe,OACZ,CAAE,IAAQhJ,IAAV,CADY,KAEd,KACKA,KAAkBA,IAAlBA,CAA2C1D,IADhD,CAFc,IAOhB1C,QAAQ0C,cAAyB0M,eChB1C,cAAmC,IAC7B,CAAChE,GAAmBjF,EAAKsD,QAALtD,CAAcP,SAAjCwF,CAA4C,MAA5CA,CAAoD,iBAApDA,gBAICpI,GAAUmD,EAAKnG,OAALmG,CAAaC,UACvBiJ,EAAQjK,EACZe,EAAKsD,QAALtD,CAAcP,SADFR,CAEZnG,KAA8B,iBAAlBA,KAASmI,IAFThC,EAGZ/C,cAGAW,EAAQ7D,MAAR6D,CAAiBqM,EAAMnQ,GAAvB8D,EACAA,EAAQ5D,IAAR4D,CAAeqM,EAAMhQ,KADrB2D,EAEAA,EAAQ9D,GAAR8D,CAAcqM,EAAMlQ,MAFpB6D,EAGAA,EAAQ3D,KAAR2D,CAAgBqM,EAAMjQ,KACtB,IAEI+G,OAAKmJ,gBAIJA,OANL,GAOK/F,WAAW,uBAAyB,EAZ3C,KAaO,IAEDpD,OAAKmJ,gBAIJA,OANA,GAOA/F,WAAW,mCC/BpB,cAAoC,MAC5B1G,GAAYsD,EAAKtD,UACjB+L,EAAgB/L,EAAUkB,KAAVlB,CAAgB,GAAhBA,EAAqB,CAArBA,EAChB,CAAEH,QAAF,CAAU0D,WAAV,EAAwBD,EAAKnG,QAC7B2E,EAAuD,CAAC,CAA9C,oBAAkB9J,OAAlB,IAEV0U,EAA4D,CAAC,CAA5C,kBAAgB1U,OAAhB,aAEhB8J,EAAU,MAAVA,CAAmB,OACxByB,MACCmJ,EAAiB7M,EAAOiC,EAAU,OAAVA,CAAoB,QAA3BjC,CAAjB6M,CAAwD,CADzDnJ,IAGGvD,UAAYoC,OACZjF,QAAQ0C,OAAS3B,OCSxB,OAAe,OASN,OAEE,GAFF,WAAA,MAAA,CATM,QAwDL,OAEC,GAFD,WAAA,MAAA,QAUE,CAVF,CAxDK,iBAsFI,OAER,GAFQ,WAAA,MAAA,yCAAA,SAmBN,CAnBM,mBAyBI,cAzBJ,CAtFJ,cA2HC,OAEL,GAFK,WAAA,MAAA,CA3HD,OA8IN,OAEE,GAFF,WAAA,MAAA,SAQI,WARJ,CA9IM,MAoKP,OAEG,GAFH,WAAA,MAAA,UAaM,MAbN,SAkBK,CAlBL,mBAyBe,UAzBf,kBAAA,2BAAA,CApKO,OAuNN,OAEE,GAFF,WAAA,MAAA,CAvNM,MA0OP,OAEG,GAFH,WAAA,MAAA,CA1OO,cAkQC,OAEL,GAFK,WAAA,MAAA,mBAAA,GAkBT,QAlBS,GAwBT,OAxBS,CAlQD,YA4SD,OAEH,GAFG,WAAA,KAAA,UAAA,uBAAA,CA5SC,CAAf,ICde,WAKF,QALE,iBAAA,iBAAA,mBAAA,UAgCH,IAAM,CAhCH,CAAA,UA0CH,IAAM,CA1CH,CAAA,aAAA,CDcf,CE3BA,QAO4B,iBASKyF,KAAc,MAyF7CqC,eAAiB,IAAM2G,sBAAsB,KAAKC,MAA3BD,CAzFsB,MAEtCC,OAASC,EAAS,KAAKD,MAAL,CAAYE,IAAZ,CAAiB,IAAjB,CAATD,CAF6B,MAKtClJ,aAAeoJ,GAAOC,WALgB,MAQtCxJ,MAAQ,eAAA,aAAA,iBAAA,CAR8B,MAetCD,UAAYA,GAAaA,EAAU0J,MAAvB1J,CAAgCA,EAAU,CAAVA,CAAhCA,EAf0B,MAgBtC1D,OAASA,GAAUA,EAAOoN,MAAjBpN,CAA0BA,EAAO,CAAPA,CAA1BA,EAhB6B,MAmBtC8D,QAAQZ,YAnB8B,QAoBpCzC,UACFyM,GAAOC,QAAPD,CAAgBhK,UAChBY,EAAQZ,YACVE,QAAQsB,KAAQ,MACZZ,QAAQZ,kBAEPgK,GAAOC,QAAPD,CAAgBhK,SAAhBgK,QAEApJ,EAAQZ,SAARY,CAAoBA,EAAQZ,SAARY,GAApBA,IARR,EApB2C,MAiCtCZ,UAAY1C,OAAOC,IAAPD,CAAY,KAAKsD,OAAL,CAAaZ,SAAzB1C,EACdE,GADcF,CACVkE,gBAEA,KAAKZ,OAAL,CAAaZ,SAAb,IAHU1C,EAMdK,IANcL,CAMT,OAAUQ,EAAEhG,KAAFgG,CAAUF,EAAE9F,KANbwF,CAjC0B,MA6CtC0C,UAAUE,QAAQiK,KAAmB,CACpCA,EAAgB9J,OAAhB8J,EAA2B7J,EAAW6J,EAAgBC,MAA3B9J,CADS,IAEtB8J,OACd,KAAK5J,UACL,KAAK1D,OACL,KAAK8D,UAEL,KAAKH,MAPX,EA7C2C,MA0DtCoJ,QA1DsC,MA4DrC9G,GAAgB,KAAKnC,OAAL,CAAamC,cA5DQ,QA+DpCsH,sBA/DoC,MAkEtC5J,MAAMsC,wBAKJ,OACA8G,GAAOlU,IAAPkU,CAAY,IAAZA,WAEC,OACDS,GAAQ3U,IAAR2U,CAAa,IAAbA,wBAEc,OACdD,GAAqB1U,IAArB0U,CAA0B,IAA1BA,yBAEe,OACfjI,GAAsBzM,IAAtByM,CAA2B,IAA3BA,EA1FiB,CAAP4H,GAoHZO,KApHYP,CAoHJ,CAAmB,WAAlB,QAAOxV,OAAP,CAAyCgW,MAAzC,CAAgChW,MAAjC,EAAkDiW,YApH9CT,GAsHZ1D,UAtHY0D,IAAAA,GAwHZC,QAxHYD"}
\ No newline at end of file
diff --git a/src/static/js/site.js b/src/static/js/site.js
new file mode 100644 (file)
index 0000000..24ef831
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+       Navigation
+*/
+
+var burger = document.querySelector(".navbar-burger");
+var menu = document.querySelector("#" + burger.dataset.target);
+burger.addEventListener('click', function () {
+    burger.classList.toggle('is-active');
+       menu.classList.toggle('is-active');
+});
+
+/*
+
+       Dropdowns
+
+*/
+
+// Get all dropdowns on the page that aren't hoverable
+const dropdowns = document.querySelectorAll('.dropdown:not(.is-hoverable)');
+
+if (dropdowns.length > 0) {
+       // For each dropdown, add event handler to open on click.
+       dropdowns.forEach(function(el) {
+               el.addEventListener('click', function(e) {
+                       closeDropdowns();
+                       e.stopPropagation();
+                       el.classList.toggle('is-active');
+               });
+       });
+
+       // If user clicks outside dropdown, close it.
+       document.addEventListener('click', function(e) {
+               closeDropdowns();
+       });
+}
+
+/*
+ * Close dropdowns by removing the "is-active" class
+ */
+function closeDropdowns() {
+       dropdowns.forEach(function(el) {
+               el.classList.remove('is-active');
+       });
+}
+
+// Close dropdowns if ESC pressed
+document.addEventListener('keydown', function(e) {
+       let event = e || window.event;
+       if (event.key === 'Esc' || event.key === 'Escape') {
+               closeDropdowns();
+       }
+});
+
+// Modals
+document.addEventListener("DOMContentLoaded", () => {
+       function openModal($el) {
+               $el.classList.add("is-active");
+       }
+
+       function closeModal($el) {
+               $el.classList.remove("is-active");
+       }
+
+       function closeAllModals() {
+               (document.querySelectorAll(".modal") || []).forEach(($modal) => {
+                       closeModal($modal);
+               });
+       }
+
+       // Add a click event on buttons to open a specific modal
+       (document.querySelectorAll(".modal-trigger") || []).forEach(($trigger) => {
+               const modal = $trigger.dataset.target;
+               const $target = document.getElementById(modal);
+
+               $trigger.addEventListener("click", () => {
+                       openModal($target);
+               });
+       });
+
+       // Add a click event on various child elements to close the parent modal
+       (document.querySelectorAll(".modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button") || []).forEach(($close) => {
+               const $target = $close.closest(".modal");
+
+               $close.addEventListener("click", (e) => {
+                       closeModal($target);
+               });
+       });
+
+       // Add a keyboard event to close all modals
+       document.addEventListener("keydown", (event) => {
+               if (event.code === "Escape") {
+                       closeAllModals();
+               }
+       });
+});
diff --git a/src/static/videos/.gitignore b/src/static/videos/.gitignore
new file mode 100644 (file)
index 0000000..9440014
--- /dev/null
@@ -0,0 +1,2 @@
+# Ignore anything that isn't the original video
+*@*
diff --git a/src/static/videos/firewall.jpg b/src/static/videos/firewall.jpg
new file mode 100644 (file)
index 0000000..5fe88a1
Binary files /dev/null and b/src/static/videos/firewall.jpg differ
diff --git a/src/static/videos/firewall.mp4 b/src/static/videos/firewall.mp4
new file mode 100644 (file)
index 0000000..de538da
Binary files /dev/null and b/src/static/videos/firewall.mp4 differ
diff --git a/src/templates/analytics/docs.html b/src/templates/analytics/docs.html
new file mode 100644 (file)
index 0000000..cb5b0b1
--- /dev/null
@@ -0,0 +1,59 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Analytics") }} - {{ _("Docs") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/analytics">{{ _("Analytics") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Documentation") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Documents") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ _("Most Popular Pages") }}</h4>
+
+                       {% module DocsList(popular_pages, show_author=False) %}
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ _("Popular Searches") }}</h4>
+
+                       <table class="table is-fullwidth">
+                               <thead>
+                                       <tr>
+                                               <th>{{ _("Search Query") }}</th>
+                                               <th class="has-text-right">{{ _("Searches") }}</th>
+                                       </tr>
+                               </thead>
+
+                               <tbody>
+                                       {% for search in sorted(popular_searches, key=lambda q: popular_searches[q], reverse=True) %}
+                                               <tr>
+                                                       <th scope="row">{{ search }}</th>
+                                                       <td class="has-text-right">{{ popular_searches[search] }}</td>
+                                               </tr>
+                                       {% end %}
+                               </tbody>
+                       </table>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/analytics/index.html b/src/templates/analytics/index.html
new file mode 100644 (file)
index 0000000..7f38022
--- /dev/null
@@ -0,0 +1,65 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Analytics") }}{% end block %}
+
+{% block container %}
+       {% import datetime %}
+
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Analytics") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Analytics") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       {# Fetch some data #}
+       {% set total_page_views     = backend.analytics.get_total_page_views(request.host) %}
+       {% set total_page_views_24h = backend.analytics.get_total_page_views(request.host,
+               since=datetime.timedelta(hours=24)) %}
+
+       <section class="section">
+               <div class="container">
+                       <div class="level">
+                               <div class="level-item has-text-centered">
+                                       <div>
+                                               <p class="heading">{{ _("Total Page Views") }}</p>
+                                               <p class="title">
+                                                       {{ total_page_views }}
+                                               </p>
+                                       </div>
+                               </div>
+
+                               <div class="level-item has-text-centered">
+                                       <div>
+                                               <p class="heading">{{ _("Total Page Views (Last 24h)") }}</p>
+                                               <p class="title">
+                                                       {{ total_page_views_24h }}
+                                               </p>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="buttons">
+                               <a class="button" href="/analytics/docs">
+                                       {{ _("Documentation") }}
+                               </a>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/analytics/modules/summary.html b/src/templates/analytics/modules/summary.html
new file mode 100644 (file)
index 0000000..8bda702
--- /dev/null
@@ -0,0 +1,15 @@
+<div class="level">
+       <div class="level-item has-text-centered">
+               <div>
+                       <p class="heading">{{ _("Total Page Views") }}</p>
+                       <p class="title">{{ total_page_views }}</p>
+               </div>
+       </div>
+
+       <div class="level-item has-text-centered">
+               <div>
+                       <p class="heading">{{ _("Total Page Views (Last 24h)") }}</p>
+                       <p class="title">{{ total_page_views_24h }}</p>
+               </div>
+       </div>
+</div>
index 472a41ea3f052d7f4a7f46cff5dda545f3bea15b..6d59ad423cdc4132183f75dfd75c726ac84c9542 100644 (file)
 
                                {% module Password() %}
 
-                               <button type="submit" class="btn btn-primary btn-block" disabled>
-                                       {{ _("Activate Account") }}
-                               </button>
+                               <div class="d-grid">
+                                       <button type="submit" class="btn btn-primary" disabled>
+                                               {{ _("Activate Account") }}
+                                       </button>
+                               </div>
                        </form>
                </div>
        </div>
index ed6da2135eb2234b876bd638d5a20e956cfa931f..eff7ffdbfe98685f65408de5a4410d7ff2ad796c 100644 (file)
                                {{ _("To kick things off, would you like to setup a donation to the IPFire Project?") }}
                        </p>
 
-                       <a class="btn btn-lg btn-block btn-primary mb-1" href="https://www.ipfire.org/donate?first_name={{ url_escape(account.first_name) }}&last_name={{ url_escape(account.last_name) }}">
-                               {{ _("Donate Now") }}
-                       </a>
+                       <div class="d-grid">
+                               <a class="btn btn-lg btn-primary mb-1" href="https://www.ipfire.org/donate?first_name={{ url_escape(account.first_name) }}&last_name={{ url_escape(account.last_name) }}">
+                                       {{ _("Donate Now") }}
+                               </a>
+                       </div>
 
-                       <a class="text-secondary small" href="/">
+                       <a class="link-secondary small" href="/">
                                {{ _("No thanks, I have already donated") }}
                        </a>
                </div>
diff --git a/src/templates/auth/join-success.html b/src/templates/auth/join-success.html
new file mode 100644 (file)
index 0000000..a6da052
--- /dev/null
@@ -0,0 +1,29 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Thank You") }}{% end block %}
+
+{% block content %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-half">
+                                       <div class="notification is-success">
+                                               <h1 class="title">
+                                                       {{ _("Thank you for joining!") }}
+                                               </h1>
+
+                                               <h4 class="subtitle">
+                                                       {{ _("Please check your email for next steps.") }}
+                                               </h4>
+
+                                               <div class="block has-text-centered">
+                                                       <span class="icon m-5">
+                                                               <i class="fas fa-check fa-5x"></i>
+                                                       </span>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/auth/join.html b/src/templates/auth/join.html
new file mode 100644 (file)
index 0000000..4a6dc1e
--- /dev/null
@@ -0,0 +1,221 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Join Our Community") }}{% end block %}
+
+{% block content %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-one-third">
+                                       <h1 class="title">
+                                               IPFire<span class="has-text-primary">_</span>
+                                       </h1>
+                                       <h4 class="subtitle">{{ _("Join Our Community") }}</h4>
+
+                                       <form id="form-join" action="" method="POST">
+                                               {% raw xsrf_form_html() %}
+
+                                               <input type="hidden" name="next" value="{{ next or "" }}">
+
+                                               <div class="block">
+                                                       <div class="field">
+                                                               <p class="control has-icons-left">
+                                                                       <span class="icon is-small is-left">
+                                                                               <i class="fas fa-at"></i>
+                                                                       </span>
+                                                                       <input class="input" type="text" name="uid"
+                                                                               placeholder="{{ _("Username") }}"
+                                                                               pattern="[a-z_][a-z0-9_-]{3,31}"
+                                                                               required autofocus>
+                                                               </p>
+
+                                                               <p id="uid-invalid" class="help is-danger invalid-feedback invalid-feedback-uid">
+                                                                       {{ _("Please choose a username in UNIX format with at least four characters, starting with a lowercase letter, followed by only lowercase letters, digits, dash and underscore") }}
+                                                               </p>
+
+                                                               <p id="uid-taken" class="help is-danger invalid-feedback invalid-feedback-uid">
+                                                                       {{ _("This username is not available") }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <div class="field">
+                                                               <div class="field-body">
+                                                                       <div class="field">
+                                                                               <p class="control has-icons-left">
+                                                                                       <span class="icon is-small is-left">
+                                                                                               <i class="fas fa-person"></i>
+                                                                                       </span>
+                                                                                       <input class="input" type="text"
+                                                                                               name="first_name" placeholder="{{ _("First Name") }}"
+                                                                                               required>
+                                                                               </p>
+                                                                       </div>
+
+                                                                       <div class="field">
+                                                                               <p class="control has-icons-left">
+                                                                                       <span class="icon is-small is-left">
+                                                                                               <i class="fas fa-person"></i>
+                                                                                       </span>
+                                                                                       <input class="input" type="text"
+                                                                                               name="last_name" placeholder="{{ _("Last Name") }}"
+                                                                                               required>
+                                                                               </p>
+                                                                       </div>
+                                                               </div>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <div class="field">
+                                                               <p class="control has-icons-left">
+                                                                       <span class="icon is-small is-left">
+                                                                               <i class="fas fa-envelope"></i>
+                                                                       </span>
+                                                                       <input class="input" type="email"
+                                                                               name="email" placeholder="{{ _("Email Address") }}" required>
+                                                               </p>
+
+                                                               <p id="email-invalid" class="help is-danger invalid-feedback invalid-feedback-email">
+                                                                       {{ _("This email address is invalid") }}
+                                                               </p>
+
+                                                               <p id="email-blacklisted" class="help is-danger invalid-feedback invalid-feedback-email">
+                                                                       {{ _("This email address cannot be used") }}
+                                                               </p>
+
+                                                               <p id="email-taken" class="help is-danger invalid-feedback invalid-feedback-email">
+                                                                       {{ _("This email address is already in use") }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               <div class="field">
+                                                       <div class="control">
+                                                               <button class="button is-primary is-medium is-fullwidth">
+                                                                       {{ _("Join Now") }}
+                                                               </button>
+                                                       </div>
+                                               </div>
+                                       </form>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
+
+{% block javascript %}
+       <script>
+               var form = $("#form-join");
+               var submit = form.find(":submit");
+
+               // Store the valid status
+               var valid = {
+                       uid: false,
+                       email: false,
+               };
+
+               // Hide all invalid feedback
+               form.find(".invalid-feedback").hide();
+
+               var check_uid;
+               var check_email;
+
+               form.find("input[name=uid]").on("keyup", function(e) {
+                       if (check_uid)
+                               clearTimeout(check_uid);
+
+                       var uid = $(this);
+
+                       check_uid = setTimeout(function() {
+                               $.get("/api/check/uid", { uid : uid.val() },
+                                       function(data) {
+                                               // Reset all classes
+                                               uid.removeClass("is-success is-danger");
+                                               valid.uid = false;
+
+                                               // Hide all feedback
+                                               $(".invalid-feedback-uid").hide();
+
+                                               switch (data.result) {
+                                                       case "ok":
+                                                               uid.addClass("is-success");
+                                                               valid.uid = true;
+                                                               break;
+
+                                                       case "invalid":
+                                                               uid.addClass("is-danger");
+                                                               $("#uid-invalid").show();
+                                                               break;
+
+                                                       case "taken":
+                                                               uid.addClass("is-danger");
+                                                               $("#uid-taken").show();
+                                                               break;
+                                               }
+
+                                               form.trigger("change");
+                                       }
+                               );
+                       }, 250);
+               });
+
+               form.find("input[name=email]").on("keyup", function(e) {
+                       if (check_email)
+                               clearTimeout(check_email);
+
+                       var email = $(this);
+
+                       check_email = setTimeout(function() {
+                               $.get("/api/check/email", { email : email.val() },
+                                       function(data) {
+                                               // Reset all classes
+                                               email.removeClass("is-success is-danger");
+                                               valid.email = false;
+
+                                               // Hide all feedback
+                                               $(".invalid-feedback-email").hide();
+
+                                               switch (data.result) {
+                                                       case "ok":
+                                                               email.addClass("is-success");
+                                                               valid.email = true;
+                                                               break;
+
+                                                       case "invalid":
+                                                               email.addClass("is-danger");
+                                                               $("#email-invalid").show();
+                                                               break;
+
+                                                       case "blacklisted":
+                                                               email.addClass("is-danger");
+                                                               $("#email-blacklisted").show();
+                                                               break;
+
+                                                       case "taken":
+                                                               email.addClass("is-danger");
+                                                               $("#email-taken").show();
+                                                               break;
+                                               }
+
+                                               form.trigger("change");
+                                       }
+                               );
+                       }, 250);
+               });
+
+               form.on("change", function() {
+                       var disabled = false;
+
+                       $.each(valid, function(field, status) {
+                               if (!status) {
+                                       disabled = true;
+                                       return false;
+                               }
+                       });
+
+                       submit.prop("disabled", disabled);
+               });
+       </script>
+{% end block %}
index d359dd4392e50799117c1be640793c955685522e..5e660af6954119ba69a1c7a9c3fbeea6e1508e34 100644 (file)
@@ -2,53 +2,64 @@
 
 {% block title %}{{ _("Log In") }}{% end block %}
 
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-4">
-                       <div class="card {% if incorrect %}border-danger{% end %} mb-5">
-                               <div class="card-body">
-                                       <h5 class="card-title text-center mb-4">{{ _("Log In") }}</h5>
-
-                                       {% if incorrect %}
-                                               <h6 class="card-subtitle mb-4 text-danger text-center">
-                                                       {{ _("You entered an invalid username or password") }}
-                                               </h6>
-                                       {% end %}
-
-                                       <form action="" method="POST">
-                                               {% raw xsrf_form_html() %}
-
-                                               {% if next %}<input type="hidden" name="next" value="{{ next }}">{% end %}
-
-                                               <div class="form-group">
-                                                       <input type="text" class="form-control form-control-lg"
-                                                               name="username" placeholder="{{ _("Username") }}"
-                                                               value="{{ username or "" }}" required autofocus>
-                                               </div>
+{% block container %}
+       <section class="hero is-primary is-fullheight-with-navbar">
+               <div class="hero-body">
+                       <div class="container">
+                               <div class="columns is-centered">
+                                       <div class="column is-one-third">
+                                               <h1 class="title">{{ _("Log In") }}</h1>
 
-                                               <div class="form-group">
-                                                       <input type="password" class="form-control form-control-lg"
-                                                               name="password" placeholder="{{ _("Password") }}" required>
-                                               </div>
+                                               <div class="block">
+                                                       <form action="" method="POST">
+                                                               {% raw xsrf_form_html() %}
 
-                                               <button type="submit" class="btn btn-primary btn-block">
-                                                       {{ _("Log in") }}
-                                               </button>
-                                       </form>
+                                                               {% if next %}<input type="hidden" name="next" value="{{ next }}">{% end %}
 
-                                       <p class="card-text text-center small mt-3">
-                                               <a class="text-muted" href="//people.ipfire.org/password-reset{% if incorrect %}?username={{ username }}{% end %}">
-                                                       {{ _("Did you forget your password?") }}
-                                               </a>
-                                       </p>
-                               </div>
-                       </div>
+                                                               <div class="field">
+                                                                       <div class="control">
+                                                                               <input class="input is-medium {% if incorrect %}is-danger{% end %}"
+                                                                                       type="text" name="username" {% if username %}value="{{ username }}"{% end %}
+                                                                                       placeholder="{{ _("Username") }}" required autofocus>
+                                                                       </div>
+                                                               </div>
+
+                                                               <div class="field">
+                                                                       <div class="control">
+                                                                               <input class="input is-medium {% if incorrect %}is-danger{% end %}"
+                                                                                       type="password" name="password" placeholder="{{ _("Password") }}" required
+                                                                                       autocomplete="off">
+                                                                       </div>
+                                                               </div>
 
-                       <h6 class="text-center mb-2">{{ _("New to IPFire?") }}</h6>
+                                                               <div class="field">
+                                                                       <div class="control">
+                                                                               <button class="button is-medium is-fullwidth">
+                                                                                       {{ _("Log In") }}
+                                                                               </button>
+                                                                       </div>
+                                                               </div>
 
-                       <a class="btn btn-secondary btn-block" href="https://people.ipfire.org/register">
-                               {{ _("Register") }}
-                       </a>
+                                                               {% if request.host.startswith("www.") %}
+                                                                       <div class="field has-text-centered">
+                                                                               <a href="/password-reset{% if incorrect %}?username={{ username }}{% end %}">
+                                                                                       {{ _("Did you forget your password?") }}
+                                                                               </a>
+                                                                       </div>
+                                                               {% end %}
+                                                       </form>
+                                               </div>
+
+                                               {% if request.host.startswith("www.") %}
+                                                       <div class="block has-text-centered">
+                                                               <a href="/join">
+                                                                       {{ _("Don't have an account, yet? Join Now") }}
+                                                               </a>
+                                                       </div>
+                                               {% end %}
+                                       </div>
+                               </div>
+                       </div>
                </div>
-       </div>
+       </section>
 {% end block %}
index 22be8b0b9e90232c86db6cc22d26138e7288956b..b4d7de547a6030317756fff85a167cab30559c1c 100644 (file)
@@ -1,62 +1,56 @@
 {% extends "../../messages/base-promo.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hey again, %s,") % account.first_name }}</strong>
-    </p>
-
-    <p>
-        {{ _("IPFire runs on supporters' donations, people like you!") }}
-    </p>
-
-    <p>
-        {{ _("Why do we need you donations?") }}
-    </p>
-
-    <ul>
-        <li>{{ _("Your money ensures the longevity and long-term success of this project.") }}</li>
-        <li>{{ _("It helps us fund developers and extend our skills") }}</li>
-        <li>{{ _("It will aid us to promote IPFire to more people around the world") }}</li>
-        <li>{{ _("This funds conferences, where we focus on future projects") }}</li>
-        <li>{{ _("It pays for our hosting") }}</li>
-    </ul>
-
-    <p>
-        {{ _("All this, as you would understand, requires money. Every single donation counts.") }}
-    </p>
-
-    <p>
-        {{ _("If you want to see IPFire thrive, we need your support.") }}
-    </p>
-
-    <p>
-        {{ _("The best way to do this is by setting up a monthly donation which you can do here:") }}
-    </p>
-
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate?frequency=monthly&amp;amount=10" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
-
-    <p>
-        {{ _("We also have other ways to donate. Please go to https://www.ipfire.org/donate for details.") }}
-    </p>
-
-    <p>
-        {{ _("Thank you so much for your support,") }}
-        <br>{{ _("-Michael") }}
-    </p>
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Hey again, %s,") % account.first_name }}</h1>
+
+                       <p>
+                               {{ _("IPFire runs on supporters' donations, people like you!") }}
+                       </p>
+
+                       <p>
+                               {{ _("Why do we need you donations?") }}
+                       </p>
+
+                       <ul>
+                               <li>{{ _("Your money ensures the longevity and long-term success of this project.") }}</li>
+                               <li>{{ _("It helps us fund developers and extend our skills") }}</li>
+                               <li>{{ _("It will aid us to promote IPFire to more people around the world") }}</li>
+                               <li>{{ _("This funds conferences, where we focus on future projects") }}</li>
+                               <li>{{ _("It pays for our hosting") }}</li>
+                       </ul>
+
+                       <p>
+                               {{ _("All this, as you would understand, requires money. Every single donation counts.") }}
+                       </p>
+
+                       <p>
+                               {{ _("If you want to see IPFire thrive, we need your support.") }}
+                       </p>
+
+                       <p>
+                               {{ _("The best way to do this is by setting up a monthly donation which you can do here:") }}
+                       </p>
+
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                                <a class="primary" href="https://www.ipfire.org/donate?frequency=monthly&amp;amount=10&amp;utm_medium=email&amp;utm_source=donation-reminder">
+                                                       {{ _("Donate Now") }}
+                                                </a>
+                                       </td>
+                               </tr>
+                       </table>
+
+                       <p>
+                               {{ _("We also have other ways to donate. Please go to https://www.ipfire.org/donate for details.") }}
+                       </p>
+
+                       <p>
+                               {{ _("Thank you so much for your support,") }}
+                               <br>{{ _("-Michael") }}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
index b7e35b9070eb67c8aebd71a758a8bf92eb4de816..a4454c42d07e7878e18e7dd5d0eff10a6504d140 100644 (file)
@@ -20,7 +20,7 @@ Subject: {{ _("Please help us with your donation!") }}
 
 {{ _("The best way to do this is by setting up a monthly donation which you can do here:") }}
 
-  https://www.ipfire.org/donate?frequency=monthly&amount=10
+  https://www.ipfire.org/donate?frequency=monthly&amount=10&utm_medium=email&utm_source=donation-reminder
 
 {{ _("We also have other ways to donate. Please go to https://www.ipfire.org/donate for details.") }}
 
@@ -28,4 +28,4 @@ Subject: {{ _("Please help us with your donation!") }}
 {{ _("-Michael")}}
 
 --
-Don't like these emails? https://people.ipfire.org/unsubscribe
+Don't like these emails? https://www.ipfire.org/unsubscribe
diff --git a/src/templates/auth/messages/join.html b/src/templates/auth/messages/join.html
new file mode 100644 (file)
index 0000000..145bcc7
--- /dev/null
@@ -0,0 +1,39 @@
+{% extends "../../messages/base.html" %}
+
+{% block hero %}
+       <tr class="hero">
+               <td>
+                       <img class="g-img" src="{{ embed_image("img/auth/join@600.jpg") }}" alt="IPFire">
+               </td>
+       </tr>
+{% end block %}
+
+{% block content %}
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Dear %s!") % first_name }}</h1>
+
+                       <p>
+                               {{ _("Welcome to our vibrant community! We're thrilled to have you on board.") }}
+                       </p>
+
+                       <p>
+                               {{ _("To activate your membership, please click on the activation link below:") }}
+                       </p>
+
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                                <a class="primary" href="https://www.ipfire.org/activate/{{ uid }}/{{ activation_code }}">
+                                                       {{ _("Activate Now") }}
+                                                </a>
+                                       </td>
+                               </tr>
+                       </table>
+
+                       <p>
+                               {{ _("We look forward to seeing you actively participate in our community.") }}
+                       </p>
+               </td>
+       </tr>
+{% end block %}
diff --git a/src/templates/auth/messages/join.txt b/src/templates/auth/messages/join.txt
new file mode 100644 (file)
index 0000000..d6674b4
--- /dev/null
@@ -0,0 +1,16 @@
+From: IPFire Project <no-reply@ipfire.org>
+To: {{ first_name }} {{ last_name }} <{{ email }}>
+Subject: {{ _("Activate Your Community Membership Now!") }}
+X-Auto-Response-Suppress: OOF
+
+{{ _("Dear %s!") % first_name }}
+
+{{ _("Welcome to our vibrant community! We're thrilled to have you on board.") }}
+
+{{ _("To activate your membership, please click on the activation link below:") }}
+
+  https://www.ipfire.org/activate/{{ uid }}/{{ activation_code }}
+
+{{ _("If the link doesn't work, copy and paste it into your browser.") }}
+
+{{ _("We look forward to seeing you actively participate in our community.") }}
index 8309d4ac8f9f7588958f778a5997ac134f2831d2..a9f1abb76dabd01eb9974df2e114c0ae21385065 100644 (file)
@@ -1,29 +1,24 @@
 {% extends "../../messages/base.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hello %s!") % account.first_name }}</strong>
-    </p>
+       <tr class="section">
+               <td>
+               <h1>{{ _("Hello %s!") % account.first_name }}</h1>
 
-    <p>
-        {{ _("You, or somebody else on your behalf, has requested to change your password.") }} {{ _("If this was not you, please notify a team member.") }}
-    </p>
+                       <p>
+                               {{ _("You, or somebody else on your behalf, has requested to change your password.") }}
+                               {{ _("If this was not you, please notify a team member.") }}
+                       </p>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://people.ipfire.org/password-reset/{{ account.uid }}/{{ reset_code }}" target="_blank">{{ _("Reset Password") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                                <a class="primary" href="https://www.ipfire.org/password-reset/{{ account.uid }}/{{ reset_code }}">
+                                                       {{ _("Reset Password") }}
+                                                </a>
+                                       </td>
+                               </tr>
+                       </table>
+               </td>
+       </tr>
 {% end block %}
index c6f81bc822fec54f47a6b8c47a60ed997caacdbc..e149add5def131b3dcff77b8abe6714a32ddfbe8 100644 (file)
@@ -9,4 +9,4 @@ X-Auto-Response-Suppress: OOF
 
 {{ _("To reset your password, please click on this link:") }}
 
-  https://people.ipfire.org/password-reset/{{ account.uid }}/{{ reset_code }}
+  https://www.ipfire.org/password-reset/{{ account.uid }}/{{ reset_code }}
index 1c9fa0fa1f8db504718528e1ffd096cd45d4194f..4505f4f225791e91fa3dabd613661e76e529c096 100644 (file)
@@ -1,43 +1,37 @@
 {% extends "../../messages/base.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hello once again, %s,") % account.first_name }}</strong>
-    </p>
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Hello once again, %s,") % account.first_name }}</h1>
 
-    <p>
-        {{ _("we hope you are enjoying using IPFire.") }}
-    </p>
+                       <p>
+                               {{ _("we hope you are enjoying using IPFire.") }}
+                       </p>
 
-    <p>
-        {{ _("Did you know that you can get help from our community at https://community.ipfire.org?") }}
-        {{ _("People like me often post on here, providing help and support.") }}
-    </p>
+                       <p>
+                               {{ _("Did you know that you can get help from our community at https://community.ipfire.org?") }}
+                               {{ _("People like me often post on here, providing help and support.") }}
+                       </p>
 
-    <p>
-        {{ _("But we also rely on you donations. Please consider helping us by setting up a small monthly donation:") }}
-    </p>
+                       <p>
+                               {{ _("But we also rely on you donations. Please consider helping us by setting up a small monthly donation:") }}
+                       </p>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate?frequency=monthly" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                                <a class="primary" href="https://www.ipfire.org/donate?frequency=monthly">
+                                                       {{ _("Donate Now") }}
+                                                </a>
+                                       </td>
+                               </tr>
+                       </table>
 
-    <p>
-        {{ _("Thank you, we really appreciate your support,") }}
-        <br>{{ _("-Arne")}}
-    </p>
+                       <p>
+                               {{ _("Thank you, we really appreciate your support,") }}
+                               <br>{{ _("-Arne")}}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
index 3ab3275a3c44508ea6193ab983aacfe16f2e9045..4b17f9c24b16cb0a7dcd39c0c0ea59e4198b0ea1 100644 (file)
@@ -1,47 +1,41 @@
 {% extends "../../messages/base.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hello %s!") % account.first_name }}</strong>
-    </p>
+       <tr class="section">
+               <td>
+               <h1>{{ _("Hello %s!") % account.first_name }}</h1>
 
-    <p>
-        {{ _("I would like to introduce myself: I'm Michael and I am one of the people behind the project. We are a team of passionate people who try to make the Internet a better place. On behalf of everyone, I would like to say: Welcome to the IPFire Project!") }}
-    </p>
+                       <p>
+                               {{ _("I would like to introduce myself: I'm Michael and I am one of the people behind the project. We are a team of passionate people who try to make the Internet a better place. On behalf of everyone, I would like to say: Welcome to the IPFire Project!") }}
+                       </p>
 
-    <p>
-        {{ _("We want you to feel a part of our team. Can I ask you to set up your profile? We would love to know a little bit more about you.") }}
-        {{ _("To do this, please log on to your profile and click the edit button.") }}
-    </p>
+                       <p>
+                               {{ _("We want you to feel a part of our team. Can I ask you to set up your profile? We would love to know a little bit more about you.") }}
+                               {{ _("To do this, please log on to your profile and click the edit button.") }}
+                       </p>
 
-    <p>
-        {{ _("I would also like to invite you to join our community at https://community.ipfire.org, if you haven't already done so.") }}
-    </p>
+                       <p>
+                               {{ _("I would also like to invite you to join our community at https://community.ipfire.org, if you haven't already done so.") }}
+                       </p>
 
-    <p>
-        {{ _("Finally, this organisation relies on the generous donations of people like you. If you can, please consider supporting this project and the team behind it, so that we can continue our long-term vision, fund developers and promote our project.") }}
-    </p>
+                       <p>
+                               {{ _("Finally, this organisation relies on the generous donations of people like you. If you can, please consider supporting this project and the team behind it, so that we can continue our long-term vision, fund developers and promote our project.") }}
+                       </p>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                                <a class="primary" href="https://www.ipfire.org/donate">
+                                                       {{ _("Donate Now") }}
+                                                </a>
+                                       </td>
+                               </tr>
+                       </table>
 
-    <p>
-        {{ _("Thank you,") }}
-        <br>{{ _("-Michael") }}
-    </p>
+                       <p>
+                               {{ _("Thank you,") }}
+                               <br>{{ _("-Michael") }}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
diff --git a/src/templates/auth/messages/register.html b/src/templates/auth/messages/register.html
deleted file mode 100644 (file)
index 75e0429..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-{% extends "../../messages/base.html" %}
-
-{% block content %}
-    <p>
-        <strong>{{ _("Hello %s!") % first_name }}</strong>
-    </p>
-
-    <p>
-        {{ _("Thank you for registering a new account with us.") }}
-    </p>
-
-    <p>
-        {{ _("This account will allow you to take part in our project. Either by joining conversations, writing documentation, or becoming a developer.") }} {{ _("There are many things you can do with your account.") }}
-    </p>
-
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://people.ipfire.org/activate/{{ uid }}/{{ activation_code }}" target="_blank">{{ _("Activate Account") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
-{% end block %}
diff --git a/src/templates/auth/messages/register.txt b/src/templates/auth/messages/register.txt
deleted file mode 100644 (file)
index 72e63e5..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-From: IPFire Project <no-reply@ipfire.org>
-To: {{ first_name }} {{ last_name }} <{{ email }}>
-Subject: {{ _("Please activate your account for the IPFire Project") }}
-X-Auto-Response-Suppress: OOF
-
-{{ _("Hello %s!") % first_name }}
-
-{{ _("Thank you for registering a new account with us.") }}
-
-{{ _("This account will allow you to take part in our project. Either by joining conversations, writing documentation, or becoming a developer.") }} {{ _("There are many things you can do with your account.") }}
-
-{{ _("To activate it, please click on this link:") }}
-
-  https://people.ipfire.org/activate/{{ uid }}/{{ activation_code }}
diff --git a/src/templates/auth/modules/password.html b/src/templates/auth/modules/password.html
new file mode 100644 (file)
index 0000000..f96869a
--- /dev/null
@@ -0,0 +1,22 @@
+<div class="field">
+       <label class="label">{{ _("New Password") }}</label>
+
+       <div class="control">
+               <input type="password" class="input" name="password1"
+                       id="password1" placeholder="{{ _("New Password") }}" required
+                       data-user-input="{% if account %}{{ " ".join((account.first_name, account.last_name)) }}{% end %}">
+       </div>
+</div>
+
+<div class="field">
+       <div class="control">
+               <input type="password" class="input" name="password2"
+                       id="password2" placeholder="{{ _("Repeat Password") }}" required>
+       </div>
+
+       <ul class="help" id="password-feedback"></ul>
+</div>
+
+<div class="block">
+       <progress class="progress is-small" value="0" max="100" id="password-strength"></progress>
+</div>
similarity index 76%
rename from src/templates/people/modules/password.js
rename to src/templates/auth/modules/password.js
index 8235fc733ebbfdd4b0d1e66decfb11ed3523a903..562c83ef16af6c821fb74999de77ba87a456fe30 100644 (file)
@@ -15,7 +15,18 @@ $(function() {
        var quality;
 
        password1.keyup(function(event) {
+               form.trigger("change");
+       });
+
+       password2.keyup(function(event) {
+               form.trigger("change");
+       });
+
+       form.on("change", function() {
+               submit.prop("disabled", true);
+
                var val1 = password1.val();
+               var val2 = password2.val();
 
                if (val1) {
                        // Estimate password quality
@@ -25,11 +36,11 @@ $(function() {
                        var percentage = (quality.score + 1) * 20;
 
                        // Set progress bar width
-                       progress.css("width", percentage + "%");
+                       progress.val(percentage);
 
                        // Clear all previous backgrounds
                        progress.removeClass([
-                               "bg-success", "bg-warning", "bg-danger"
+                               "is-success", "is-warning", "is-danger"
                        ]);
 
                        // Make progress bar show in the right colour
@@ -37,25 +48,28 @@ $(function() {
                                case 0:
                                case 1:
                                case 2:
-                                       progress.addClass("bg-danger");
+                                       progress.addClass("is-danger");
                                        break;
 
                                case 3:
-                                       progress.addClass("bg-warning");
+                                       progress.addClass("is-warning");
                                        break;
 
                                case 4:
-                                       progress.addClass("bg-success");
+                                       progress.addClass("is-success");
                                        break;
                        }
 
                        // Show any feedback
-                       warning.empty();
                        feedback.empty();
 
                        if (quality.feedback) {
+                               if (val2 && (val1 !== val2)) {
+                                       feedback.append("<li>{{ _("Passwords do not match") }}</li>");
+                               }
+
                                if (quality.feedback.warning) {
-                                       warning.html(quality.feedback.warning);
+                                       feedback.append("<li>" + quality.feedback.warning + "</li>");
                                }
 
                                $.each(quality.feedback.suggestions, function (i, suggestion) {
@@ -63,37 +77,16 @@ $(function() {
                                });
                        }
                } else {
-                       progress.css("width", "0%");
+                       progress.val(0);
 
                        // Clear all feedback
-                       warning.empty();
                        feedback.empty();
                }
 
-               form.trigger("change");
-       });
-
-       password2.keyup(function(event) {
-               form.trigger("change");
-       });
-
-       form.on("change", function() {
-               $("#password-mismatch").hide();
-               submit.prop("disabled", true);
-
-               var val1 = password1.val();
-               var val2 = password2.val();
-
                // We cannot submit the form when password2 is empty
                if (!val2)
                        return;
 
-               // If the passwords match, we allow to submit the form
-               if (val1 !== val2) {
-                       $("#password-mismatch").show();
-                       return;
-               }
-
                if (!quality || quality.score < 3)
                        return;
 
index c1306d7f580dbfbf9b0a9a7208213b1c4cf4dbe2..d8d223cc7c54d7cfc4a73873e46a99b16cb11d7e 100644 (file)
@@ -2,28 +2,39 @@
 
 {% block title %}{{ _("Password Reset") }}{% end block %}
 
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-4">
-                       <div class="card">
-                               <div class="card-body">
-                                       <h5 class="card-title text-center mb-4">{{ _("Password Reset") }}</h5>
+{% block container %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-one-third">
+                                       <h1 class="title">
+                                               IPFire<span class="has-text-primary">_</span>
+                                       </h1>
+                                       <h4 class="subtitle">{{ _("Reset Your Password") }}</h4>
 
-                                       <form action="" method="POST">
-                                               {% raw xsrf_form_html() %}
+                                       <div class="block">
+                                               <form action="" method="POST">
+                                                       {% raw xsrf_form_html() %}
 
-                                               <div class="form-group">
-                                                       <input type="text" class="form-control form-control-lg"
-                                                               name="username" placeholder="{{ _("Username") }}"
-                                                               value="{{ username or "" }}" required autofocus>
-                                               </div>
+                                                       <div class="field">
+                                                               <div class="control">
+                                                                       <input class="input is-medium"
+                                                                               type="text" name="username" {% if username %}value="{{ username }}"{% end %}
+                                                                               placeholder="{{ _("Username") }}" required autofocus>
+                                                               </div>
+                                                       </div>
 
-                                               <button type="submit" class="btn btn-primary btn-block">
-                                                       {{ _("Reset Password") }}
-                                               </button>
-                                       </form>
+                                                       <div class="field">
+                                                               <div class="control">
+                                                                       <button class="button is-primary is-medium is-fullwidth">
+                                                                               {{ _("Reset Password") }}
+                                                                       </button>
+                                                               </div>
+                                                       </div>
+                                               </form>
+                                       </div>
                                </div>
                        </div>
                </div>
-       </div>
-{% end block %}
+       </section>
+{% end block %}
\ No newline at end of file
index ff45d61a3ac590666f5126b575a1f838a3851a30..f11a5342caa2d6259207cf3b4f274fb19154861d 100644 (file)
@@ -3,17 +3,18 @@
 {% block title %}{{ _("Password Reset") }}{% end block %}
 
 {% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col-12 col-md-6">
-                       <div class="card bg-success text-white p-md-5">
-                               <div class="card-body text-center">
-                                       <span class="fas fa-lock fa-5x my-4"></span>
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-one-third">
+                                       <h1 class="title">
+                                               IPFire<span class="has-text-primary">_</span>
+                                       </h1>
+                                       <h4 class="subtitle">{{ _("Reset Your Password") }}</h4>
 
-                                       <p class="lead">
-                                               {{ _("You will shortly receive an email with instructions on how to reset your password.") }}
-                                       </p>
+                                       <p class="is-size-5">{{ _("You will shortly receive an email with instructions on how to reset your password.") }}</p>
                                </div>
                        </div>
                </div>
-       </div>
+       </section>
 {% end block %}
index 3a26a170c07c0e1a45a6bd78b4633013f508eb3d..f7cfc2f508243415daf49c759721c9b7da2a1d91 100644 (file)
@@ -2,20 +2,33 @@
 
 {% block title %}{{ _("Password Reset") }}{% end block %}
 
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-4">
-                       <h5 class=" mb-4">{{ _("Reset Your Password") }}</h5>
+{% block container %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-one-third">
+                                       <h1 class="title">
+                                               IPFire<span class="has-text-primary">_</span>
+                                       </h1>
+                                       <h4 class="subtitle">{{ _("Reset Your Password") }}</h4>
 
-                       <form action="" method="POST">
-                               {% raw xsrf_form_html() %}
+                                       <div class="block">
+                                               <form action="" method="POST">
+                                                       {% raw xsrf_form_html() %}
 
-                               {% module Password(account) %}
+                                                       {% module Password(account) %}
 
-                               <button type="submit" class="btn btn-primary btn-block" disabled>
-                                       {{ _("Reset Password") }}
-                               </button>
-                       </form>
+                                                       <div class="field">
+                                                               <div class="control">
+                                                                       <button class="button is-primary is-medium is-fullwidth">
+                                                                               {{ _("Reset Password") }}
+                                                                       </button>
+                                                               </div>
+                                                       </div>
+                                               </form>
+                                       </div>
+                               </div>
+                       </div>
                </div>
-       </div>
-{% end block %}
+       </section>
+{% end block %}
\ No newline at end of file
diff --git a/src/templates/auth/register-spam.html b/src/templates/auth/register-spam.html
deleted file mode 100644 (file)
index 6b44690..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Oops!") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col-12 col-md-6">
-                       <div class="card bg-warning text-white p-md-5">
-                               <div class="card-body text-center">
-                                       <span class="fas fa-exclamation fa-5x my-4"></span>
-
-                                       <p class="lead">
-                                               {{ _("Unfortunately we could not create your account because you have shown up on our spam radar.") }}
-                                               {{ _("Please get in touch if you think that this is an error.") }}
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/auth/register-success.html b/src/templates/auth/register-success.html
deleted file mode 100644 (file)
index e8b1b20..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Thank You") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col-12 col-md-6">
-                       <div class="card bg-success text-white p-md-5">
-                               <div class="card-body text-center">
-                                       <span class="fas fa-check fa-5x my-4"></span>
-
-                                       <p class="lead">
-                                               {{ _("Your account has been created.") }}
-                                               {{ _("Please check your email for next steps.") }}
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/auth/register.html b/src/templates/auth/register.html
deleted file mode 100644 (file)
index cf95ac6..0000000
+++ /dev/null
@@ -1,184 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Register") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-6">
-                       <h4 class="mb-4">{{ _("Register A New Account") }}</h4>
-
-                       <p class="lead">
-                               {{ _("Become a part of our community by registering an account!") }}
-                       </p>
-
-                       <form id="form-register" action="" method="POST">
-                               {% raw xsrf_form_html() %}
-
-                               <input type="hidden" name="next" value="{{ next or "" }}">
-
-                               <div class="form-group">
-                                       <div class="input-group">
-                                               <div class="input-group-prepend">
-                                                       <span class="input-group-text">@</span>
-                                               </div>
-                                               <input type="text" class="form-control form-control-lg"
-                                                       name="uid" placeholder="{{ _("Username") }}" required autofocus
-                                                       pattern="[a-z_][a-z0-9_-]{3,31}">
-                                       </div>
-                                       <div id="uid-invalid" class="invalid-feedback invalid-feedback-uid">
-                                               {{ _("Please choose a username in UNIX format with at least four characters, starting with a lowercase letter, followed by only lowercase letters, digits, dash and underscore") }}
-                                       </div>
-                                       <div id="uid-taken" class="invalid-feedback invalid-feedback-uid">
-                                               {{ _("This username is not available") }}
-                                       </div>
-                               </div>
-
-                               <div class="form-row mb-3">
-                                       <div class="col">
-                                               <label>{{ _("First Name") }}</label>
-
-                                               <input type="text" class="form-control" name="first_name"
-                                                       placeholder="{{ _("First Name") }}" required>
-                                       </div>
-
-                                       <div class="col">
-                                               <label>{{ _("Last Name") }}</label>
-
-                                               <input type="text" class="form-control" name="last_name"
-                                                       placeholder="{{ _("Last Name") }}" required>
-                                       </div>
-                               </div>
-
-                               <div class="form-group">
-                                       <input type="email" class="form-control"
-                                               name="email" placeholder="{{ _("Email Address") }}" required>
-                                       <div id="email-invalid" class="invalid-feedback invalid-feedback-email">
-                                               {{ _("This email address is invalid") }}
-                                       </div>
-                                       <div id="email-blacklisted" class="invalid-feedback invalid-feedback-email">
-                                               {{ _("This email address cannot be used") }}
-                                       </div>
-                                       <div id="email-taken" class="invalid-feedback invalid-feedback-email">
-                                               {{ _("This email address is already in use") }}
-                                       </div>
-                               </div>
-
-                               <button type="submit" class="btn btn-primary btn-block" disabled>
-                                       {{ _("Register") }}
-                               </button>
-                       </form>
-               </div>
-       </div>
-{% end block %}
-
-{% block javascript %}
-       <script>
-               var form = $("#form-register");
-               var submit = form.find(":submit");
-
-               // Store the valid status
-               var valid = {
-                       uid: false,
-                       email: false,
-               };
-
-               var check_uid;
-               var check_email;
-
-               form.find("input[name=uid]").on("keyup", function(e) {
-                       if (check_uid)
-                               clearTimeout(check_uid);
-
-                       var uid = $(this);
-
-                       check_uid = setTimeout(function() {
-                               $.get("/api/check/uid", { uid : uid.val() },
-                                       function(data) {
-                                               // Reset all classes
-                                               uid.removeClass("is-valid is-invalid");
-                                               valid.uid = false;
-
-                                               // Hide all feedback
-                                               $(".invalid-feedback-uid").hide();
-
-                                               switch (data.result) {
-                                                       case "ok":
-                                                               uid.addClass("is-valid");
-                                                               valid.uid = true;
-                                                               break;
-
-                                                       case "invalid":
-                                                               uid.addClass("is-invalid");
-                                                               $("#uid-invalid").show();
-                                                               break;
-
-                                                       case "taken":
-                                                               uid.addClass("is-invalid");
-                                                               $("#uid-taken").show();
-                                                               break;
-                                               }
-
-                                               form.trigger("change");
-                                       }
-                               );
-                       }, 250);
-               });
-
-               form.find("input[name=email]").on("keyup", function(e) {
-                       if (check_email)
-                               clearTimeout(check_email);
-
-                       var email = $(this);
-
-                       check_email = setTimeout(function() {
-                               $.get("/api/check/email", { email : email.val() },
-                                       function(data) {
-                                               // Reset all classes
-                                               email.removeClass("is-valid is-invalid");
-                                               valid.email = false;
-
-                                               // Hide all feedback
-                                               $(".invalid-feedback-email").hide();
-
-                                               switch (data.result) {
-                                                       case "ok":
-                                                               email.addClass("is-valid");
-                                                               valid.email = true;
-                                                               break;
-
-                                                       case "invalid":
-                                                               email.addClass("is-invalid");
-                                                               $("#email-invalid").show();
-                                                               break;
-
-                                                       case "blacklisted":
-                                                               email.addClass("is-invalid");
-                                                               $("#email-blacklisted").show();
-                                                               break;
-
-                                                       case "taken":
-                                                               email.addClass("is-invalid");
-                                                               $("#email-taken").show();
-                                                               break;
-                                               }
-
-                                               form.trigger("change");
-                                       }
-                               );
-                       }, 250);
-               });
-
-               form.on("change", function() {
-                       var disabled = false;
-
-                       $.each(valid, function(field, status) {
-                               if (!status) {
-                                       disabled = true;
-                                       return false;
-                               }
-                       });
-
-                       submit.prop("disabled", disabled);
-               });
-       </script>
-{% end block %}
index d3f995d18817fd8b141a2db625a89d6c9ffe4e9e..87f7242f4261eb42d2d2c4369030664e1f681365 100644 (file)
                {% block head %}{% end block %}
        </head>
 
-       <body id="page-top" class="{{ hostname.replace(".", "-") }}">
-               <nav class="navbar navbar-dark navbar-expand-lg mb-4">
+       <body class="is-flex is-flex-direction-column">
+               <nav class="navbar" role="navigation" aria-label="main navigation">
                        <div class="container">
-                               <a class="navbar-brand" href="/">
-                                       <strong>IPFire</strong>
-
-                                       {% if hostname == "blog.ipfire.org" %}
-                                               {{ _("Blog") }}
-                                       {% elif hostname == "fireinfo.ipfire.org" %}
-                                               {{ _("Fireinfo") }}
-                                       {% elif hostname == "location.ipfire.org" %}
-                                               {{ _("Location") }}
-                                       {% elif hostname == "mirrors.ipfire.org" %}
-                                               {{ _("Mirrors") }}
-                                       {% elif hostname == "people.ipfire.org" %}
-                                               {{ _("People") }}
-                                       {% elif hostname == "wiki.ipfire.org" %}
-                                               {{ _("Wiki") }}
+                               <div class="navbar-brand is-size-4">
+                                       {% if request.path.startswith("/location") %}
+                                               <a class="navbar-item" href="/location">
+                                                       {% module IPFireLogo("Location") %}
+                                               </a>
+                                       {% else %}
+                                               <a class="navbar-item" href="/">
+                                                       {% if request.path.startswith("/fireinfo") %}
+                                                               {% module IPFireLogo("Fireinfo") %}
+                                                       {% elif hostname.startswith("nopaste.") %}
+                                                               {% module IPFireLogo("NoPaste") %}
+                                                       {% else %}
+                                                               {% module IPFireLogo() %}
+                                                       {% end %}
+                                               </a>
                                        {% end %}
-                               </a>
-
-                               {% block menu %}
-                                       {% if hostname in ("www.ipfire.org", "dev.ipfire.org") %}
-                                               <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                               aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                       <span class="fas fa-bars"></span>
-                                               </button>
-
-                                               <div class="collapse navbar-collapse" id="navbar">
-                                                       <ul class="navbar-nav ml-auto">
-                                                               <li class="nav-item d-sm-block d-md-block d-lg-none">
-                                                                       <a class="nav-link {% if request.path == "/" %}active{% end %}" href="/">{{ _("Home") }}</a>
-                                                               </li>
-
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/features" %}active{% end %}" href="/features">{{ _("Features") }}</a>
-                                                               </li>
-
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/support" %}active{% end %}" href="/support">{{ _("Support") }}</a>
-                                                               </li>
-
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link" href="https://blog.ipfire.org/">{{ _("Blog") }}</a>
-                                                               </li>
-
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link" href="https://community.ipfire.org/">{{ _("Community") }}</a>
-                                                               </li>
-
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path.startswith("/download") %}active{% end %}" href="/download">{{ _("Download") }}</a>
-                                                               </li>
-                                                       </ul>
-
-                                                       <a class="btn btn-primary mt-2 mt-lg-0 ml-lg-2" href="/donate">
-                                                               {{ _("Donate") }}
-                                                       </a>
-
-                                                       <a class="btn btn-lwl mt-2 mt-lg-0 ml-lg-2" href="https://store.lightningwirelabs.com/">
-                                                               {{ _("Buy") }}
-                                                       </a>
-                                               </div>
-                                       {% elif hostname == "blog.ipfire.org" %}
-                                               <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                               aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                       <span class="fas fa-bars"></span>
-                                               </button>
-
-                                               <div class="collapse navbar-collapse" id="navbar">
-                                                       <ul class="navbar-nav ml-auto d-lg-none">
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/" %}active{% end %}" href="/">
-                                                                               {{ _("Newest") }}
-                                                                       </a>
-                                                               </li>
 
-                                                               {% if current_user %}
-                                                                       <li class="nav-item">
-                                                                               <a class="nav-link {% if request.path == "/drafts" %}active{% end %}" href="/drafts">
-                                                                                       {{ _("My Drafts") }}
+                                       <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMainMenu">
+                                               <span aria-hidden="true"></span>
+                                               <span aria-hidden="true"></span>
+                                               <span aria-hidden="true"></span>
+                                       </a>
+                               </div>
+                               <div class="navbar-menu" id="navbarMainMenu">
+                                       {% block menu %}
+                                               {% if hostname.startswith("www.") or hostname.startswith("dev.") %}
+                                                       <div class="navbar-end">
+                                                               {# Show a search bar for blog #}
+                                                               {% if request.path.startswith("/blog") %}
+                                                                       {# Navigation for Blog Authors #}
+                                                                       {% if current_user and current_user.is_blog_author() %}
+                                                                               <a class="navbar-item is-tab {% if request.path == "/blog/drafts" %}is-active{% end %}" href="/blog/drafts">
+                                                                                       {{ _("My Drafts") }}
                                                                                </a>
-                                                                       </li>
+                                                                       {% end %}
+
+                                                                       <div class="navbar-item">
+                                                                               <form action="/blog" method="GET">
+                                                                                       <div class="field">
+                                                                                               <div class="control has-icons-left">
+                                                                                                       <input class="input" type="text"
+                                                                                                               name="q" {% if q %}value="{{ q }}"{% end %}
+                                                                                                               placeholder="{{ _("Search Blog...") }}">
+                                                                                                       <span class="icon is-small is-left">
+                                                                                                               <i class="fas fa-search"></i>
+                                                                                                       </span>
+                                                                                               </div>
+                                                                                       </div>
+                                                                               </form>
+                                                                       </div>
+                                                               {# Show a search bar for docs #}
+                                                               {% elif request.path.startswith("/docs") %}
+                                                                       <div class="navbar-item">
+                                                                               <form action="/docs/search" method="GET">
+                                                                                       <div class="field">
+                                                                                               <div class="control has-icons-left">
+                                                                                                       <input class="input" type="text"
+                                                                                                               name="q" {% if q %}value="{{ q }}"{% end %}
+                                                                                                               placeholder="{{ _("Search Documentation...") }}">
+                                                                                                       <span class="icon is-small is-left">
+                                                                                                               <i class="fas fa-search"></i>
+                                                                                                       </span>
+                                                                                               </div>
+                                                                                       </div>
+                                                                               </form>
+                                                                       </div>
                                                                {% end %}
 
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/tags/featured" %}active{% end %}" href="/tags/featured">
-                                                                               {{ _("Featured") }}
+                                                               {# Location #}
+                                                               {% if request.path.startswith("/location") %}
+                                                                       <a class="navbar-item is-tab
+                                                                                       {% if request.path.startswith("/location/how-to-use") %}is-active{% end %}"
+                                                                                       href="/location/how-to-use">
+                                                                               {{ _("How To Use?") }}
                                                                        </a>
-                                                               </li>
-
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/tags/lightningwirelabs.com" %}active{% end %} d-flex justify-content-between"
-                                                                                       href="/tags/lightningwirelabs.com">
-                                                                               <span>{{ _("Lightning Wire Labs") }}</span>
 
-                                                                               <img class="img-fluid" src="{{ static_url("img/lightningwirelabs-logo.svg") }}"
-                                                                                       alt="{{ _("Lightning Wire Labs") }}">
+                                                                       <a class="navbar-item is-tab
+                                                                                       {% if request.path == "/location/install" %}is-active{% end %}"
+                                                                                       href="/location/install">
+                                                                               {{ _("Install") }}
                                                                        </a>
-                                                               </li>
 
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link d-flex justify-content-between" href="/feed.xml">
-                                                                               <span>{{ _("RSS Feed") }}</span> <span class="fas fa-rss"></span>
+                                                                       <a class="navbar-item is-tab
+                                                                                       {% if request.path == "/location/report-a-problem" %}is-active{% end %}"
+                                                                                       href="/location/report-a-problem">
+                                                                               {{ _("Report A Problem") }}
                                                                        </a>
-                                                               </li>
-                                                       </ul>
 
-                                                       <form class="form-inline ml-lg-auto my-2 my-lg-0" action="/search" method="GET">
-                                                               <input class="form-control form-control-sm" type="search" name="q"
-                                                                       placeholder="{{ _("Search...") }}" aria-label="{{ _("Search") }}" value="{% try %}{{ q }}{% except %}{% end %}">
-                                                       </form>
+                                                               {# Main #}
+                                                               {% else %}
+                                                                       <a class="navbar-item is-tab {% if request.path == "/about" %}is-active{% end %}" href="/about">{{ _("About") }}</a>
 
-                                                       <a class="btn btn-primary ml-lg-2" href="https://www.ipfire.org/donate">
-                                                               {{ _("Donate") }}
-                                                       </a>
+                                                                       <a class="navbar-item is-tab {% if request.path.startswith("/docs") %}is-active{% end %}" href="/docs">{{ _("Documentation") }}</a>
 
-                                                       <a class="btn btn-lwl mt-2 mt-lg-0 ml-lg-2" href="https://store.lightningwirelabs.com/">
-                                                               {{ _("Buy") }}
-                                                       </a>
-                                               </div>
-                                       {% elif hostname == "fireinfo.ipfire.org" %}
-                                               <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                               aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                       <span class="fas fa-bars"></span>
-                                               </button>
-
-                                               <div class="collapse navbar-collapse" id="navbar">
-                                                       <ul class="navbar-nav ml-auto">
-                                                               {% if current_user and current_user.is_staff() %}
-                                                                       <li class="nav-item">
-                                                                               <a class="nav-link {% if request.path.startswith("/admin") %}active{% end %}" href="/admin">
-                                                                                       {{ _("Admin") }}
-                                                                               </a>
-                                                                       </li>
+                                                                       <a class="navbar-item is-tab {% if request.path.startswith("/download") %}is-active{% end %}" href="/download">{{ _("Download") }}</a>
+
+                                                                       <a class="navbar-item is-tab {% if request.path.startswith("/blog") %}is-active{% end %}" href="/blog">{{ _("Blog") }}</a>
+
+                                                                       <a class="navbar-item is-tab {% if request.path == "/help" %}is-active{% end %}" href="/help">{{ _("Help") }}</a>
                                                                {% end %}
 
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path.startswith("/vendors") %}active{% end %}" href="/vendors">
-                                                                               {{ _("Vendors") }}
+                                                               <div class="navbar-item">
+                                                                       <a class="button is-lwl has-text-weight-bold is-uppercase"
+                                                                                       href="https://store.lightningwirelabs.com/?utm_source={{ hostname }}&amp;utm_medium=navigation">
+                                                                               {{ _("Buy") }}
                                                                        </a>
-                                                               </li>
-                                                       </ul>
-                                               </div>
-                                       {% elif hostname == "location.ipfire.org" %}
-                                               <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                               aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                       <span class="fas fa-bars"></span>
-                                               </button>
-
-                                               <div class="collapse navbar-collapse" id="navbar">
-                                                       <ul class="navbar-nav ml-auto">
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/how-to-use" %}active{% end %}" href="/how-to-use">
-                                                                               {{ _("How To Use") }}
-                                                                       </a>
-                                                               </li>
+                                                               </div>
 
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link {% if request.path == "/download" %}active{% end %}" href="/download">
-                                                                               {{ _("Download") }}
+                                                               <div class="navbar-item">
+                                                                       <a class="button is-primary has-text-weight-bold is-uppercase"
+                                                                                       href="/donate">
+                                                                               {{ _("Donate") }}
                                                                        </a>
-                                                               </li>
-                                                       </ul>
+                                                               </div>
 
-                                                       <a class="btn btn-primary ml-lg-2" href="https://www.ipfire.org/donate">
-                                                               {{ _("Donate") }}
-                                                       </a>
-                                               </div>
-                                       {% elif hostname == "nopaste.ipfire.org" %}
-                                               <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                               aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                       <span class="fas fa-bars"></span>
-                                               </button>
-
-                                               <div class="collapse navbar-collapse" id="navbar">
-                                                       <ul class="navbar-nav ml-auto">
-                                                               <li class="nav-item">
-                                                                       <a class="nav-link" href="/?mode=upload">{{ _("Upload File") }}</a>
-                                                               </li>
-                                                       </ul>
-                                               </div>
-                                       {% elif hostname == "people.ipfire.org" %}
-                                               {% if current_user %}
-                                                       <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                                       aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                               <span class="fas fa-bars"></span>
-                                                       </button>
-
-                                                       <div class="collapse navbar-collapse" id="navbar">
-                                                               <ul class="navbar-nav ml-auto mr-3">
-                                                                       <li class="nav-item">
-                                                                               <a class="nav-link {% if request.path == "/users/%s" % current_user.uid %}active{% end %}" href="/users/{{ current_user.uid }}">
-                                                                                       {{ _("My Profile") }}
+                                                               {% if current_user %}
+                                                                       <div class="navbar-item has-dropdown is-hoverable">
+                                                                               <a class="navbar-link is-arrowless" href="/users/{{ current_user.uid }}">
+                                                                                       <figure class="image">
+                                                                                               <img class="is-rounded" style="width: auto" src="{{ current_user.avatar_url(128) }}">
+                                                                                       </figure>
                                                                                </a>
-                                                                       </li>
-
-                                                                       {% if current_user.is_staff() %}
-                                                                               <li class="nav-item">
-                                                                                       <a class="nav-link {% if request.path == "/users" %}active{% end %}" href="/users">
-                                                                                               {{ _("Users") }}
-                                                                                       </a>
-                                                                               </li>
 
-                                                                               <li class="nav-item">
-                                                                                       <a class="nav-link {% if request.path.startswith("/groups") %}active{% end %}" href="/groups">
-                                                                                               {{ _("Groups") }}
+                                                                               <div class="navbar-dropdown">
+                                                                                       <a class="navbar-item" href="/users/{{ current_user.uid }}/passwd">
+                                                                                               {{ _("Change Password") }}
                                                                                        </a>
-                                                                               </li>
 
-                                                                               <li class="nav-item">
-                                                                                       <a class="nav-link {% if request.path == "/stats" %}active{% end %}" href="/stats">
-                                                                                               {{ _("Stats") }}
-                                                                                       </a>
-                                                                               </li>
-                                                                       {% end %}
+                                                                                       <hr class="navbar-divider">
 
-                                                                       {% if current_user.has_sip() %}
-                                                                               <li class="nav-item">
-                                                                                       <a class="nav-link {% if request.path.startswith("/conferences") %}active{% end %}" href="/conferences">
-                                                                                               {{ _("Conferences") }}
+                                                                                       <a class="navbar-item" href="/logout">
+                                                                                               {{ _("Logout")}}
                                                                                        </a>
-                                                                               </li>
-                                                                       {% end %}
-                                                               </ul>
+                                                                               </div>
+                                                                       </div>
+                                                               {% else %}
+                                                                       <a class="navbar-item is-tab" href="/login?next={{ request.path }}">
+                                                                               <i class="fas fa-right-to-bracket" title="{{ _("Login") }}"></i>
+                                                                       </a>
+                                                               {% end %}
+                                                       </div>
+                                               {% elif request.path.startswith("/fireinfo") %}
+                                                       <div class="navbar-end">
+                                                               {% if current_user and current_user.is_staff() %}
+                                                                       <a class="navbar-item is-tab {% if request.path.startswith("/admin") %}is-active{% end %}" href="/admin">
+                                                                               {{ _("Admin") }}
+                                                                       </a>
+                                                               {% end %}
 
-                                                               <form class="form-inline my-2 my-lg-0" action="/search" method="GET">
-                                                                       <input class="form-control form-control-sm mr-sm-2" type="search" name="q"
-                                                                               placeholder="{{ _("Search") }}" aria-label="{{ _("Search") }}" value="{% try %}{{ q }}{% except %}{% end %}">
-                                                               </form>
+                                                               <a class="navbar-item is-tab {% if request.path.startswith("/vendors") %}is-active{% end %}" href="/vendors">
+                                                                       {{ _("Vendors") }}
+                                                               </a>
+                                                       </div>
+                                               {% elif hostname.startswith("nopaste.") %}
+                                                       <div class="navbar-end">
+                                                               <a class="navbar-item is-tab {% if request.path == "/" %}is-active{% end %}" href="/">
+                                                                       {{ _("Paste") }}
+                                                               </a>
+
+                                                               <a class="navbar-item is-tab {% if request.path == "/upload" %}is-active{% end %}" href="/upload">
+                                                                       {{ _("Upload") }}
+                                                               </a>
                                                        </div>
                                                {% end %}
-                                       {% elif hostname == "wiki.ipfire.org" %}
-                                               <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
-                                                               aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
-                                                       <span class="fas fa-bars"></span>
-                                               </button>
-
-                                               <div class="collapse navbar-collapse" id="navbar">
-                                                       <form class="form-inline ml-auto my-2 my-lg-0" action="/search" method="GET">
-                                                               <input class="form-control form-control-sm" type="search" name="q"
-                                                                       placeholder="{{ _("Search...") }}" aria-label="{{ _("Search") }}" value="{% try %}{{ q }}{% except %}{% end %}">
-                                                       </form>
-
-                                                       <a class="btn btn-primary ml-lg-2" href="https://www.ipfire.org/donate">
-                                                               {{ _("Donate") }}
-                                                       </a>
-
-                                                       <a class="btn btn-lwl mt-2 mt-lg-0 ml-lg-2" href="https://store.lightningwirelabs.com/">
-                                                               {{ _("Buy") }}
-                                                       </a>
-                                               </div>
-                                       {% end %}
-                               {% end block %}
+                                       {% end block %}
+                               </div>
                        </div>
                </nav>
 
-               {% block container %}
-                       <div class="container">
-                               {% block content %}{% end block %}
-                       </div>
-               {% end block %}
+               <div class="is-flex-grow-1 is-flex-shrink-0">
+                       {% block container %}
+                               <div class="container">
+                                       {% block content %}{% end block %}
+                               </div>
+                       {% end block %}
+               </div>
 
                {% block footer %}
-                       <footer>
-                               <div class="footer">
-                                       {% if hostname in ("www.ipfire.org", "dev.ipfire.org") %}
-                                               <div class="footer-info">
-                                                       <div class="container pb-3">
-                                                               <div class="row mb-6 justify-content-between">
-                                                                       <div class="col-12 col-lg-4 mb-4">
-                                                                               <a class="btn btn-primary btn-block mb-3" href="https://people.ipfire.org/register">
-                                                                                       {{ _("Join Us To Stay Up To Date") }}
-                                                                               </a>
+                       <footer class="footer is-flex-shrink-0">
+                               <div class="container">
+                                       {% if request.path == "/docs" %}
+                                               <div class="level">
+                                                       <div class="level-left">
+                                                               <div class="level-item">
+                                                                       <a href="/docs/recent-changes">
+                                                                               {{ _("Recent Changes") }}
+                                                                       </a>
+                                                               </div>
 
-                                                                               <p>
-                                                                                       {{ _("Sign up to our community to take part and get the latest news") }}
-                                                                               </p>
+                                                               {% if current_user %}
+                                                                       <div class="level-item">
+                                                                               <a href="/docs/watchlist">
+                                                                                       {{ _("My Watchlist") }}
+                                                                               </a>
                                                                        </div>
+                                                               {% end %}
 
-                                                                       <div class="col-12 col-lg-3 mb-4 small">
-                                                                               <h6>{{ _("Looking For More?") }}</h6>
-
-                                                                               <div class="row">
-                                                                                       <div class="col">
-                                                                                               <ul class="list-unstyled">
-                                                                                                       <li>
-                                                                                                               <a href="/features">{{ _("Features") }}</a>
-                                                                                                       </li>
-
-                                                                                                       <li>
-                                                                                                               <a href="/support">{{ _("Support") }}</a>
-                                                                                                       </li>
-
-                                                                                                       <li>
-                                                                                                               <a href="https://wiki.ipfire.org/devel">{{ _("Development") }}</a>
-                                                                                                       </li>
-                                                                                               </ul>
-                                                                                       </div>
-
-                                                                                       <div class="col">
-                                                                                               <ul class="list-unstyled">
-                                                                                                       <li>
-                                                                                                               <a href="/download">{{ _("Download") }}</a>
-                                                                                                       </li>
-
-                                                                                                       <li>
-                                                                                                               <a href="https://blog.ipfire.org">{{ _("Blog") }}</a>
-                                                                                                       </li>
+                                                               <div class="level-item">
+                                                                       <a href="/docs/tree">
+                                                                               {{ _("Tree") }}
+                                                                       </a>
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                       {% end %}
 
-                                                                                                       <li>
-                                                                                                               <a href="https://community.ipfire.org/">{{ _("Community") }}</a>
-                                                                                                       </li>
-                                                                                               </ul>
-                                                                                       </div>
-                                                                               </div>
+                                       <div class="level">
+                                               <div class="level-left">
+                                                       <div class="level-item">
+                                                               <div class="level is-mobile">
+                                                                       <div class="level-item">
+                                                                               {{ year }} &copy; IPFire.org
                                                                        </div>
 
-                                                                       <div class="col-12 col-lg-4 text-center mb-4">
-                                                                               <div class="btn-toolbar justify-content-center">
-                                                                                       <a class="btn btn-primary btn-lg px-4 my-3" href="/donate">
-                                                                                               {{ _("Donate") }}
-                                                                                       </a>
+                                                                       <div class="level-item">
+                                                                               <a href="/legal">{{ _("Legal") }}</a>
+                                                                       </div>
 
-                                                                                       <a class="btn btn-lwl btn-lg px-4 my-3 ml-2" href="https://store.lightningwirelabs.com/">
-                                                                                               {{ _("Buy") }}
-                                                                                       </a>
+                                                                       {% if current_user and current_user.is_admin() %}
+                                                                               <div class="level-item">
+                                                                                       <a href="/analytics">{{ _("Analytics") }}</a>
                                                                                </div>
+                                                                       {% end %}
 
-                                                                               <div class="btn-toolbar justify-content-center">
-                                                                                       <a class="btn btn-link" href="https://twitter.com/ipfire">
-                                                                                               <span class="fab fa-twitter"></span>
-                                                                                       </a>
-
-                                                                                       <a class="btn btn-link" href="https://www.facebook.com/IPFire.org">
-                                                                                               <span class="fab fa-facebook"></span>
-                                                                                       </a>
-
-                                                                                       <a class="btn btn-link" href="https://youtube.com/user/ipfireproject">
-                                                                                               <span class="fab fa-youtube"></span>
-                                                                                       </a>
-                                                                               </div>
+                                                                       <div class="level-item">
+                                                                               <a href="/sitemap">{{ _("Sitemap") }}</a>
                                                                        </div>
                                                                </div>
                                                        </div>
                                                </div>
-                                       {% elif hostname == "wiki.ipfire.org" %}
-                                               <div class="footer-info">
-                                                       <div class="container pb-3">
-                                                               <ul class="list-inline">
-                                                                       <li class="list-inline-item">
-                                                                               <a href="/watchlist">{{ _("My Watchlist") }}
-                                                                       </li>
-
-                                                                       <li class="list-inline-item">
-                                                                               <a href="/recent-changes">{{ _("Recent Changes") }}
-                                                                       </li>
-
-                                                                       <li class="list-inline-item">
-                                                                               <a href="/tree">{{ _("Tree") }}
-                                                                       </li>
-                                                               </ul>
-                                                       </div>
-                                               </div>
-                                       {% end %}
 
-                                       <div class="copyright">
-                                               <div class="container">
-                                                       <div class="row flex-lg-row-reverse">
-                                                               <div class="col-12 col-lg-6 text-center text-lg-right">
-                                                                       {% if not current_user and hostname in ("blog.ipfire.org", "fireinfo.ipfire.org", "nopaste.ipfire.org", "wiki.ipfire.org") %}
-                                                                               <a href="/login">{{ _("Login") }}</a>
-                                                                       {% elif current_user %}
-                                                                               <p class="mb-0">
-                                                                                       <span class="mr-2">
-                                                                                               {{ _("You are currently logged in as %s") % current_user }}
-                                                                                       </span>
-
-                                                                                       <a href="/logout">{{ _("Logout") }}</a>
-                                                                               </p>
-                                                                       {% end %}
-                                                               </div>
-
-                                                               <div class="col-12 col-lg-6 text-center text-lg-left">
-                                                                       &copy; {{ year }} - IPFire - {{ _("The Open Source Firewall") }}
-                                                                       - <a href="https://www.ipfire.org/legal">{{ _("Legal") }}</a>
+                                               <div class="level-right">
+                                                       <div class="level-item">
+                                                               <div class="level is-mobile">
+                                                                       <div class="level-item">
+                                                                               <a href="https://social.ipfire.org/@news" title="{{ _("Mastodon") }}">
+                                                                                       <i class="fa-brands fa-mastodon px-2"></i>
+                                                                               </a>
+                                                                       </div>
+                                                                       <div class="level-item">
+                                                                               <a href="https://x.com/ipfire" title="{{ _("X") }}">
+                                                                                       <i class="fa-brands fa-x-twitter px-2"></i>
+                                                                               </a>
+                                                                       </div>
+                                                                       <div class="level-item">
+                                                                               <a href="https://linkedin.com/company/ipfire" title="{{ _("LinkedIn") }}">
+                                                                                       <i class="fa-brands fa-linkedin-in px-2"></i>
+                                                                               </a>
+                                                                       </div>
+                                                                       <div class="level-item">
+                                                                               <a href="https://www.facebook.com/IPFire.org/" title="{{ _("Facebook") }}">
+                                                                                       <i class="fa-brands fa-facebook-f px-2"></i>
+                                                                               </a>
+                                                                       </div>
                                                                </div>
                                                        </div>
                                                </div>
                        </footer>
                {% end block %}
 
-               <script src="{{ static_url("js/jquery-3.3.1.min.js") }}"></script>
-               <script src="{{ static_url("js/popper.min.js") }}"></script>
-               <script src="{{ static_url("js/bootstrap.min.js") }}"></script>
+               <script src="{{ static_url("js/jquery-3.6.0.min.js") }}"></script>
+               <script src="{{ static_url("js/site.js") }}"></script>
                {% block javascript %}{% end block %}
        </body>
 </html>
diff --git a/src/templates/blog/author.html b/src/templates/blog/author.html
deleted file mode 100644 (file)
index 545f820..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ author.name }}{% end block %}
-
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <div class="row justify-content-center my-5">
-                               <div class="col-12 col-sm-8 col-md-6 col-xl-4 d-flex flex-column align-items-center">
-                                       <img class="img-fluid rounded-circle mb-4" src="{{ author.avatar_url(512) }}" alt="{{ author }}" />
-
-                                       <h4>{{ author }}</h4>
-                               </div>
-                       </div>
-
-                       {% module BlogList(posts) %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/blog/base.html b/src/templates/blog/base.html
deleted file mode 100644 (file)
index 14f7576..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-{% extends "../base.html" %}
-
-{% block head %}
-       <link rel="alternate" type="application/atom+xml" title="RSS" href="https://blog.ipfire.org/feed.xml" />
-       {% block meta %}{% end block %}
-{% end block %}
-
-{% block content %}
-       <div class="row">
-               <div class="col-12 col-lg-3 d-none d-lg-block">
-                       <nav class="mb-3">
-                               <ul class="nav flex-column">
-                                       <li class="nav-item">
-                                               <a class="nav-link {% if request.path == "/" %}active{% end %}" href="/">
-                                                       {{ _("Newest") }}
-                                               </a>
-                                       </li>
-
-                                       {% if current_user %}
-                                               <li class="nav-item">
-                                                       <a class="nav-link {% if request.path == "/drafts" %}active{% end %}" href="/drafts">
-                                                               {{ _("My Drafts") }}
-                                                       </a>
-                                               </li>
-                                       {% end %}
-
-                                       <li class="nav-item">
-                                               <a class="nav-link {% if request.path == "/tags/featured" %}active{% end %}" href="/tags/featured">
-                                                       {{ _("Featured") }}
-                                               </a>
-                                       </li>
-
-                                       <li class="nav-item">
-                                               <a class="nav-link {% if request.path == "/tags/lightningwirelabs.com" %}active{% end %} d-flex justify-content-between"
-                                                               href="/tags/lightningwirelabs.com">
-                                                       <span>{{ _("Lightning Wire Labs") }}</span>
-
-                                                       <img class="img-fluid" src="{{ static_url("img/lightningwirelabs-logo.svg") }}"
-                                                               alt="{{ _("Lightning Wire Labs") }}">
-                                               </a>
-                                       </li>
-                               </ul>
-                       </nav>
-
-                       <a class="btn btn-primary btn-block mb-3" href="/feed.xml">
-                               <i class="fas fa-rss-square fa-3x mb-2"></i>
-                               <br>{{ _("Subscribe to our Blog") }}
-                       </a>
-
-                       <a class="btn btn-twitter btn-block mb-5" href="https://twitter.com/ipfire">
-                               {{ _("Or follow us on Twitter") }}
-                               <i class="fab fa-twitter"></i>
-                       </a>
-
-                       {% module BlogHistoryNavigation() %}
-               </div>
-
-               <div class="col-12 col-lg-9">
-                       {% block main %}
-                               <div class="row justify-content-center">
-                                       <div class="col-12 col-md-6">
-                                               {% block modal %}{% end block %}
-                                       </div>
-                               </div>
-                       {% end block %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/blog/compose.html b/src/templates/blog/compose.html
deleted file mode 100644 (file)
index 8aad111..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{% if post %}{{ _("Edit %s") % post.title }}{% else %}{{ _("Compose A New Article") }}{% end %}{% end block %}
-
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <form action="" method="POST">
-                               {% raw xsrf_form_html() %}
-
-                               <div class="form-group">
-                                       <input type="text" class="form-control" name="title" placeholder="{{ _("Title") }}"
-                                               {% if post %}value="{{ post.title }}"{% end %} required>
-                               </div>
-
-                               <div class="form-group">
-                                       <textarea class="form-control" rows="16" name="text" placeholder="{{ _("Text") }}"
-                                               required>{% if post %}{{ post.text }}{% end %}</textarea>
-                               </div>
-
-                               <div class="form-group row">
-                                       <label class="col-sm-2 col-form-label">{{ _("Tags") }}</label>
-                                       <div class="col-sm-10">
-                                               <input type="text" class="form-control" name="tags"
-                                                       {% if post %}value="{{ " ".join(post.tags) }}"{% end %}>
-                                       </div>
-                               </div>
-
-                               <button type="submit" class="btn btn-primary btn-block">
-                                       {{ _("Save") }}
-                               </button>
-
-                               {% if post %}
-                                       <a class="btn btn-outline-primary btn-block" href="/post/{{ post.slug }}/delete">
-                                               {{ _("Delete") }}
-                                       </a>
-                               {% end %}
-                       </form>
-               </div>
-       </div>
-{% end block %}
index b4230b9a5086bb3b8209e77a8e664f36e2540f18..5761deb063acd236984e685d6bd2484b07dc83a3 100644 (file)
@@ -1,4 +1,4 @@
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ _("Delete %s") % post.title }}{% end block %}
 
                        <form action="" method="POST">
                                {% raw xsrf_form_html() %}
 
-                               <button type="submit" class="btn btn-primary btn-block">{{ _("Delete") }}</button>
-                               <a class="btn btn-secondary btn-block" href="/post/{{ post.slug }}">{{ _("Cancel") }}</a>
+                               <div class="d-grid gap-2">
+                                       <button type="submit" class="btn btn-primary">{{ _("Delete") }}</button>
+                                       <a class="btn btn-secondary" href="/post/{{ post.slug }}">{{ _("Cancel") }}</a>
+                               </div>
                        </form>
                </div>
        </div>
index 6fce2dd24a8b3eca45ec63ce88896cc897bef32f..6f0a055fd68a43c8037271c47221a302d6fb111f 100644 (file)
@@ -1,41 +1,45 @@
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ _("My Drafts") }}{% end block %}
 
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <h5>{{ _("My Drafts") }}</h5>
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/blog">{{ _("Blog") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("My Drafts") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
 
-                       {% if drafts %}
-                               {% for post in drafts %}
-                                       <strong class="mb-0">
-                                               <a href="/post/{{ post.slug }}">{{ post.title }}</a>
-                                       </strong>
-
-                                       <p class="text-muted small">
-                                               {% if post.published_at %}
-                                                       <span class="text-danger">{{ _("Scheduled to be published %s") % locale.format_date(post.published_at, relative=False) }}</span>,
-                                               {% end %}
+                               <h1 class="title">{{ _("My Drafts") }}</h1>
+                       </div>
+               </div>
+       </section>
 
-                                               {% if post.updated_at %}
-                                                       {{ _("Updated %s") % locale.format_date(post.updated_at) }}
-                                               {% else %}
-                                                       {{ _("Created %s") % locale.format_date(post.created_at) }}
-                                               {% end %}
-                                       </p>
-                               {% end %}
+       <section class="section">
+               <div class="container">
+                       {% if drafts %}
+                               {% module BlogList(drafts, show_author=False) %}
                        {% else %}
-                               <p class="text-center text-muted my-4">
-                                       {{ _("You currently do not have any drafts") }}
-                               </p>
+                               <div class="notification">
+                                       {{ _("You don't have any drafts, yet") }}
+                               </div>
                        {% end %}
 
-                       <div class="btn-toolbar justify-content-center">
-                               <a class="btn btn-primary" href="/compose">
-                                       {{ _("Write a new post") }}
+                       <div class="block">
+                               <a class="button is-primary is-fullwidth" href="/blog/write">
+                                       {{ _("Write a New Post") }}
                                </a>
                        </div>
                </div>
-       </div>
+       </section>
 {% end block %}
index 98fe72e635f0256ccd8cd75d9a8227ce9f136e8e..ac29e6c1c4795ce40ab7f746f17fac95322ff2c6 100644 (file)
@@ -3,10 +3,10 @@
 {% import ipfire.accounts as accounts %}
 
 <feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-GB">
-       <id>https://blog.ipfire.org/feed.xml</id>
+       <id>https://www.ipfire.org/blog/feed.xml</id>
 
-       <link href="https://blog.ipfire.org/feed.xml" rel="self" type="application/atom+xml" />
-       <link href="https://blog.ipfire.org" rel="alternate" type="text/html"/>
+       <link href="https://www.ipfire.org/blog/feed.xml" rel="self" type="application/atom+xml" />
+       <link href="https://www.ipfire.org/blog" rel="alternate" type="text/html"/>
        <link rel="payment" title="Donate!" href="https://www.ipfire.org/donate" type="text/html" />
 
        <title>IPFire Blog</title>
@@ -16,9 +16,9 @@
 
        {% for post in posts %}
                <entry>
-                       <id>https://blog.ipfire.org/post/{{ post.slug }}</id>
+                       <id>https://www.ipfire.org/blog/{{ post.slug }}</id>
                        <title type="html">{{ post.title }}</title>
-                       <link href="https://blog.ipfire.org/post/{{ post.slug }}" rel="alternate" type="text/html" title="{{ post.title }}" />
+                       <link href="https://www.ipfire.org/blog/{{ post.slug }}" rel="alternate" type="text/html" title="{{ post.title }}" />
                        <author>
                                <name>{{ post.author }}</name>
                                {% if isinstance(post.author, accounts.Account) %}
index dce3d3008d3ad842bb95a1b2ebac95b7c906e44d..20e5efb9eec131533e9e95968f4a8b6a1cb87f80 100644 (file)
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ _("Welcome!") }}{% end block %}
 
 {% block head %}
        <meta name="description" content="{{ _("The IPFire Blog has the latest news from the IPFire Project about Development, Current Affairs, and many more interesting things.") }}" />
+
+       <!-- Feed URL -->
+       <link rel="alternate" type="application/atom+xml" title="RSS" href="/blog/feed.xml" />
 {% end %}
 
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       {% module BlogPosts(posts) %}
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Blog") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("IPFire Blog") }}</h1>
+
+                               {% if q %}
+                                       <h6 class="subtitle">
+                                               {{ _("Search Results for '%s'") % q }}
+                                       </h6>
+                               {% end %}
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns">
+                               <div class="column is-8">
+                                       {% if latest_post %}
+                                               <div class="notification">
+                                                       <h4 class="title is-4">
+                                                               <a href="/blog/{{ latest_post.slug }}">
+                                                                       {{ _("Latest: %s") % latest_post.title }}
+                                                               </a>
+                                                       </h4>
+
+                                                       <div class="content">
+                                                               {{ latest_post.excerpt }}
+                                                       </div>
+
+                                                       <p>
+                                                               <a href="/blog/{{ latest_post.slug }}">{{ _("Read More") }}</a>
+                                                       </p>
+                                               </div>
+                                       {% end %}
+
+                                       {% if q and not posts %}
+                                               <div class="notification">
+                                                       {{ _("No Results Found For '%s'") % q }}
+                                               </div>
+                                       {% end %}
+
+                                       {% module BlogList(posts, relative=True) %}
+                               </div>
+
+                               <div class="column is-4">
+                                       {# Show a button to sign up for anonymous users #}
+                                       {% if not current_user %}
+                                               <div class="block">
+                                                       <a class="button is-primary is-large is-fullwidth wrap-text" href="/join">
+                                                               <span class="icon">
+                                                                       <i class="fa-solid fa-envelope"></i>
+                                                               </span>
+                                                               <span>{{ _("Join Now & Subscribe to our Newsletter") }}</span>
+                                                       </a>
+                                               </div>
+
+                                       {# Show a button to subscribe if users are logged in, but not yet subscribed #}
+                                       {% elif current_user and not current_user.consents_to_promotional_emails %}
+                                               <div class="block">
+                                                       <a class="button is-success is-large is-fullwidth wrap-text" href="/subscribe">
+                                                               <span class="icon">
+                                                                       <i class="fa-solid fa-envelope"></i>
+                                                               </span>
+                                                               <span>{{ _("Subscribe to our Newsletter") }}</span>
+                                                       </a>
+                                               </div>
+                                       {% end %}
+
+                                       <div class="block">
+                                               <div class="notification">
+                                                       <h5 class="title is-5">{{ _("Follow Us!") }}</h5>
+
+                                                       <div class="block">
+                                                               <div class="level is-mobile">
+                                                                       <div class="level-item">
+                                                                               <a href="/feed.xml" title="{{ _("RSS Feed") }}">
+                                                                                       <i class="fas fa-rss fa-2x"></i>
+                                                                               </a>
+                                                                       </div>
+                                                                       <div class="level-item">
+                                                                               <a href="https://social.ipfire.org/@news" title="{{ _("Mastodon") }}">
+                                                                                       <i class="fa-brands fa-mastodon fa-2x"></i>
+                                                                               </a>
+                                                                       </div>
+                                                                       <div class="level-item">
+                                                                               <a href="https://twitter.com/ipfire" title="{{ _("Twitter") }}">
+                                                                                       <i class="fa-brands fa-twitter fa-2x"></i>
+                                                                               </a>
+                                                                       </div>
+                                                                       <div class="level-item">
+                                                                               <a href="https://linkedin.com/company/ipfire" title="{{ _("LinkedIn") }}">
+                                                                                       <i class="fa-brands fa-linkedin-in fa-2x"></i>
+                                                                               </a>
+                                                                       </div>
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+
+                       {# Show links to older years... #}
+                       {% module BlogHistoryNavigation() %}
                </div>
-       </div>
+       </section>
 {% end block %}
index 566069bf824d7f584c31397932b44d3c51439723..6b8fbf6698400b76e4f69ac79f6b28c7fcb8f16e 100644 (file)
@@ -1,33 +1,30 @@
 {% extends "../../messages/base-promo.html" %}
 
-{% block content %}
-    <p>
-        {{ _("there is a new post from %s on the IPFire Blog:") % post.author }}
-    </p>
+{% block title %}{{ post.title }}{% end block %}
+
+{# Preview text shown in the inbox preview... #}
+{% block preview %}{{ post.excerpt }}{% end block %}
 
-    <p>
-        <strong>{{ post.title }}</strong>
-    </p>
+{% block content %}
+       <tr class="section">
+               <td>
+                       <h1>
+                               {{ post.title }}
+                       </h1>
 
-    {% if post.excerpt %}
-        <blockquote>{{ post.excerpt }}</blockquote>
-    {% end %}
+                       <p>
+                               {{ post.excerpt }}
+                       </p>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://blog.ipfire.org/post/{{ post.slug }}" target="_blank">{{ _("Click Here To Read More") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                                <a class="primary" href="https://www.ipfire.org/blog/{{ post.slug }}?utm_medium=email&amp;utm_source=blog-announcement">
+                                                       {{ _("Read The Full Post On Our Blog") }}
+                                                </a>
+                                       </td>
+                               </tr>
+                       </table>
+               </td>
+       </tr>
 {% end block %}
index 5e497475d2ba574d3b0d286b209c4c7232022ade..2d6a1a2c895ec949b89bd6743f7fa254b1818178 100644 (file)
@@ -15,4 +15,4 @@ X-Auto-Response-Suppress: OOF
 
 {{ _("Click here to read more:") }}
 
-  https://blog.ipfire.org/post/{{ post.slug }}
+  https://www.ipfire.org/blog/{{ post.slug }}?utm_medium=email&utm_source=blog-announcement
index 1ff45de3e0ade7c4a056b2840617bb8a8407826a..0d82502c0095b5e8251b674866dc54860e4a28f8 100644 (file)
@@ -1,14 +1,13 @@
-<p class="small text-uppercase text-muted ml-3">{{ _("History") }}</p>
+<div class="level">
+       <div class="level-left">
+               <div class="level-item">
+                       {{ _("Archive") }}
+               </div>
 
-<nav class="mb-5">
-    <ul class="nav flex-column">
-
-        {% for year in years %}
-            <li class="nav-item">
-                <a class="nav-link {% if request.path == "/years/%s" % year %}active{% end %}" href="/years/{{ year }}">
-                    {{ year }}
-                </a>
-            </li>
-        {% end %}
-    </ul>
-</nav>
+               {% for year in years %}
+                       <div class="level-item">
+                               <a href="/blog/years/{{ year }}">{{ year }}</a>
+                       </div>
+               {% end %}
+       </div>
+</div>
index 4b210d0245852ec0784af75f913ea6952ed49879..33ba7bf8649ae0418386ea2d9356137c70a9c888 100644 (file)
@@ -1,12 +1,23 @@
 {% for post in posts %}
-       <strong class="mb-0">
-               <a {% if "lightningwirelabs.com" in post.tags %}class="text-lwl"{% end %} href="/post/{{ post.slug }}">{{ post.title }}</a>
-       </strong>
-       <p class="text-muted small">
-               {{ locale.format_date(post.published_at, shorter=True, relative=False) }}
+       <p>
+               <h5 class="title is-5">
+                       <a {% if "lightningwirelabs.com" in post.tags %}class="has-text-lwl"{% end %} href="/blog/{{ post.slug }}">
+                               {{ post.title }}
+                       </a>
+               </h5>
 
-               {% if "lightningwirelabs.com" in post.tags %}
-                       <span class="text-lwl">{{ _("by Lightning Wire Labs") }}</span>
-               {% end %}
+               <h6 class="subtitle is-6">
+                       {% if post.published_at %}
+                               {{ locale.format_date(post.published_at, shorter=True, relative=relative) }}
+                       {% elif post.created_at %}
+                               {{ _("Created %s") % locale.format_date(post.created_at, shorter=True, relative=True) }}
+                       {% end %}
+
+                       {% if "lightningwirelabs.com" in post.tags %}
+                               <span class="has-text-lwl">{{ _("by Lightning Wire Labs") }}</span>
+                       {% elif show_author and post.author %}
+                               <a href="/users/{{ post.author.uid }}">{{ _("by %s") % post.author }}</a>
+                       {% end %}
+               </h6>
        </p>
 {% end %}
diff --git a/src/templates/blog/modules/post.html b/src/templates/blog/modules/post.html
deleted file mode 100644 (file)
index 75d0922..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-{% import ipfire.accounts as accounts %}
-
-<div class="blog-post {% if "lightningwirelabs.com" in post.tags %}lightning-wire-labs{% end %}">
-       <div class="blog-header">
-               <h4 class="card-title">
-                       <a href="https://blog.ipfire.org/post/{{ post.slug }}">
-                               {{ post.title }}
-                       </a>
-               </h4>
-
-               <p class="small text-muted">
-                       {{ _("by") }}
-
-                       {% if isinstance(post.author, accounts.Account) %}
-                               <a href="/authors/{{ post.author.uid }}">{{ post.author.name }}</a>,
-                       {% else %}
-                               <strong>{{ post.author }}</strong>,
-                       {% end %}
-
-                       {% if post.is_published() %}
-                               {% if post.updated_at and post.updated_at > post.published_at %}
-                                       {{ locale.format_date(post.published_at, shorter=True, relative=False) }},
-                                       {{ _("Updated %s") % locale.format_date(post.updated_at, shorter=True) }}
-                               {% else %}
-                                       {{ locale.format_date(post.published_at, shorter=True, relative=False) }}
-                               {% end %}
-                       {% elif post.published_at %}
-                               <span class="text-danger">{{ _("Scheduled to be published %s") % locale.format_date(post.published_at, relative=False) }}</span>
-                       {% else %}
-                               {{ _("Not published") }}
-                       {% end %}
-               </p>
-       </div>
-
-       {% if not post.is_published() and post.is_editable(current_user) %}
-               <div class="row">
-                       <div class="col-12 col-md-6 mb-3">
-                               <a class="btn btn-success btn-block" href="/post/{{ post.slug }}/edit">
-                                       <span class="fas fa-edit mr-2"></span> {{ _("Edit") }}
-                               </a>
-                       </div>
-
-                       <div class="col-12 col-md-6 mb-3">
-                               <a class="btn btn-primary btn-block" href="/post/{{ post.slug }}/publish">
-                                       <span class="fas fa-book-reader mr-2"></span> {{ _("Publish") }}
-                               </a>
-                       </div>
-               </div>
-       {% end %}
-
-       <div class="blog-content">
-               {% raw post.html %}
-       </div>
-
-       <div class="btn-toolbar justify-content-center">
-               {% if "lightningwirelabs.com" in post.tags and post.link %}
-                       <a class="btn btn-lwl" href="{{ post.link }}">
-                               Go to Lightning Wire Labs <span class="fas fa-external-link-alt ml-2"></span>
-                       </a>
-               {% end %}
-
-               {% if post.release %}
-                       <a class="btn btn-primary" href="https://www.ipfire.org/download/{{ post.release.slug }}">
-                               {{ _("Download") }}
-                       </a>
-               {% end %}
-
-               {% if post.release or "donate" in post.tags %}
-                       <a class="btn btn-outline-primary ml-2" href="https://www.ipfire.org/donate">
-                               {{ _("Donate") }}
-                       </a>
-               {% end %}
-       </div>
-</div>
diff --git a/src/templates/blog/modules/posts.html b/src/templates/blog/modules/posts.html
deleted file mode 100644 (file)
index 094ae38..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{% for i, post in enumerate(posts) %}
-    {% module BlogPost(post) %}
-
-    {% if i < (len(posts) - 1) %}
-        <hr class="divider">
-    {% end %}
-{% end %}
index 210617e8f6b0e14d09e43e8150bdbd6ab9edc7d7..28015f288915f0115fbdfec5dff568258f4cae3f 100644 (file)
@@ -1,4 +1,4 @@
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ post.title }}{% end block %}
 
@@ -10,7 +10,7 @@
        <meta property="og:title" content="{{ post.title }} - The IPFire Blog" />
        <meta property="og:description" content="{{ post.excerpt }}" />
        <meta property="og:url" content="{{ request.full_url() }}" />
-       <meta property="og:image" content="https://blog.ipfire.org/{{ static_url("img/ipfire-tux.png") }}" />
+       <meta property="og:image" content="{{ static_url("img/ipfire-tux.png") }}" />
 
        <meta property="og:type" content="article" />
        {% if post.published_at %}
        <meta property="twitter:card" content="summary_large_image" />
        <meta property="twitter:title" content="{{ post.title }} - The IPFire Blog" />
        <meta property="twitter:description" content="{{ post.excerpt }}" />
-       <meta property="twitter:image" content="https://blog.ipfire.org/{{ static_url("img/ipfire-tux.png") }}" />
+       <meta property="twitter:image" content="{{ static_url("img/ipfire-tux.png") }}" />
 {% end block %}
 
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       {% module BlogPost(post) %}
+{% block container %}
+       {% import ipfire.accounts as accounts %}
+
+       <section class="hero {% if "lightningwirelabs.com" in post.tags %}is-lwl{% elif post.is_published() %}is-primary{% else %}is-light{% end %}">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/blog">{{ _("Blog") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ post.title }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ post.title }}</h1>
+
+                               <h6 class="subtitle">
+                                       {{ _("by") }}
+
+                                       {% if isinstance(post.author, accounts.Account) %}
+                                               <a href="/users/{{ post.author.uid }}">{{ post.author.name }}</a>,
+                                       {% else %}
+                                               <strong>{{ post.author }}</strong>,
+                                       {% end %}
+
+                                       {% if post.is_published() %}
+                                               {% if post.updated_at and post.updated_at > post.published_at %}
+                                                       {{ locale.format_date(post.published_at, shorter=True, relative=False) }},
+                                                       {{ _("Updated %s") % locale.format_date(post.updated_at, shorter=True) }}
+                                               {% else %}
+                                                       {{ locale.format_date(post.published_at, shorter=True, relative=False) }}
+                                               {% end %}
+                                       {% elif post.published_at %}
+                                               {{ _("Scheduled to be published %s") % locale.format_date(post.published_at, relative=False) }}
+                                       {% else %}
+                                               {{ _("Not published, yet") }}
+                                       {% end %}
+                               </h6>
+                       </div>
+               </div>
+       </section>
+
+       {# Encourage people to sign up & subscribe... #}
+       {% if not current_user or not current_user.consents_to_promotional_emails %}
+               <section class="has-background-light">
+                       <div class="container">
+                               <p class="has-text-centered px-2 py-1">
+                                       {{ _("Do you like what you are reading?") }}
+                                       {{ _("Subscribe to our newsletter and don't miss out on the latest...") }}
+
+                                       &nbsp;
+
+                                       {% if not current_user %}
+                                               <a class="has-text-weight-bold" href="/join">
+                                                       {{ _("Join Now") }}
+                                               </a>
+                                       {% else %}
+                                               <a class="has-text-weight-bold" href="/subscribe">
+                                                       {{ _("Subscribe Now") }}
+                                               </a>
+                                       {% end %}
+                               </p>
+                       </div>
+               </section>
+       {% end %}
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-justify-content-space-between">
+                               <div class="column is-8">
+                                       <div class="buttons are-medium">
+                                               {% if "lightningwirelabs.com" in post.tags and post.link %}
+                                                       <a class="button is-lwl is-fullwidth" href="{{ post.link }}">
+                                                               <span class="icon">
+                                                                       <i class="fas fa-external-link-alt"></i>
+                                                               </span>
+                                                               <span>{{ _("Go to Lightning Wire Labs") }}</span>
+                                                       </a>
+                                               {% end %}
+
+                                               {% if post.release or "donate" in post.tags %}
+                                                       <a class="button is-primary is-fullwidth" href="/donate">
+                                                               <span class="icon">
+                                                                       <i class="fas fa-heart"></i>
+                                                               </span>
+                                                               <span>{{ _("Donate") }}</span>
+                                                       </a>
+                                               {% end %}
+
+                                               {% if post.release %}
+                                                       <a class="button is-dark is-fullwidth" href="/downloads/{{ post.release.slug }}">
+                                                               <span class="icon">
+                                                                       <i class="fas fa-download"></i>
+                                                               </span>
+                                                               <span>{{ _("Download") }} <span class="is-hidden-mobile">{{ post.release }}</span></span>
+
+                                                       </a>
+                                               {% end %}
+                                       </div>
+
+                                       <div class="block">
+                                               <div class="content">
+                                                       {% raw post.html %}
+                                               </div>
+                                       </div>
+                               </div>
+
+                               {# Author Stuff #}
+
+                               {% if post.is_editable(current_user) %}
+                                       <div class="column is-3">
+                                               <div class="buttons">
+                                                       {% if not post.is_published() %}
+                                                               <a class="button is-primary is-fullwidth" href="/blog/{{ post.slug }}/publish">
+                                                                       <span class="icon">
+                                                                               <i class="fa-solid fa-users"></i>
+                                                                       </span>
+                                                                       <span>{{ _("Publish") }}</span>
+                                                               </a>
+                                                       {% end %}
+
+                                                       <a class="button is-light is-fullwidth" href="/blog/{{ post.slug }}/edit">
+                                                               <span class="icon">
+                                                                       <i class="fas fa-edit"></i>
+                                                               </span>
+                                                               <span>{{ _("Edit") }}</span>
+                                                       </a>
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
                </div>
-       </div>
+       </section>
+
+       {# Analytics #}
+       {% if post.is_published() %}
+               {% if current_user and current_user.is_admin() %}
+                       <section class="hero is-dark">
+                               <div class="hero-body">
+                                       <div class="container">
+                                               <h4 class="title is-4">{{ _("Analytics") }}</h4>
+
+                                               {% module AnalyticsSummary() %}
+                                       </div>
+                               </div>
+                       </section>
+               {% end %}
+       {% end %}
 {% end block %}
index fa29b18c97a35aa4ddef0927a0d03f3cc48882c5..4e6d9c9cb5b00d251ee472bb82477930091d7374 100644 (file)
@@ -1,29 +1,73 @@
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ _("Publish %s") % post.title }}{% end block %}
 
-{% block modal %}
-       <div class="card">
-               <div class="card-body">
-                       <h5 class="card-title mb-1">{{ _("Publish Post") }}</h5>
-                       <h6 class="card-subtitle text-muted mb-3">{{ post.title }}</h6>
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/blog">Blog</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/blog/">{{ post.title }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Publish %s") % post.title }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h3 class="title is-3">{{ _("Publish Post") }}</h3>
+                               <h4 class="title is-4">{{ post.title }}</h4>
+                       </div>
+               </div>
+       </section>
 
-                       <form action="" method="POST">
-                               {% raw xsrf_form_html() %}
+       <div class="container">
+               <section class="section has-text-centered">
+                       <div class="columns is-centered">
+                               <div class="column is-one-fifth">
+                                       <h5 class="title is-5">When to Publish</h5>
 
-                               <input type="hidden" name="when">
+                                       <form action="" method="POST">
+                                               {% raw xsrf_form_html() %}
 
-                               <div class="form-group">
-                                       <label>{{ _("Time to Publish") }}</label>
+                                               <div class="block">
+                                                       <div class="field">
+                                                               <input type="hidden" name="when">
+                                                       </div>
+                                               </div>
 
-                                       <input type="datetime-local" class="form-control"
-                                               pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}" required>
-                               </div>
+                                               <div class="block">
+                                                       <div class="field">
+                                                               <p class="control">
+                                                                       <input type="datetime-local" class="input"
+                                                                               pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}" required>
+                                                               </p>
+                                                       </div>
+                                               </div>
 
-                               <button type="submit" class="btn btn-primary btn-block">{{ _("Publish") }}</button>
-                               <a class="btn btn-secondary btn-block" href="/post/{{ post.slug }}">{{ _("Cancel") }}</a>
+                                               <div class="block">
+                                                       <div class="field">
+                                                               <div class="level">
+                                                                       <div class="level-left">
+                                                                               <button type="submit" class="button is-success has-text-weight-bold is-halfwidth">{{ _("Publish") }}</button>
+                                                                       </div>
+                                                                       <div class="level-right">
+                                                                               <a class="button is-danger has-text-weight-bold is-halfwidth" href="/post/{{ post.slug }}">{{ _("Cancel") }}</a>
+                                                                       </div>
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
                        </form>
-               </div>
+               </section>
        </div>
 {% end block %}
 
diff --git a/src/templates/blog/search-results.html b/src/templates/blog/search-results.html
deleted file mode 100644 (file)
index 5264912..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Search results for '%s'") % q }}{% end block %}
-
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <h5>{{ _("Search results for '%s'") % q }}</h5>
-
-                       {% module BlogPosts(posts) %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/blog/tag.html b/src/templates/blog/tag.html
deleted file mode 100644 (file)
index 749c6aa..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Posts tagged with '%s'") % tag }}{% end block %}
-
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       {% if posts %}
-                               {% module BlogPosts(posts) %}
-                       {% else %}
-                               <p class="text-center text-muted my-5">
-                                       {{ _("There are no posts tagged with '%s'") % tag }}
-                               </p>
-                       {% end %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/blog/write.html b/src/templates/blog/write.html
new file mode 100644 (file)
index 0000000..b953efe
--- /dev/null
@@ -0,0 +1,83 @@
+{% extends "../base.html" %}
+
+{% block title %}
+       {% if post %}
+               {{ _("Edit %s") % post.title }}
+       {% else %}
+               {{ _("Write A New Post") }}
+       {% end %}
+{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/blog">{{ _("Blog") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Write") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Write a New Post") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns">
+                               <div class="column is-three-fifth">
+                                       <form action="" method="POST">
+                                               {% raw xsrf_form_html() %}
+
+                                               <div class="field">
+                                                       <div class="control">
+                                                               <input class="input" type="text" name="title" placeholder="{{ _("Title") }}"
+                                                                       {% if post %}value="{{ post.title }}"{% end %} required>
+                                                       </div>
+                                               </div>
+
+                                               <div class="field">
+                                                       <div class="control">
+                                                               <textarea class="textarea" name="text" rows="16"
+                                                                       required>{% if post %}{{ post.text }}{% end %}</textarea>
+                                                       </div>
+                                               </div>
+
+                                               <div class="field">
+                                                       <label class="label">{{ _("Tags") }}</label>
+                                                       <div class="control">
+                                                               <input class="input" type="text" name="tags"
+                                                                       {% if post %}value="{{ " ".join(post.tags) }}"{% end %}>
+                                                       </div>
+                                               </div>
+
+                                               <div class="field is-grouped">
+                                                       <div class="control">
+                                                               <button type="submit" class="button is-primary">
+                                                                       {{ _("Save") }}
+                                                               </button>
+                                                       </div>
+
+                                                       {% if post %}
+                                                               <div class="control">
+                                                                       <a class="button is-danger is-outline" href="/blog/{{ post.slug }}/delete">
+                                                                               {{ _("Delete") }}
+                                                                       </a>
+                                                               </div>
+                                                       {% end %}
+                                               </div>
+                                       </form>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
index 5d36007e2b449bf5c8de8abcf9b8c411f7bdb4ea..8718cd7f0718581d118f34a06b5b9c080ae5a376 100644 (file)
@@ -1,13 +1,39 @@
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ _("Posts in %s") % year }}{% end block %}
 
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <h5>{{ _("Posts in %s") % year }}</h5>
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/blog">Blog</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ year }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h1 class="title">
+                                       {{ _("Posts in %s") % year }}
+                               </h1>
+                       </div>
+               </div>
+       </section>
+       <section class="section">
+               <div class="container">
+                       <div class="columns">
+                               <div class="column is-8">
+                                       {% module BlogList(posts) %}
+                               </div>
+                       </div>
 
-                       {% module BlogList(posts) %}
+                       {% module BlogHistoryNavigation() %}
                </div>
-       </div>
+       </section>
 {% end block %}
diff --git a/src/templates/docs/404.html b/src/templates/docs/404.html
new file mode 100644 (file)
index 0000000..8860c31
--- /dev/null
@@ -0,0 +1,24 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Page Not Found") }}{% end block %}
+
+{% block container %}
+    {% import os.path %}
+
+    <section class="hero is-light is-fullheight-with-navbar">
+               <div class="hero-body">
+                       <div class="container">
+                               <h5 class="title is-5">
+                                       {{ _("Error 404") }}
+                </h5>
+                <h2 class="title is-2">
+                    {{ _("This Page Does Not Exist") }}
+                </h2>
+
+                <a class="button is-primary" href="{{ os.path.join(request.path, "_edit") }}">
+                    {{ _("Create Now") }}
+                </a>
+                       </div>
+               </div>
+       </section>
+{% end block %}
\ No newline at end of file
similarity index 62%
rename from src/templates/wiki/base.html
rename to src/templates/docs/base.html
index b3acb4b51576bc995964c1b530fd70542f392b56..f151cbe1d7d86f931ddb4a8e2272cc6e202d0d72 100644 (file)
@@ -1,7 +1,7 @@
 {% extends "../base.html" %}
 
-{% block content %}
-       {% module WikiNavbar() %}
+{% block container %}
+       {% module DocsHeader() %}
 
        {% block main %}{% end block %}
 {% end block %}
diff --git a/src/templates/docs/confirm-delete.html b/src/templates/docs/confirm-delete.html
new file mode 100644 (file)
index 0000000..cf6115e
--- /dev/null
@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+
+{% block title %}{{ _("Delete %s") % file.filename }}{% end block %}
+
+{% block container %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-half">
+                                       <h1 class="title">
+                                               {{ _("Delete File") }}
+                                       </h1>
+                                       <h4 class="subtitle">{{ file.filename }}</h4>
+
+                                       <div class="block has-text-danger">
+                                               <form action="" method="POST">
+                                                       {% raw xsrf_form_html() %}
+
+                                                       <div class="field">
+                                                               <p>
+                                                                       {{ _("Do you really want to delete %(filename)s in %(path)s?") % { "filename" : file.filename, "path" : file.path } }}
+                                                               </p>
+                                                       </div>
+
+                                                       <div class="field">
+                                                               <div class="control">
+                                                                       <button class="button is-danger is-fullwidth">
+                                                                               {{ _("Delete File") }}
+                                                                       </button>
+                                                               </div>
+                                                       </div>
+                                               </form>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/docs/confirm-restore.html b/src/templates/docs/confirm-restore.html
new file mode 100644 (file)
index 0000000..7df1a4b
--- /dev/null
@@ -0,0 +1,54 @@
+{% extends "base.html" %}
+
+{% block title %}{{ _("Restore %s") % page.page }}{% end block %}
+
+{% block content %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/docs">{{ _("Documentation") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="">{{ page.page }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Restore %s") % page.page }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Restore %s") % page.page }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <div class="container">
+               <section class="section">
+                       <p>
+                               {{ _("Do you really want to restore this page to its revision from %s?") % locale.format_date(page.timestamp) }}
+                       </p>
+
+                       <form action="/docs/_restore" method="POST">
+                               {% raw xsrf_form_html() %}
+
+                               <input type="hidden" name="path" value="{{ page.page }}">
+                               <input type="hidden" name="revision" value="{{ page.timestamp.isoformat() }}">
+
+                               <div class="control">
+                                       <input class="input" type="text" name="comment"
+                                               placeholder="{{ _("Comment") }}">
+                               </div>
+
+                               <button type="submit" class="button is-warning">
+                                       {{ _("Restore") }}
+                               </button>
+                       </form>
+               </section>
+       </div>
+{% end block %}
similarity index 53%
rename from src/templates/wiki/diff.html
rename to src/templates/docs/diff.html
index 71f171d5e8084fd195a1ef7966951b39fd3f7030..18cb5e75a82a2747776b6cba3cd736347037d540 100644 (file)
@@ -3,27 +3,27 @@
 {% block title %}{{ _("Differences in Revisions") }} - {{ page.title }}{% end block %}
 
 {% block main %}
-       <div class="card mb-3">
-               <div class="card-body">
-                       <h4 class="card-title">
+       <div class="container">
+               <section class="section">
+                       <h4 class="title is-4 has-text-centered">
                                {{ _("Differences in Revisions: %s") % page.title }}
                        </h4>
 
-                       <div class="row mb-3">
-                               <div class="col col-md-5 text-center">
-                                       <h6 class="mb-0">{{ _("Older Revision") }}</h6>
+                       <div class="columns">
+                               <div class="column is-one-third has-text-centered">
+                                       <h6 class="title is-6">{{ _("Older Revision") }}</h6>
 
                                        <a href="{{ a.url }}?revision={{ a.timestamp.isoformat() }}">
                                                {{ locale.format_date(a.timestamp) }}
                                        </a>
                                </div>
 
-                               <div class="col col-md-2 text-center">
+                               <div class="column is-one-third has-text-centered">
                                        &raquo;
                                </div>
 
-                               <div class="col col-md-5 text-center">
-                                       <h6 class="mb-0">{{ _("Newer Revision") }}</h6>
+                               <div class="column is-one-third has-text-centered">
+                                       <h6 class="title is-6">{{ _("Newer Revision") }}</h6>
 
                                        <a href="{{ b.url }}?revision={{ b.timestamp.isoformat() }}">
                                                {{ locale.format_date(b.timestamp) }}
                        </div>
 
                        {% if b.changes %}
-                               <div class="alert alert-light">
-                                       {{ b.changes }}
+                               <div class="notification has-text-centered">
+                                       {{ b.changes }}
                                </div>
                        {% end %}
 
-                       {% module WikiDiff(a, b) %}
+                       {% module DocsDiff(a, b) %}
                </div>
        </div>
 {% end block %}
diff --git a/src/templates/docs/edit.html b/src/templates/docs/edit.html
new file mode 100644 (file)
index 0000000..37fbd9c
--- /dev/null
@@ -0,0 +1,138 @@
+{% extends "base.html" %}
+
+{% block title %}{% if page %}{{ _("Edit %s") % page.title }}{% else %}{{ _("Create A New Page") }}{% end %}{% end block %}
+
+{% block main %}
+       {% import os.path %}
+
+       <div class="container">
+               <section class="section">
+                       <form action="" method="POST" class="editor" data-render_url="/docs{{ os.path.join(path, "_render") }}">
+                               {% raw xsrf_form_html() %}
+
+                               <div class="level">
+                                       <div class="level-left">
+                                               <div class="level-item">
+                                                       <div class="field has-addons">
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="bold" title="{{ _("Bold") }} [{{ _("Ctrl") }}-B]">
+                                                                               <i class="fas fa-bold"></i>
+                                                                       </button>
+                                                               </p>
+
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="italic" title="{{ _("Italic") }} [{{ _("Ctrl") }}-I]">
+                                                                               <i class="fas fa-italic"></i>
+                                                                       </button>
+                                                               </p>
+
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="code" title="{{ _("Code") }} [{{ _("Ctrl") }}-C]">
+                                                                               <i class="fas fa-code"></i>
+                                                                       </button>
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               <div class="level-item">
+                                                       <div class="field has-addons">
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="headline-up" title="{{ _("Headline one level up") }}">
+                                                                               <i class="fas fa-chevron-left"></i>
+                                                                       </button>
+                                                               </p>
+
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="headline" title="{{ _("Headline") }} [{{ _("Ctrl") }}-H]">
+                                                                               <i class="fas fa-heading"></i>
+                                                                       </button>
+                                                               </p>
+
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="headline-down" title="{{ _("Headline one level down") }}">
+                                                                               <i class="fas fa-chevron-right"></i>
+                                                                       </button>
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               <div class="level-item">
+                                                       <div class="field has-addons">
+                                                               <p class="control">
+                                                                       <button type="button" class="button"
+                                                                                       id="link" title="{{ _("Link") }} [{{ _("Ctrl") }}-L]">
+                                                                               <i class="fas fa-link"></i>
+                                                                       </button>
+                                                               </p>
+
+                                                               <p class="control">
+                                                                       <a class="button" href="/docs{{ path }}/_files"
+                                                                                       target="_blank" title="{{ _("Files") }}">
+                                                                               <i class="fas fa-images"></i>
+                                                                       </a>
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
+
+                               <div class="field">
+                                       <div class="control">
+                                               <textarea class="textarea" rows="16" name="content" id="content" placeholder="{{ _("Text") }}"
+                                               >{% if page and page.markdown %}{{ page.markdown }}{% end %}</textarea>
+                                       </div>
+                               </div>
+
+                               <div class="field">
+                                       <label class="label">{{ _("What has changed?") }}</label>
+                                       <div class="control">
+                                               <input type="text" class="input" name="changes" required>
+                                       </div>
+                               </div>
+
+                               {% if not page or not page.is_watched_by(current_user) %}
+                                       <div class="field">
+                                               <div class="control">
+                                                       <label class="checkbox">
+                                                               <input type="checkbox" name="watch" checked>
+                                                               {{ _("Watch this page") }}
+                                                       </label>
+                                               </div>
+
+                                               <p class="help">
+                                                       {{_("Get notified when this page is changed") }}
+                                               </p>
+                                       </div>
+                               {% end %}
+
+                               <div class="control">
+                                       <button type="submit" class="button is-primary">
+                                               {% if page %}{{ _("Save Page") }}{% else %}{{ _("Create Page") }}{% end %}
+                                       </button>
+                               </div>
+                       </form>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div id="preview" class="fade show">
+                               <h4 class="title is-4">{{ _("Preview") }}</h4>
+
+                               <div id="preview-content" class="content">
+                                       {{ _("Loading...") }}
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
+
+{% block javascript %}
+       <script src="{{ static_url("js/editor.js") }}"></script>
+{% end block %}
diff --git a/src/templates/docs/files/detail.html b/src/templates/docs/files/detail.html
new file mode 100644 (file)
index 0000000..3466feb
--- /dev/null
@@ -0,0 +1,190 @@
+{% extends "../page.html" %}
+
+{% block title %}{{ file.filename }}{% end block %}
+
+{% block head %}{% end block %}
+
+{% block main %}
+       <section class="section">
+               <div class="container">
+                       {% if file.is_image() %}
+                               <div class="block">
+                                       <div class="notification">
+                                               <figure class="image">
+                                                       <img src="{{ file.url }}?revision={{ file.created_at.isoformat() }}&s=1920" alt="{{ file.filename }}">
+                                               </figure>
+                                       </div>
+                               </div>
+                       {% elif file.is_pdf() %}
+                               <div class="block">
+                                       <object class="pdf-viewer" data="{{ file.url }}?revision={{ file.created_at.isoformat() }}"
+                                                       title="{{ file.filename }}" type="{{ file.mimetype }}">
+                                               <p>
+                                                       {{ _("This PDF attachment could not be displayed.") }}
+                                                       <a href="{{ file.url }}?revision={{ file.created_at.isoformat() }}">{{ _("Click here to download") }}</a>
+                                               </p>
+                                       </object>
+                               </div>
+                       {% end %}
+
+                       <div class="block">
+                               <a class="button is-primary is-fullwidth" href="{{ file.url }}?revision={{ file.created_at.isoformat() }}">
+                                       <span class="icon">
+                                               <i class="fas fa-file-download"></i>
+                                       </span>
+                                       <span>{{ _("Download") }} ({{ format_size(file.size) }})</span>
+                               </a>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% if file.is_image() %}
+                               <h5 class="title is-5">{{ _("Usage") }}</h6>
+
+                               <div class="block">
+                                       <pre><code>![](./{{ file.filename }})</code></pre>
+                               </div>
+
+                               <h6 class="title is-6">{{ _("Or with an optional caption") }}</h6>
+
+                               <div class="block">
+                                       <pre><code>![](./{{ file.filename }} "{{ _("Caption") }}")</code></pre>
+                               </div>
+                       {% end %}
+
+                       <h5 class="title is-5">{{ _("Details") }}</h5>
+
+                       <table class="table is-fullwidth">
+                               <tr>
+                                       <th scope="row">
+                                               {{ _("Filename") }}
+                                       </th>
+                                       <td>
+                                               <code>{{ file.filename }}</code>
+                                       </td>
+                               </tr>
+
+                               {% if file.author %}
+                                       <tr>
+                                               <th scope="row">
+                                                       {{ _("Author") }}
+                                               </th>
+                                               <td>
+                                                       <a href="/users/{{ file.author.uid }}">{{ file.author }}</a>
+                                               </td>
+                                       </tr>
+                               {% end %}
+
+                               <tr>
+                                       <th scope="row">
+                                               {{ _("Uploaded at") }}
+                                       </th>
+                                       <td>
+                                               {{ locale.format_date(file.created_at) }}
+                                       </td>
+                               </tr>
+
+                               {% if file.deleted_at %}
+                                       <tr>
+                                               <th scope="row">
+                                                       {{ _("Deleted at") }}
+                                               </th>
+                                               <td>
+                                                       {{ locale.format_date(file.deleted_at) }}
+                                               </td>
+                                       </tr>
+                               {% end %}
+
+                               {% set revisions = file.get_revisions() %}
+                               {% if len(revisions) > 1 %}
+                                       {% for i, r in enumerate(revisions) %}
+                                               <tr>
+                                                       {% if i == 0 %}
+                                                               <th scope="row">{{ _("Other Revisions") }}</th>
+                                                       {% else %}
+                                                               <td></td>
+                                                       {% end %}
+
+                                                       <td>
+                                                               <a href="{{ r.url }}?action=detail&revision={{ r.created_at.isoformat() }}">
+                                                                       {{ _("Uploaded %(time)s by %(author)s") % { "time" : locale.format_date(r.created_at), "author" : r.author } }}
+                                                               </a>
+                                                       </td>
+                                               </tr>
+                                       {% end %}
+                               {% end %}
+                       </table>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% if file.pages %}
+                               <h6 class="title is-6">{{ _("Used By") }}</h6>
+
+                               <div class="block">
+                                       <ul>
+                                               {% for page in file.pages %}
+                                                       <li>
+                                                               <a href="{{ page.url }}">
+                                                                       {% for p, b in page.breadcrumbs %}
+                                                                               {{ b }} /
+                                                                       {% end %}
+
+                                                                       {{ page.title }}
+                                                               </a>
+                                                       </li>
+                                               {% end %}
+                                       </ul>
+                               </div>
+                       {% end %}
+
+                       <h6 class="title is-6">{{ _("Delete") }}</h6>
+
+                       <div class="block">
+                               <a class="button is-danger" href="{{ file.url }}/_delete" {% if not file.can_be_deleted() %}disabled{% end %}>
+                                       {{ _("Delete") }}
+                               </a>
+                       </div>
+
+                       <h6 class="title is-6">{{ _("Upload Newer Revision") }}</h6>
+
+                       <form method="POST" action="/docs/_upload" enctype="multipart/form-data">
+                               {% raw xsrf_form_html() %}
+
+                               <input type="hidden" name="path" value="{{ file.path }}">
+                               <input type="hidden" name="filename" value="{{ file.filename }}">
+
+                               <div class="field">
+                                       <div class="control">
+                                               <div class="file is-boxed">
+                                                       <label class="file-label">
+                                                               <input class="file-input" type="file" name="file">
+                                                               <span class="file-cta">
+                                                                       <span class="file-icon">
+                                                                               <i class="fas fa-upload"></i>
+                                                                       </span>
+                                                                       <span class="file-label">
+                                                                               {{ _("Choose a file to upload") }}
+                                                                       </span>
+                                                               </span>
+                                                       </label>
+                                               </div>
+                                       </div>
+
+                                       <p class="help">
+                                               {{ _("Uploading a new file to replaces this one to fix any errata in the current version") }}
+                                       </p>
+                               </div>
+
+                               <div class="field">
+                                       <div class="control">
+                                               <input class="button is-primary" type="submit" value="{{ _("Upload") }}">
+                                       </div>
+                               </div>
+                       </form>
+               </div>
+       </div>
+{% end block %}
diff --git a/src/templates/docs/files/index.html b/src/templates/docs/files/index.html
new file mode 100644 (file)
index 0000000..ca01a43
--- /dev/null
@@ -0,0 +1,75 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Files") }}{% end block %}
+
+{% block main %}
+       {% if files %}
+               <section class="section">
+                       <div class="container">
+                               <div class="columns is-multiline">
+                                       {% for f in files %}
+                                               <div class="column is-flex is-flex-direction-column is-half is-one-quarter-desktop">
+                                                       <div class="notification has-text-centered is-flex is-flex-direction-column" style="height: 100%">
+                                                               <div class="block is-flex is-flex-grow-1 is-flex-direction-column is-justify-content-center">
+                                                                       {% if f.is_image() %}
+                                                                               <a href="{{ f.url }}?action=detail">
+                                                                                       <figure class="image">
+                                                                                               <img src="{{ f.url }}?s=512" alt="{{ f.filename }}">
+                                                                                       </figure>
+                                                                               </a>
+                                                                       {% elif "pdf" in f.mimetype %}
+                                                                               <span class="icon">
+                                                                                       <i class="fas fa-file-pdf fa-5x"></i>
+                                                                               </span>
+                                                                       {% else %}
+                                                                               <span class="icon">
+                                                                                       <i class="fas fa-file fa-5x"></i>
+                                                                               </span>
+                                                                       {% end %}
+                                                               </div>
+
+                                                               <div class="block">
+                                                                       <h6 class="title is-6">
+                                                                               <a href="{{ f.url }}?action=detail">{{ f.filename }}</a>
+                                                                       </h6>
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                       {% end %}
+                               </div>
+                       </div>
+               </section>
+       {% end %}
+
+       <section class="section">
+               <div class="container">
+                       <h5 class="title is-5">{{ _("Upload File") }}</h5>
+
+                       <form method="POST" action="/docs/_upload" enctype="multipart/form-data">
+                               {% raw xsrf_form_html() %}
+
+                               <input type="hidden" name="path" value="{{ path }}">
+
+                               <div class="block">
+                                       <div class="file">
+                                               <label class="file-label">
+                                                       <input class="file-input" type="file" name="file" required>
+                                                       <span class="file-cta">
+                                                               <span class="file-icon">
+                                                                       <i class="fas fa-upload"></i>
+                                                               </span>
+                                                               <span class="file-label">
+                                                                       {{ _("Choose a file to upload") }}
+                                                               </span>
+                                                       </span>
+                                               </label>
+                                       </div>
+                               </div>
+
+                               <div class="block">
+                                       <input class="button is-primary" type="submit" value="{{ _("Upload") }}">
+                               </div>
+                       </form>
+               </div>
+       </div>
+{% end block %}
diff --git a/src/templates/docs/modules/diff.html b/src/templates/docs/modules/diff.html
new file mode 100644 (file)
index 0000000..fc2f760
--- /dev/null
@@ -0,0 +1,13 @@
+<div class="container">
+       <table class="table is-family-monospace">
+               <tbody>
+                       {% for line in diff %}
+                               {% if not line.startswith("?") %}
+                                       <tr class="{% if line.startswith("+") %}has-background-success-light{% elif line.startswith("-") %}has-background-danger-light{% end %}">
+                                               <td>{% if line[2:] %}{{ line[2:] }}{% else %}&nbsp;{% end %}</td>
+                                       </tr>
+                               {% end %}
+                       {% end %}
+               </tbody>
+       </table>
+</div>
diff --git a/src/templates/docs/modules/header.html b/src/templates/docs/modules/header.html
new file mode 100644 (file)
index 0000000..4c091cd
--- /dev/null
@@ -0,0 +1,43 @@
+<section class="hero is-light">
+       <div class="hero-body">
+               <div class="container">
+                       <nav class="breadcrumb" aria-label="breadcrumbs">
+                               <ul>
+                                       <li>
+                                               <a href="/">
+                                                       {{ _("Home") }}
+                                               </a>
+                                       </li>
+
+                                       <li>
+                                               <a href="/docs">
+                                                       {{ _("Documentation") }}
+                                               </a>
+                                       </li>
+
+                                       {% for p, title in breadcrumbs %}
+                                               <li>
+                                                       <a href="{{ p }}">{{ title }}</a>
+                                               </li>
+                                       {% end %}
+
+                                       {% if page_title %}
+                                               <li {% if not suffix %}class="is-active"{% end %}>
+                                                       <a href="/docs{{ page }}" {% if not suffix %}aria-current="page"{% end %}>
+                                                               {{ page_title }}
+                                                       </a>
+                                               </li>
+                                       {% end %}
+
+                                       {% if suffix %}
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ suffix }}</a>
+                                               </li>
+                                       {% end %}
+                               </ul>
+                       </nav>
+
+                       <h1 class="title">{{ page_title or _("IPFire Documentation") }}</h1>
+               </div>
+       </div>
+</section>
diff --git a/src/templates/docs/modules/list.html b/src/templates/docs/modules/list.html
new file mode 100644 (file)
index 0000000..8fe2e26
--- /dev/null
@@ -0,0 +1,44 @@
+{% for page in pages %}
+       {% if page.check_acl(current_user) %}
+               <div class="block">
+                       <p class="is-size-6">
+                               {% if show_breadcrumbs %}
+                                       {% for url, title in page.breadcrumbs %}
+                                               <a href="{{ url }}" >{{ title }}</a> /
+                                       {% end %}
+                               {% end %}
+
+                               <a href="{{ page.url }}{% if link_revision %}?revision={{ page.timestamp.isoformat() }}{% end %}">{{ page.title }}</a>
+                       </p>
+
+                       <p class="is-size-7">
+                               {% if show_author %}
+                                       {{ _("Last edited %s") % locale.format_date(page.timestamp, shorter=True, relative=False) }}
+
+                                       {% if page.author %}
+                                               {{ _("by") }}
+                                               <a href="/users/{{ page.author.uid }}">{{ page.author }}</a>
+                                       {% end %}
+                               {% end %}
+
+                               {% if show_changes %}
+                                       {% if page.changes %}
+                                               &bull; {{ page.changes }}
+                                       {% end %}
+
+                                       {% if page.previous_revision %}
+                                               <a href="{{ page.url }}?action=diff&a={{ page.previous_revision.timestamp.isoformat() }}&b={{ page.timestamp.isoformat() }}">
+                                                       <span class="fas fa-exchange-alt ml-1"></span>
+                                               </a>
+                                       {% end %}
+                               {% end %}
+
+                               {% if not page.is_latest_revision() %}
+                                       <a href="{{ page.url }}?action=restore&revision={{ page.timestamp.isoformat() }}" title="{{ _("Restore") }}">
+                                               <span class="fas fa-undo ml-1"></span>
+                                       </a>
+                               {% end %}
+                       </p>
+               </div>
+       {% end %}
+{% end %}
diff --git a/src/templates/docs/page.html b/src/templates/docs/page.html
new file mode 100644 (file)
index 0000000..914776e
--- /dev/null
@@ -0,0 +1,96 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page.title }}{% end block %}
+
+{% block head %}
+       <!-- Facebook OpenGraph -->
+       <meta property="og:site_name" content="IPFire Documentation" />
+       <meta property="og:title" content="{{ page.title }} - The IPFire Documentation" />
+       <meta property="og:url" content="{{ page.full_url }}" />
+       <meta property="og:image" content="https://www.ipfire.org/{{ static_url("img/ipfire-tux.png") }}" />
+
+       <meta property="og:type" content="article" />
+       <meta property="og:article:modified_time" content="{{ page.timestamp.isoformat() }}" />
+
+       <!-- Twitter -->
+       <meta property="twitter:site" content="@ipfire" />
+       <meta property="twitter:card" content="summary_large_image" />
+       <meta property="twitter:title" content="{{ page.title }} - The IPFire Documentation" />
+       <meta property="twitter:image" content="https://www.ipfire.org/{{ static_url("img/ipfire-tux.png") }}" />
+{% end block %}
+
+{% block main %}
+       {% import os.path %}
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns">
+                               <div class="column is-three-quarters-widescreen">
+                                       <div class="content">
+                                               {% raw page.html %}
+                                       </div>
+
+                                       <hr>
+
+                                       <div class="block">
+                                               <a class="button is-primary is-fullwidth" href="{{ os.path.join(request.path, "_edit") }}">
+                                                       <span class="icon-text">
+                                                               <span class="icon">
+                                                                       <i class="fas fa-edit"></i>
+                                                               </span>
+                                                               <span>
+                                                                       {{ _("Edit Page") }}
+                                                                       {% if not current_user %}&dash; {{ _("Yes, you can edit!") }}{% end %}
+                                                               </span>
+                                                       </span>
+                                               </a>
+                                       </div>
+
+                                       <div class="block">
+                                               <small>
+                                                       <div class="level">
+                                                               <div class="level-left">
+                                                                       {% if current_user %}
+                                                                               {% if page.is_watched_by(current_user) %}
+                                                                                       <a class="level-item" href="{{ os.path.join(page.url, "_unwatch") }}">
+                                                                                               <i class="fas fa-star" title="{{ _("Stop watching this page") }}"></i>
+                                                                                       </a>
+                                                                               {% else %}
+                                                                                       <a class="level-item" href="{{ os.path.join(page.url, "_watch") }}">
+                                                                                               <i class="far fa-star" title="{{ _("Watch this page") }}"></i>
+                                                                                       </a>
+                                                                               {% end %}
+                                                                       {% end %}
+
+                                                                       <a class="level-item" href="{% if page.author %}/users/{{ page.author.uid }}{% else %}#{% end %}">
+                                                                               {{ _("Last changed by %(author)s, %(when)s") % {
+                                                                                       "author" : page.author or _("an unknown author"),
+                                                                                       "when"   : locale.format_date(page.timestamp, shorter=True),
+                                                                               } }}
+                                                                       </a>
+
+                                                                       <a class="level-item" href="{{ request.path }}?action=revisions">
+                                                                               {{ _("View Older Revisions") }}
+                                                                       </a>
+                                                               </div>
+                                                       </div>
+                                               </small>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       {# Analytics #}
+       {% if current_user and current_user.is_admin() %}
+               <section class="hero is-dark">
+                       <div class="hero-body">
+                               <div class="container">
+                                       <h4 class="title is-4">{{ _("Analytics") }}</h4>
+
+                                       {% module AnalyticsSummary() %}
+                               </div>
+                       </div>
+               </section>
+       {% end %}
+{% end block %}
diff --git a/src/templates/docs/recent-changes.html b/src/templates/docs/recent-changes.html
new file mode 100644 (file)
index 0000000..46a2d4d
--- /dev/null
@@ -0,0 +1,33 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Recent Changes") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/docs">{{ _("Documentation") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Recent Changes") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Recent Changes") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% module DocsList(recent_changes, show_changes=True) %}
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/docs/revisions.html b/src/templates/docs/revisions.html
new file mode 100644 (file)
index 0000000..90f74ee
--- /dev/null
@@ -0,0 +1,13 @@
+{% extends "page.html" %}
+
+{% block title %}{{ _("Revisions of %s") % page.title }}{% end block %}
+
+{% block main %}
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ _("Revisions of %s") % page.title }}</h4>
+
+                       {% module DocsList(page.get_revisions(), show_breadcrumbs=False, link_revision=True, show_changes=True) %}
+               </div>
+       </section>
+{% end block %}
\ No newline at end of file
diff --git a/src/templates/docs/search-results.html b/src/templates/docs/search-results.html
new file mode 100644 (file)
index 0000000..cb27676
--- /dev/null
@@ -0,0 +1,42 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Search results for '%s'") % q }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Documentation") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("IPFire Documentation") }}</h1>
+
+                               {% if q %}
+                                       <h6 class="subtitle">
+                                               {{ _("Search Results for '%s'") % q }}
+                                       </h6>
+                               {% end %}
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% if q and not pages %}
+                               <div class="notification">
+                                       {{ _("No Results Found For '%s'") % q }}
+                               </div>
+                       {% end %}
+
+                       {% module DocsList(pages, show_author=False) %}
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/docs/tree.html b/src/templates/docs/tree.html
new file mode 100644 (file)
index 0000000..de43eea
--- /dev/null
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+
+{% block title %}{{ _("Tree") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">{{ _("Home") }}</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/docs">{{ _("Documentation") }}</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Tree") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Tree") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% module DocsList(pages, show_author=False) %}
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/docs/watchlist.html b/src/templates/docs/watchlist.html
new file mode 100644 (file)
index 0000000..e4e50d2
--- /dev/null
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+
+{% block title %}{{ _("Your Watchlist") }}{% end block %}
+
+{% block main %}
+       <div class="container">
+               <section class="section">
+                       <h5 class="title is-5">{{ _("Your Watchlist") }}</h5>
+
+                       {% if pages %}
+                               {% module DocsList(pages) %}
+                       {% else %}
+                               <div class="notification">
+                                       {{ _("You do not have any pages on your watchlist") }}
+                               </div>
+                       {% end %}
+               </section>
+       </div>
+{% end block %}
index ca7ca4031ea4f934a261f0b359a0b46f378066b9..414eaee52233214d8a4f9799f6f79b5247b24901 100644 (file)
 {% block container %}
        {% set amounts = (10, 25, 50, 75, 100, 250) %}
 
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1 class="display-2">{{ _("Donate") }}</h1>
-
-                                       <p>
-                                               {{ _("Please support our project with your donation today") }}
-                                       </p>
-                               </div>
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">Donate</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h1 class="title">
+                                       Donate
+                               </h1>
+                               <p class="subtitle">{{ _("Please support our project with your donation today") }}</p>
                        </div>
                </div>
        </section>
 
-       <section class="inverse">
+       <section class="section">
                <div class="container">
-                       <div class="row justify-content-center">
-                               <div class="col-12 col-md-8">
-                                       <form action="" method="POST">
+                       <div class="columns">
+                               <div class="column is-8">
+                                       <form id="donation-form" action="" method="POST">
                                                {% raw xsrf_form_html() %}
 
-                                               <div class="row">
-                                                       <div class="col-12">
-                                                               <h6>Frequency</h6>
+                                               <h5 class="title is-5">I am an ...</h5>
+
+                                               <input type="hidden" name="type" value="individual">
+
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <label class="radio">
+                                                                       <input class="custom-control-input" type="radio"
+                                                                               name="type-selector" id="type-individual" value="individual" checked>
+
+                                                                               {{ _("Individual") }}
+                                                               </label>
+                                                               <label class="custom-control-label" for="type-individual"></label>
+                                                               <label class="radio">
+                                                                       <input class="custom-control-input" type="radio"
+                                                                               name="type-selector" id="type-organization" value="organization">
+
+                                                                               {{ _("Organization") }}
+                                                               </label>
+                                                               <label class="custom-control-label" for="type-organization"></label>
+                                                       </div>
+                                               </div>
+
+                                               <h5 class="title is-5">Frequency</h5>
 
-                                                               <input type="hidden" name="frequency" value="{{ frequency }}">
+                                               <input type="hidden" name="frequency" value="{{ frequency }}">
 
-                                                               <div class="custom-control custom-radio custom-control-inline">
-                                                                       <input class="custom-control-input" type="radio" name="frequency-selector" id="frequency-one-time"
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <label class="radio">
+                                                                       <input class="form-check-input" type="radio" name="frequency-selector" id="frequency-one-time"
                                                                                value="one-time" {% if frequency == "one-time" %}checked{% end %}>
-                                                                       <label class="custom-control-label" for="frequency-one-time">{{ _("One Time") }}</label>
-                                                               </div>
 
-                                                               <div class="custom-control custom-radio custom-control-inline">
-                                                                       <input class="custom-control-input" type="radio" name="frequency-selector" id="frequency-monthly"
+                                                                       {{ _("One Time") }}
+                                                               </label>
+                                                               <label class="form-check-label" for="frequency-one-time"></label>
+
+                                                               <label class="radio">
+                                                                       <input class="form-check-input" type="radio" name="frequency-selector" id="frequency-monthly"
                                                                                value="monthly" {% if frequency == "monthly" %}checked{% end %}>
-                                                                       <label class="custom-control-label" for="frequency-monthly">{{ _("Monthly") }}</label>
-                                                               </div>
+
+                                                                       {{ _("Monthly") }}
+                                                               </label>
+                                                               <label class="form-check-label" for="frequency-monthly"></label>
                                                        </div>
                                                </div>
 
-                                               <div class="mt-5 mb-3 p-4 bg-light rounded">
-                                                       <h6>{{ _("Choose an amount") }}</h6>
+                                               <div class="block">
+                                                       <h5 class="title is-5">{{ _("Choose an amount") }}</h5>
+                                               </div>
 
-                                                       <input type="hidden" name="currency" value="{{ currency }}">
+                                               <input type="hidden" name="currency" value="{{ currency }}">
+
+                                               <div class="block">
+                                                       <div class="buttons" data-toggle="buttons">
+                                                               <p class="control">
+                                                                       {% for a in amounts %}
+                                                                               <label class="button is-outlined is-primary">
+                                                                                       <input type="radio" name="amount-selector" value="{{ a }}" {% if amount == a %}checked{% end %}
+                                                                                               autocomplete="off" class="is-hidden"> <span class="EUR">€</span><span class="USD">$</span>{{ a }}
+                                                                               </label>
+                                                                       {% end %}
+                                                               </p>
+                                                       </div>
+                                               </div>
 
-                                                       <div class="btn-group-toggle flex-wrap text-center mb-3" data-toggle="buttons">
-                                                               {% for a in amounts %}
-                                                                       <label class="btn btn-outline-primary btn-lg mb-2">
-                                                                               <input type="radio" name="amount-selector" value="{{ a }}" {% if amount == a %}checked{% end %}
-                                                                                       autocomplete="off"> <span class="EUR">€</span><span class="USD">$</span>{{ a }}
-                                                                       </label>
-                                                               {% end %}
+                                               <div class="block">
+                                                       <h6 class="title is-6">Or Enter your own</h6>
+
+                                                       <div class="columns">
+                                                               <div class="column is-3">
+                                                                       <div class="field">
+                                                                               <p class="control has-icons-left">
+                                                                                       <input type="number" class="input" name="amount" min="5" step="0.01"
+                                                                                       {% if amount %}value="{{ "%.2f" % amount }}"{% end %}>
+                                                                                       <span class="input-group-text EUR icon is-small is-left">
+                                                                                               <i class="fas fa-euro-sign"></i>
+                                                                                       </span>
+                                                                                       <span class="input-group-text USD icon is-small is-left">
+                                                                                               <i class="fas fa-dollar-sign"></i>
+                                                                                       </span>
+                                                                               </p>
+                                                                       </div>
+                                                               </div>
                                                        </div>
 
-                                                       <p class="text-center small">
-                                                               <a data-toggle="collapse" href="#more" role="button">
-                                                                       {{ _("More Options") }}
-                                                               </a>
+                                                       <p class="small text-end">
+                                                               <a class="toggleCurrency EUR" href="#">{{ _("Prefer donating in US Dollar?") }}</a>
+                                                               <a class="toggleCurrency USD" href="#">{{ _("Prefer donating in Euro?") }}</a>
                                                        </p>
+                                               </div>
 
-                                                       <div class="collapse" id="more">
-                                                               <div class="form-group row">
-                                                                       <label class="col-sm-5 col-form-label col-form-label-lg">
-                                                                               Enter your own
-                                                                       </label>
-
-                                                                       <div class="col-sm-7">
-                                                                               <div class="input-group input-group-lg">
-                                                                                       <div class="input-group-prepend">
-                                                                                               <span class="input-group-text EUR">€</span>
-                                                                                               <span class="input-group-text USD">$</span>
-                                                                                       </div>
-
-                                                                                       <input type="number" class="form-control form-control-lg" name="amount" min="5" step="0.01"
-                                                                                               {% if amount %}value="{{ "%.2f" % amount }}"{% end %}>
-                                                                               </div>
-                                                                       </div>
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <div class="organization organization-required">
+                                                                       <input type="text" class="input" name="organization"
+                                                                               placeholder="{{ _("Organization") }}">
                                                                </div>
-
-                                                               <p class="small text-right">
-                                                                       <a class="toggleCurrency EUR" href="#">{{ _("Prefer donating in US Dollar?") }}</a>
-                                                                       <a class="toggleCurrency USD" href="#">{{ _("Prefer donating in Euro?") }}</a>
+                                                       </div>
+                                               </div>
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <div class="organization">
+                                                                       <input type="text" class="input" name="vat_number"
+                                                                               placeholder="{{ _("VAT Number") }}">
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                               <div class="block">
+                                                       <div class="organization">
+                                                               <p class="is-size-6">
+                                                                       If you are an organization in Europe and you are VAT-registered,
+                                                                       please provide your VAT number here.
+                                                                       It will make your donation VAT free (if applicable) and the project
+                                                                       will benefit from more of your help
+                                                                       Please see the FAQ below for more details about VAT.
                                                                </p>
                                                        </div>
+                                               </div>
 
-                                                       <div class="row my-5">
-                                                               <div class="col d-flex flex-column">
-                                                                       <div class="form-row">
-                                                                               <div class="col">
-                                                                                       <div class="custom-control custom-radio custom-control-inline mb-3">
-                                                                                               <input class="custom-control-input" type="radio" name="title" id="mr" value="Mr." checked>
-                                                                                               <label class="custom-control-label" for="mr">{{ _("Mr.") }}</label>
-                                                                                       </div>
-                                                                                       <div class="custom-control custom-radio custom-control-inline mb-3">
-                                                                                               <input class="custom-control-input" type="radio" name="title" id="mrs" value="Mrs.">
-                                                                                               <label class="custom-control-label" for="mrs">{{ _("Mrs.") }}</label>
-                                                                                       </div>
-                                                                               </div>
-                                                                       </div>
+                                               <div class="columns">
+                                                       <div class="column is-2">
+                                                               <div class="control">
+                                                                       <input class="form-check-input" type="radio" name="title" id="mr" value="Mr." checked>
+                                                                       <label class="form-check-label" for="mr">{{ _("Mr.") }}</label>
+                                                               </div>
+                                                       </div>
+                                                       <div class="column is-2">
+                                                               <div class="control">
+                                                                       <input class="form-check-input" type="radio" name="title" id="mrs" value="Mrs.">
+                                                                       <label class="form-check-label" for="mrs">{{ _("Mrs.") }}</label>
+                                                               </div>
+                                                       </div>
+                                               </div>
 
-                                                                       <div class="form-row">
-                                                                               <div class="col-sm-6">
-                                                                                       <div class="form-group">
-                                                                                               <input type="text" class="form-control" name="first_name"
-                                                                                                       placeholder="{{ _("First Name" )}}" required
-                                                                                                       {% if first_name %}value="{{ first_name }}"{% end %}>
-                                                                                       </div>
-                                                                               </div>
-
-                                                                               <div class="col-sm-6">
-                                                                                       <div class="form-group">
-                                                                                               <input type="text" class="form-control" name="last_name"
-                                                                                                       placeholder="{{ _("Last Name" )}}" required
-                                                                                                       {% if last_name %}value="{{ last_name }}"{% end %}>
-                                                                                       </div>
-                                                                               </div>
-                                                                       </div>
+                                               <div class="columns">
+                                                       <div class="column">
+                                                               <div class="control">
+                                                                       <input type="text" class="input" name="first_name"
+                                                                               placeholder="{{ _("First Name" )}}" required
+                                                                               {% if current_user %}value="{{ current_user.first_name }}"{% end %}>
+                                                               </div>
+                                                       </div>
 
-                                                                       <div class="form-group">
-                                                                               <input type="email" class="form-control" name="email"
-                                                                                       placeholder="{{ _("Email Address") }}" required>
-                                                                       </div>
+                                                       <div class="column">
+                                                               <div class="control">
+                                                                       <input type="text" class="input" name="last_name"
+                                                                               placeholder="{{ _("Last Name" )}}" required
+                                                                               {% if current_user %}value="{{ current_user.last_name }}"{% end %}>
+                                                               </div>
+                                                       </div>
+                                               </div>
 
-                                                                       <div class="form-group">
-                                                                               <input type="text" class="form-control" name="street1"
-                                                                                       placeholder="{{ _("Address Line 1") }}" required>
-                                                                       </div>
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <input type="email" class="input" name="email"
+                                                                       placeholder="{{ _("Email Address") }}" required
+                                                                       {% if current_user %}value="{{ current_user.email }}"{% end %}>
+                                                       </div>
+                                               </div>
 
-                                                                       <div class="form-group">
-                                                                               <input type="text" class="form-control" name="street2"
-                                                                                       placeholder="{{ _("Address Line 2") }}">
-                                                                       </div>
+                                               {% set lines = current_user.street.splitlines() if current_user and current_user.street else [] %}
 
-                                                                       <div class="form-row">
-                                                                               <div class="col-sm-6">
-                                                                                       <div class="form-group">
-                                                                                               <input type="text" class="form-control" name="city"
-                                                                                                       placeholder="{{ _("City") }}" required>
-                                                                                       </div>
-                                                                               </div>
-
-                                                                               <div class="col-sm-6">
-                                                                                       <div class="form-group">
-                                                                                               <input type="text" class="form-control" name="post_code"
-                                                                                                       placeholder="{{ _("Post Code") }}" required>
-                                                                                       </div>
-                                                                               </div>
-                                                                       </div>
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <input type="text" class="input" name="street1"
+                                                                       placeholder="{{ _("Address Line 1") }}" required
+                                                                       {% if lines %}value="{{ lines[0] }}"{% end %}>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <input type="text" class="input" name="street2"
+                                                                       placeholder="{{ _("Address Line 2") }}"
+                                                                       {% if lines and len(lines) > 1 %}value="{{ lines[1] }}"{% end %}>
+                                                       </div>
+                                               </div>
 
-                                                                       <div class="form-row">
-                                                                               <div class="col-sm-6">
-                                                                                       <div class="form-group">
-                                                                                               <select class="form-control" name="country_code" required>
-                                                                                                       <option value="">- {{ _("Country") }} -</option>
-
-                                                                                                       {% for c in countries %}
-                                                                                                               <option value="{{ c.alpha2 }}" {% if country == c.alpha2 %}selected{% end %}>{{ c.name }}</option>
-                                                                                                       {% end %}
-                                                                                               </select>
-                                                                                       </div>
-                                                                               </div>
-
-                                                                               <div class="col-sm-6">
-                                                                                       <div class="form-group">
-                                                                                               <input type="text" class="form-control" name="state"
-                                                                                                       placeholder="{{ _("State (optional)") }}">
-                                                                                       </div>
-                                                                               </div>
+                                               <div class="columns">
+                                                       <div class="column">
+                                                               <div class="control">
+                                                                       <input type="text" class="input" name="city"
+                                                                               placeholder="{{ _("City") }}" required
+                                                                               {% if current_user %}value="{{ current_user.city }}"{% end %}>
+                                                               </div>
+                                                       </div>
+                                                       <div class="column">
+                                                               <div class="control">
+                                                                       <input type="text" class="input" name="post_code"
+                                                                               placeholder="{{ _("Post Code") }}" required
+                                                                               {% if current_user %}value="{{ current_user.postal_code }}"{% end %}>
+                                                               </div>
+                                                       </div>
+                                               </div>
+
+                                               <div class="columns">
+                                                       <div class="column is-half">
+                                                               <div class="control">
+                                                                       <div class="select">
+                                                                               <select name="country_code" required>
+                                                                                       <option value="">- {{ _("Country") }} -</option>
+
+                                                                                       {% for c in countries %}
+                                                                                               <option value="{{ c.alpha2 }}" {% if country == c.alpha2 %}selected{% end %}>{{ c.name }}</option>
+                                                                                       {% end %}
+                                                                               </select>
                                                                        </div>
+                                                               </div>
+                                                       </div>
 
-                                                                       <input type="submit" class="btn btn-primary btn-lg"
-                                                                               id="donate" value="{{ _("Donate Now") }}">
+                                                       <div class="column">
+                                                               <div class="control">
+                                                                       <input type="text" class="input" name="state"
+                                                                               placeholder="{{ _("State (optional)") }}">
                                                                </div>
                                                        </div>
+                                               </div>
 
-                                                       <div class="row">
-                                                               <div class="col d-flex justify-content-around text-muted">
+                                               <div class="block">
+                                                       <div class="control">
+                                                               <button type="submit" class="button is-primary is-fullwidth is-medium" id="donate">
+                                                                       {{ _("Donate Now") }}
+                                                               </button>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <div class="level">
+                                                               <div class="level-item has-text-centered">
                                                                        <span class="pf pf-2x pf-american-express"></span>
+                                                               </div>
+                                                               <div class="level-item has-text-centered">
                                                                        <span class="pf pf-2x pf-mastercard-alt"></span>
+                                                               </div>
+                                                               <div class="level-item has-text-centered">
                                                                        <span class="pf pf-2x pf-visa"></span>
+                                                               </div>
+                                                               <div class="level-item has-text-centered">
                                                                        <span class="pf pf-2x pf-sepa"></span>
-                                                                       <span class="pf pf-2x pf-paypal"></span>
                                                                </div>
-                                                       </div>
-
-                                                       <div class="modal fade" tabindex="-1" role="dialog" id="modal-monthly-suggestions" aria-hidden="true">
-                                                               <div class="modal-dialog modal-dialog-centered" role="document">
-                                                                       <div class="modal-content">
-                                                                               <div class="modal-header">
-                                                                                       <h5 class="modal-title">{{ _("Would you like to support us monthly?") }}</h5>
-
-                                                                                       <button type="button" class="close" data-dismiss="modal" aria-label="{{ _("Close") }}">
-                                                                                               <span aria-hidden="true">&times;</span>
-                                                                                       </button>
-                                                                               </div>
-
-                                                                               <div class="modal-body">
-                                                                                       <p class="mb-4">
-                                                                                               {{ _("Long-term donations are an even better investment into our project, create lasting change for everyone who is working on the project and make IPFire even better!") }}
-                                                                                       </p>
-
-                                                                                       <div class="row mb-4">
-                                                                                               {% for factor, default in ((3, False), (2, True), (1, False)) %}
-                                                                                                       <div class="col d-flex align-items-center">
-                                                                                                               <button type="submit"
-                                                                                                                               class="btn btn-primary btn-block {% if default %}btn-lg{% else %}btn-sm{% end %} monthly-amount-suggestion"
-                                                                                                                               data-factor="{{ factor }}" data-amount="">
-                                                                                                                       <span class="EUR">€</span><span class="USD">$</span><span class="suggested-amount"></span>
-                                                                                                                       <br>
-                                                                                                                       <small>/ {{ _("month") }}</small>
-                                                                                                               </button>
-                                                                                                       </div>
-                                                                                               {% end %}
-                                                                                       </div>
-
-                                                                                       <button type="submit" class="btn btn-secondary btn-block">
-                                                                                               Donate <span class="EUR">€</span><span class="USD">$</span><span class="amount"></span> once
-                                                                                       </button>
-                                                                               </div>
-                                                                       </div>
+                                                               <div class="level-item has-text-centered">
+                                                                       <span class="pf pf-2x pf-paypal"></span>
                                                                </div>
                                                        </div>
                                                </div>
 
-                                               <p class="small text-muted mb-0">
+                                               <p class="is-size-6">
                                                        The organization you will be donating to is Lightning Wire Labs GmbH who is
                                                        kindly handling donations for the IPFire project.
                                                        After clicking "Donate Now", you will be redirected to Lightning Wire Labs
                </div>
        </section>
 
-       <section>
+       <section class="section">
                <div class="container">
-                       <div class="row justify-content-center">
-                               <div class="col-12 col-md-8">
-                                       <h5>Why should I donate?</h5>
+                       <div class="columns">
+                               <div class="column is-8">
+                                       <div class="block">
+                                               <h5 class="title is-5">Why should I donate?</h5>
 
-                                       <p>
-                                               At IPFire, we are working hard to provide you with a free firewall distribution
-                                               that is like no other. We release updates regularly and enhance functionality
-                                               to make IPFire more secure, faster and easier to use.
-                                       </p>
+                                               <p>
+                                                       At IPFire, we are working hard to provide you with a free firewall distribution
+                                                       that is like no other. We release updates regularly and enhance functionality
+                                                       to make IPFire more secure, faster and easier to use.
+                                               </p>
 
-                                       <p>
-                                               To achieve our high standards that we have set for ourselves, we need your help.
+                                               <p>
+                                                       To achieve our high standards that we have set for ourselves, we need your help.
 
-                                               Only with your donation, we can get the right tools, people and utilities that
-                                               we need to make our work most efficient and reach our maximum potential.
+                                                       Only with your donation, we can get the right tools, people and utilities that
+                                                       we need to make our work most efficient and reach our maximum potential.
 
-                                               Only with your donation, we can achieve our vision to make the Internet a safer
-                                               place that is fair for everyone and giving equal opportunities.
-                                       </p>
+                                                       Only with your donation, we can achieve our vision to make the Internet a safer
+                                                       place that is fair for everyone and giving equal opportunities.
+                                               </p>
+                                       </div>
 
-                                       <h5>How much should I give?</h5>
+                                       <div class="block">
+                                               <h5 class="title is-5">How much should I give?</h5>
 
-                                       <p>
-                                               We are grateful for every single donation, but of course, we appreciate
-                                               if you help us as much as you can.
-                                       </p>
+                                               <p>
+                                                       We are grateful for every single donation, but of course, we appreciate
+                                                       if you help us as much as you can.
+                                               </p>
 
-                                       <p>
-                                               We rely on steady contributions from companies to keep the project healthy
-                                               and encourage them to set up a monthly donation.
-                                       </p>
+                                               <p>
+                                                       We rely on steady contributions from companies to keep the project healthy
+                                                       and encourage them to set up a monthly donation.
+                                               </p>
+                                       </div>
 
                                        <div class="faq">
-                                               <h5>Frequently Asked Questions</h5>
+                                               <h5 class="title is-5">Frequently Asked Questions</h5>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-bank-transfer">
-                                                               Do you accept bank transfer via SEPA?
-                                                       </a>
-                                               </p>
+                                               <div class="block">
+                                                       <h6 class="title is-6">
+                                                                       Do you accept bank transfer via SEPA?
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-bank-transfer">
                                                        <p>
                                                                We do accept direct transfers through SEPA. These are our bank details:
                                                        </p>
                                                        </dl>
                                                </div>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-address">
+                                               <div class="block">
+                                                       <h6 class="title is-6">
                                                                Why do you need my address in order to process a donation?
-                                                       </a>
-                                               </p>
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-address">
                                                        <p>
                                                                We understand that your privacy is very important.
                                                        </p>
                                                        </p>
                                                </div>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-credit-card">
+                                               <div class="block">
+                                                       <h6 class="title is-6">
                                                                I am concerned about losing my credit card details
-                                                       </a>
-                                               </p>
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-credit-card">
                                                        <p>
                                                                Credit card fraud is a serious and common problem.
                                                                To avoid that you and we fall victim of that, we comply with
                                                        </p>
                                                </div>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-tax-deduction">
+                                               <div class="block">
+                                                       <h6 class="title is-6">
                                                                Is my donation tax deductible?
-                                                       </a>
-                                               </p>
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-tax-deduction">
                                                        <p>
                                                                IPFire is not registered as a charitable organization and therefore donations
                                                                are not tax deductible in Germany or the European Union.
                                                        </p>
                                                </div>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-cc">
+                                               <div class="block">
+                                                       <h6 class="title is-6">
                                                                What will be charged to my credit card?
-                                                       </a>
-                                               </p>
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-cc">
                                                        <p>
                                                                We are based in Germany. Donations by credit card from other countries
                                                                may be subject to an international payment fee charged by your credit card issuer.
                                                        </p>
                                                </div>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-cancel">
+                                               <div class="block">
+                                                       <h6 class="title is-6">
                                                                How do I cancel or change my recurring donation?
-                                                       </a>
-                                               </p>
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-cancel">
                                                        <p>
                                                                Your credit card statements or bank statements will contain a link
                                                                that you can follow to cancel your donation at any time.
                                                        </p>
                                                </div>
 
-                                               <p>
-                                                       <a data-toggle="collapse" href="#faq-email">
+                                               <div class="block">
+                                                       <h6 class="title is-6">
                                                                Who can I email directly with questions about donating?
-                                                       </a>
-                                               </p>
+                                                       </h6>
 
-                                               <div class="collapse" id="faq-email">
                                                        <p>
                                                                If you have a question about donating to the IPFire Project,
                                                                please contact us at <a href="mailto:donate@ipfire.org">donate@ipfire.org</a>.
 {% block javascript %}
        <script type="text/javascript">
                $(document).ready(function() {
+                       var type      = $("input[name='type']");
+                       var vat_number= $("input[name='vat_number']");
                        var amount    = $("input[name='amount']");
                        var currency  = $("input[name='currency']");
                        var frequency = $("input[name='frequency']");
                        var submit    = $("#donate");
-                       var modal     = $("#modal-monthly-suggestions");
+                       var form      = $("#donation-form");
+
+                       // A cache for VAT numbers
+                       var vat_numbers = {};
+                       var check_vat_number;
 
                        // Adjust form to default currency
                        if (currency.val() == "EUR") {
                        }
 
                        if (amount.val()) {
-                               $("#more").collapse("show");
+                               //$("#more").collapse("show");
                                amount.change();
                        }
 
+                       // Copy selected type
+                       $("input[name='type-selector']").on("change", function() {
+               var value = $(this).val();
+                               if (value) {
+                                               type.val(value);
+                                               type.change();
+                               }
+                       });
+
+                       //  Update Organization / Individual on radio-click
+                       type.on("change", function() {
+                                var value = $(this).val();
+
+                               if (value == "individual") {
+                                       $(".organization").hide();
+                                       $(".organization-required").find("input").prop("required", false);
+
+                                       $(".vat-included").show();
+                                       $(".vat-excluded").hide();
+                               } else if (value == "organization") {
+                                       $(".organization").show();
+                                       $(".organization-required").find("input").prop("required", true);
+
+                                       $(".vat-included").hide();
+                                       $(".vat-excluded").show();
+                               }
+                       });
+
                        $(".toggleCurrency").click(function(event) {
                                event.preventDefault();
 
                                }
                        });
 
+
                        // Copy amount when clicking on a radio buttons
                        $("input[name='amount-selector']").on("change", function() {
                                var value = $(this).val();
                                        // Check matching elements
                                        $("input[name='amount-selector']").each(function (i, selector) {
                                                var s = $(selector);
-                                               var b = s.parent(".btn");
+                                               var b = s.parent(".button");
                                                var v = parseFloat(s.val());
 
                                                if (value == v) {
-                                                       b.addClass("active");
+                                                       b.removeClass("is-outlined");
                                                } else {
-                                                       b.removeClass("active");
+                                                       b.addClass("is-outlined");
                                                }
                                        });
 
                                        // Update all amounts
                                        $(".amount").html(value);
+                               }
+                       });
 
-                                       // Update suggestions
-                                       $(".monthly-amount-suggestion").each(function (i, element) {
-                                               var factor = $(element).data("factor");
-                                               if (!factor)
-                                                       return;
+                       // Check VAT numbers
+                       vat_number.on("change keyup mouseup", function() {
+                               if (check_vat_number)
+                                       clearTimeout(check_vat_number);
 
-                                               var suggested_amount = Math.floor(value / factor);
+                               var input = $(this);
+                               var value = input.val();
 
-                                               // Update value
-                                               $(element).data("amount", suggested_amount);
+                               console.log(vat_numbers);
 
-                                               // Update text on button
-                                               $(element).find(".suggested-amount").html(suggested_amount);
-                                       });
-                               }
-                       });
+                               // Fetch the parent control element
+                               var control = input.closest(".control");
 
-                       submit.click(function (event) {
-                               if (frequency.val() == "one-time" && amount.val() > 10) {
-                                       event.preventDefault();
+                               // Remove all classes
+                               input.removeClass("is-success is-danger");
 
-                                       modal.modal("show");
-                               }
-                       });
+                               // Do nothing if there is no data
+                               if (!value)
+                                       return;
 
-                       $(".monthly-amount-suggestion").click(function (event) {
-                               // Set frequency to monthly
-                               $("input[name='frequency-selector']").prop("checked", false);
-                               $("input[name='frequency-selector'][value='monthly']").prop("checked", true);
-                               frequency.val("monthly");
+                               // Show that this is now processing
+                               control.addClass("is-loading");
 
-                               // Set amount
-                               var value = $(this).data("amount");
-                               amount.val(value);
-                               amount.change();
+                               // Called when we have an API response
+                               var finished = function(result) {
+                                       // We are no longer processing
+                                       control.removeClass("is-loading");
+
+                                       if (result.valid) {
+                                               input.addClass("is-success");
+                                       } else {
+                                               input.addClass("is-danger");
+                                       }
+
+                                       // Cache the result if not already done do
+                                       if (!vat_numbers[value])
+                                               vat_numbers[value] = result;
 
-                               // Hide the modal
-                               modal.modal("hide");
+                                       form.trigger("change");
+                               };
+
+                               console.log(vat_numbers[value]);
+
+                               // Deliver there cached result if available
+                               if (vat_numbers[value])
+                                       return finished(vat_numbers[value]);
+
+                               // Send API request after 250ms to avoid hammering it and running into the ratelimiting
+                               setTimeout(function() {
+                                       $.get("/donate/check-vat-number", { vat_number : value }, finished);
+                               }, 250);
                        });
 
+                       // Disable all submit buttons after the form has been submitted
+                       form.one("submit", function() {
+                               // Show that something is happening...
+                               submit.addClass("is-loading");
+
+                               // Prevent the user from submitting the form again
+                               submit.prop("disabled", true);
+                       });
+
+                       // Update everything depending on type
+                       type.change();
+
                        // Update form with initial amount
                        amount.change();
                });
index 59209910a53f87039a3c5c4f0362e6f312466dbc..3bbd6017e3140f5475ec0d4919dd3e1f018390d4 100644 (file)
@@ -22,4 +22,4 @@ X-Auto-Response-Suppress: OOF
 {{ _("On behalf of the whole team behind IPFire: Thank you!") }}
 
 --
-Don't like these emails? https://people.ipfire.org/unsubscribe
+Don't like these emails? https://www.ipfire.org/unsubscribe
index c55ac8b940ecd85d916339b3a334401e4533ed12..995ec20dd90f7a7b5862bbe9d1d3689996d03d0c 100644 (file)
@@ -1,46 +1,43 @@
 {% extends "../../messages/base-promo.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Dear %s,") % account.first_name }}</strong>
-    </p>
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Hello %s,") % account.first_name }}</h1>
 
-    <p>
-        {{ _("At IPFire, we’ve been compiling our Christmas wish list to send to the North Pole, working to make sure we can support the continued development of the project over the next year.") }}
-    </p>
+                       <p>
+                               Just like the last couple of years, 2023 has not been without it’s challenges. Here
+                               at IPFire, we’re looking back at the year and highlighting the positivity we’ve had
+                               from our friends and colleagues across the globe and the progress we've made on
+                               our project.
+                       </p>
 
-    <p>
-        {{ _("2020 has been an incredibly challenging year for all of us in the Open Source community and your donation makes a huge difference, enabling us to achieve the milestones we’ve set out in our 12 month plan.") }}
-    </p>
+                       <p>
+                               We want to spread positivity and joy so why not get in touch on
+                               Mastodon (https://social.ipfire.org/@news), Twitter (https://twitter.com/ipfire) or
+                               LinkedIn (https://www.linkedin.com/company/ipfire) and share your highlights of the year
+                               or why you like working with IPFire?
+                               We’ll be sharing and retweeting some of our favourites in the weeks to come.
+                       </p>
 
-    <p>
-        {{ _("Please click below to help IPFire with a donation this Christmas:") }}
-    </p>
+                       <p>
+                               If you really liked working with IPFire this year, why not consider donating to our project?
+                       </p>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                               <a class="primary" href="https://www.ipfire.org/donate?utm_medium=email&amp;utm_campaign=christmas-1">
+                                                       {{ _("Donate") }}
+                                               </a>
+                                       </td>
+                               </tr>
+                       </table>
 
-    <p>
-        {{ _("We hope you keep safe and well in the lead up to the festive season.") }}
-    </p>
-
-    <p>
-        {{ _("Best,") }}
-        <br>{{ _("-Your IPFire Team") }}
-    </p>
+                       <p>
+                               {{ _("All the best,") }}
+                               <br>{{ _("-Your IPFire Team") }}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
index a8c1f998af7364f1792abb1b2cb822378bf075a7..39704d0d5776cb61d49507c1e14835b7c7ff4618 100644 (file)
@@ -1,23 +1,28 @@
 From: IPFire Project <no-reply@ipfire.org>
 To: {{ account.email_to }}
-Subject: {{ _("Dear Santa...") }}
+Subject: {{ _("IPFire Highlights of 2023") }}
 Precedence: bulk
 X-Auto-Response-Suppress: OOF
 
-{{ _("Dear %s,") % account.first_name }}
+{{ _("Hello %s,") % account.first_name }}
 
-{{ _("At IPFire, we’ve been compiling our Christmas wish list to send to the North Pole, working to make sure we can support the continued development of the project over the next year.") }}
+Just like the last couple of years, 2023 has not been without it’s challenges. Here
+at IPFire, we’re looking back at the year and highlighting the positivity we’ve had
+from our friends and colleagues across the globe and the progress we've made on
+our project.
 
-{{ _("2020 has been an incredibly challenging year for all of us in the Open Source community and your donation makes a huge difference, enabling us to achieve the milestones we’ve set out in our 12 month plan.") }}
+We want to spread positivity and joy so why not get in touch on
+Mastodon (https://social.ipfire.org/@news), Twitter (https://twitter.com/ipfire) or
+LinkedIn (https://www.linkedin.com/company/ipfire) and share your highlights of the year
+or why you like working with IPFire?
+We’ll be sharing and retweeting some of our favourites in the weeks to come.
 
-{{ _("Please click below to help IPFire with a donation this Christmas:") }}
+If you really liked working with IPFire this year, why not consider donating to our project?
 
- https://www.ipfire.org/donate
+ https://www.ipfire.org/donate?utm_medium=email&utm_campaign=christmas-1
 
-{{ _("We hope you keep safe and well in the lead up to the festive season.") }}
-
-{{ _("Best,") }}
+{{ _("All the best,") }}
 -{{ _("Your IPFire Team") }}
 
 --
-{{ _("Don't like these emails?") }} https://people.ipfire.org/unsubscribe
+{{ _("Don't like these emails?") }} https://www.ipfire.org/unsubscribe
index 72c57a9d19ee9ef8446b0ed53a6f3b4ae58e61bb..6d497e304dd5a948e56936f4c6b260c57ff0f6aa 100644 (file)
@@ -1,45 +1,48 @@
 {% extends "../../messages/base-promo.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hello %s,") % account.first_name }}</strong>
-    </p>
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Hi %s,") % account.first_name }}</h1
 
-    <p>
-        {{ _("In the lead up to Christmas, we wanted to share where your donations have lent support over the last year.") }}
-    </p>
+                       <p>
+                               For so many people around the world, the end of the calendar year is party season and
+                               at IPFire we’re celebrating as much as anyone else. Why are we celebrating?
+                               We’re celebrating what’s to come next year, of course!
+                       </p>
 
-    <ul>
-        <li>{{ _("Funding for development and extending our skills") }}</li>
-        <li>{{ _("Donations pay for our hosting") }}</li>
-        <li>{{ _("Additional new features such as IPFire Location and Safe Search") }}</li>
-        <li>{{ _("Secures the longevity of the project") }}</li>
-    </ul>
+                       <p>
+                               Thanks to some super-hardwork by our team in 2023, over the next 12 months, you’ll see
+                               the following:
+                       </p>
 
-    <p>
-        {{ _("When you think about your Christmas gifting this year, we hope you’ll consider a donation to the IPFire project. Click here to share your support:") }}
-    </p>
+                       <ul>
+                               <li>Relaunch of the IPFire website, helping us promote our mission and improve our outreach.</li>
+                               <li>Improve usability by making documentation and user guide more accessible.</li>
+                               <li>Helping you, the users, to contribute your guidance to the IPFire Wiki and make
+                                       suggestions for improvements to our code.</li>
+                       </ul>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <p>
+                               Of course, we’d not be able to do any of this without feedback from our huge user base
+                               and our fantastic development team. If you’d like to help our development team towards
+                               their goals for next year, you can donate here:
+                       </p>
 
-    <p>
-        {{ _("All the best,") }}
-        <br>{{ _("-Your IPFire Team") }}
-    </p>
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                               <a class="primary" href="https://www.ipfire.org/donate?utm_medium=email&amp;utm_campaign=christmas-2">
+                                                       {{ _("Donate") }}
+                                               </a>
+                                       </td>
+                               </tr>
+                       </table>
+
+                       <p>
+                               {{ _("Party, party, party!") }}
+                               <br>{{ _("-Your IPFire Team") }}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
index 3b38418182b5f2b19c2e6307fe4fa539539a5d4e..061d27d6e01eba3defea56fdd626196182ec626f 100644 (file)
@@ -1,24 +1,31 @@
 From: IPFire Project <no-reply@ipfire.org>
 To: {{ account.email_to }}
-Subject: {{ _("We’re dreaming of a #FFFFFF Christmas") }}
+Subject: {{ _("December is Party Season!") }}
 Precedence: bulk
 X-Auto-Response-Suppress: OOF
 
-{{ _("Hello %s,") % account.first_name }}
+{{ _("Hi %s,") % account.first_name }}
 
-{{ _("In the lead up to Christmas, we wanted to share where your donations have lent support over the last year.") }}
+For so many people around the world, the end of the calendar year is party season and
+at IPFire we’re celebrating as much as anyone else. Why are we celebrating?
+We’re celebrating what’s to come next year, of course!
 
-* {{ _("Funding for development and extending our skills") }}
-* {{ _("Donations pay for our hosting") }}
-* {{ _("Additional new features such as IPFire Location and Safe Search") }}
-* {{ _("Secures the longevity of the project") }}
+Thanks to some super-hardwork by our team in 2023, over the next 12 months, you’ll see
+the following:
 
-{{ _("When you think about your Christmas gifting this year, we hope you’ll consider a donation to the IPFire project. Click here to share your support:") }}
+  * Relaunch of the IPFire website, helping us promote our mission and improve our outreach.
+  * Improve usability by making documentation and user guide more accessible.
+  * Helping you, the users, to contribute your guidance to the IPFire Wiki and make
+    suggestions for improvements to our code.
 
-  https://www.ipfire.org/donate
+Of course, we’d not be able to do any of this without feedback from our huge user base
+and our fantastic development team. If you’d like to help our development team towards
+their goals for next year, you can donate here:
 
-{{ _("All the best,") }}
+  https://www.ipfire.org/donate?utm_medium=email&utm_campaign=christmas-2
+
+Party, party, party!
 -{{ _("Your IPFire Team") }}
 
 --
-{{ _("Don't like these emails?") }} https://people.ipfire.org/unsubscribe
+{{ _("Don't like these emails?") }} https://www.ipfire.org/unsubscribe
index 09dedefdbc8f225db6391b0b171e29ef3cd7d3de..03c44e607463a1634a9009e541d41313d64d42a0 100644 (file)
@@ -1,33 +1,48 @@
 {% extends "../../messages/base-promo.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hi %s,") % account.first_name }}</strong>
-    </p>
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Dear %s,") % account.first_name }}</h1>
 
-    <p>
-        {{ _("with only days to go, shopping being completed, presents being wrapped, and the tree being decorated, would you consider one of the presents at the bottom of your tree to be for IPFire?") }}
-    </p>
+                       <p>
+                               The year has drawn to an end and we just wanted to take a moment to say thank you.
+                               Your support for the IPFire project is hugely appreciated and we’re grateful that you’ve
+                               taken the time to be part of our ever-growing community.
+                       </p>
 
-    <p>
-        {{ _("Your donation helps us making IPFire more amazing.") }}
-    </p>
+                       <p>
+                               We’re celebrating our best year yet including the recent success of our developer summit
+                               where we made huge progress on the build system for IPFire 3. This isn’t all that happened
+                               in 2023 though! We had may contributors who helped us to release 10 updates to
+                               millions of users across every continent.
+                               Yes, even our penguin friends in the antarctic are using IPFire!
+                       </p>
 
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate?frequency=monthly&amp;amount=10" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
+                       <p>
+                               We couldn’t have done this without hundreds of donations just like yours. If you’re feeling
+                               the love and you can spare a little money, help us start 2024 with a bang!
+                       </p>
+
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                               <a class="primary" href="https://www.ipfire.org/donate?utm_medium=email&amp;utm_campaign=christmas-3">
+                                                       {{ _("Donate") }}
+                                               </a>
+                                       </td>
+                               </tr>
+                       </table>
+
+                       <p>
+                               Finally, whatever you’re celebrating at the end of this year, we wish you all the best
+                               for the festive season.
+                       </p>
+
+                       <p>
+                               {{ _("Thank you,") }}
+                               <br>{{ _("-Your IPFire Team") }}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
index 96ddade09bfd01b4ab85ccace002140791092e11..e5902bc1944bc3b71de71f2339ee52a7d86c7c32 100644 (file)
@@ -1,19 +1,31 @@
 From: IPFire Project <no-reply@ipfire.org>
 To: {{ account.email_to }}
-Subject: {{ _("’tis the season to be jolly") }}
+Subject: {{ _("Seasons Greetings!") }}
 Precedence: bulk
 X-Auto-Response-Suppress: OOF
 
-{{ _("Hello %s,") % account.first_name }}
+{{ _("Hi %s,") % account.first_name }}
 
-{{ _("With not long to go until the Christmas break and preparations well under way, would you consider gifting a donation to the IPFire project?") }}
+The year has drawn to an end and we just wanted to take a moment to say thank you.
+Your support for the IPFire project is hugely appreciated and we’re grateful that you’ve
+taken the time to be part of our ever-growing community.
 
-{{ _("Your donation makes a huge difference and allows for continued development of the project:") }}
+We’re celebrating our best year yet including the recent success of our developer summit
+where we made huge progress on the build system for IPFire 3. This isn’t all that happened
+in 2023 though! We had may contributors who helped us to release 10 updates to
+millions of users across every continent.
+Yes, even our penguin friends in the antarctic are using IPFire!
 
- https://www.ipfire.org/donate
-{{ _("Fa la la la la, la la la la!") }}
+We couldn’t have done this without hundreds of donations just like yours. If you’re feeling
+the love and you can spare a little money, help us start 2024 with a bang!
+
+  https://www.ipfire.org/donate?utm_medium=email&utm_campaign=christmas-3
+
+Finally, whatever you’re celebrating at the end of this year, we wish you all the best
+for the festive season.
+
+{{ _("Thank you,") }}
 -{{ _("Your IPFire Team") }}
 
 --
-{{ _("Don't like these emails?") }} https://people.ipfire.org/unsubscribe
+{{ _("Don't like these emails?") }} https://www.ipfire.org/unsubscribe
index 127bfcef692ca1634d3e61edd7354c4ba4317445..6c0eb754894ccf846c8c70472822db094be61326 100644 (file)
@@ -1,50 +1,46 @@
 {% extends "../../messages/base-promo.html" %}
 
 {% block content %}
-    <p>
-        <strong>{{ _("Hello %s,") % account.first_name }}</strong>
-    </p>
-
-    <p>
-        {{ _("Merry Christmas, Fröhliche Weihnachten, Joyeux Noël, Feliz Navidad, 01001101 01100101 01110010 01110010 01111001 00100000 01000011 01101000 01110010 01101001 01110011 01110100 01101101 01100001 01110011") }}
-    </p>
-
-    <p>
-               {{ _("A huge thank you to all of our financial contributors for your donations this year - they have helped with improvements to the day to day use of IPFire and these changes couldn’t have been made without you.") }}
-    </p>
-
-    <p>
-        {{ _("To my fellow team members and contributors, thank you for your support in the last year. Despite a year which has been challenging for us all, we’ve made a huge amount of progress.") }}
-    </p>
-
-    <p>
-        {{ _("I would like to wish everyone a happy, safe and peaceful holiday season and look forward to continuing our work in the new year.") }}
-    </p>
-
-    <p>
-        {{ _("If you haven’t donated yet, you can do so below:") }}
-    </p>
-
-       <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
-               <tbody>
-                       <tr>
-                               <td align="left">
-                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                               <tbody>
-                                                       <tr>
-                                                               <td>
-                                    <a href="https://www.ipfire.org/donate" target="_blank">{{ _("Donate Now") }}</a>
-                                </td>
-                                                       </tr>
-                                               </tbody>
-                                       </table>
-                               </td>
-                       </tr>
-               </tbody>
-       </table>
-
-    <p>
-        {{ _("Thank you once again and Merry Christmas,") }}
-        <br>{{ _("-Michael") }}
-    </p>
+       <tr class="section">
+               <td>
+                       <h1>{{ _("Hi %s,") % account.first_name }}</h1>
+
+                       <p>
+                               Did you see the highlights of 2023 we shared on our socials? If you didn’t, check our our
+                               Mastodon (https://social.ipfire.org/@news), Twitter (https://twitter.com/ipfire) and our
+                               LinkedIn (https://www.linkedin.com/company/ipfire) to see some of our favourite users
+                               stories sent in by our friends and colleagues using IPFire.
+                       </p>
+
+                       <p>
+                               I wanted to take the opportunity to thank you for all of your support in 2023 and thank you
+                               in advance for your continued support as we head into 2024. As you know, we’re the only
+                               remaining truly Open Source Linux-based firewall and it is important to us not just to
+                               continue to develop this great project, but also to remain Open Source and available to all.
+                               Our mission is to continue to provide the same level of service with continued enhancements
+                               and better usability and all of this requires the continued to support of our fantastic users.
+                       </p>
+
+                       <p>
+                               If you’d like to help us kick off 2024 the right way, please click the link where
+                               you can donate to our project. Every donation is appreciated as it contributes to the
+                               continued success of our project and helps us keep our software open to all.
+                       </p>
+
+                       <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                               <tr class="button">
+                                       <td>
+                                               <a class="primary" href="https://www.ipfire.org/donate?utm_medium=email&amp;utm_campaign=christmas-4">
+                                                       {{ _("Donate") }}
+                                               </a>
+                                       </td>
+                               </tr>
+                       </table>
+
+                       <p>
+                               {{ _("Happy New Year!")}}
+                               <br>{{ _("-Michael") }}
+                       </p>
+               </td>
+       </tr>
 {% end block %}
index 562ede0c428520439b52fc9b1a9a0835b5843701..c6bd221be62624e302bc4f48d215ae6a59420c52 100644 (file)
@@ -1,25 +1,31 @@
 From: Michael Tremer <michael.tremer@ipfire.org>
 To: {{ account.email_to }}
-Subject: {{ _("Merry Christmas!") }}
+Subject: {{ _("Wishing You a Happy and Prosperous New Year") }}
 Precedence: bulk
 X-Auto-Response-Suppress: OOF
 
-{{ _("Hello %s,") % account.first_name }}
+{{ _("Hi %s,") % account.first_name }}
 
-{{ _("Merry Christmas, Fröhliche Weihnachten, Joyeux Noël, Feliz Navidad, 01001101 01100101 01110010 01110010 01111001 00100000 01000011 01101000 01110010 01101001 01110011 01110100 01101101 01100001 01110011") }}
+Did you see the highlights of 2023 we shared on our socials? If you didn’t, check our our
+Mastodon (https://social.ipfire.org/@news), Twitter (https://twitter.com/ipfire) and our
+LinkedIn (https://www.linkedin.com/company/ipfire) to see some of our favourite users
+stories sent in by our friends and colleagues using IPFire.
 
-{{ _("A huge thank you to all of our financial contributors for your donations this year - they have helped with improvements to the day to day use of IPFire and these changes couldn’t have been made without you.") }}
+I wanted to take the opportunity to thank you for all of your support in 2023 and thank you
+in advance for your continued support as we head into 2024. As you know, we’re the only
+remaining truly Open Source Linux-based firewall and it is important to us not just to
+continue to develop this great project, but also to remain Open Source and available to all.
+Our mission is to continue to provide the same level of service with continued enhancements
+and better usability and all of this requires the continued to support of our fantastic users.
 
-{{ _("To my fellow team members and contributors, thank you for your support in the last year. Despite a year which has been challenging for us all, we’ve made a huge amount of progress.") }}
+If you’d like to help us kick off 2024 the right way, please click the link where
+you can donate to our project. Every donation is appreciated as it contributes to the
+continued success of our project and helps us keep our software open to all.
 
-{{ _("I would like to wish everyone a happy, safe and peaceful holiday season and look forward to continuing our work in the new year.") }}
+  https://www.ipfire.org/donate?utm_medium=email&utm_campaign=christmas-4
 
-{{ _("If you haven’t donated yet, you can do so below:") }}
-
-  https://www.ipfire.org/donate
-
-{{ _("Thank you once again and Merry Christmas,") }}
+{{ _("Happy New Year!")}}
 {{ _("-Michael") }}
 
 --
-{{ _("Don't like these emails?") }} https://people.ipfire.org/unsubscribe
+{{ _("Don't like these emails?") }} https://www.ipfire.org/unsubscribe
index 2b6d9a38c06853278f1297d14ab2a79373be265e..4a5256810a4db9df8326fafcd1eb114f51db6d4e 100644 (file)
@@ -3,16 +3,15 @@
 {% block title %}{{ _("Thank You!") }}{% end block %}
 
 {% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1 class="display-2">{{ _("Thank You") }}</h1>
-
-                                       <p>
-                                               {{ _("Thank you very much for your donation to the IPFire Project.") }}
-                                       </p>
-                               </div>
+       <section class="hero is-primary is-fullheight-with-navbar">
+               <div class="hero-body">
+                       <div class="container">
+                               <h1 class="title">
+                                       {{ _("Thank You") }}
+                               </h1>
+                               <p class="subtitle">
+                                       {{ _("Thank you very much for your donation to the IPFire Project.") }}
+                               </p>
                        </div>
                </div>
        </section>
diff --git a/src/templates/download/release.html b/src/templates/download/release.html
deleted file mode 100644 (file)
index 98fbcb7..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ release }}{% end block %}
-
-{% block container %}
-       <section>
-               <div class="container">
-                       <h6 class="mb-0">{{ _("Download") }}</h6>
-
-                       <h1 class="mb-0">{{ release }}</h1>
-
-                       <h6 class="mb-5">
-                               {{ _("Released %s") % locale.format_date(release.published, relative=True, shorter=True) }}
-
-                               {% if release.blog %}
-                                       &bull;
-                                       <a href="https://blog.ipfire.org/post/{{ release.blog.slug }}">{{ _("Release Notes") }}</a>
-                               {% end %}
-                       </h6>
-
-                       {% for arch in release.primary_arches %}
-                               <div class="my-5">
-                                       <h5>{{ arch }}</h5>
-
-                                       <ul class="list-group">
-                                               {% for file in release.get_files_by_arch(arch) %}
-                                                       <li class="list-group-item">
-                                                               <div class="d-flex w-100 justify-content-between">
-                                                                       <a href="{{ file.url }}">{{ _(file.desc) }}</a>
-
-                                                                       {% if file.size >= 1024 * 1024 %}
-                                                                               <span class="text-muted">{{ format_size(file.size) }}</span>
-                                                                       {% end %}
-                                                               </div>
-
-                                                               <ul class="list-inline text-muted small mb-0 d-none d-md-block">
-                                                                       <li class="list-inline-item">
-                                                                               {{ "%s: %s" % ("SHA256" if file.sha256 else "SHA1", file.sha256 or file.sha1) }}
-                                                                       </li>
-
-                                                                       {% if file.torrent_url %}
-                                                                               <li class="list-inline-item">
-                                                                                       <a href="{{ file.torrent_url }}">
-                                                                                               <i class="fas fa-download"></i> {{ _("Torrent Download") }}
-                                                                                       </a>
-                                                                               </li>
-                                                                       {% end %}
-                                                               </ul>
-                                                       </li>
-                                               {% end %}
-                                       </ul>
-                               </div>
-                       {% end %}
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row flex-md-row-reverse">
-                               <div class="col-12 col-md-4 text-center text-md-right">
-                                       <span class="fas fa-cloud fa-10x my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-8">
-                                       <h1>{{ _("Running IPFire in the Cloud?") }}</h1>
-
-                                       <p>
-                                               {{ _("IPFire is now available in the Amazon Cloud.") }}
-                                               {{ _("Create flexible firewall rules and use our Intrusion Detection System to protect your servers in the Cloud.") }}
-                                               {{ _("Connect to them securely using our VPN technologies.") }}
-                                       </p>
-
-                                       <a class="btn btn-lwl" href="https://aws.amazon.com/marketplace/pp/B07HYRD4FX">
-                                               {{ _("Go to Amazon Web Services") }} <span class="fas fa-external-link-alt ml-2"></span>
-                                       </a>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       {% if release.secondary_arches %}
-               <section>
-                       <div class="container">
-                               <h3>{{ _("Secondary Architectures") }}</h3>
-
-                               {% for arch in release.secondary_arches %}
-                                       <div class="my-5">
-                                               <h5>
-                                                       {{ arch }}
-
-                                                       {% if arch in release.experimental_arches %}
-                                                               <span class="badge badge-success small">{{ _("Experimental") }}</span>
-                                                       {% else %}
-                                                               <span class="badge badge-warning small">{{ _("Legacy") }}</span>
-                                                       {% end %}
-                                               </h5>
-
-                                               <ul class="list-group">
-                                                       {% for file in release.get_files_by_arch(arch) %}
-                                                               <li class="list-group-item">
-                                                                       <div class="d-flex w-100 justify-content-between">
-                                                                               <a href="{{ file.url }}">{{ _(file.desc) }}</a>
-
-                                                                               {% if file.size >= 1024 * 1024 %}
-                                                                                       <span class="text-muted">{{ format_size(file.size) }}</span>
-                                                                               {% end %}
-                                                                       </div>
-
-                                                                       <ul class="list-inline text-muted small mb-0 d-none d-md-block">
-                                                                               <li class="list-inline-item">
-                                                                                       {{ "%s: %s" % ("SHA256" if file.sha256 else "SHA1", file.sha256 or file.sha1) }}
-                                                                               </li>
-
-                                                                               {% if file.torrent_url %}
-                                                                                       <li class="list-inline-item">
-                                                                                               <a href="{{ file.torrent_url }}">
-                                                                                                       <i class="fas fa-download"></i> {{ _("Torrent Download") }}
-                                                                                               </a>
-                                                                                       </li>
-                                                                               {% end %}
-                                                                       </ul>
-                                                               </li>
-                                                       {% end %}
-                                               </ul>
-                                       </div>
-                               {% end %}
-                       </div>
-               </section>
-       {% end %}
-{% end block %}
diff --git a/src/templates/downloads/cloud.html b/src/templates/downloads/cloud.html
new file mode 100644 (file)
index 0000000..eb382e1
--- /dev/null
@@ -0,0 +1,166 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Cloud") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-info">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/downloads">Downloads</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">Cloud</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">
+                                       {{ _("Cloud") }}
+                               </h1>
+
+                               <p class="subtitle">
+                                       {{ _("Explore IPFire In The Cloud") }}
+                               </p>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="block">
+                               <div class="content is-size-5">
+                                       <p>
+                                               Unlock the flexibility of IPFire in the cloud by choosing from our
+                                               trusted cloud service providers.
+                                               These partners offer seamless integration and optimal performance for
+                                               hosting IPFire, ensuring robust network security in virtual environments.
+                                       </p>
+
+                                       <p>
+                                               Whether you're a small business, enterprise, or individual user, find
+                                               the right cloud provider to suit your needs.
+                                               Enhance your cybersecurity measures by deploying IPFire in the cloud
+                                               with one of our reliable partners.
+                                       </p>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-multiline is-centered is-vcentered">
+                               <div class="column is-one-third">
+                                       <div class="notification">
+                                               <figure class="image is-1by1">
+                                                       <img src="{{ static_url("img/downloads/cloud/aws.svg") }}">
+                                               </figure>
+
+                                               <div class="block">
+                                                       <div class="columns">
+                                                               <div class="column">
+                                                                       <a class="button is-info is-fullwidth" href="https://aws.amazon.com/marketplace/pp/prodview-opiegephkjalm">
+                                                                               {{ _("ARM64") }}
+                                                                       </a>
+                                                               </div>
+
+                                                               <div class="column">
+                                                                       <a class="button is-info is-fullwidth" href="https://aws.amazon.com/marketplace/pp/B07HYRD4FX">
+                                                                               {{ _("x86") }}
+                                                                       </a>
+                                                               </div>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <a class="button is-white is-fullwidth" href="/docs/installation/aws">
+                                                               {{ _("Documentation") }}
+                                                       </a>
+                                               </div>
+                                       </div>
+                               </div>
+
+                               <div class="column is-one-third">
+                                       <div class="notification">
+                                               <figure class="image is-1by1">
+                                                       <img src="{{ static_url("img/downloads/cloud/exoscale.svg") }}">
+                                               </figure>
+
+                                               <div class="block">
+                                                       <a class="button is-info is-fullwidth" href="https://www.exoscale.com/marketplace/listing/lwl-ipfire-open-source-firewall-payg/">
+                                                               {{ _("Go To Exoscale") }}
+                                                       </a>
+                                               </div>
+
+                                               <div class="block">
+                                                       <a class="button is-white is-fullwidth" href="/docs/installation/exoscale">
+                                                               {{ _("Documentation") }}
+                                                       </a>
+                                               </div>
+                                       </div>
+                               </div>
+
+                               <div class="column is-one-third">
+                                       <div class="notification">
+                                               <figure class="image is-1by1">
+                                                       <img src="{{ static_url("img/downloads/cloud/hetzner.svg") }}">
+                                               </figure>
+
+                                               <div class="block">
+                                                       <a class="button is-white is-fullwidth" href="/docs/installation/hetzner-cloud">
+                                                               {{ _("Documentation") }}
+                                                       </a>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="block">
+                               <h4 class="title is-4">Why Choose IPFire in the Cloud?</h4>
+
+                               <ul>
+                                       <li>
+                                               <strong>Scalability:</strong>
+                                               Easily scale your network security infrastructure as your business grows.
+                                       </li>
+
+                                       <li>
+                                               <strong>Flexibility:</strong>
+                                               Enjoy the flexibility of cloud deployment with the robust security of IPFire.
+                                       </li>
+
+                                       <li>
+                                               <strong>Performance:</strong>
+                                               Leverage the high-performance capabilities of leading cloud providers for optimal security.
+                                       </li>
+                               </ul>
+                       </div>
+
+                       <div class="block">
+                               <h4 class="title is-4">Getting Started</h4>
+
+                               <div class="content">
+                                       <p>
+                                               Explore the offerings of our featured cloud providers and select the one
+                                               that aligns with your requirements. Follow the integration guides and
+                                               documentation for a smooth deployment experience.
+                                       </p>
+
+                                       <p>
+                                               Secure your digital assets in the cloud with IPFire!
+                                       </p>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/downloads/mirrors.html b/src/templates/downloads/mirrors.html
new file mode 100644 (file)
index 0000000..04816c7
--- /dev/null
@@ -0,0 +1,75 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Mirrors") }}{% end block %}
+
+{% block container %}
+       {% set total = sum((len(m) for c, m in mirrors.items())) %}
+       {% set countries = len(mirrors) %}
+
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li>
+                                                       <a href="/downloads">Downloads</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">Mirrors</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h1 class="title">
+                                       {{ _("Mirrors") }}
+                               </h1>
+                               <p class="subtitle">
+                                       {{ _("IPFire downloads from %(total)s mirrors in %(countries)s countries, "
+                                               "sponsored by diverse organizations, ensure global accessibility for all"
+                                               ) % { "total" : total, "countries" : countries } }}
+                               </p>
+                       </div>
+               </div>
+       </section>
+
+       {% for country in sorted(mirrors, key=lambda c: c.name) %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">
+                                       <i class="flag-icon flag-icon-{{ country.alpha2.lower() }}"></i>
+                                       {{ country.name }}
+                               </h4>
+
+                               <div class="columns is-multiline">
+                                       {% for mirror in mirrors[country] %}
+                                               <div class="column is-one-third">
+                                                       <div class="card">
+                                                               <div class="card-content">
+                                                                       <h6 class="title is-6">
+                                                                               {% if mirror.state == "DOWN" %}
+                                                                                       <i class="fa-solid fa-circle has-text-danger" title="{{ _("Down since %s") % locale.format_date(mirror.last_update) }}"></i>
+                                                                               {% elif mirror.state == "OUTOFSYNC" %}
+                                                                                       <i class="fa-solid fa-circle has-text-warning" title="{{ _("Out Of Sync since %s") % locale.format_date(mirror.last_update) }}"></i>
+                                                                               {% else %}
+                                                                                       <i class="fa-solid fa-circle has-text-success" title="{{ _("Online") }}"></i>
+                                                                               {% end %}
+
+                                                                               <a href="{{ mirror.url }}">{{ mirror.owner or mirror.hostname }}</a>
+                                                                       </h6>
+
+                                                                       {% if mirror.asn %}
+                                                                               <p>
+                                                                                       {{ mirror.address.autonomous_system or "AS%s" % mirror.asn }}
+                                                                               </p>
+                                                                       {% end %}
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                       {% end %}
+                               </div>
+                       </div>
+               </section>
+       {% end %}
+{% end block %}
diff --git a/src/templates/downloads/release.html b/src/templates/downloads/release.html
new file mode 100644 (file)
index 0000000..0673391
--- /dev/null
@@ -0,0 +1,93 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ release }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">Downloads</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">
+                                       {{ release }}
+                               </h1>
+
+                               <h6 class="subtitle">
+                                       {{ _("Released %s") % locale.format_date(release.published, relative=True, shorter=True) }}
+                               </h6>
+
+                               <div class="columns is-multiline">
+                                       {% for arch in release.arches %}
+                                               <div class="column is-half is-one-quarter-widescreen is-one-fifth-fullhd">
+                                                       <div class="block p-5">
+                                                               <h5 class="title is-5">{{ arch }}</h5>
+
+                                                               <ul>
+                                                                       {% for file in release.get_files_by_arch(arch) %}
+                                                                               <li>
+                                                                                       <a class="download-splash" href="{{ file.url }}"
+                                                                                                       title="{{ "%s: %s" % ("SHA256" if file.sha256 else "SHA1", file.sha256 or file.sha1) }}">
+                                                                                               <span class="icon-text">
+                                                                                                       <span class="icon">
+                                                                                                               <i class="fas fa-download"></i>
+                                                                                                       </span>
+                                                                                                       <span>
+                                                                                                               {{ _(file.desc) }}
+                                                                                                               ({{ format_size(file.size) }})
+                                                                                                       </span>
+                                                                                               </span>
+                                                                                       </a>
+                                                                               </li>
+                                                                       {% end %}
+                                                               </ul>
+                                                       </div>
+                                               </div>
+                                       {% end %}
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="has-text-white has-background-info">
+               <div class="container">
+                       <p class="has-text-centered px-2 py-1">
+                               {{ _("Deploying IPFire In The Cloud?") }}
+
+                               <a class="has-text-white has-text-weight-bold" href="/downloads/cloud">
+                                       {{ _("Read More") }}
+                               </a>
+                       </p>
+               </div>
+       </section>
+
+       {% if release.blog %}
+               <section class="section">
+                       <div class="container">
+                               <h3 class="title is-3">{{ _("Release Notes") }}</h3>
+
+                               <div class="content">
+                                       {% raw release.blog.html %}
+                               </div>
+                       </div>
+               </section>
+       {% end %}
+{% end block %}
+
+{% block javascript %}
+       <script type="text/javascript">
+               $("a.download-splash").click(function(e) {
+                       e.preventDefault();
+
+                       window.location = "/downloads/thank-you?file=" + this.href;
+               });
+       </script>
+{% end %}
diff --git a/src/templates/downloads/thank-you.html b/src/templates/downloads/thank-you.html
new file mode 100644 (file)
index 0000000..559cc66
--- /dev/null
@@ -0,0 +1,72 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Thank You For Downloading IPFire") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-medium">
+               <div class="hero-body">
+                       <div class="container">
+                               <h1 class="title">
+                                       Thank You For Choosing IPFire<span class="has-text-primary">_</span>
+                               </h1>
+                               <h4 class="subtitle">{{ _("Your download will begin shortly.") }}</h4>
+
+                               <div class="block">
+                                       <p class="download-path"></p>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="block">
+                               <p class="is-size-5">
+                                       {{ _("In the meantime, explore our documentation and community forums for helpful resources.") }}
+                               </p>
+
+                               <p class="is-size-5">
+                                       {{ _("Need professional support? Check out Lightning Wire Labs for expert assistance.") }}
+                                       {{ _("Welcome to the secure world of IPFire!") }}
+                               </p>
+                       </div>
+
+                       <div class="buttons are-medium">
+                               <a class="button is-dark is-fullwidth" href="/docs/installation">
+                                       {{ _("Read The Installation Guide") }}
+                               </a>
+
+                               <a class="button is-primary is-fullwidth" href="/donate">
+                                       {{ _("Donate To Support The Project") }}
+                               </a>
+
+                               <a class="button is-lwl is-fullwidth" href="https://store.lightningwirelabs.com/products/groups/support">
+                                       {{ _("Check Out Our Support Plans") }}
+                               </a>
+                       </div>
+               </div>
+       </section>
+{% end block %}
+
+{% block javascript %}
+       <script type="text/javascript">
+               $("p.download-path").ready(function() {
+                       const params = new URLSearchParams(window.location.search);
+
+                       var file = params.get("file");
+
+                       // Avoid downloading files from other websites
+                       if (!file.startsWith("https://downloads.ipfire.org/"))
+                               return;
+
+                       $("p.download-path").prepend($("<a>", {
+                               href: encodeURI(file),
+                               text: file
+                       }));
+
+                       setTimeout(function() {
+                               window.location = file
+                       }, "2000");
+               });
+       </script>
+{% end %}
index 02cc4fa19851bc67e8ce9cd11c7beeddc9332f18..5b6e9a2e9462cca32c7a01878040ed63932957f9 100644 (file)
@@ -3,16 +3,20 @@
 {% block title %}{{ message or status_code }}{% end block %}
 
 {% block container %}
-       <div class="container">
-               <div class="row justify-content-center mt-5">
-                       <div class="col col-md-5">
-                               <h5 class="mb-0">{% block message %}{{ _("Error %s") % status_code }}{% end block %}</h5>
-                               <h1>{{ _("oops, something went wrong") }}</h1>
+       <section class="hero is-primary is-fullheight-with-navbar">
+               <div class="hero-body">
+                       <div class="container">
+                               <p class="title">
+                                       {{ _("Oops - We Are Sorry, But Something Went Wrong") }}
+                               </p>
+                               <p class="subtitle">
+                                       {% block message %}{{ _("Error %s") % status_code }}{% end block %}
+                               </p>
 
                                {% block main %}{% end block %}
                        </div>
                </div>
-       </div>
+       </section>
 {% end block %}
 
 {% block footer %}{% end block %}
index 00a5442504e9d34285521a3d8cd33ad8af8b2a4f..bbc5b816630ddeca52708ba3e4feda7dbe486bf5 100644 (file)
@@ -3,19 +3,81 @@
 {% block title %}{{ _("Admin") }}{% end block %}
 
 {% block container %}
-       <section>
+       <section class="hero is-primary is-medium">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb is-medium" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               Admin
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title is-1">Fireinfo Admin</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
                <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6 text-center">
-                                       <h1>{{ "{:,d}".format(total) }}</h1>
-                                       <h4>{{ _("Total amount of profiles") }}</h4>
+                       <div class="columns">
+                               <div class="column">
+                                       <div class="has-text-centered">
+                                               <h1 class="title">{{ "{:,d}".format(total) }}</h1>
+                                               <h4 class="title is-4">{{ _("Total Profiles") }}</h4>
+                                       </div>
                                </div>
 
-                               <div class="col col-lg-6 text-center">
-                                       <h1>{{ "%.2f%%" % (with_data * 100 / total) }}</h1>
-                                       <h4>{{ _("Reporting back to us") }}</h4>
+                               <div class="column">
+                                       <div class="has-text-centered">
+                                               <h1 class="title">{{ "%.2f%%" % (with_data * 100 / total) }}</h1>
+                                               <h4 class="title is-4">{{ _("Reporting back to us") }}</h4>
+                                       </div>
                                </div>
                        </div>
                </div>
        </section>
+
+       {% if asn_map %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">{{ _("Autonomous Systems") }}</h4>
+
+                               <table class="table is-fullwidth">
+                                       <tr>
+                                               <th>{{ _("Autonomous System") }}</th>
+                                               <th class="has-text-right">{{ _("Total Profiles") }}</th>
+                                               <th class="has-text-right">{{ _("Percentage") }}</th>
+                                       </tr>
+
+                                       {% for asn in sorted(asn_map, key=lambda asn: asn_map[asn], reverse=True) %}
+                                               {% set c, p = asn_map[asn] %}
+
+                                               <tr>
+                                                       <th scope="row">{{ asn }}</th>
+                                                       <td class="has-text-right">
+                                                               {{ c }}
+                                                       </td>
+                                                       <td class="has-text-right">
+                                                               {{ "%.2f%%" % (p * 100) }}
+                                                       </td>
+                                               </tr>
+                                       {% end %}
+                               </table>
+                       </div>
+               </section>
+       {% end %}
 {% end block %}
index 5e067d9b84fb6942f1d7da562ea48f412fc77752..10a4b75250f9bb3003181dd4406697cfc728ef68 100644 (file)
@@ -3,25 +3,48 @@
 {% block title %}{{ driver }}{% end block %}
 
 {% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12">
-                                       <h1>{{ driver }}</h1>
-
-                                       <p>
-                                               {{ _("All known devices run by %s") % driver }}
-                                       </p>
-                               </div>
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb is-medium" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo/drivers">
+                                                               {{ _("Drivers") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               {{ driver }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
                        </div>
                </div>
        </section>
 
-       <div class="container">
-               <div class="row">
-                       <div class="col-12">
-                               {% module FireinfoDeviceTable(driver_map) %}
+       <section class="section">
+               <div class="container">
+                       <h1 class="title">{{ driver }}</h1>
+                               <h2 class="title is-2">{{ _("All known devices run by %s") % driver }}</h2>
                        </div>
                </div>
-       </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% module FireinfoDeviceTable(devices) %}
+               </div>
+       </section>
 {% end block %}
index e07cb07d9dc0a7019fd4e0ed99d1e08f509b9f2f..0ce96926aec607945940584adaf1df30ed802504 100644 (file)
 {% block title %}{{ _("Fireinfo") }}{% end block %}
 
 {% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-8">
-                                       <h1 class="display-2">{{ _("Fireinfo") }}</h1>
-
-                                       <p>
-                                               <strong>Fireinfo</strong> is a tool that anonymously collects statistical
-                                               data from IPFire systems
-                                       </p>
-
-                                       <a class="btn btn-primary btn-lg my-3" href="/profile/random">
-                                               {{ _("Show a Random Profile") }}
-                                       </a>
-                               </div>
+       <section class="hero is-medium is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Fireinfo") }}</h1>
+
+                               <a class="button is-dark is-medium" href="/fireinfo/profile/random">
+                                       {{ _("Show a Random Profile") }}
+                               </a>
                        </div>
                </div>
        </section>
 
-       <section class="inverse">
+       <section class="section">
                <div class="container">
-                       <div class="row justify-content-center align-items-center">
+                       <div class="columns is-vcentered">
                                {% if latest_release %}
-                                       <div class="col-12 col-md-4 text-center text-md-right">
+                                       {% set usage = latest_release.get_usage(when=when) %}
+
+                                       <div class="column is-half is-centered">
                                                <div>
-                                                       <h1 class="display-1 text-primary mb-0">
-                                                               {{ "%.2f%%" % (latest_release.penetration * 100) }}
+                                                       <h1 class="title has-text-primary">
+                                                               {{ "%.2f%%" % (usage * 100) }}
                                                        </h1>
 
-                                                       <h5>
+                                                       <h5 class="title is-5">
                                                                {{ _("of all IPFire systems are on the latest release, released %s") % locale.format_date(latest_release.published, relative=True, shorter=True) }}
                                                        </h5>
 
-                                                       <a class="btn btn-sm btn-light" href="/releases">
+                                                       <a class="button is-primary is-outlined" href="/fireinfo/releases">
                                                                {{ _("See All Releases") }}
                                                        </a>
                                                </div>
                                        </div>
                                {% end %}
 
-                               <div class="col-12 col-md-3 text-center text-md-left">
-                                       <img class="img-fluid my-5" src="{{ static_url("img/ipfire-tux.png") }}" alt="IPFire Logo" />
+                               <div class="column is-one-third is-centered">
+                                       <img src="{{ static_url("img/ipfire-tux.png") }}" alt="IPFire Logo" />
                                </div>
                        </div>
                </div>
        </section>
 
-       <section>
+       <section class="section">
                <div class="container">
-                       <div class="row">
-                               <div class="col-12">
-                                       <h4>{{ _("Locations") }}</h4>
-
-                                       <dl class="row">
-                                               {% for country_code, percentage in locations %}
-                                                       {% if percentage >= 0.01 %}
-                                                               <dt class="col-sm-6">
-                                                                       <span class="flag-icon flag-icon-{{ country_code.lower() }} small mr-1"></span>
-                                                                       {{ format_country_name(country_code) }}
-                                                               </dt>
-
-                                                               <dd class="col-sm-6">
-                                                                       {% module ProgressBar(percentage, "success") %}
-                                                               </dd>
-                                                       {% end %}
-                                               {% end %}
-                                       </dl>
-                               </div>
-                       </div>
+                       <h4 class="title is-4">{{ _("Locations") }}</h4>
+
+                       {% for cc in sorted(locations, key=lambda cc: locations[cc], reverse=True) %}
+                               <div class="columns is-mobile">
+                                       {% if locations[cc] >= 0.01 %}
+                                               <div class="column is-one-fifth">
+                                                       <span class="flag-icon flag-icon-{{ cc.lower() }}"></span>
+                                                       <span class="">{{ format_country_name(cc) }}</span>
+                                               </div>
 
-                       <div class="row">
-                               <div class="col-12">
-                                       <small>
-                                               {{ _("IPFire is also running in these countries: %s") % locale.list([(format_country_name(c) or c)  for c, p in locations if p < 0.01]) }}
-                                       </small>
+                                               <div class="column is-7">
+                                                       {% module ProgressBar(locations[cc], "success") %}
+                                               </div>
+                                       {% end %}
                                </div>
-                       </div>
+                       {% end %}
+
+                       {% set other_countries = [cc for cc in locations if locations[cc] < 0.01] %}
+
+                       {% if other_countries %}
+                               <p>
+                                       <span class="has-text-weight-bold">IPFire<span class="has-text-primary">_</span></span>
+                                       {{_("is also running in these countries: %s") % locale.list([(format_country_name(cc) or cc) for cc in other_countries]) }}
+                               </p>
+                       {% end %}
                </div>
        </section>
 
-       <section class="inverse">
+       <section class="section">
                <div class="container">
-                       <div class="row align-items-center">
-                               <div class="col-12 col-md-6">
-                                       <h4>{{ _("CPU Vendors") }}</h4>
-
-                                       <dl class="row">
-                                               {% for name, percentage in cpu_vendors %}
-                                                       <dt class="col-sm-3">{{ name }}</dt>
-
-                                                       <dd class="col-sm-9">
-                                                               {% module ProgressBar(percentage, "success") %}
-                                                       </dd>
+                       <div class="columns is-vcentered">
+                               <div class="column is-half">
+                                       <div class="block">
+                                               <h4 class="title is-4">{{ _("CPU Vendors") }}</h4>
+
+                                               {% for vendor in sorted(cpu_vendors, key=lambda v: cpu_vendors[v], reverse=True) %}
+                                                       <div class="columns">
+                                                               <div class="column is-1">{{ vendor or _("Unknown") }}</div>
+
+                                                               <div class="column">
+                                                                       {% module ProgressBar(cpu_vendors[vendor], "success") %}
+                                                               </div>
+                                                       </div>
                                                {% end %}
-                                       </dl>
 
-                                       <a class="btn btn-sm btn-light mb-3" href="/processors">
-                                               {{ _("Processor Features") }}
-                                       </a>
+                                               <a class="button is-primary" href="/fireinfo/processors">
+                                                       {{ _("Processor Features") }}
+                                               </a>
+                                       </div>
 
-                                       <h4>{{ _("Architectures") }}</h4>
+                                       <div class="block">
+                                               <h4 class="title is-4">{{ _("Architectures") }}</h4>
 
-                                       <dl class="row">
-                                               {% for name, percentage in arches %}
-                                                       <dt class="col-sm-3">{{ name }}</dt>
+                                               {% for arch in sorted(arches, key=lambda a: arches[a], reverse=True) %}
+                                                       <div class="columns">
+                                                               <div class="column is-1">{{ arch }}</div>
 
-                                                       <dd class="col-sm-9">
-                                                               {% module ProgressBar(percentage, "success") %}
-                                                       </dd>
+                                                               <div class="column">
+                                                                       {% module ProgressBar(arches[arch], "success") %}
+                                                               </div>
+                                                       </div>
                                                {% end %}
-                                       </dl>
+                                       </div>
                                </div>
+                               <div class="column is-half has-text-centered">
+                                               <h1 class="title">{{ format_size(memory_avg * 1024, "MB") }}</h1>
 
-                               <div class="col-12 col-md-6 text-center">
-                                       <h1>{{ format_size(memory_avg * 1024, "MB") }}</h1>
-
-                                       <p class="text-muted">
-                                               {{ _("Average Amount of Memory") }}
-                                       </p>
+                                               <span class="tag">
+                                                               {{ _("Average Amount of Memory") }}
+                                               </span>
                                </div>
                        </div>
                </div>
        </section>
 
-       <section>
+       <section class="section">
                <div class="container">
-                       <div class="row justify-content-center align-items-center">
-                               <div class="col-12 col-md-4 text-center text-md-right">
-                                       <div>
-                                               <h1 class="display-1 text-success mb-0">
-                                                       {{ "%.2f%%" % (virtual_ratio * 100) }}
-                                               </h1>
-
-                                               <h5>{{ _("of all IPFire systems are running in a virtual environment") }}</h5>
-                                       </div>
+                       <div class="columns is-vcentered">
+                               <div class="column is-half has-text-centered">
+                                       <h1 class="title has-text-primary">
+                                               {{ "%.2f%%" % (virtual_ratio * 100) }}
+                                       </h1>
+
+                                       <h5 class="title is-5">{{ _("of all IPFire systems are running in a virtual environment") }}</h5>
                                </div>
 
-                               <div class="col-12 col-md-6">
-                                       <h4>{{ _("Top Hypervisors") }}</h4>
+                               <div class="column is-half">
+                                       <h4 class="title is-4">{{ _("Top Hypervisors") }}</h4>
 
-                                       <dl class="row">
-                                               {% for name, percentage in hypervisors %}
-                                                       {% if percentage >= 0.01 %}
-                                                               <dt class="col-sm-3">
-                                                                       {% if name == "unknown" %}
+                                       {% for vendor in sorted(hypervisors, key=lambda v: hypervisors[v], reverse=True) %}
+                                               <div class="columns">
+                                                       {% if hypervisors[vendor] >= 0.01 %}
+                                                               <div class="column is-1">
+                                                                       {% if vendor == "unknown" %}
                                                                                <span class="text-muted">{{ _("Unknown") }}</span>
-                                                                       {% elif name == "VMWare" %}
+                                                                       {% elif vendor == "VMWare" %}
                                                                                VMware
                                                                        {% else %}
-                                                                               {{ name }}
+                                                                               {{ vendor }}
                                                                        {% end %}
-                                                               </dt>
+                                                               </div>
 
-                                                               <dd class="col-sm-9">
-                                                                       {% module ProgressBar(percentage, "success") %}
-                                                               </dd>
+                                                               <div class="column is-8">
+                                                                       {% module ProgressBar(hypervisors[vendor], "success") %}
+                                                               </div>
                                                        {% end %}
-                                               {% end %}
-                                       </dl>
+                                               </div>
+                                       {% end %}
                                </div>
                        </div>
                </div>
index d5b2b75591640a7d75d31d9aa42ed697456bc27a..5325984bd2a971db598ef43a07e3f49dcf5a9c41 100644 (file)
@@ -1,7 +1,9 @@
 {% for group, devices in groups %}
-       <div class="mb-5">
-               <h4>{{ group }}</h4>
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ group }}</h4>
 
-               {% module FireinfoDeviceTable(devices, show_group=False) %}
-       </div>
+                       {% module FireinfoDeviceTable(devices, show_group=False) %}
+               </div>
+       </section>
 {% end %}
index 6f054310cb29255740ac9b03e312f5817b5a60d4..9faef1f409a1bccf087af7f6a109e309debdca1e 100644 (file)
@@ -1,22 +1,33 @@
-<ul class="list-group {% if embedded %}list-group-flush{% end %}">
-       {% for d in sorted(devices) %}
-               <div class="list-group-item flex-column align-items-start">
-                       <p class="small text-muted mb-0">
-                               {% if show_group %}{{ d.cls }}{% end %}
+{% for d in sorted(devices) %}
+       <div class="level">
+               <div class="level-left">
+                       <div class="level-item">
+                               <p>
+                                       {% if show_group %}{{ d.cls }}{% end %}
 
-                               {% if d.subsystem == "usb" %}
-                                       <span class="fab fa-usb"></span>
-                               {% end %}
-                       </p>
-
-                       <p class="mb-0">
-                               <a href="/vendors/{{ d.subsystem }}/{{ d.vendor }}">{{ d.vendor_string }}</a>
-                               &dash; {{ d.model_string or "N/A (%s)" % d.model }}
+                                       {% if d.subsystem == "usb" %}
+                                               <span class="fab fa-usb"></span>
+                                       {% end %}
+                               </p>
+                       </div>
+                       <div class="level-item">
+                               <p>
+                                       {% if d.vendor_string %}
+                                               <a href="/fireinfo/vendors/{{ d.subsystem }}/{{ d.vendor }}">{{ d.vendor_string }}</a>
+                                       {% else %}
+                                               <p>N/A</p>
+                                       {% end %}
 
+                                       &dash; {{ d.model_string or "N/A (%s)" % d.model }}
+                               </p>
+                       </div>
+                       <div class="level-item">
                                {% if d.driver %}
-                                       (<a href="/drivers/{{ d.driver }}">{{ d.driver }}</a>)
+                                       <span class="tag">
+                                               <a href="/fireinfo/drivers/{{ d.driver }}">{{ d.driver }}</a>
+                                       </span>
                                {% end %}
-                       </p>
+                       </div>
                </div>
-       {% end %}
-</ul>
+       </div>
+{% end %}
index 58daac126ae09b28a5c62307fa755f2f4679139e..05aba9b19971f11cc6b8afa918fc70099d03f2a9 100644 (file)
 {% block title %}{{ _("Processors") }}{% end block %}
 
 {% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12">
-                                       <h1>{{ _("Processors") }}</h1>
-                               </div>
+       {% set map = backend.fireinfo.get_cpu_flags_map(when=when) %}
+
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               {{ _("Processors") }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Processors") }}</h1>
                        </div>
                </div>
        </section>
 
-       <div class="container">
-               {% for platform in flags %}
-                       <h2>{{ platform }}</h2>
+       <section class="section">
+               <div class="container">
+                       {% for arch in sorted(map) %}
+                               <h2 class="title is-2">{{ arch }}</h2>
 
-                       <dl class="row">
-                               {% for flag, percentage in flags[platform] %}
-                                       <dt class="col-sm-4">
-                                               {% if flag == "aes" %}
-                                                       {{ _("AES Instruction Set") }}
-                                               {% elif flag == "avx" %}
-                                                       {{ _("AVX") }}
-                                               {% elif flag == "avx2" %}
-                                                       {{ _("AVX2") }}
-                                               {% elif flag == "lpae" %}
-                                                       {{ _("LPAE") }}
-                                               {% elif flag == "mmx" %}
-                                                       {{ _("MMX") }}
-                                               {% elif flag == "mmxext" %}
-                                                       {{ _("MMX2") }}
-                                               {% elif flag == "neon" %}
-                                                       {{ _("NEON") }}
-                                               {% elif flag == "nx" %}
-                                                       {{ _("No eXecute bit") }}
-                                               {% elif flag == "lm" %}
-                                                       {{ _("Supports 64 bits") }}
-                                               {% elif flag == "pae" %}
-                                                       {{ _("PAE") }}
-                                               {% elif flag == "pclmulqdq" %}
-                                                       {{ _("PCLMULQDQ") }}
-                                               {% elif flag == "pni" %}
-                                                       {{ _("SSE3") }}
-                                               {% elif flag == "popcnt" %}
-                                                       {{ _("POPCNT") }}
-                                               {% elif flag == "rdrand" %}
-                                                       {{ _("RDRAND") }}
-                                               {% elif flag == "rdseed" %}
-                                                       {{ _("RDSEED") }}
-                                               {% elif flag == "sha" %}
-                                                       {{ _("SHA") }}
-                                               {% elif flag == "sse" %}
-                                                       {{ _("SSE") }}
-                                               {% elif flag == "sse2" %}
-                                                       {{ _("SSE2") }}
-                                               {% elif flag == "sse4a" %}
-                                                       {{ _("SSE4a") }}
-                                               {% elif flag == "sse4_1" %}
-                                                       {{ _("SSE4.1") }}
-                                               {% elif flag == "sse4_2" %}
-                                                       {{ _("SSE4.2") }}
-                                               {% elif flag == "ssse3" %}
-                                                       {{ _("SSSE3") }}
-                                               {% elif flag == "thumb" %}
-                                                       {{ _("Thumb") }}
-                                               {% elif flag == "thumb2" %}
-                                                       {{ _("Thumb2") }}
-                                               {% elif flag == "thumbee" %}
-                                                       {{ _("ThumbEE") }}
-                                               {% elif flag == "vfpv3" %}
-                                                       {{ _("VFPv3") }}
-                                               {% elif flag == "vfpv4" %}
-                                                       {{ _("VFPv4") }}
-                                               {% elif flag == "virt" %}
-                                                       {{ _("Virtualization") }}
-                                               {% else %}
-                                                       {{ flag }}
-                                               {% end %}
-                                       </dt>
+                               {% for flag in sorted(map[arch], key=lambda f: map[arch][f], reverse=True) %}
+                                       {% set p = map[arch][flag] %}
 
-                                       <dd class="col-sm-8">
-                                               {% if percentage >= 0.95 %}
-                                                       {% module ProgressBar(percentage, "success") %}
-                                               {% elif percentage >= 0.5 %}
-                                                       {% module ProgressBar(percentage, "warning") %}
-                                               {% elif percentage >= 0.1 %}
-                                                       {% module ProgressBar(percentage, "info") %}
-                                               {% else %}
-                                                       {% module ProgressBar(percentage, "danger") %}
-                                               {% end %}
-                                       </dd>
+                                       <div class="columns">
+                                               <div class="column is-2">
+                                                       {% if flag == "aes" %}
+                                                               {{ _("AES Instruction Set") }}
+                                                       {% elif flag == "avx" %}
+                                                               {{ _("AVX") }}
+                                                       {% elif flag == "avx2" %}
+                                                               {{ _("AVX2") }}
+                                                       {% elif flag == "lpae" %}
+                                                               {{ _("LPAE") }}
+                                                       {% elif flag == "mmx" %}
+                                                               {{ _("MMX") }}
+                                                       {% elif flag == "mmxext" %}
+                                                               {{ _("MMX2") }}
+                                                       {% elif flag == "neon" %}
+                                                               {{ _("NEON") }}
+                                                       {% elif flag == "nx" %}
+                                                               {{ _("No eXecute bit") }}
+                                                       {% elif flag == "lm" %}
+                                                               {{ _("Supports 64 bits") }}
+                                                       {% elif flag == "pae" %}
+                                                               {{ _("PAE") }}
+                                                       {% elif flag == "pclmulqdq" %}
+                                                               {{ _("PCLMULQDQ") }}
+                                                       {% elif flag == "pni" %}
+                                                               {{ _("SSE3") }}
+                                                       {% elif flag == "popcnt" %}
+                                                               {{ _("POPCNT") }}
+                                                       {% elif flag == "rdrand" %}
+                                                               {{ _("RDRAND") }}
+                                                       {% elif flag == "rdseed" %}
+                                                               {{ _("RDSEED") }}
+                                                       {% elif flag == "sha" %}
+                                                               {{ _("SHA") }}
+                                                       {% elif flag == "sse" %}
+                                                               {{ _("SSE") }}
+                                                       {% elif flag == "sse2" %}
+                                                               {{ _("SSE2") }}
+                                                       {% elif flag == "sse4a" %}
+                                                               {{ _("SSE4a") }}
+                                                       {% elif flag == "sse4_1" %}
+                                                               {{ _("SSE4.1") }}
+                                                       {% elif flag == "sse4_2" %}
+                                                               {{ _("SSE4.2") }}
+                                                       {% elif flag == "ssse3" %}
+                                                               {{ _("SSSE3") }}
+                                                       {% elif flag == "thumb" %}
+                                                               {{ _("Thumb") }}
+                                                       {% elif flag == "thumb2" %}
+                                                               {{ _("Thumb2") }}
+                                                       {% elif flag == "thumbee" %}
+                                                               {{ _("ThumbEE") }}
+                                                       {% elif flag == "vfpv3" %}
+                                                               {{ _("VFPv3") }}
+                                                       {% elif flag == "vfpv4" %}
+                                                               {{ _("VFPv4") }}
+                                                       {% elif flag == "virt" %}
+                                                               {{ _("Virtualization") }}
+                                                       {% else %}
+                                                               {{ flag }}
+                                                       {% end %}
+                                               </div>
+
+                                               <div class="column">
+                                                       {% if p >= 0.95 %}
+                                                               {% module ProgressBar(p, "success") %}
+                                                       {% elif p >= 0.5 %}
+                                                               {% module ProgressBar(p, "warning") %}
+                                                       {% elif p >= 0.1 %}
+                                                               {% module ProgressBar(p, "info") %}
+                                                       {% else %}
+                                                               {% module ProgressBar(p, "danger") %}
+                                                       {% end %}
+                                               </div>
+                                       </div>
                                {% end %}
-                       </dl>
-               {% end %}
-       </div>
+                       {% end %}
+               </div>
+       </section>
 {% end block %}
index b63d424df681260b358542b9e04c59e98f69785a..5ee46c1fc5f745294273c81b2a604cdba36dfa36 100644 (file)
 
 {% block title %}{{ _("Profile %s") % profile.public_id }}{% end block %}
 
-{% block content %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-8 offset-lg-1">
-                                       <h1 class="display-2 mb-0">{{ _("Profile") }}</h1>
-                                       <h5>{{ profile.public_id }}</h5>
-                               </div>
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo/profile/random">
+                                                               {{ _("Random Profile") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="/fireinfo/profile/random">
+                                                               {{ profile.public_id }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Profile") }}</h1>
+                               <h4 class="subtitle is-4">{{ profile.public_id }}</h4>
                        </div>
                </div>
        </section>
 
-       <div class="container">
-               <div class="row justify-content-center">
-                       <div class="col-12 col-lg-10">
-                               {% if profile.appliance_id %}
-                                       <div class="card mb-3">
-                                               <div class="card-body">
-                                                       <div class="row">
-                                                               <div class="col-12 col-sm-7">
-                                                                       <small>{{ _("This is a") }}</small>
-                                                                       <h5 class="card-title text-lwl">{{ profile.appliance }}</h5>
-                                                               </div>
-
-                                                               <div class="col-12 col-sm-5 d-flex align-items-center">
-                                                                       <a class="btn btn-lwl btn-block text-truncate" href="https://www.lightningwirelabs.com">
-                                                                               {{ _("Go to Lightning Wire Labs") }} <span class="fas fa-external-link-alt ml-2"></span>
-                                                                       </a>
-                                                               </div>
-                                                       </div>
-                                               </div>
-                                       </div>
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <h4 class="title is-4">
+                                       {{ _("Running %s") % profile.system.release }}
+                               </h4>
+
+                               <h5 class="title is-5">
+                                               {{ _("Last update %s") % locale.format_date(profile.last_updated_at) }}
+                               </h5>
+
+                               {% for zone in profile.network %}
+                                       {% if zone == "red" %}
+                                       <span class="tag is-danger">{{ _("RED") }}</span>
+                                       {% elif zone == "green" %}
+                                       <span class="tag is-success">{{ _("GREEN") }}</span>
+                                       {% elif zone == "orange" %}
+                                       <span class="tag is-warning">{{ _("ORANGE") }}</span>
+                                       {% elif zone == "blue" %}
+                                       <span class="tag is-info">{{ _("BLUE") }}</span>
+                                       {% end %}
                                {% end %}
+                       </div>
+               </div>
+       </section>
 
-                               <div class="card mb-5">
-                                       <div class="card-body">
-                                               <div class="row">
-                                                       <div class="col-12 col-sm-8 mb-4">
-                                                               <h5 class="card-title mb-0">
-                                                                       {{ _("Running %s") % profile.release }}
-                                                               </h5>
-
-                                                               <small class="text-muted">
-                                                                       {{ _("Last update %s") % locale.format_date(profile.time_updated) }}
-                                                               </small>
-                                                       </div>
-
-                                                       <div class="col-12 col-sm-4 text-center text-sm-right mb-4">
-                                                               {% for zone in profile.network %}
-                                                                       {% if zone == "red" %}
-                                                                               <span class="badge badge-danger">{{ _("RED") }}</span>
-                                                                       {% elif zone == "green" %}
-                                                                               <span class="badge badge-success">{{ _("GREEN") }}</span>
-                                                                       {% elif zone == "orange" %}
-                                                                               <span class="badge badge-warning">{{ _("ORANGE") }}</span>
-                                                                       {% elif zone == "blue" %}
-                                                                               <span class="badge badge-info">{{ _("BLUE") }}</span>
-                                                                       {% end %}
-                                                               {% end %}
-                                                       </div>
-                                               </div>
-
-                                               <dl class="row mb-0">
-                                                       {% if profile.virtual %}
-                                                               <dt class="col-sm-3">{{ _("Hypervisor") }}</dt>
-                                                               <dd class="col-sm-9">
-                                                                       {% if profile.hypervisor == "VMWare" %}
-                                                                               {{ _("VMware") }}
-                                                                       {% elif profile.hypervisor is None %}
-                                                                               {{ _("Unknown") }}
-                                                                       {% else %}
-                                                                               {{ profile.hypervisor }}
-                                                                       {% end %}
-                                                               </dd>
-                                                       {% elif not profile.appliance_id and profile.system %}
-                                                               <dt class="col-sm-3">{{ _("System") }}</dt>
-                                                               <dd class="col-sm-9">
-                                                                       {% if profile.system_vendor %}
-                                                                               {{ profile.system_vendor }}
-                                                                       {% end %}
-
-                                                                       {% if profile.system_vendor and profile.system_model %}
-                                                                               &dash;
-                                                                       {% end %}
-
-                                                                       {% if profile.system_model %}
-                                                                               {{ profile.system_model }}
-                                                                       {% end %}
-                                                               </dd>
+       <div class="container">
+               <section class="section">
+                       <div class="block">
+                               {% if profile.is_virtual() %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("Hypervisor") }}</div>
+                                               <div class="column is-9">
+                                                       {% if profile.hypervisor == "VMWare" %}
+                                                               {{ _("VMware") }}
+                                                       {% elif profile.hypervisor is None %}
+                                                               {{ _("Unknown") }}
+                                                       {% else %}
+                                                               {{ profile.hypervisor }}
                                                        {% end %}
-
-                                                       {% if profile.processor %}
-                                                               <dt class="col-sm-3">{{ _("Processor") }}</dt>
-                                                               <dd class="col-sm-9">
-                                                                       <p class="card-text mb-0">{{ profile.processor }}</p>
-
-                                                                       <ul class="list-inline mb-0">
-                                                                               {% for cap, available in profile.processor.capabilities %}
-                                                                                       <li class="list-inline-item">
-                                                                                               <span class="badge {% if available %}badge-success{% else %}badge-light{% end %}">
-                                                                                                       {% if cap == "64bit" %}
-                                                                                                               {{ _("64 bit") }}
-                                                                                                       {% elif cap == "aes" %}
-                                                                                                               {{ _("AES-NI") }}
-                                                                                                       {% elif cap == "nx" %}
-                                                                                                               {{ _("NX") }}
-                                                                                                       {% elif cap == "pae" %}
-                                                                                                               {{ _("PAE") }}
-                                                                                                       {% elif cap == "rdrand" %}
-                                                                                                               {{ _("RDRAND") }}
-                                                                                                       {% elif cap == "virt" %}
-                                                                                                               {{ _("VT-x/AMD-V") }}
-                                                                                                       {% end %}
-                                                                                               </span>
-                                                                                       </li>
-                                                                               {% end %}
-                                                                       </ul>
-                                                               </dd>
+                                               </div>
+                                       </div>
+                                       {% elif profile.system %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("System") }}</div>
+                                               <div class="column is-9">
+                                                       {% if profile.system.vendor %}
+                                                               {{ profile.system.vendor }}
                                                        {% end %}
 
-                                                       {% if profile.memory %}
-                                                               <dt class="col-md-3">{{ _("Memory") }}</dt>
-                                                               <dd class="col-md-9">
-                                                                       {{ format_size(profile.memory) }}
-                                                               </dd>
+                                                       {% if profile.system.vendor and profile.system.model %}
+                                                               &dash;
                                                        {% end %}
 
-                                                       {% if profile.storage %}
-                                                               <dt class="col-md-3">{{ _("Storage") }}</dt>
-                                                               <dd class="col-md-9">
-                                                                       {{ format_size(profile.storage) }}
-                                                               </dd>
+                                                       {% if profile.system.model %}
+                                                               {{ profile.system.model }}
                                                        {% end %}
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
 
-                                                       {% if profile.location %}
-                                                               <dt class="col-md-3">{{ _("Location") }}</dt>
-                                                               <dd class="col-md-9">
-                                                                       {{ profile.location_string }}
-                                                               </dd>
+                       <div class="block">
+                               {% if profile.processor %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("Processor") }}</div>
+                                               <div class="column is-9">
+                                                       <p>{{ profile.processor }}</p>
+
+                                                       {% for cap, available in profile.processor.capabilities %}
+                                                               <span class="tag {% if available %}is-success{% else %}is-light{% end %}">
+                                                                       {% if cap == "64bit" %}
+                                                                               {{ _("64 bit") }}
+                                                                       {% elif cap == "aes" %}
+                                                                               {{ _("AES-NI") }}
+                                                                       {% elif cap == "nx" %}
+                                                                               {{ _("NX") }}
+                                                                       {% elif cap == "pae" %}
+                                                                               {{ _("PAE") }}
+                                                                       {% elif cap == "rdrand" %}
+                                                                               {{ _("RDRAND") }}
+                                                                       {% elif cap == "virt" %}
+                                                                               {{ _("VT-x/AMD-V") }}
+                                                                       {% end %}
+                                                               </span>
                                                        {% end %}
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
 
-                                                       {% if profile.language %}
-                                                               <dt class="col-md-3">{{ _("Language") }}</dt>
-                                                               <dd class="col-md-9">
-                                                                       {{ format_language_name(profile.language) }}
-                                                               </dd>
-                                                       {% end %}
-                                               </dl>
+                       <div class="block">
+                               {% if profile.system.memory %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("Memory") }}</div>
+                                               <div class="column is-9">
+                                                       {{ format_size(profile.system.memory) }}
+                                               </div>
                                        </div>
+                               {% end %}
+                       </div>
 
-                                       {% if profile.devices %}
-                                               {% module FireinfoDeviceTable([d for d in profile.devices if d.is_showable()], embedded=True) %}
-                                       {% end %}
-                               </div>
+                       <div class="block">
+                               {% if profile.system.storage %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("Storage") }}</div>
+                                               <div class="column is-9">
+                                                       {{ format_size(profile.system.storage) }}
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
 
-                               <h5>{{ _("Signature images") }}</h5>
+                       <div class="block">
+                               {% if profile.location %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("Location") }}</div>
+                                               <div class="column is-9">
+                                                       {{ profile.location_string }}
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
 
-                               <ul class="list-unstyled">
-                                       {% for i in range(1) %}
-                                               <li class="list-inline-item">
-                                                       <!-- XXX need some bbcode here -->
-                                                       <a href="//i-use.ipfire.org/profile/{{ profile.public_id }}/{{ i }}.png">
-                                                               <img class="img-fluid" src="//i-use.ipfire.org/profile/{{ profile.public_id }}/{{ i }}.png"
-                                                                       alt="{{ _("Signature image") }}" />
-                                                       </a>
-                                               </li>
-                                       {% end %}
-                               </ul>
+                       <div class="block">
+                               {% if profile.system.language %}
+                                       <div class="columns">
+                                               <div class="column is-3">{{ _("Language") }}</div>
+                                               <div class="column is-9">
+                                                       {{ format_language_name(profile.system.language) }}
+                                               </div>
+                                       </div>
+                               {% end %}
                        </div>
-               </div>
+               </section>
+
+               <section class="section">
+                       {% if profile.devices %}
+                               {% module FireinfoDeviceTable([d for d in profile.devices if d.is_showable()], embedded=True) %}
+                       {% end %}
+               </section>
+
+               <section class="section">
+                       <h5>{{ _("Signature images") }}</h5>
+
+                       <ul class="list-unstyled">
+                               {% for i in range(1) %}
+                                       <li class="list-inline-item">
+                                               <!-- XXX need some bbcode here -->
+                                               <a href="//i-use.ipfire.org/profile/{{ profile.public_id }}/{{ i }}.png">
+                                                       <img class="img-fluid" src="//i-use.ipfire.org/profile/{{ profile.public_id }}/{{ i }}.png"
+                                                               alt="{{ _("Signature image") }}" />
+                                               </a>
+                                       </li>
+                               {% end %}
+                       </ul>
+               </section>
        </div>
 {% end block %}
index 9ff05746a84000ab660106aea523345b29d0c400..43864f4de8d1e5145e6e08d197acf9ad7b460e47 100644 (file)
@@ -3,37 +3,57 @@
 {% block title %}{{ _("Releases") }}{% end block %}
 
 {% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12">
-                                       <h1>{{ _("Releases") }}</h1>
-                               </div>
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               {{ _("Releases") }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Releases") }}</h1>
                        </div>
                </div>
        </section>
 
-       <div class="container">
-               <dl class="row">
-                       {% for name, percentage in releases %}
-                               <dt class="col-sm-5">{{ name.replace("core", "Core Update ") }}</dt>
+       <section class="section">
+               <div class="container">
+                       {% for name in sorted(releases, key=lambda n: releases[n], reverse=True) %}
+                               <div class="columns">
+                                       <div class="column is-4">{{ name.replace("core", "Core Update ") }}</div>
 
-                               <dd class="col-sm-7">
-                                       {% module ProgressBar(percentage, "primary") %}
-                               </dd>
+                                       <div class="column">
+                                               {% module ProgressBar(releases[name], "primary") %}
+                                       </div>
+                               </div>
                        {% end %}
-               </dl>
 
-               <h2>{{ _("Kernels") }}</h2>
+                       <h2 class="title is-2">{{ _("Kernels") }}</h2>
 
-               <dl class="row">
-                       {% for name, percentage in kernels %}
-                               <dt class="col-sm-5">{{ name }}</dt>
+                       {% for name in sorted(kernels, key=lambda n: kernels[n], reverse=True) %}
+                               <div class="columns">
+                                       <div class="column is-4">{{ name }}</div>
 
-                               <dd class="col-sm-7">
-                                       {% module ProgressBar(percentage, "info") %}
-                               </dd>
+                                       <div class="column">
+                                               {% module ProgressBar(kernels[name], "info") %}
+                                       </div>
+                               </div>
                        {% end %}
-               </dl>
-       </div>
+               </div>
+       </section>
 {% end block %}
index 1862bc806906e45d2f02314fb4fa421dd6c71c5f..637f6ff982e957d4f15079de60f25c3e2e39fba1 100644 (file)
@@ -2,17 +2,36 @@
 
 {% block title %}{{ vendor_name }}{% end block %}
 
-{% block content %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12">
-                                       <h1>{{ vendor_name }}</h1>
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb is-medium" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo/vendors">
+                                                               {{ _("Vendors") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               {{ vendor_name }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
 
-                                       <p>
-                                               {{ _("All known devices by %s") % vendor_name }}
-                                       </p>
-                               </div>
+                               <h2 class="title is-2">{{ _("All known devices by %s") % vendor_name }}</h2>
                        </div>
                </div>
        </section>
index 5e66ed74ecd41a8eae1495c1a6b8f1a64d0ff82f..33dca376e39ee0c18d1991a767bafdc508f35d16 100644 (file)
@@ -3,32 +3,48 @@
 {% block title %}{{ _("Vendors") }}{% end block %}
 
 {% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12">
-                                       <h1>{{ _("Vendors") }}</h1>
-                               </div>
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb is-medium" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               Home
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/fireinfo">
+                                                               {{ _("Fireinfo") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#">
+                                                               {{ _("Vendors") }}
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title is-1">{{ _("Vendors") }}</h1>
                        </div>
                </div>
        </section>
 
-       <div class="container">
-               <div class="row justify-content-center">
-                       <div class="col-12 col-md-6">
-                               <dl class="row">
-                                       {% for vendor, subsystems in vendors %}
-                                               {% if vendor %}
-                                                       <dt class="col-sm-9">{{ vendor }}</dt>
-                                                       <dd class="col-sm-3">
-                                                               {% for subsystem, vendor_id in sorted(subsystems) %}
-                                                                       <a href="/vendors/{{ subsystem }}/{{ vendor_id }}">{{ subsystem.upper() }}</a>
-                                                               {% end %}
-                                                       </dd>
+       <section class="section">
+               <div class="container">
+                       {% for vendor in sorted(vendors) %}
+                               <div class="columns">
+                                       <div class="column is-4">{{ vendor }}</div>
+                                       <div class="column is-3">
+                                               {% for subsystem, vendor_id in sorted(vendors[vendor]) %}
+                                                       <a href="/fireinfo/vendors/{{ subsystem }}/{{ vendor_id }}">
+                                                               {{ subsystem.upper() }}
+                                                       </a>
                                                {% end %}
-                                       {% end %}
-                               </dl>
-                       </div>
+                                       </div>
+                               </div>
+                       {% end %}
                </div>
-       </div>
+       </section>
 {% end block %}
index 17520e3be13bf772a6d269e5244455650298800b..ad5f982ad676f2411e0d02a0f2b54825621ee178 100644 (file)
 
 {% block head %}
        <meta name="description" content="{{ _("IPFire is a hardened, versatile, state-of-the-art Open Source firewall based on Linux.") }}" />
-       <link rel="alternate" type="application/atom+xml" title="RSS" href="https://blog.ipfire.org/feed.xml" />
+       <link rel="alternate" type="application/atom+xml" title="RSS" href="/blog/feed.xml" />
 {% end block %}
 
 {% block title %}{{ _("Welcome to IPFire") }}{% end block %}
 
 {% block container %}
-       <header class="cover">
-               <div class="container h-100">
-                       {% module ChristmasBanner() %}
-
-                       <div class="row d-flex h-100 flex-fill flex-md-row-reverse align-items-center">
-                               <div class="col-12 col-md-5 my-5 text-center">
-                                       <img class="img-fluid" src="{{ static_url("img/ipfire-tux.png") }}" alt="IPFire Logo" />
+       {# Christmas Banner #}
+       {% if now.month == 12 and now.day >= 10 %}
+               <section class="hero is-small is-primary">
+                       <div class="hero-body">
+                               <div class="container">
+                                       <p class="has-text-centered px-2 py-1">
+                                               <i class="fas fa-gifts"></i>
+                                               <i class="fa-solid fa-candy-cane"></i>
+                                               <i class="fa-solid fa-sleigh"></i>
+
+                                               This festive season, spread cheer and security!
+                                               Support IPFire with a holiday donation for a safer online world.
+                                               Merry Christmas!
+
+                                               &nbsp;
+
+                                               <a class="has-text-weight-bold" href="/donate">
+                                                       {{ _("Donate") }}
+                                               </a>
+                                       </p>
                                </div>
-
-                               <div class="col-12 col-md-7 px-3">
-                                       <h1 class="mb-3">{{ _("The Open Source Firewall") }}</h1>
-
-                                       {% if latest_release %}
-                                               <span>
-                                                       {{ _("Latest Release:") }}
-                                                       <a href="/download">{{ latest_release.name }}</a>
-                                                       {{ _("from %s") % locale.format_date(latest_release.date, shorter=True) }}
-                                               </span>
+                       </div>
+               </section>
+       {% end %}
+
+       {% if latest_release %}
+               <section class="has-background-light">
+                       <div class="container">
+                               <p class="has-text-centered px-2 py-1">
+                                       {{ _("Latest Release: %(release)s from %(when)s") \
+                                               % { "release" : latest_release.name, "when" : locale.format_day(latest_release.date, dow=False) } }}
+
+                                       {% if latest_release.blog %}
+                                               &nbsp;
+
+                                               <a class="has-text-weight-bold" href="/blog/{{ latest_release.blog.slug }}">
+                                                       {{ _("Read More") }}
+                                               </a>
                                        {% end %}
-
-                                       <div class="btn-toolbar my-5">
-                                               <a class="btn btn-outline-primary glow-primary btn-lg mr-2" href="/download">{{ _("Download") }}</a>
-                                               <a class="btn btn-outline-secondary glow-secondary btn-lg ml-2" href="/features">{{ _("Features") }}</a>
-                                       </div>
-                               </div>
+                               </p>
                        </div>
+               </section>
+       {% end %}
+
+       <section class="hero is-large is-primary" id="hero-index">
+               <video autoplay muted loop playsinline poster="{{ static_url("videos/firewall.jpg") }}">
+                       {# AV1 for modern browsers that support it #}
+                       <source src="{{ static_url("videos/firewall@1920.av1.mp4") }}" type="video/mp4; codecs=av01.0.05M.08" />
+
+                       {# H.265/HEVC for modern browsers #}
+                       <source src="{{ static_url("videos/firewall@1920.h265.mp4") }}" type="video/mp4; codecs=hvc1" />
+
+                       {# H.264/AVC for people who have a hardware decoder for it #}
+                       <source src="{{ static_url("videos/firewall@1920.h264.mp4") }}" type="video/mp4; codecs=avc1.4D401E" />
+
+                       {# VP9 as compatibility option for anything else #}
+                       <source src="{{ static_url("videos/firewall@1920.vp9.mp4") }}" type="video/mp4; codecs=vp9" />
+
+                       <p>
+                               Blazing Fire by Oleg Gamulinskii (CC)
+                               https://www.pexels.com/video/blazing-fire-2715412/
+                       </p>
+               </video>
+
+               <div class="hero-body">
+                       <div class="container">
+                               <h1 class="title is-1">
+                                       {{ _("IPFire") }}_ {{ _("More Than A Firewall") }}
+                               </h1>
+
+                               <h4 class="subtitle is-4">
+                                       The Open Source Linux-based Firewall Operating System with a Comprehensive Feature Set
+                               </h4>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h3 class="title is-2 has-text-centered">
+                               {{ _("IPFire") }}<span class="has-text-primary">_</span> &dash; {{ _("The Open Source Firewall") }}
+                       </h3>
                </div>
-       </header>
+       </section>
 
-       <section class="inverse">
+       <section class="section">
                <div class="container">
-                       <div class="row mb-5">
-                               <div class="col-12 col-md-9 col-lg-7">
-                                       <h1 class="mb-0">{{ _("Secure your network with IPFire") }}</h1>
+                       <div class="columns">
+                               <div class="column has-text-centered">
+                                       <span class="fas fa-shield-alt fa-4x p-4"></span>
 
-                                       <p>
-                                               {{ _("IPFire is a hardened, versatile, state-of-the-art Open Source firewall based on Linux.") }}
-                                               {{ _("Its ease of use, high performance in any scenario and extensibility make it usable for everyone.") }}
-                                       </p>
+                                       <h4 class="title is-4">{{ _("Robust Network Security") }}</h4>
 
-                                       <a class="btn btn-secondary mb-5" href="/features">{{ _("Learn More") }}</a>
+                                       <p class="is-size-5">
+                                               IPFire offers a powerful and secure firewall solution designed
+                                               to protect networks against evolving cyber threats.
+                                       </p>
                                </div>
-                       </div>
 
-                       <div class="row mt-5">
-                               <div class="col-md-6 col-lg-4 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fas fa-shield-alt fa-2x text-primary px-3"></span>
-                                       </div>
+                               <div class="column has-text-centered">
+                                       <span class="fas fa-terminal fa-4x p-4"></span>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Security") }}</h5>
+                                       <h4 class="title is-4">{{ _("Tailored to Your Needs") }}</h4>
 
-                                               <p>
-                                                       {{ _("Security is the highest priority in IPFire.") }}
-                                                       {{ _("It is hardened to protect itself from attacks from the Internet and prevents attacks on your network.") }}
-                                               </p>
-                                       </div>
+                                       <p class="is-size-5">
+                                               Enjoy the flexibility of customizing IPFire to match your unique
+                                               network requirements, ensuring a personalized and secure setup.
+                                       </p>
                                </div>
 
-                               <div class="col-md-6 col-lg-4 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fas fa-fire fa-2x text-primary px-3"></span>
-                                       </div>
+                               <div class="column has-text-centered">
+                                       <span class="fas fa-fire fa-4x p-4"></span>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Firewall") }}</h5>
+                                       <h4 class="title is-4">{{ _("Community-Driven") }}</h4>
 
-                                               <p>
-                                                       {{ _("Its powerful firewall engine and Intrusion Prevention System protects your network against attacks from the Internet and Denial-of-Service attacks.") }}
-                                               </p>
-                                       </div>
+                                       <p class="is-size-5">
+                                               Be part of a thriving community where users and developers collaborate,
+                                               sharing insights and innovations to enhance IPFire.
+                                       </p>
                                </div>
+                       </div>
 
-                               <div class="col-md-6 col-lg-4 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fab fa-osi fa-2x text-primary px-3"></span>
-                                       </div>
+                       <div class="columns is-centered">
+                               <div class="column is-two-thirds p-5">
+                                       <a class="button is-primary is-medium is-fullwidth" href="/about">
+                                               {{ _("Learn More About IPFire") }}
+                                       </a>
+                               </div>
+                       </div>
+               </div>
+       </section>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Open Source") }}</h5>
+       <section class="section">
+               <div class="container">
+                       <div class="box p-6 has-background-lwl has-text-white">
+                               <div class="columns is-mobile is-vcentered">
+                                       <div class="column">
+                                               <h3 class="title is-3 has-text-white">{{ _("Appliances and Support") }}</h3>
+
+                                               <div class="block">
+                                                       <p>
+                                                               Experience next-level security with our high-performance
+                                                               appliances, crafted to optimize IPFire's capabilities.
+                                                               Whether you're fortifying a business infrastructure or
+                                                               safeguarding your home network, we have the right
+                                                               appliance solution tailored for you.
+                                                       </p>
+
+                                                       <p>
+                                                               We are your partners in ensuring continuous network security.
+                                                               Our support solutions keep your systems running smoothly,
+                                                               providing peace of mind in the ever-evolving landscape of
+                                                               security.
+                                                       </p>
+                                               </div>
+
+                                               <div class="block">
+                                                       <a class="button is-white has-text-lwl has-text-weight-bold" href="https://store.lightningwirelabs.com/?utm_source={{ hostname }}&amp;utm_medium=frontpage">
+                                                               {{ _("Go to Store") }}
+                                                       </a>
+                                               </div>
+                                       </div>
 
-                                               <p>
-                                                       {{ _("IPFire is free software and developed by an open community and trusted by hundreds of thousands of users from all around the world.") }}
-                                               </p>
+                                       <div class="column is-narrow is-hidden-mobile has-text-centered">
+                                               <figure class="image m-5 is-128x128">
+                                                       <img src="{{ static_url("img/lightningwirelabs-logo.svg") }}" alt="{{ _("Lightning Wire Labs") }}">
+                                               </figure>
                                        </div>
                                </div>
                        </div>
diff --git a/src/templates/lists/index.html b/src/templates/lists/index.html
new file mode 100644 (file)
index 0000000..61c6d39
--- /dev/null
@@ -0,0 +1,67 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Lists") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Mailing Lists") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Mailing Lists") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% for list in sorted(lists) %}
+                               <div class="block">
+                                       <div class="box">
+                                               <h4 class="title is-4">
+                                                       {{ list }}
+                                               </h4>
+
+                                               <h6 class="subtitle is-6">
+                                                       {{ list.description }}
+                                               </h6>
+
+                                               <div class="buttons">
+                                                       <a class="button is-light" href="{{ list.archive_url }}">
+                                                               <span class="icon-text">
+                                                                       <span class="icon">
+                                                                               <i class="fa-solid fa-inbox"></i>
+                                                                       </span>
+                                                                       <span>{{ _("Archive") }}</span>
+                                                               </span>
+                                                       </a>
+
+                                                       {% if current_user %}
+                                                               {% if list in subscribed_lists %}
+                                                                       <a class="button is-success is-outlined" href="/lists/{{ list.list_id }}/unsubscribe">
+                                                                               {{ _("Subscribed") }}
+                                                                       </a>
+                                                               {% else %}
+                                                                       <a class="button is-success" href="/lists/{{ list.list_id }}/subscribe">
+                                                                               {{ _("Subscribe") }}
+                                                                       </a>
+                                                               {% end %}
+                                                       {% end %}
+                                               </div>
+                                       </div>
+                               </div>
+                       {% end %}
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/location/base.html b/src/templates/location/base.html
deleted file mode 100644 (file)
index 815f28b..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "../base.html" %}
-
-{% block content %}
-       <h1 class="text-center my-5">
-               <a class="text-white" href="/lookup/{{ address }}">{{ address }}</a>
-       </h1>
-
-       <section>
-               <div class="row justify-content-center">
-                       <div class="col-12 col-lg-6">
-                               {% block main %}{% end block %}
-                       </div>
-               </div>
-       </section>
-{% end block %}
diff --git a/src/templates/location/blacklists.html b/src/templates/location/blacklists.html
deleted file mode 100644 (file)
index fb3421a..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Blacklist Status of %s") % address }}{% end block %}
-
-{% block main %}
-       <div class="card mb-4">
-               <div class="card-body">
-                       <h5 class="card-title mb-0">{{ _("Blacklists") }}</h5>
-               </div>
-
-               <ul class="list-group list-group-flush">
-                       {% for bl in sorted(blacklists) %}
-                               {% if blacklists[bl] %}
-                                       {% set code, reason = blacklists[bl] %}
-
-                                       <li class="list-group-item {% if code %}list-group-item-danger{% else %}list-group-item-success{% end %}">
-                                               <p class="mb-0">{{ bl }}</p>
-
-                                               {% if reason %}
-                                                       <small class="text-muted">{{ reason }}</small>
-                                               {% end %}
-                                       </li>
-                               {% end %}
-                       {% end %}
-               </ul>
-       </div>
-{% end block %}
diff --git a/src/templates/location/download.html b/src/templates/location/download.html
deleted file mode 100644 (file)
index bc2ce18..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Download") }}{% end block %}
-
-{% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1 class="display-2">{{ _("Download") }}</h1>
-
-                                       <p>
-                                               Learn how to download and install <code>libloc</code>
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row justify-content-between flex-lg-row-reverse">
-                               <div class="col-12 col-lg-4 text-center text-lg-right">
-                                       <i class="fas fa-download fa-10x"></i>
-                               </div>
-
-                               <div class="col-12 col-lg-8">
-                                       <h1>{{ _("Source") }}</h1>
-                                       
-                                       <p>
-                                               Build <code>libloc</code>, the software that powers IPFire Location
-                                               from source.
-                                       </p>
-
-                                       <div class="btn-toolbar">
-                                               <a class="btn btn-secondary mr-2" href="https://source.ipfire.org/releases/libloc/">
-                                                       {{ _("Download Source") }}
-                                               </a>
-
-                                               <a class="btn btn-secondary" href="https://git.ipfire.org/?p=location/libloc.git;a=summary">
-                                                       {{ _("Browse Source") }}
-                                               </a>
-                                       </div>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <p class="lead my-5">
-                               We provide pre-compiled packages for various distributions to get you
-                               started with <code>libloc</code> quicker
-                       </p>
-
-                       <div class="row justify-content-center flex-lg-row-reverse">
-                               <div class="col-12 col-sm-6 col-lg-2 text-center text-lg-right">
-                                       <img class="img-fluid w-100 my-5" src="{{ static_url("img/ipfire-tux.png") }}"
-                                               alt="{{ _("IPFire") }}">
-                               </div>
-
-                               <div class="col-12 col-lg-10">
-                                       <h1>{{ _("IPFire") }}</h1>
-
-                                       <p>
-                                               IPFire Location comes pre-installed with every IPFire system
-                                               and powers firewall rules based on source/destination country
-                                               as well as geographic reports.
-                                       </p>
-                               </div>
-                       </div>
-
-                       <div class="row justify-content-center flex-lg-row-reverse">
-                               <div class="col-12 col-sm-6 col-lg-2 text-center text-lg-right">
-                                       <img class="img-fluid w-100 my-5" src="{{ static_url("img/debian-logo.svg") }}"
-                                               alt="{{ _("Debian") }}">
-                               </div>
-
-                               <div class="col-12 col-lg-10">
-                                       <h1>{{ _("Debian") }}</h1>
-
-                                       <p>
-                                               The supported architectures are amd64, arm64, i386 and armhf.
-                                       </p>
-
-                                       {% for release in ("buster", "bullseye", "sid") %}
-                                               <h4 class="mb-1">{{ _("Debian %s") % release }}</h4>
-
-                                               <p>
-                                                       Create a new file <code>/etc/apt/sources.list.d/location.list</code>
-                                               </p>
-
-                                               <pre class="pre-light mb-4"><code>deb&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;https://packages.ipfire.org/location {{ release }}/
-deb-src&nbsp;https://packages.ipfire.org/location {{ release }}/</code></pre>
-                                       {% end %}
-
-                                       <p>
-                                               All packages are cryptographically signed.
-                                               To install the key, run this command:
-                                       </p>
-
-                                       <pre class="pre-light mb-4"><code>curl https://packages.ipfire.org/79842AA7CDBA7AE3-pub.asc | apt-key add -</code></pre>
-
-                                       <p>
-                                               Finally download the package lists and install <code>libloc</code>
-                                       </p>
-                                       
-                                       <pre class="pre-light mb-4"><code>apt-get update
-apt-get install location</code></pre>
-                               </div>
-                       </div>
-               </div>
-       </section>
-{% end block %}
diff --git a/src/templates/location/how-to-use.html b/src/templates/location/how-to-use.html
deleted file mode 100644 (file)
index acb8183..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("How To Use") }}{% end block %}
-
-{% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1 class="display-2">{{ _("How To Use") }}</h1>
-
-                                       <p>
-                                               <code>libloc</code> is versatile, fast and easy to use
-                                               in any application.
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row justify-content-between flex-md-row-reverse">
-                               <div class="col-12 col-md-4 text-center text-md-right">
-                                       <img class="img-fluid w-100 my-5" src="{{ static_url("img/bash-logo.svg") }}"
-                                               alt="{{ _("CLI") }}">
-                               </div>
-
-                               <div class="col-12 col-md-8">
-                                       <h1>{{ _("Command Line") }}</h1>
-
-                                       <p>
-                                               <code>libloc</code> comes with a command line tool which
-                                               makes it easy to test the library or integrate it into
-                                               your shell scripts.
-                                               <code>location(8)</code> knows a couple of commands
-                                               to retrieve country or Autonomous System of an IP address
-                                               and can generate lists of networks to be imported into
-                                               other software.
-                                       </p>
-
-                                       <p>
-                                               Although this is not the fastest way to lookup a large number
-                                               of IP addresses, <code>location(8)</code> is versatile
-                                               and very easy to use.
-                                       </p>
-
-                                       <a class="btn btn-secondary" href="https://man-pages.ipfire.org/libloc/location.html">
-                                               {{ _("Man Page") }}
-                                       </a>
-                               </div>
-                       </div>
-
-                       <div class="row">
-                               <div class="col-12">
-                                       <h6>{{ _("Search for an Autonomous System by Name") }}</h6>
-
-                                       <pre class="mb-4"><code>$ location search-as "Lightning Wire Labs"
-AS204867 (Lightning Wire Labs GmbH)</code></pre>
-
-                                       <h6>{{ _("Lookup an IP Address") }}</h6>
-
-                                       <pre class="mb-4"><code>$ location lookup 81.3.27.38
-81.3.27.38 belongs to 81.3.27.0/24 which is a part of AS24679 (Hostway Deutschland GmbH)</code></pre>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <div class="row justify-content-between flex-md-row-reverse">
-                               <div class="col-12 col-md-4 text-center text-md-right">
-                                       <img class="img-fluid w-100 my-5" src="{{ static_url("img/python-logo.svg") }}"
-                                               alt="{{ _("Python") }}">
-                               </div>
-
-                               <div class="col-12 col-md-6">
-                                       <h1>{{ _("Python") }}</h1>
-
-                                       <p>
-                                               <code>libloc</code> comes with native Python bindings which
-                                               are used by its main command-line tool
-                                               <a class="text-white" href="https://man-pages.ipfire.org/libloc/location.html">
-                                                       <code>location</code>
-                                               </a>.
-                                               They are the most advanced bindings as they support reading
-                                               from the database as well as writing to it.
-                                       </p>
-                               </div>
-                       </div>
-
-                       <div class="row">
-                               <div class="col-12">
-                                       <h6>{{ _("Load the database") }}</h6>
-
-                                       <pre class="pre-light mb-4"><code>Python 3.7.3 (default, Apr  3 2019, 05:39:12)
-[GCC 8.3.0] on linux
-Type "help", "copyright", "credits" or "license" for more information.
-&gt;&gt;&gt; import location
-&gt;&gt;&gt; d = location.Database("/usr/share/location/database.db")</code></pre>
-
-                                       <h6>{{ _("Search for an Autonomous System by Name") }}</h6>
-
-                                       <pre class="pre-light mb-4"><code>&gt;&gt;&gt; for i in d.search_as("Lightning Wire Labs"):
-... &nbsp;&nbsp;print(i)
-...
-AS204867 (Lightning Wire Labs GmbH)</code></pre>
-
-                                       <h6>{{ _("Lookup an IP Address") }}</h6>
-
-                                       <pre class="pre-light"><code>&gt;&gt;&gt; n = d.lookup("81.3.27.38")
-&gt;&gt;&gt; n
-&lt;location.Network 81.3.27.0/24&gt;
-&gt;&gt;&gt; n.asn
-24679
-&gt;&gt;&gt; n.country_code
-'DE'</code></pre>
-                               </div>
-                       </div>
-               </div>
-       </section>
-{% end block %}
diff --git a/src/templates/location/how-to-use/cli.html b/src/templates/location/how-to-use/cli.html
new file mode 100644 (file)
index 0000000..03568c9
--- /dev/null
@@ -0,0 +1,86 @@
+{% extends "../../base.html" %}
+
+{% block title %}{{ _("How To Use?") }} - {{ _("Command Line") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location/how-to-use">
+                                                               {{ _("How To Use?") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Command Line") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Command Line") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns">
+                               <div class="column">
+                                       <div class="content">
+                                               <p>
+                                                       <code>libloc</code> comes with a command line tool which
+                                                       makes it easy to test the library or integrate it into
+                                                       your shell scripts.
+                                                       <code>location(8)</code> knows a couple of commands
+                                                       to retrieve country or Autonomous System of an IP address
+                                                       and can generate lists of networks to be imported into
+                                                       other software.
+                                               </p>
+
+                                               <p>
+                                                       Although this is not the fastest way to lookup a large number
+                                                       of IP addresses, <code>location(8)</code> is versatile
+                                                       and very easy to use.
+                                               </p>
+
+                                               <a class="button is-light" href="https://man-pages.ipfire.org/libloc/location.html">
+                                                       {{ _("Man Page") }}
+                                               </a>
+                                       </div>
+                               </div>
+
+                               <div class="column is-narrow">
+                                       <figure class="image is-256x256">
+                                               <img src="{{ static_url("img/bash-logo.svg") }}" alt="{{ _("CLI") }}">
+                                       </figure>
+                               </div>
+                       </div>
+
+                       <div class="block">
+                               <h6 class="title is-6">{{ _("Search for an Autonomous System by Name") }}</h6>
+
+                               <pre><code>$ location search-as "Lightning Wire Labs"
+AS204867 (Lightning Wire Labs GmbH)</code></pre>
+                       </div>
+
+                       <div class="block">
+                               <h6 class="title is-6">{{ _("Lookup an IP Address") }}</h6>
+
+                               <pre><code>$ location lookup 81.3.27.38
+81.3.27.38 belongs to 81.3.27.0/24 which is a part of AS24679 (Hostway Deutschland GmbH)</code></pre>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/location/how-to-use/dns.html b/src/templates/location/how-to-use/dns.html
new file mode 100644 (file)
index 0000000..ffee965
--- /dev/null
@@ -0,0 +1,169 @@
+{% extends "../../base.html" %}
+
+{% block title %}{{ _("How To Use?") }} - {{ _("DNS") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location/how-to-use">
+                                                               {{ _("How To Use?") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("DNS") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("DNS") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="block">
+                               <p class="is-size-5">
+                                       If you cannot use the IPFire Location database on your hosts,
+                                       we offer a DNS service to query parts of the data just by
+                                       sending a DNS query allowing to integrate this database into
+                                       more applications.
+                               </p>
+                       </div>
+
+                       <div class="block">
+                               <p class="is-size-5">
+                                       Queries function on the principle of reverse lookups and are
+                                       fast and globally cacheable.
+                                       DNSSEC allows to trust the data just like a local database.
+                               </p>
+                       </div>
+
+                       <div class="notification">
+                               This is a new, experimental feature. The API, behaviour and format of the
+                               responses might be subject to change.
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">
+                               {{ _("Query Country Codes") }} - <code>cc.location.ipfire.org</code>
+                       </h4>
+
+                       <div class="content">
+                               <p>
+                                       You can query the country code of an IP address.
+                               </p>
+                       </div>
+
+                       <pre><code>$ dig +short TXT 0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.2.b.0.8.7.6.0.1.0.0.2.cc.location.ipfire.org
+"DE"
+
+$ dig +short TXT 38.27.3.81.cc.location.ipfire.org
+"DE"</code></pre>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">
+                               {{ _("Query Origins") }} - <code>origin.location.ipfire.org</code>
+                       </h4>
+
+                       <div class="content">
+                               <p>
+                                       You can query the origin of an IP address which will give you
+                                       the AS number and name where that IP address originates from.
+                               </p>
+                       </div>
+
+                       <pre><code>$ dig +short TXT 0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.2.b.0.8.7.6.0.1.0.0.2.origin.location.ipfire.org
+"AS204867 - Lightning Wire Labs GmbH"
+
+$ dig +short TXT 38.27.3.81.origin.location.ipfire.org
+"AS24679 - kyberio GmbH"</code></pre>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">
+                               {{ _("Query Prefixes") }} - <code>prefix.location.ipfire.org</code>
+                       </h4>
+
+                       <div class="content">
+                               <p>
+                                       You can query an IP address for the subnet it belongs to.
+                                       This won't always match the announced route in the global
+                                       routing table.
+                               </p>
+                       </div>
+
+                       <pre><code>$ dig +short TXT 0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.2.b.0.8.7.6.0.1.0.0.2.prefix.location.ipfire.org
+"2001:678:b28::/48"
+
+$ dig +short TXT 8.8.8.8.prefix.location.ipfire.org
+"8.8.8.0/24"</code></pre>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">
+                               {{ _("Query AS Names") }} - <code>asn.location.ipfire.org</code>
+                       </h4>
+
+                       <div class="content">
+                               <p>
+                                       Resolve an AS number into a human-readable description.
+                               </p>
+                       </div>
+
+                       <pre><code>$ dig +short TXT 204867.asn.location.ipfire.org
+"Lightning Wire Labs GmbH"
+
+$ dig +short TXT 3320.asn.location.ipfire.org
+"Deutsche Telekom AG"
+
+$ dig +short TXT 15169.asn.location.ipfire.org
+"Google LLC"</code></pre>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">
+                               {{ _("Query Bogons") }} - <code>bogons.location.ipfire.org</code>
+                       </h4>
+
+                       <div class="content">
+                               <p>
+                                       Check if an IP address is considered being a bogon.
+
+                                       That means that if an IP address is not part of the global routing
+                                       table, we will return <code>127.0.0.2</code>,
+                                       otherwise we will return <code>NXDOMAIN</code>.
+                               </p>
+                       </div>
+
+                       <pre><code>$ dig +short A 0.0.0.10.bogons.location.ipfire.org
+127.0.0.2</code></pre>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/location/how-to-use/index.html b/src/templates/location/how-to-use/index.html
new file mode 100644 (file)
index 0000000..df3e1c4
--- /dev/null
@@ -0,0 +1,163 @@
+{% extends "../../base.html" %}
+
+{% block title %}{{ _("How To Use?") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("How To Use?") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("How To Use?") }}</h1>
+
+                               <div class="block">
+                                       <p class="is-size-5">
+                                               IPFire Location consists of two integral components:
+                                               <code>libloc</code>, the implementation responsible for processing
+                                               geolocation data, and the actual database file containing the
+                                               comprehensive repository of IP address locations.
+                                       </p>
+                               </div>
+
+                               <div class="block">
+                                       <p class="is-size-5">
+                                               IPFire Location offers versatile usage options tailored to diverse
+                                               needs and preferences as there are so many possible applications.
+                                               Below is a list with all available options to choose which is
+                                               is best suited for your individual organization.
+                                       </p>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="buttons are-medium">
+                               <a class="button is-light is-fullwidth" href="/location/how-to-use/cli">
+                                       <span class="icon">
+                                               <i class="fa-solid fa-terminal"></i>
+                                       </span>
+                                       <span>{{ _("Command Line") }}</span>
+                               </a>
+
+                               <a class="button is-light is-fullwidth" href="/location/how-to-use/c" disabled>
+                                       {{ _("C/C++") }}
+                               </a>
+
+                               <a class="button is-light is-fullwidth" href="/location/how-to-use/python">
+                                       <span class="icon">
+                                               <i class="fa-brands fa-python"></i>
+                                       </span>
+                                       <span>{{ _("Python") }}</span>
+                               </a>
+
+                               <a class="button is-light is-fullwidth" href="/location/how-to-use/lua" disabled>
+                                       {{ _("Lua") }}
+                               </a>
+
+                               <a class="button is-light is-fullwidth" href="/location/how-to-use/dns">
+                                       {{ _("DNS") }}
+                               </a>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ _("The IPFire Location Database") }}</h4>
+
+                       <div class="content">
+                               <p>
+                                       For this project, we have created a new and unique database format.
+                                       Only this way, it was possible to achieve our goals that we have laid for ourselves.
+                               </p>
+                       </div>
+
+                       <div class="block">
+                               <p class="title is-5">
+                                       Updated Daily
+                               </p>
+
+                               <div class="content">
+                                       <p>
+                                               The IPFire Location database undergoes daily refreshes to
+                                               ensure that it remains up-to-date and accurate.
+                                       </p>
+                               </div>
+                       </div>
+
+                       <div class="block">
+                               <p class="title is-5">
+                                       Small Downloads
+                               </p>
+
+                               <div class="content">
+                                       <p>
+                                               The database is very small for the amount of information it stores
+                                               allowing frequent updates without using a lot of bandwidth and disk space.
+                                               A smart download mechanism ensures that the database is only being
+                                               downloaded when needed allowing IPFire Location to be deployed in
+                                               IoT scenarios where bandwidth is scarse.
+                                       </p>
+
+                                       </p>
+                                               Other formats like CSV are many hundred megabytes in size
+                                               when the IPFire Location database only needs a few megabytes.
+                                       </p>
+                               </div>
+                       </div>
+
+                       <div class="block">
+                               <p class="title is-5">
+                                       Intelligent Storage
+                               </p>
+
+                               <div class="content">
+                                       <p>
+                                               All data is stored using optimal data structures for compression
+                                               and lookup speed.
+                                               Using binary trees and binary searches, there is no way to access
+                                               the data any faster.
+                                       </p>
+
+                                       <p>
+                                               Anything that is being stored in the database can be accessed
+                                               in nanoseconds allowing IPFire Location to be used where thousands
+                                               of lookups a second are needed.
+                                       </p>
+                               </div>
+                       </div>
+
+                       <div class="block">
+                               <p class="title is-5">
+                                       Cryptographically Signed
+                               </p>
+
+                               <div class="content">
+                                       <p>
+                                               Because IPFire Location is cryptographically signed, its authenticity
+                                               can be verified when being downloaded. Since it is being deployed in
+                                               security applications a maliciously crafted database could large damage.
+                                       </p>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/location/how-to-use/python.html b/src/templates/location/how-to-use/python.html
new file mode 100644 (file)
index 0000000..33e67a1
--- /dev/null
@@ -0,0 +1,94 @@
+{% extends "../../base.html" %}
+
+{% block title %}{{ _("How To Use?") }} - {{ _("Python") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location/how-to-use">
+                                                               {{ _("How To Use?") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Python") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Python") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns">
+                               <div class="column">
+                                       <div class="content">
+                                               <p>
+                                                       <code>libloc</code> comes with native Python bindings which
+                                                       are used by its main command-line tool
+                                                       <a class="text-white" href="https://man-pages.ipfire.org/libloc/location.html">
+                                                               <code>location</code>
+                                                       </a>.
+                                                       They are the most advanced bindings as they support reading
+                                                       from the database as well as writing to it.
+                                               </p>
+                                       </div>
+                               </div>
+
+                               <div class="column is-narrow">
+                                       <figure class="image is-128x128">
+                                               <img src="{{ static_url("img/python-logo.svg") }}"
+                                                       alt="{{ _("Python") }}">
+                                       </figure>
+                               </div>
+                       </div>
+
+                       <div class="block">
+                               <h6 class="title is-6">{{ _("Load the database") }}</h6>
+
+                               <pre><code>Python 3.7.3 (default, Apr  3 2019, 05:39:12)
+[GCC 8.3.0] on linux
+Type "help", "copyright", "credits" or "license" for more information.
+&gt;&gt;&gt; import location
+&gt;&gt;&gt; d = location.Database("/usr/share/location/database.db")</code></pre>
+                       </div>
+
+                       <div class="block">
+                               <h6 class="title is-6">{{ _("Search for an Autonomous System by Name") }}</h6>
+
+                               <pre><code>&gt;&gt;&gt; for i in d.search_as("Lightning Wire Labs"):
+... &nbsp;&nbsp;print(i)
+...
+AS204867 (Lightning Wire Labs GmbH)</code></pre>
+                       </div>
+
+                       <div class="block">
+                               <h6 class="title is-6">{{ _("Lookup an IP Address") }}</h6>
+
+                               <pre><code>&gt;&gt;&gt; n = d.lookup("81.3.27.38")
+&gt;&gt;&gt; n
+&lt;location.Network 81.3.27.0/24&gt;
+&gt;&gt;&gt; n.asn
+24679
+&gt;&gt;&gt; n.country_code
+'DE'</code></pre>
+                       </div>
+               </div>
+       </section>
+{% end block %}
index 0e0ddccd7c3079fe4f455e543522cf1c3d5a4af9..4eb4a9c3208f4e14169d7baf7c7948bb2746044b 100644 (file)
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block head %}
        <meta name="description" content="{{ _("A powerful location database to find people on the Internet") }}" />
 {% end block %}
 
-{% block title %}{{ _("Welcome to IPFire") }}{% end block %}
+{% block title %}{{ _("Welcome to IPFire Location") }}{% end block %}
 
 {% block container %}
-       <header class="cover">
-               <div class="container d-flex h-100 align-items-center">
-                       <div class="row flex-fill justify-content-center">
-                               <div class="col-12 col-lg-6 text-center">
-                                       <h1 class="mb-5">
-                                               {{ _("Hey, %s!" % address) }}
-                                       </h1>
-
-                                       {% if address.country_code %}
-                                               <div class="my-5">
-                                                       <h1 class="display-4 flag-icon flag-icon-{{ address.country_code.lower() }}"></h1>
-
-                                                       <p class="lead">
-                                                               {{ _("You are visiting from %s") % format_country_name(address.country_code) }}
-                                                       </p>
-                                               </div>
-                                       {% end %}
+       <section class="hero is-medium is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <h1 class="title">{{ _("IPFire Location") }}</h1>
 
-                                       <a class="btn btn-light btn-block" href="/lookup/{{ address }}">
-                                               {{ _("Show Me More") }}
-                                       </a>
-                               </div>
-                       </div>
-               </div>
-       </header>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row mb-5">
-                               <div class="col-12">
-                                       <h1 class="mb-3">{{ _("What is IPFire Location?") }}</h1>
-
-                                       <p>
-                                               IPFire Location can be used in firewalls or other threat
-                                               detection software, load-balancers, online shops, websites,
-                                               analytics &amp; reporting tools and more to detect the
-                                               originating country by IP address.
-                                               We are proud that our software is faster than others
-                                               by maintaining a smaller memory footprint which puts it
-                                               first in performance.
-                                       </p>
-
-                                       <p>
-                                               Our daily updated database does not only have information
-                                               about the originating country of all IPv6 and IPv4 addresses.
-                                               It identifies the Autonomous System (AS) these IP
-                                               addresses belong to, as well and more...
-                                       </p>
-
-                                       <p>
-                                               <code>libloc</code> is the C/C++ library that fires our
-                                               location services and runs on *nix, Mac OS X and more.
-                                               Integration into existing software is very easy and
-                                               bindings for languages like Python and Perl are available.
-                                       </p>
-
-                                       <div class="btn-toolbar mb-5">
-                                               <a class="btn btn-secondary mr-2" href="/how-to-use">{{ _("How To Use") }}</a>
-                                               <a class="btn btn-primary" href="https://www.ipfire.org/donate">
-                                                       {{ _("Donate") }}
-                                               </a>
-                                       </div>
-                               </div>
-                       </div>
+                               <h6 class="subtitle mb-6">
+                                       {{ _("Discover the Internet, One Location at a Time") }}
+                               </h6>
 
-                       <div class="row mt-5">
-                               <div class="col-md-12 col-lg-6 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fas fa-biohazard fa-2x text-primary px-3"></span>
-                                       </div>
+                               <div class="columns is-multiline my-6">
+                                       <div class="column is-half">
+                                               <div class="columns is-mobile is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fas fa-biohazard fa-5x"></span>
+                                                       </div>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Threat Detection") }}</h5>
+                                                       <div class="column">
+                                                               <p class="title is-5">{{ _("Threat Detection") }}</p>
 
-                                               <p>
-                                                       {{ _("Location information is crucial to identify where an attacker is coming from.") }}
-                                                       <br>
-                                                       {{ _("Analyze your traffic for malicious autonomous systems and block the straight away with IPFire.") }}
-                                               </p>
+                                                               <div class="content">
+                                                                       <p>
+                                                                               By accurately identifying the geographical location of IP addresses,
+                                                                               users can implement targeted security measures to mitigate risks
+                                                                               associated with specific regions or countries.
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               </div>
                                        </div>
-                               </div>
 
-                               <div class="col-md-12 col-lg-6 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fas fa-balance-scale fa-2x text-primary px-3"></span>
-                                       </div>
+                                       <div class="column is-half">
+                                               <div class="columns is-mobile is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fas fa-location-dot fa-5x"></span>
+                                                       </div>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Load-Balancing") }}</h5>
+                                                       <div class="column">
+                                                               <p class="title is-5">{{ _("Localization") }}</p>
 
-                                               <p>
-                                                       {{ _("Redirect your users to the nearest data center to given them a better user experience with faster websites and faster downloads.") }}
-                                               </p>
+                                                               <div class="content">
+                                                                       <p>
+                                                                               Businesses can use geolocation data to tailor content and services based
+                                                                               on the location of their users, enhancing user experience and engagement.
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               </div>
                                        </div>
-                               </div>
 
-                               <div class="col-md-12 col-lg-6 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fas fa-route fa-2x text-primary px-3"></span>
+                                       <div class="column is-half">
+                                               <div class="columns is-mobile is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fas fa-lock fa-5x"></span>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">{{ _("Access Control") }}</p>
+
+                                                               <div class="content">
+                                                                       <p>
+                                                                               The Location database enables users to enforce access controls based on
+                                                                               geographic criteria, allowing them to restrict or grant access to
+                                                                               resources based on location.
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               </div>
                                        </div>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Online Visitors") }}</h5>
+                                       <div class="column is-half">
+                                               <div class="columns is-mobile is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fas fa-ethernet fa-5x"></span>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">{{ _("Network Optimization") }}</p>
 
-                                               <p>
-                                                       {{ _("Comply with legal requirements and show visitors the correct information depending on the country they are visiting from.") }}
-                                               </p>
+                                                               <div class="content">
+                                                                       <p>
+                                                                               Geolocation data can be used to optimize network performance by directing
+                                                                               traffic through the most efficient routes based on geographic proximity.
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               </div>
                                        </div>
-                               </div>
 
-                               <div class="col-md-12 col-lg-6 mb-5 d-flex">
-                                       <div class="align-self-stretch">
-                                               <span class="fab fa-osi fa-2x text-primary px-3"></span>
+                                       <div class="column is-half">
+                                               <div class="columns is-mobile is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fas fa-gavel fa-5x"></span>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">{{ _("Compliance") }}</p>
+
+                                                               <div class="content">
+                                                                       <p>
+                                                                               Many regulatory requirements necessitate geolocation data for compliance
+                                                                               purposes.
+                                                                               The IPFire Location database helps users meet these requirements by providing
+                                                                               accurate location information.
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               </div>
                                        </div>
 
-                                       <div class="flex-column">
-                                               <h5 class="mb-3">{{ _("Open Source") }}</h5>
+                                       <div class="column is-half">
+                                               <div class="columns is-mobile is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fab fa-osi fa-5x"></span>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">{{ _("Open Source & Free Forever") }}</p>
 
-                                               <p>
-                                                       {{ _("libloc is free software and relies on support from the community.") }}
-                                                       {{ _("You can support us by helping to improve our database or with your donation.") }}
-                                               </p>
+                                                               <div class="content">
+                                                                       <p>
+                                                                               IPFire Location is open source, ensuring transparency, flexibility, and
+                                                                               community-driven development for users who value open and collaborative solutions.
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               </div>
                                        </div>
                                </div>
                        </div>
+               </div>
+       </section>
 
-                       <div class="row">
-                               <div class="col-12">
-                                       <h3 class="mb-3">{{ _("Who Is Using IPFire Location?") }}</h3>
+       <section class="hero">
+               <div class="hero-body">
+                       <div class="container">
+                               <h5 class="title is-5">{{ _("Why Use IPFire Location?") }}</h5>
 
-                                       <div class="row row-cols-2 row-cols-lg-4 justify-content-around align-items-center">
-                                               <div class="col text-center">
-                                                       <img src="{{ static_url("img/ipfire-tux.png") }}"
-                                                               class="img-fluid mb-3" alt="{{ _("IPFire Logo") }}">
+                               <div class="columns">
+                                       <div class="column is-half">
+                                               <div class="block">
+                                                       <p class="title is-5">{{ _("Accuracy") }}</p>
 
-                                                       <h6>
-                                                               <a href="https://www.ipfire.org/" rel="noopener">IPFire</a>
-                                                       </h6>
+                                                       <div class="content">
+                                                               <p>
+                                                                       {{ _("IPFire Location aims to be the most accurate database on the market.") }}
+                                                                       {{ _("By gathering data from many sources, we achieve highest accuracy.") }}
+                                                               </p>
+                                                       </div>
                                                </div>
 
-                                               <div class="col text-center">
-                                                       <img src="{{ static_url("img/tor.svg") }}"
-                                                               class="img-fluid mb-3" alt="{{ _("Tor Project Logo") }}">
+                                               <div class="block">
+                                                       <p class="title is-5">{{ _("A Multitude Of Information") }}</p>
 
-                                                       <h6>
-                                                               The <a href="https://www.torproject.org/" rel="noopener">Tor Project</a>
-                                                       </h6>
+                                                       <div class="content">
+                                                               <p>
+                                                                       {{ _("Unlike other Geolocation databases, IPFire Location does not only hold country information. Instead we have:") }}
+                                                               </p>
+
+                                                               <ul>
+                                                                       <li>{{ _("Country Codes") }}</li>
+                                                                       <li>{{ _("Network Prefixes") }}</li>
+                                                                       <li>{{ _("AS Number & Names") }}</li>
+                                                                       <li>
+                                                                               {{ _("Flags for") }}
+                                                                               <ul>
+                                                                                       <li>{{ _("Anycast Networks") }}</li>
+                                                                                       <li>{{ _("Satellite Networks") }}</li>
+                                                                                       <li>{{ _("Anonymous Proxies") }}</li>
+                                                                                       <li>{{ _("Hostile Networks") }}</li>
+                                                                               </ul>
+                                                                       </li>
+                                                                       <li>
+                                                                               {{ _("A full list of bogons") }}
+                                                                       </li>
+                                                               </ul>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-half">
+                                               <div class="block">
+                                                       <p class="title is-5">{{ _("Performance") }}</p>
+
+                                                       <div class="content">
+                                                               <p>
+                                                                       {{ _("For some applications, performance is key.") }}
+                                                                       {{ _("IPFire Location is designed to organise all data for the fastest lookups enabling applications that were not possible before.") }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <p class="title is-5">{{ _("Easy Integration") }}</p>
+
+                                                       <div class="content">
+                                                               <p>
+                                                                       {{ _("IPFire Location uses a proprietary database format that is read and written by our own software.") }}
+                                                                       {{ _("A lightweight C library is the core of this application and there are bindings available for Python, Perl and Lua, too.") }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               <div class="block">
+                                                       <p class="title is-5">{{ _("Secure And Instant Updates") }}</p>
+
+                                                       <div class="content">
+                                                               <p>
+                                                                       {{ _("The database is small to download and cryptographically signed which is a necessity to be deployed in security applications.") }}
+                                                               </p>
+                                                       </div>
                                                </div>
                                        </div>
                                </div>
+
+                               <a class="button is-medium is-primary is-fullwidth" href="/location/how-to-use">
+                                       {{ _("Learn How To Use IPFire Location") }}
+                               </a>
                        </div>
                </div>
        </section>
 
-       {% if posts %}
-               <section>
+       <section class="hero">
+               <div class="hero-body">
                        <div class="container">
-                               <div class="row justify-content-center">
-                                       <div class="col-12 col-lg-10">
-                                               <h3>{{ _("Related News from the IPFire Blog") }}</h3>
+                               <h5 class="title is-5">{{ _("Who Is Using IPFire Location?") }}</h5>
 
-                                               <div class="card my-3">
-                                                       <div class="card-body">
-                                                               {% module BlogPosts(posts) %}
-                                                       </div>
-                                               </div>
+                               <div class="level">
+                                       <div class="level-item">
+                                               <h1 class="title is-1">
+                                                       {% module IPFireLogo() %}
+                                               </h1>
+                                       </div>
+
+                                       <div class="level-item">
+                                               <a href="https://www.torproject.org/" rel="noopener">
+                                                       <figure class="image is-128x128 is-flex is-align-items-center">
+                                                               <img src="{{ static_url("img/tor.svg") }}" alt="{{ _("The Tor Project") }}">
+                                                       </figure>
+                                               </a>
+                                       </div>
 
-                                               <a class="btn btn-primary btn-lg btn-block" href="https://blog.ipfire.org/tags/location">
-                                                       {{ _("Read More") }}
+                                       <div class="level-item">
+                                               <a href="https://f-droid.org/" rel="noopener">
+                                                       <figure class="image is-128x128 is-flex is-align-items-center">
+                                                               <img src="{{ static_url("img/fdroid-logo.svg") }}" alt="{{ _("F-Droid") }}">
+                                                       </figure>
                                                </a>
                                        </div>
                                </div>
                        </div>
-               </section>
-       {% end %}
+               </div>
+       </section>
 {% end block %}
diff --git a/src/templates/location/install.html b/src/templates/location/install.html
new file mode 100644 (file)
index 0000000..d8cd45d
--- /dev/null
@@ -0,0 +1,130 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Install IPFire Location") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Install") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Install IPFire Location") }}</h1>
+
+                               <h4 class="subtitle">
+                                       IPFire Location supports a variety of distributions.
+
+                                       Select your favorite one, install the packages and be ready
+                                       to use IPFire Location in seconds.
+                               </h4>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered is-multiline py-5">
+                               <div class="column is-one-third">
+                                       <a class="button is-large is-fullwidth is-light" href="/">
+                                               {% module IPFireLogo() %}
+                                       </a>
+                               </div>
+
+                               <div class="column is-one-third">
+                                       <a class="button is-large is-fullwidth is-light" href="https://archlinux.org/packages/extra/x86_64/libloc/">
+                                               <span class="icon">
+                                                       <i class="fa-brands fa-linux"></i>
+                                               </span>
+                                               <span>Archlinux</span>
+                                       </a>
+                               </div>
+
+                               <div class="column is-one-third">
+                                       <a class="button is-large is-fullwidth is-light" href="https://packages.debian.org/source/stable/libloc">
+                                               <span class="icon">
+                                                       <i class="fa-brands fa-debian"></i>
+                                               </span>
+                                               <span>Debian</span>
+                                       </a>
+                               </div>
+
+                               <div class="column is-one-third">
+                                       <a class="button is-large is-fullwidth is-light" href="https://packages.fedoraproject.org/pkgs/libloc/">
+                                               <span class="icon">
+                                                       <i class="fa-brands fa-fedora"></i>
+                                               </span>
+                                               <span>Fedora</span>
+                                       </a>
+                               </div>
+
+                               <div class="column is-one-third">
+                                       <a class="button is-large is-fullwidth is-light" href="https://packages.ubuntu.com/source/stable/libloc">
+                                               <span class="icon">
+                                                       <i class="fa-brands fa-ubuntu"></i>
+                                               </span>
+                                               <span>Ubuntu</span>
+                                       </a>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ _("Source") }}</h4>
+
+                       <div class="content">
+                               <p>
+                                       If you want to review the source or if you want to compile
+                                       IPFire Location on your own, you can download the source here.
+                               </p>
+                       </div>
+
+                       <div class="buttons">
+                               <a class="button is-light" href="https://source.ipfire.org/releases/libloc/">
+                                       {{ _("Download Source") }}
+                               </a>
+
+                               <a class="button is-light" href="https://git.ipfire.org/?p=location/libloc.git;a=summary">
+                                       {{ _("Browse Source") }}
+                               </a>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <h4 class="title is-4">{{ _("Database") }}</h4>
+
+                       <div class="content">
+                               <p>
+                                       If you have installed IPFire Location as a package, the database
+                                       on your system will be updated automatically.
+                                       Alternatively you can trigger a manual update by running
+                                       <code>location update</code>.
+                               </p>
+                       </div>
+
+                       <div class="buttons">
+                               <a class="button is-light" href="https://location.ipfire.org/databases/1/location.db.xz">
+                                       {{ _("Download Database") }}
+                               </a>
+                       </div>
+               </div>
+       </section>
+{% end block %}
index f4a2e824d31ebcd8335b67eba4f390205d5b311c..0a1a5a47732ac792c508a0e383894c34bae8c009 100644 (file)
@@ -1,49 +1,88 @@
-{% extends "base.html" %}
+{% extends "../base.html" %}
 
 {% block title %}{{ _("Location of %s") % address }}{% end block %}
 
-{% block main %}
-       <div class="card">
-               {% if address.country_code %}
-                       <div class="card-img-top">
-                               {% module Map(address.country_code) %}
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Lookup %s") % address }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Lookup %s") % address }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-vcentered">
+                               <div class="column is-7 has-text-centered">
+                                       <div class="block">
+                                               <p class="heading">{{ _("Network") }}</p>
+                                               <p class="title">{{ address.network }}</p>
+                                       </div>
+
+                                       <div class="block">
+                                               <p class="heading">{{ _("Autonomous System") }}</p>
+                                               <p class="title">
+                                                       {{ address.autonomous_system or ("AS%s" % address.asn if address.asn else _("N/A")) }}
+                                               </p>
+                                       </div>
+
+                                       {% if address.country_code %}
+                                               <div class="block">
+                                                       <p class="heading">{{ _("Country") }}</p>
+                                                       <p class="title">
+                                                               {{ format_country_name(address.country_code) }}
+                                                       </p>
+                                               </div>
+                                       {% end %}
+
+                                       <div class="tags">
+                                               {% if address.is_anonymous_proxy() %}
+                                                       <span class="tag">
+                                                               {{ _("Anonymous Proxy") }}
+                                                       </span>
+                                               {% end %}
+
+                                               {% if address.is_satellite_provider() %}
+                                                       <span class="tag">
+                                                               {{ _("Satellite Provider") }}
+                                                       </span>
+                                               {% end %}
+
+                                               {% if address.is_anycast() %}
+                                                       <span class="tag">
+                                                               {{ _("Anycast") }}
+                                                       </span>
+                                               {% end %}
+                                       </div>
+                               </div>
+
+                               <div class="column">
+                                       {% if address.country_code %}
+                                               <div class="box">
+                                                       {% module Map(address.country_code) %}
+                                               </div>
+                                       {% end %}
+                               </div>
                        </div>
-               {% end %}
-
-               <div class="card-body">
-                       <dl class="row">
-                               <dt class="col-sm-4">{{ _("Network") }}</dt>
-                               <dd class="col-sm-8">{{ address.network }}</dd>
-
-                               <dt class="col-sm-4">{{ _("Announced by") }}</dt>
-                               <dd class="col-sm-8">{{ address.autonomous_system or ("AS%s" % address.asn if address.asn else _("N/A")) }}</dd>
-
-                               {% if address.country_code %}
-                                       <dt class="col-sm-4">{{ _("Country") }}</dt>
-                                       <dd class="col-sm-8">
-                                               {{ format_country_name(address.country_code) }}
-                                       </dd>
-                               {% end %}
-
-                               <dt class="col-sm-4"></dt>
-                               <dd class="col-sm-8">
-                                       <span class="badge {% if address.is_anonymous_proxy() %}badge-success{% else %}badge-light{% end %}">
-                                               {{ _("Anonymous Proxy") }}
-                                       </span>
-
-                                       <span class="badge {% if address.is_satellite_provider() %}badge-success{% else %}badge-light{% end %}">
-                                               {{ _("Satellite Provider") }}
-                                       </span>
-
-                                       <span class="badge {% if address.is_anycast() %}badge-success{% else %}badge-light{% end %}">
-                                               {{ _("Anycast") }}
-                                       </span>
-                               </dd>
-                       </dl>
-
-                       <a class="btn btn-light btn-block" href="/lookup/{{ address }}/blacklists">
-                               {{ _("Blacklist Status") }}
-                       </a>
                </div>
-       </div>
+       </section>
 {% end block %}
diff --git a/src/templates/location/report-a-problem.html b/src/templates/location/report-a-problem.html
new file mode 100644 (file)
index 0000000..9c1080e
--- /dev/null
@@ -0,0 +1,64 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Report A Problem") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-light">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/location">
+                                                               {{ _("Location") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Report A Problem") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Report A Problem") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="content is-size-5">
+                               <p>
+                                       Although we aim for the highest accuracy when creating IPFire Location,
+                                       there is a chance that we are getting things wrong.
+                                       If you have discovered a potential problem in our database, it would
+                                       be great if you could report it to us. It is only a few steps:
+                               </p>
+
+                               <ol>
+                                       {% if not current_user %}
+                                               <li>
+                                                       If you don't have one already,
+                                                       create an <a href="/join">account</a>.
+                                               </li>
+                                       {% end %}
+
+                                       <li>
+                                               Gather as much information as possible around your problem.
+                                               The more information we have available, the faster we can fix it.
+                                       </li>
+
+                                       <li>
+                                               <a href="https://bugzilla.ipfire.org/enter_bug.cgi?product=Location%20Database&amp;component=Database">
+                                                       Create a ticket
+                                               </a> on Bugzilla
+                                       </li>
+                               </ol>
+                       </div>
+               </div>
+       </section>
+{% end block %}
index 2c56f343ce7d94569798fc4d9348bf8afc0995b4..7a95b02743b019f20024aef6f97eb0cc9f1b5058 100644 (file)
@@ -1,6 +1,7 @@
 {% extends "base.html" %}
 
 {% block footer %}
-       {{ _("Don't like these emails?") }}
-       <a href="https://people.ipfire.org/unsubscribe">{{ _("Unsubscribe") }}</a>.
+       <unsubscribe>
+               <a href="https://www.ipfire.org/unsubscribe">{{ _("Unsubscribe") }}</a>
+       </unsubscribe>
 {% end block %}
index 421225d128b1e54b75684da1b8ff51c09089b2d1..145b5d10dcb529b0c4338237a7e74d69a4f145c2 100644 (file)
 <!DOCTYPE html>
-<html>
-       <head>
-               <meta name="viewport" content="width=device-width">
-               <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
-               <title>{% block title %}{% end block %}</title>
-               <style media="all" type="text/css">
-                       {% include "main.css" %}
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
+<head>
+       {# Based on https://www.cerberusemail.com #}
+    <meta charset="utf-8">
+
+       {# Enable "responsiveness" #}
+    <meta name="viewport" content="width=device-width">
+
+       {# Use the latest (edge) version of IE rendering engine #}
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+
+       {# Disable auto-scale in iOS 10 Mail entirely #}
+    <meta name="x-apple-disable-message-reformatting">
+
+       {# Tell iOS not to automatically link certain text strings #}
+    <meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
+
+       {# Declare supported color schemes #}
+    <meta name="color-scheme" content="light dark">
+    <meta name="supported-color-schemes" content="light dark">
+
+       {# Title #}
+    <title>{% block title %}{% end block %}</title>
+
+    {# Make background images in 72ppi Outlook render at correct size #}
+    <!--[if gte mso 9]>
+    <xml>
+        <o:OfficeDocumentSettings>
+            <o:PixelsPerInch>96</o:PixelsPerInch>
+        </o:OfficeDocumentSettings>
+    </xml>
+    <![endif]-->
+
+   {# Desktop Outlook chokes on web font references and defaults to Times New Roman, so we force a safe fallback font #}
+    <!--[if mso]>
+        <style>
+            * {
+                font-family: sans-serif !important;
+            }
+        </style>
+    <![endif]-->
+
+    {# All other clients get the webfont reference; some will render the font and others will silently fail to the fallbacks.
+               More on that here: https://web.archive.org/web/20190717120616/http://stylecampaign.com/blog/2015/02/webfont-support-in-email/ #}
+    <!--[if !mso]>
+               <style>
+                       {% include "fonts.css" %}
                </style>
-       </head>
-
-       <body>
-               <span class="preheader">{% block preheader %}{% end preheader %}</span>
-               <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
-                       <tr>
-                               <td class="container">
-                                       <div class="content">
-                                               {% block container %}
-                                                       <table role="presentation" class="main">
-                                                               <tr>
-                                                                       <td class="logo">
-                                                                               <img src="https://www.ipfire.org/static/img/ipfire-tux.png" alt="{{ _("IPFire Logo") }}">
-                                                                       </td>
-                                                               </tr>
-                                                               <tr>
-                                                                       <td class="wrapper">
-                                                                               <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                                                                       <tr>
-                                                                                               <td>
-                                                                                                       {% block content %}{% end block %}
-                                                                                               </td>
-                                                                                       </tr>
-                                                                               </table>
-                                                                       </td>
-                                                               </tr>
-                                                       </table>
-                                               {% end block %}
-
-                                               <div class="footer">
-                                                       <table role="presentation" border="0" cellpadding="0" cellspacing="0">
-                                                               <tr>
-                                                                       <td class="content-block">
-                                                                               <span class="apple-link">{{ _("The IPFire Project" )}}</span>
-
-                                                                               <br>
-
-                                                                               {% block footer %}{% end block %}
-                                                                       </td>
-                                                               </tr>
-                                                       </table>
-                                               </div>
-                                       </div>
+    <![endif]-->
+
+       {# Import the main CSS #}
+    <style>
+               {# Tell the email client that both light and dark styles are provided #}
+               :root {
+                       color-scheme: light dark
+                       supported-color-schemes: light dark
+               }
+
+               {% include "main.css" %}
+    </style>
+</head>
+
+<body class="bg">
+       <center role="article" aria-roledescription="email" lang="en" class="bg">
+               <!--[if mso | IE]>
+               <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" class="bg">
+               <tr>
+               <td>
+               <![endif]-->
+                       {# Visually Hidden Pre-header Text #}
+                       <div class="pre-header" aria-hidden="true">
+                               {% block preview %}{% end block %}
+                       </div>
+
+                       {# Create white space after the desired preview text so email clients don’t pull other distracting text into the inbox preview #}
+                       <div class="whitespace">
+                               &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
+                       </div>
+
+                       <div class="container">
+                               <!--[if mso]>
+                               <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="600">
+                               <tr>
+                               <td>
+                               <![endif]-->
+
+                               {# Body #}
+                               <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                                       {% block body %}
+                                               {# Header #}
+                                               <tr class="header">
+                                                       <td>
+                                                               <h1>
+                                                                       IPFire<span class="has-text-primary">_</span>
+                                                               </h1>
+                                                       </td>
+                                               </tr>
+
+                                               {# Hero #}
+                                               {% block hero %}{% end block %}
+
+                                               {# Content #}
+                                               <tr class="content">
+                                                       <td>
+                                                               <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
+                                                                       {% block content %}{% end block %}
+                                                               </tabke>
+                                                       </td>
+                                               </tr>
+                                       {% end block %}
+                               </table>
+
+                               {# Footer #}
+                               <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0">
+                                       <tr class="footer">
+                                               <td>
+                                                       {{ _("The IPFire Project") }},
+                                                       {{ _("c/o") }} Lightning Wire Labs GmbH,
+                                                       <span class="unstyle-auto-detected-links">Gerhardstraße 8, 45711 Datteln, Germany</span>
+
+                                                       <br><br>
+
+                                                       {% block footer %}{% end block %}
+                                               </td>
+                                       </tr>
+                               </table>
+
+                               <!--[if mso]>
                                </td>
-                       </tr>
+                               </tr>
+                               </table>
+                               <![endif]-->
+                       </div>
+
+               <!--[if mso | IE]>
+               </td>
+               </tr>
                </table>
-       </body>
+               <![endif]-->
+    </center>
+</body>
 </html>
diff --git a/src/templates/messages/fonts.sass b/src/templates/messages/fonts.sass
new file mode 100644 (file)
index 0000000..368d054
--- /dev/null
@@ -0,0 +1,7 @@
+$baseurl: "https://michael.dev.ipfire.org"
+
+// Use our main font by default
+*
+       font-family: Prompt, sans-serif
+
+@import "../../sass/_fonts.sass"
diff --git a/src/templates/messages/main.sass b/src/templates/messages/main.sass
new file mode 100644 (file)
index 0000000..bc0830e
--- /dev/null
@@ -0,0 +1,267 @@
+
+// Fonts
+$font-family:                                  Prompt, sans-serif
+
+$font-weight-normal:                   500
+$font-weight-bold:                             700
+
+// Container
+$container-width:                              600px
+
+// A unit to use for padding
+$pad:                                                  20px
+
+// Borders
+$border-radius:                                        4px
+
+// Colours
+$black:                                                        hsl(0, 0%, 4%)
+$white:                                                        hsl(0, 0%, 100%)
+$grey:                                                 hsl(0, 0%, 97%)
+$light:                                                        hsl(0, 0%, 80%)
+
+$primary:                                              #ff2e52
+$primary-inverted:                             $white
+
+// Background Colours
+$bg-light:                                             $white
+$bg-dark:                                              $grey
+
+// Text Colour
+$text:                                                 $black
+$link:                                                 $primary
+
+// Font sizes
+$font-size-small:                              12px
+$font-size-normal:                             16px
+$font-size-large:                              20px
+
+$line-height-small:                            16px
+$line-height-normal:                   22px
+$line-height-large:                            28px
+
+// Headings
+$title-1:                                              30px
+$line-height-title-1:                  40px
+
+// Remove spaces around the email design added by some email clients
+html, body
+       margin: 0 auto !important
+       padding: 0 !important
+       height: 100% !important
+       width: 100% !important
+
+// Stop email clients resizing small text
+*
+       -ms-text-size-adjust: 100%
+       -webkit-text-size-adjust: 100%
+
+// Centers email on Android 4.4
+div[style*="margin: 16px 0"]
+       margin: 0 !important
+
+// forces Samsung Android mail clients to use the entire viewport
+#MessageViewBody, #MessageWebViewDiv
+       width: 100% !important
+
+// Stop Outlook from adding extra spacing to tables
+table, td
+       mso-table-lspace: 0pt !important
+       mso-table-rspace: 0pt !important
+
+// Fix a webkit padding issue
+table
+       border-spacing: 0 !important
+       border-collapse: collapse !important
+       table-layout: fixed !important
+       margin: 0 auto !important
+
+// Use a better rendering method when resizing images in IE
+img
+       -ms-interpolation-mode: bicubic
+
+// Prevent Windows 10 Mail from underlining links despite inline CSS
+a
+       text-decoration: none
+
+// A work-around for email clients meddling in triggered links
+a[x-apple-data-detectors], .unstyle-auto-detected-links a, .aBn
+       border-bottom: 0 !important
+       cursor: default !important
+       color: inherit !important
+       text-decoration: none !important
+       font-size: inherit !important
+       font-family: inherit !important
+       font-weight: inherit !important
+       line-height: inherit !important
+
+// Prevent Gmail from displaying a download button on large, non-linked images
+.a6S
+       display: none !important
+       opacity: 0.01 !important
+
+// Prevent Gmail from changing the text color in conversation threads.
+.im
+       color: inherit !important
+
+// If the above doesn't work, add a .g-img class to any image in question.
+img.g-img + div
+       display: none !important
+
+// Set font
+*
+       font-family: $font-family
+       font-weight: $font-weight-normal
+
+body
+       mso-line-height-rule: exactly
+
+// Links
+a
+       color: $link
+
+       &:hover
+               text-decoration: underline
+
+// Center all content
+center
+       width: 100%
+
+// Visually Hidden Pre-header Text
+.pre-header
+       max-height: 0
+       overflow: hidden
+       mso-hide: all
+
+// Some whitespace
+.whitespace
+       display: none
+       font-size: 1px
+       line-height: 1px
+       max-height: 0px
+       max-width: 0px
+       opacity: 0
+       overflow: hidden
+       mso-hide: all
+
+// The main container
+.container
+       max-width: $container-width
+       margin: 0 auto
+
+       // Improve readability on small screens
+       @media screen and (max-width: 600px)
+               p
+                       font-size: 17px !important;
+
+// Make tables fill the entire viewport horizontally
+table
+       width: 100%
+       margin: auto
+
+       // The header box
+       tr.header
+               td
+                       padding: $pad 0
+                       text-align: center
+
+                       h1
+                               margin: 0 0 10px 0
+                               font-size: 50px
+                               line-height: 60px
+                               font-weight: $font-weight-bold
+
+                               span
+                                       color: $primary
+                                       font-weight: i$font-weight-bold
+
+       // The hero unit
+       tr.hero
+               td
+                       img
+                               display: block
+                               border: 0
+                               width: 100%
+                               max-width: $container-width
+                               height: auto
+                               background: $grey
+                               margin: auto
+
+       // Content (i.e. the big box)
+       tr.content
+               td
+                       background-color: $bg-dark
+                       color: $text
+
+                       @media (prefers-color-scheme: dark)
+                               background-color: $bg-light
+
+                       table
+                               // One block in the box
+                               tr.section
+                                       td
+                                               padding: $pad
+                                               font-size: $font-size-normal
+                                               line-height: $line-height-normal
+
+                                               // Headings
+                                               h1
+                                                       margin: 0 0 10px 0
+                                                       font-size: $title-1
+                                                       line-height: $line-height-title-1
+
+                                               // Text
+                                               p
+                                                       padding: 8px 0
+                                                       margin: 0
+
+                                                       &:last-child
+                                                               padding: 0
+
+                                               // Links
+                                               a
+                                                       color: $link
+
+                                                       &:hover
+                                                               text-decoration: underline
+
+                               // Buttons
+                               tr.button
+                                       td
+                                               a
+                                                       display: block
+                                                       border: 1px solid $primary
+                                                       border-radius: $border-radius
+                                                       text-align: center
+                                                       font-size: $font-size-large
+                                                       font-weight: $font-weight-bold
+                                                       line-height: $line-height-large
+                                                       text-decoration: none
+                                                       padding: 16px 20px
+                                                       color: $white
+
+                                                       &.primary
+                                                               background-color: $primary
+                                                               color: $primary-inverted
+
+                                                               &:hover
+                                                                       background-color: $primary-inverted
+                                                                       color: $primary
+
+                               // Change the padding on the last element
+                               //tr:last-child
+                               //      td
+                               //              padding: 0 $pad
+
+       // Footer
+       tr.footer
+               td
+                       padding: $pad
+                       font-size: $font-size-small
+                       line-height: $line-height-small
+                       color: $light
+                       text-align: center
+
+                       // Make links grey, too
+                       a
+                               color: inherit
diff --git a/src/templates/messages/main.scss b/src/templates/messages/main.scss
deleted file mode 100644 (file)
index 2e44dbf..0000000
+++ /dev/null
@@ -1,347 +0,0 @@
-@import "../../scss/variables";
-
-@import "../../bootstrap/scss/functions";
-@import "../../bootstrap/scss/variables";
-
-@import "../../scss/_fonts.scss";
-
-// Use font sizes in px
-$font-size-base:            18px;
-$small-font-size:           12px;
-
-$h1-font-size:              48px;
-$h2-font-size:                         40px;
-$h3-font-size:                         36px;
-$h4-font-size:                         32px;
-$headings-margin-bottom:       20px;
-
-$paragraph-margin-bottom:      14px;
-
-// Resets
-img {
-       border: none;
-       -ms-interpolation-mode: bicubic;
-       max-width: 100%;
-}
-
-body {
-       background-color: $body-bg;
-       font-family: $font-family-sans-serif;
-       -webkit-font-smoothing: antialiased;
-       font-size: $font-size-base;
-       line-height: $line-height-base;
-       margin: 0;
-       padding: 0;
-       -ms-text-size-adjust: 100%;
-       -webkit-text-size-adjust: 100%;
-}
-
-table {
-       border-collapse: separate;
-       mso-table-lspace: 0pt;
-       mso-table-rspace: 0pt;
-       width: 100%;
-
-       td {
-               font-family: $font-family-sans-serif;
-               font-size: $font-size-base;
-               vertical-align: top;
-       }
-}
-
-// Basic Styling
-
-.body {
-       background-color: $body-bg;
-       width: 100%;
-}
-
-/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
-.container {
-       display: block;
-       margin: 0 auto !important;
-
-       // Center the container
-       max-width: 580px;
-       padding: 10px;
-       width: 580px;
-}
-
-/* This should also be a block element, so that it will fill 100% of the .container */
-.content {
-       box-sizing: border-box;
-       display: block;
-       margin: 0 auto;
-       max-width: 580px;
-       padding: 10px;
-}
-
-// Headers, Footers, Containers
-
-.main {
-       background: $white;
-       color: $dark;
-       border-radius: $card-border-radius;
-       width: 100%;
-
-       .logo {
-               text-align: center;
-
-               img {
-                       height: 196px;
-                       padding: 24px 0 12px 0;
-               }
-       }
-}
-
-.wrapper {
-       box-sizing: border-box;
-       padding: 20px;
-}
-
-.content-block {
-       padding-bottom: 10px;
-       padding-top: 10px;
-}
-
-.footer {
-       clear: both;
-       margin-top: 10px;
-       text-align: center;
-       width: 100%;
-
-       td, p, span, a {
-               color: $light;
-               font-size: $small-font-size;
-               text-align: center;
-       }
-}
-
-// Typography
-
-h1, h2, h3, h4 {
-       color: $dark;
-       font-family: $font-family-sans-serif;
-       font-weight: $headings-font-weight;
-       line-height: $headings-line-height;
-       margin: 0;
-       margin-bottom: $headings-margin-bottom;
-}
-
-h1 {
-       font-size: $h1-font-size;
-       text-align: center;
-       text-transform: capitalize;
-}
-
-p, ul, ol {
-       font-family: $font-family-sans-serif;
-       font-size: $font-size-base;
-       font-weight: normal;
-       margin: 0;
-       margin-bottom: $paragraph-margin-bottom;
-}
-
-a {
-       color: $link-color;
-       text-decoration: underline;
-}
-
-blockquote {
-       font-style: italic;
-}
-
-// Buttons
-
-.btn {
-       box-sizing: border-box;
-       width: 100%;
-
-       > tbody > tr > td {
-               padding-bottom: 15px;
-       }
-
-       table {
-               width: 100%;
-
-               td {
-                       background-color: #ffffff;
-                       border-radius: $btn-border-radius;
-                       text-align: center;
-               }
-       }
-
-       a {
-               width: 100%;
-               background-color: #ffffff;
-               border: 1px solid $link-color;
-               border-radius: $btn-border-radius;
-               box-sizing: border-box;
-               color: $link-color;
-               cursor: pointer;
-               display: inline-block;
-               font-size: $font-size-base;
-               font-weight: $btn-font-weight;
-               margin: 0;
-               padding: $btn-padding-y $btn-padding-x;
-               text-decoration: none;
-               text-transform: uppercase;
-       }
-}
-
-.btn-primary {
-       table td {
-               background-color: $link-color;
-       }
-
-       a {
-               background-color: $link-color;
-               border-color: $link-color;
-               color: #ffffff;
-       }
-}
-
-// Other
-
-.align-center {
-       text-align: center;
-}
-
-.align-right {
-       text-align: right;
-}
-
-.align-left {
-       text-align: left;
-}
-
-.clear {
-       clear: both;
-}
-
-.mt-0 {
-       margin-top: 0;
-}
-
-.mb-0 {
-       margin-bottom: 0;
-}
-
-.preheader {
-       color: transparent;
-       display: none;
-       height: 0;
-       max-height: 0;
-       max-width: 0;
-       opacity: 0;
-       overflow: hidden;
-       mso-hide: all;
-       visibility: hidden;
-       width: 0;
-}
-
-.powered-by a {
-       text-decoration: none;
-}
-
-hr {
-       border: 0;
-       border-bottom: $hr-border-width solid $hr-border-color;
-       margin: 20px 0;
-}
-
-// Make this all mobile-friendly
-
-@media only screen and (max-width: 620px) {
-       table[class=body] {
-               h1 {
-                       font-size: $h1-font-size !important;
-                       margin-bottom: $headings-margin-bottom !important;
-               }
-
-               p, ul, ol, td, span, a {
-                       font-size: $font-size-base !important;
-               }
-
-               .wrapper, .article {
-                       padding: 10px !important;
-               }
-
-               .content {
-                       padding: 0 !important;
-               }
-
-               .container {
-                       padding: 0 !important;
-                       width: 100% !important;
-               }
-
-               .main {
-                       background: $dark;
-                       color: $white;
-                       border-left-width: 0 !important;
-                       border-radius: 0 !important;
-                       border-right-width: 0 !important;
-               }
-
-               .img-responsive {
-                       height: auto !important;
-                       max-width: 100% !important;
-                       width: auto !important;
-               }
-       }
-}
-
-// Hack for Dark Mode
-
-@media (prefers-dark-interface) {
-       .body {
-               background-color: none;
-       }
-
-       .main {
-               background: $white !important;
-               color: $dark !important;
-       }
-}
-
-// Hack for Outlook
-
-@media all {
-       .ExternalClass {
-               width: 100%;
-
-               &, p, span, font, td, div {
-                       line-height: 100%;
-               }
-       }
-
-       .apple-link a {
-               color: inherit !important;
-               font-family: inherit !important;
-               font-size: inherit !important;
-               font-weight: inherit !important;
-               line-height: inherit !important;
-               text-decoration: none !important;
-       }
-
-       #MessageViewBody a {
-               color: inherit;
-               text-decoration: none;
-               font-size: inherit;
-               font-family: inherit;
-               font-weight: inherit;
-               line-height: inherit;
-       }
-
-       .btn-primary {
-               table td:hover {
-                       background-color: $link-hover-color !important;
-               }
-
-               a:hover {
-                       background-color: $link-hover-color !important;
-                       border-color: $link-hover-color !important;
-               }
-       }
-}
diff --git a/src/templates/mirrors/index.html b/src/templates/mirrors/index.html
deleted file mode 100644 (file)
index ad358c7..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Mirrors") }}{% end block %}
-
-{% block content %}
-       {% set total = sum((len(m) for c, m in mirrors.items())) %}
-
-       <h4 class="my-5 text-muted text-center">
-               {{ _("We are currently running %s mirror servers") % total }}
-       </h4>
-
-       {% set countries = sorted(mirrors, key=lambda c: c.name) %}
-
-       <div class="row justify-content-center">
-               <div class="col-12 col-md-6">
-                       {% for country in countries %}
-                               <a name="{{ country.alpha2 }}"></a>
-                               <div class="my-4 d-flex justify-content-between ">
-                                       <div>
-                                               <h4 class="mb-0">{{ country.name }}</h4>
-                                               <span class="small text-muted">
-                                                       {{ _("One Mirror", "%(num)s Mirrors", len(mirrors[country])) % { "num" : len(mirrors[country]) } }}
-                                               </span>
-                                       </div>
-
-                                       <h4 class="flag-icon flag-icon-{{ country.alpha2.lower() }}"></h4>
-                               </div>
-
-                               <div class="list-group">
-                                       {% for m in mirrors[country] %}
-                                               <a href="/mirrors/{{ m.hostname }}" class="list-group-item list-group-item-action
-                                                               list-group-item-{% if m.state == "UP" %}success{% elif m.state == "DOWN" %}danger{% else %}warning{% end %}
-                                                               flex-column align-items-start">
-                                                       <h5 class="mb-1">{{ m.hostname }}</h5>
-
-                                                       {% if m.owner %}
-                                                               <p class="mb-0 text-truncate text-muted">{{ m.owner }}</p>
-                                                       {% end %}
-                                               </a>
-                                       {% end %}
-                               </div>
-                       {% end %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/mirrors/mirror.html b/src/templates/mirrors/mirror.html
deleted file mode 100644 (file)
index 71c31d8..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Mirror %s") % mirror }}{% end block %}
-
-{% block content %}
-       <section>
-               <div class="container">
-                       <h1 class="mb-0">{{ mirror }}</h1>
-
-                       {% if mirror.owner %}
-                               <p>{{ _("by %s") % mirror.owner }}</p>
-                       {% end %}
-               </div>
-       </section>
-
-       <div class="row justify-content-center">
-               <div class="col-12 col-md-6">
-                       <div class="card mb-4">
-                               {# <div class="card-img-top">
-                                       {% module Map() %}
-                               </div> #}
-
-                               <div class="card-body">
-                                       <dl class="mb-0">
-                                               {% if mirror.asn %}
-                                                       <dt>{{ _("Autonomous System") }}</dt>
-                                                       <dd>{{ mirror.address.autonomous_system or "AS%s" % mirror.asn }}</dd>
-                                               {% end %}
-
-                                               <dt>{{ _("Country") }}</dt>
-                                               <dd>{{ mirror.country.name }}</dd>
-                                       </dl>
-                               </div>
-
-                               <ul class="list-group list-group-flush">
-                                       {% if mirror.state == "UP" %}
-                                               <li class="list-group-item list-group-item-success flex-column align-items-start">
-                                                       <p class="mb-1">{{ _("The mirror is up") }}</p>
-
-                                                       <small class="text-muted">
-                                                               {{ _("Last updated %s") % locale.format_date(mirror.last_update) }}
-                                                       </small>
-                                               </li>
-                                       {% elif mirror.state == "DOWN" %}
-                                               <li class="list-group-item list-group-item-danger flex-column align-items-start">
-                                                       <p class="mb-1">{{ _("The mirror is down") }}</p>
-
-                                                       <small class="text-muted">
-                                                               {{ _("Last updated %s") % locale.format_date(mirror.last_update) }}
-                                                       </small>
-                                               </li>
-                                       {% elif mirror.state == "OUTOFSYNC" %}
-                                               <li class="list-group-item list-group-item-warning flex-column align-items-start">
-                                                       <p class="mb-1">{{ _("The mirror is out of sync") }}</p>
-
-                                                       <small class="text-muted">
-                                                               {{ _("Last updated %s") % locale.format_date(mirror.last_update) }}
-                                                       </small>
-                                               </li>
-                                       {% end %}
-                               </ul>
-                       </div>
-
-                       <a class="btn btn-primary btn-block" href="{{ mirror.url }}">
-                               {{ _("Browse Mirror") }} <span class="fas fa-external-link-alt ml-2"></span>
-                       </a>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/modules/christmas-banner.html b/src/templates/modules/christmas-banner.html
deleted file mode 100644 (file)
index ad7e67d..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-{% if now.month == 12 %}
-    <div class="container">
-        <div class="card glow-primary border-primary">
-            <div class="card-body">
-                <div class="row">
-                    <div class="col-2 d-flex align-items-center justify-content-center">
-                        <i class="fas fa-gifts fa-2x text-primary"></i>
-                    </div>
-
-                    <div class="col-10 col-lg-6 mb-3 mb-lg-0">
-                        <h6 class="mb-0">{{ _("Give a gift to us for Christmas!") }}</h6>
-
-                        <p class="card-text">
-                            {{ _("Support the IPFire Project with your donation") }}
-                        </p>
-                    </div>
-
-                    <div class="col-12 col-lg-4">
-                        <a class="btn btn-primary btn-block" href="/donate">
-                            {{ _("Give A Gift Today") }}
-                        </a>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-{% end %}
diff --git a/src/templates/modules/ipfire-logo.html b/src/templates/modules/ipfire-logo.html
new file mode 100644 (file)
index 0000000..f5840d7
--- /dev/null
@@ -0,0 +1,20 @@
+{% set pride_colors = ("pride-red", "pride-orange", "pride-yellow", "pride-green", "pride-blue", "pride-purple") %}
+
+<strong>
+       {# Christmas #}
+       {% if now.month == 12 %}
+               IPFire<span class="has-text-primary">_</span>{{ suffix or "" }} 🎄
+
+       {# Halloween #}
+       {% elif now.month == 10 and now.day >= 28 %}
+               IPFire<span class="has-text-primary">_</span>{{ suffix or "" }} 🎃
+
+       {# Pride Month #}
+       {% elif now.month == 7 %}
+               {% for color, i in zip(pride_colors, "IPFire") %}<span class="has-text-{{ color }}">{{ i }}</span>{% end %}_{{ suffix or "" }}
+
+       {# Other times of the year #}
+       {% else %}
+               IPFire<span class="has-text-primary">_</span>{{ suffix or "" }}
+       {% end %}
+</strong>
diff --git a/src/templates/modules/menu.html b/src/templates/modules/menu.html
deleted file mode 100644 (file)
index e69de29..0000000
index 429cdb51b4c31f110d9461d7cd65351fa7e14626..53d88b481d4e9a618ca140711b6452f69866b933 100644 (file)
@@ -1,14 +1,14 @@
-<div class="row">
-       <div class="col-4 col-sm-3 col-lg-2 text-right">
-               {{ "%.2f" % value }}%
+<div class="columns">
+       <div class="column is-3 has-text-right">
+               <p>
+                       {{ "%.2f" % value }}%
+               </p>
        </div>
 
-       <div class="col">
-               <div class="progress">
-                       <div class="progress-bar {% if colour %}{{ "bg-%s" % colour }}{% end %}"
-                                       role="progressbar" aria-valuenow="{{ "%.0f" % value }}"
-                                       aria-valuemin="0" aria-valuemax="100" style="width: {{ "%.2f" % value }}%;">
-                       </div>
-               </div>
+       <div class="column">
+               <progress class="progress {% if colour %}{{ "is-%s" % colour }}{% end %}"
+                                       value="{{ "%.0f" % value }}" max="100">
+                                               {{"%.2f" % value }}
+               </progress>
        </div>
 </div>
index ea3bf19b8f56265e30a5cc8e5ae8f2f4f981cad1..85a6e8e315b35f6ae78228ccb8bf7a94d069c4c2 100644 (file)
@@ -1,66 +1,91 @@
 {% extends "../base.html" %}
 
-{% block title %}
-       {% if mode == "paste" %}
-               {{ _("New Paste") }}
-       {% elif mode == "upload" %}
-               {{ _("Upload File") }}
-       {% end %}
-{% end block %}
+{% block title %}{{ _("New Paste") }}{% end block %}
 
-{% block content %}
-       <div class="row justify-content-center">
-               <div class="col-12 col-md-8">
-                       {% if mode == "paste" %}
-                               <h1>{{ _("New Paste") }}</h2>
-                       {% elif mode == "upload" %}
-                               <h1>{{ _("Upload File") }}</h3>
-                       {% end %}
+{% block container %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-half">
+                                       <form action="" method="POST" enctype="multipart/form-data">
+                                               {% raw xsrf_form_html() %}
 
-                       <form class="form-horizontal" action="" method="POST" enctype="multipart/form-data">
-                               {% raw xsrf_form_html() %}
+                                               {% block paste %}
+                                                       <div class="field">
+                                                               <div class="control">
+                                                                       <textarea class="textarea" rows="12" name="content" required
+                                                                               placeholder="{{ _("Paste your content here...") }}"></textarea>
+                                                               </div>
+                                                       </div>
+                                               {% end %}
 
-                               <input type="hidden" name="mode" value="{{ mode }}">
+                                               <div class="field">
+                                                       <label class="label">{{ _("Subject") }}</label>
 
-                               {% if mode == "paste" %}
-                                       <div class="form-group">
-                                               <label>{{ _("Subject") }}</label>
-                                               <input type="text" class="form-control" name="subject"
-                                                       placeholder="{{ _("Subject") }} ({{ _("optional") }})">
-                                       </div>
-                               {% end %}
+                                                       <div class="control">
+                                                               <input class="input" type="text" name="subject"
+                                                                       placeholder="{{ _("Subject") }} ({{ _("optional") }})">
+                                                       </div>
+                                               </div>
 
-                               <div class="form-group">
-                                       {% if mode == "paste" %}
-                                               <textarea class="form-control" rows="12" name="content"
-                                                       placeholder="{{ _("Please paste your content here...") }}"></textarea>
-                                       {% elif mode == "upload" %}
-                                               <label>{{ _("File") }}</label>
-                                               <input type="file" name="file" class="form-control-file">
+                                               <div class="field">
+                                                       <label class="label">{{ _("Expires After") }}</label>
 
-                                               {% if max_size %}
-                                                       <small class="form-text text-muted">
-                                                               {{ _("You may upload up to %s") % format_size(max_size) }}
-                                                       </small>
-                                               {% end %}
-                                       {% end %}
+                                                       <div class="control">
+                                                               <div class="select is-fullwidth" name="expires">
+                                                                       <select>
+                                                                               <option value="0">{{ _("never") }}</option>
+                                                                               <option value="600">{{ _("after ten minutes") }}</option>
+                                                                               <option value="3600">{{ _("after one hour") }}</option>
+                                                                               <option value="{{ 24 * 3600 }}">{{ _("after one day") }}</option>
+                                                                               <option value="{{ 7 * 24 * 3600 }}">{{ _("after one week") }}</option>
+                                                                               <option value="{{ 30 * 24 * 3600 }}" selected>{{ _("after one month") }}</option>
+                                                                       </select>
+                                                               </div>
+                                                       </div>
+                                               </div>
+
+                                               <div class="field">
+                                                       <div class="control">
+                                                               <button type="submit" class="button is-primary is-fullwidth">
+                                                                       <span class="icon-text">
+                                                                               <span class="icon">
+                                                                                       <i class="fa-solid fa-upload"></i>
+                                                                               </span>
+                                                                               <span>{{ _("Upload") }}</span>
+                                                                       </span>
+                                                               </button>
+                                                       </div>
+                                               </div>
+                                       </form>
                                </div>
+                       </div>
+               </div>
+       </section>
 
-                               <div class="form-group">
-                                       <label>{{ _("Expires") }}</label>
+       <section class="section">
+               <div class="container">
+                       <div class="notification">
+                               <h4 class="title is-4">{{ _("Console Interface") }}</h4>
 
-                                       <select class="form-control" name="expires">
-                                               <option value="0">{{ _("never") }}</option>
-                                               <option value="600">{{ _("after ten minutes") }}</option>
-                                               <option value="3600">{{ _("after one hour") }}</option>
-                                               <option value="{{ 24 * 3600 }}">{{ _("after one day") }}</option>
-                                               <option value="{{ 7 * 24 * 3600 }}">{{ _("after one week") }}</option>
-                                               <option value="{{ 30 * 24 * 3600 }}" selected>{{ _("after one month") }}</option>
-                                       </select>
-                               </div>
+                               <div class="content">
+                                       <p>
+                                               {{ _("You can also upload files using the console like so:") }}
+                                       </p>
+
+                                       <pre>
+curl -u {{ current_user.uid }} -T- https://{{ request.host }} < file.txt
+                                       </pre>
 
-                               <button type="submit" class="btn btn-primary btn-block">{{ _("Submit") }}</button>
-                       </form>
+                                       <p>
+                                               {{ _("Or you can stream data into it like so:") }}
+                                       </p>
+
+                                       <pre>
+some command | curl -u {{ current_user.uid }} -T- https://{{ request.host }}
+                                       </pre>
+                               </div>
+                       </div>
                </div>
-       </div>
+       </section>
 {% end block %}
diff --git a/src/templates/nopaste/upload.html b/src/templates/nopaste/upload.html
new file mode 100644 (file)
index 0000000..a6475e4
--- /dev/null
@@ -0,0 +1,26 @@
+{% extends "create.html" %}
+
+{% block paste %}
+       <div class="field">
+               <label class="label">{{ _("File") }}</label>
+
+               <div class="control">
+                       <div class="file has-name is-fullwidth">
+                               <label class="file-label">
+                                       <input class="file-input" type="file" name="file">
+
+                                       <span class="file-cta">
+                                               <span class="file-icon">
+                                                       <i class="fas fa-upload"></i>
+                                               </span>
+                                               <span class="file-label">
+                                                       {{ _("Choose a file…") }}
+                                               </span>
+                                       </span>
+
+                                       <span class="file-name"></span>
+                               </label>
+                       </div>
+               </div>
+       </div>
+{% end block %}
index e99764614d970b43a31284fda63e1cc0a3ddd9c7..5ba3ad6ce33686ebe023bb48f90a96b60f061e0d 100644 (file)
@@ -1,44 +1,53 @@
 {% extends "../base.html" %}
 
-{% block title %}{{ entry.subject or _("Paste %s") % entry.uuid }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center">
-               <div class="col col-md-10">
-                       <div class="card">
-                               <div class="card-body">
-                                       <h3 class="card-title mb-1">{{ entry.subject or _("Paste %s") % entry.uuid }}</h3>
-                                       <h6 class="card-subtitle text-muted mb-3">
-                                               {{ _("Uploaded %s") % locale.format_date(entry.time_created) }}
-
-                                               {% if entry.account %}
-                                                       {{ _("by") }}
-                                                       <a href="https://people.ipfire.org/users/{{ entry.account.uid }}">{{ entry.account }}</a>
-                                               {% else %}
-                                                       {{ _("from %s") % entry.address }}
-                                               {% end %}
-                                       </h6>
-
-                                       <hr>
-
-                                       {% if content %}
-                                               <div class="mb-3">
-                                                       {% module Code(content) %}
-                                               </div>
-
-                                               <hr>
-                                       {% elif entry.mimetype.startswith("image/") %}
-                                               <img class="img-fluid mb-3" src="/raw/{{ entry.uuid }}">
-
-                                               <hr>
+{% block title %}{{ paste }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <h1 class="title">
+                                       {{ paste.subject or _("Paste") }}
+                               </h1>
+
+                               <h6 class="subtitle">
+                                       {{ _("Uploaded %s by %s") % (locale.format_date(paste.time_created), paste.account or "N/A") }}
+
+                                       {# Show IP address of uploader to admins #}
+                                       {% if current_user and current_user.is_admin() %}
+                                               &bull; {{ paste.address }}
+                                               {% if paste.country %}&bull; {{ paste.country }}{% end %}
+                                               {% if paste.asn %}&bull; {{ paste.asn }}{% end %}
                                        {% end %}
+                               </h6>
+                       </div>
+               </div>
+       </section>
 
-                                       <a class="btn btn-primary btn-lg btn-block" href="/raw/{{ entry.uuid }}">
-                                               <span class="fas fa-file-download"></span>
-                                               {{ _("Download") }} ({{ format_size(entry.size) }})
-                                       </a>
+       <section class="section">
+               <div class="container">
+                       {% if paste.mimetype.startswith("text/") %}
+                               <div class="block">
+                                       {% module Code(paste.blob) %}
+                               </div>
+                       {% elif paste.mimetype.startswith("image/") %}
+                               <div class="block">
+                                       <figure class="image">
+                                               <img src="/raw/{{ paste.uuid }}">
+                                       </figure>
                                </div>
+                       {% end %}
+
+                       <div class="buttons">
+                               <a class="button is-primary is-fullwidth" href="/raw/{{ paste.uuid }}">
+                                       <span class="icon-text">
+                                               <span class="icon">
+                                                       <i class="fas fa-file-download"></i>
+                                               </span>
+                                               <span>{{ _("Download") }} ({{ format_size(paste.size) }})</span>
+                                       </span>
+                               </a>
                        </div>
                </div>
-       </div>
+       </section>
 {% end block %}
diff --git a/src/templates/people/base.html b/src/templates/people/base.html
deleted file mode 100644 (file)
index e8f7ef9..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ account }}{% end block %}
-
-{% block content %}
-       <div class="row">
-               {% block sidebar %}
-                       <div class="col-12 col-md-3">
-                               <img class="img-fluid rounded-circle my-2" src="{{ account.avatar_url(512) }}" alt="{{ account }}" />
-
-                               <div class="my-3">
-                                       <h3 class="mb-0">
-                                               <a class="text-white" href="/users/{{ account.uid }}">{{ account }}</a>
-                                       </h3>
-                                       <span class="text-monospace">{{ account.uid }}</span>
-                               </div>
-
-                               {% if current_user.is_staff() and account.has_sip() %}
-                                       <h5>
-                                               {{ account.sip_id }}
-
-                                               <small class="ml-2">
-                                                       {% module SIPStatus(account) %}
-                                               </small>
-                                       </h5>
-                               {% end %}
-
-                               <div class="btn-toolbar mb-3">
-                                       {% if account.has_shell() %}
-                                               <a class="btn btn-light btn-sm btn-block" href="/~{{ account.uid }}/">
-                                                       <span class="fas fa-home mr-2"></span> {{ _("Home Directory") }}
-                                               </a>
-                                       {% end %}
-
-                                       {% if account.can_be_managed_by(current_user) %}
-                                               {% if account.has_sip() %}
-                                                       <a class="btn btn-light btn-sm btn-block" href="/users/{{ account.uid }}/calls">
-                                                               <span class="fas fa-phone mr-2"></span> {{ _("Calls") }}
-
-                                                               {% if account.sip_channels %}
-                                                                       <span class="badge badge-success ml-1">{{ len(account.sip_channels) }}</span>
-                                                               {% end %}
-                                                       </a>
-                                               {% end %}
-
-                                               <a class="btn btn-warning btn-sm btn-block" href="/users/{{ account.uid }}/edit">
-                                                       <span class="fas fa-edit mr-2"></span> {{ _("Edit") }}
-                                               </a>
-
-                                               <a class="btn btn-light btn-sm btn-block" href="/users/{{ account.uid }}/passwd">
-                                                       {{ _("Change Password") }}
-                                               </a>
-                                       {% end %}
-                               </div>
-                       </div>
-               {% end %}
-
-               <div class="col-12 col-md-8 offset-md-1">
-                       {% block main %}{% end block %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/call.html b/src/templates/people/call.html
deleted file mode 100644 (file)
index 3fb4b09..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ account }} - {{ _("Call") }}{% end block %}
-
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <h4 class="card-title mb-1">
-                               {% if call.direction == "inbound" %}
-                                       {{ _("Call to") }}
-
-                                       {% if call.callee %}
-                                               <a href="/users/{{ call.callee.uid }}">{{ call.callee }}</a>
-                                       {% else %}
-                                               {{ format_phone_number(call.callee_number) }}
-                                       {% end %}
-                               {% elif call.direction == "outbound" %}
-                                       {{ _("Call from") }}
-
-                                       {% if call.caller %}
-                                               <a href="/users/{{ call.caller.uid }}">{{ call.caller }}</a>
-                                       {% else %}
-                                               {{ format_phone_number(call.caller_number) }}
-                                       {% end %}
-                               {% end %}
-                       </h4>
-                       <h6 class="card-subtitle text-muted mb-4">{{ locale.format_date(call.time_answered or call.time_start) }}</h6>
-
-                       <h3 class="text-center my-5">
-                               {% if call.duration %}
-                                       {{ format_time(call.duration) }}
-                               {% else %}
-                                       {{ _("Not Answered") }}
-                               {% end %}
-                       </h3>
-
-                       <h6>{{ _("Media Information") }}</h6>
-
-                       <div class="row">
-                               {% for c in (call, call.bleg) %}
-                                       {% if c %}
-                                               <div class="col">
-                                                       <div class="card card-body bg-light">
-                                                               <p>
-                                                                       <strong>
-                                                                               {% if c == call %}
-                                                                                       {{ _("Your Leg") }}
-                                                                               {% else %}
-                                                                                       {{ _("Other Leg") }}
-                                                                               {% end %}
-                                                                       </strong>
-                                                               </p>
-
-                                                               <p class="text-center">
-                                                                       {% module MOS(c) %}
-                                                               </p>
-
-                                                               <dl class="row mb-0">
-                                                                       <dt class="col-sm-6">{{ _("Codec") }}</dt>
-                                                                       <dd class="col-sm-6">{{ c.codec or _("N/A") }}</dd>
-
-                                                                       <dt class="col-sm-6">{{ _("Data Transferred") }}</dt>
-                                                                       <dd class="col-sm-6">{{ format_size(c.size) }}</dd>
-
-                                                                       <dt class="col-sm-6">{{ _("User Agent") }}</dt>
-                                                                       <dd class="col-sm-6">{{ c.user_agent or _("N/A") }}</dd>
-                                                               </dl>
-                                                       </div>
-                                               </div>
-                                       {% end %}
-                               {% end %}
-                       </div>
-               </div>
-       </div>
-{% end %}
diff --git a/src/templates/people/calls.html b/src/templates/people/calls.html
deleted file mode 100644 (file)
index 5c3f41b..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ account }} - {{ _("Calls") }}{% end block %}
-
-{% block main %}
-       <h1>{{ _("Calls") }}</h1>
-
-       {% module Channels(current_user) %}
-
-       <div class="card">
-               <div class="card-body">
-                       <nav>
-                               <ul class="pagination justify-content-center">
-                                       {% set yesterday = date - datetime.timedelta(days=1) %}
-                                       {% set tomorrow  = date + datetime.timedelta(days=1) %}
-
-                                       <li class="page-item">
-                                               <a class="page-link" href="/users/{{ account.uid }}/calls/{{ yesterday }}">
-                                                       &laquo; {{ locale.format_day(yesterday) }}
-                                               </a>
-                                       </li>
-
-                                       <li class="page-item active">
-                                               <a class="page-link" href="/users/{{ account.uid }}/calls/{{ date }}" tabindex="-1">
-                                                       {{ locale.format_day(date) }}
-                                               </a>
-                                       </li>
-
-                                       <li class="page-item {% if tomorrow > now %}disabled{% end %}">
-                                               <a class="page-link" href="/users/{{ account.uid }}/calls/{{ tomorrow }}">
-                                                       {{ locale.format_day(tomorrow) }} &raquo;
-                                               </a>
-                                       </li>
-                               </ul>
-                       </nav>
-
-                       {% module CDR(account, date=date) %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/conferences.html b/src/templates/people/conferences.html
deleted file mode 100644 (file)
index b382c5d..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Conferences") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center">
-               <div class="col col-md-8">
-                       <h1>{{ _("Conferences") }}</h1>
-
-                       {% if conferences %}
-                               {% for c in conferences %}
-                                       <div class="card mb-3" name="{{ c.handle }}">
-                                               <div class="card-body">
-                                                       <h5 class="card-title">{{ _("Conference Room %s") % c.number }}</h5>
-
-                                                       <p class="card-text small text-muted">
-                                                               {{ _("This conference room has one participant", "This conference room has %(num)s participants", len(c)) % { "num" : len(c) } }}
-                                                       </p>
-                                               </div>
-
-                                               <ul class="list-group list-group-flush">
-                                                       {% for chan in c.channels %}
-                                                               <li class="list-group-item">
-                                                                       <div class="d-flex w-100 justify-content-between">
-                                                                               <div>
-                                                                                       {% if chan.caller %}
-                                                                                               <a href="/users/{{ chan.caller.uid }}">{{ chan.caller }}</a>
-                                                                                       {% else %}
-                                                                                               {{ chan.caller_name }}
-                                                                                       {% end %}
-               
-                                                                                       <span class="text-muted">({{ format_phone_number(chan.caller_number) }})</span>
-                                                                               </div>
-
-                                                                               <span>{{ format_time(chan.duration) }}</span>
-                                                                       </div>
-
-                                                                       <span class="text-muted small">
-                                                                               {% if chan.is_secure() %}
-                                                                                       <span class="fas fa-lock" title="{{ chan.secure }}"></span>
-                                                                               {% end %}
-       
-                                                                               {{ chan.codec }}
-                                                                       </span>
-                                                               </li>
-                                                       {% end %}
-                                               </ul>
-                                       </div>
-                               {% end %}
-                       {% else %}
-                               <p class="text-muted text-center my-5">
-                                       {{ _("There are currently no conferences") }}
-                               </p>
-                       {% end %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/group.html b/src/templates/people/group.html
deleted file mode 100644 (file)
index d2d966f..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ group }}{% end block %}
-
-{% block sidebar %}
-       <div class="col-12 col-md-3">
-               <h3 class="my-3">
-                       <a class="text-white" href="/groups/{{ group.gid }}">{{ group }}</a>
-               </h3>
-       </div>
-{% end block %}
-
-{% block main %}
-       <div class="card mb-3">
-               {% if group.email %}
-                       <div class="card-body">
-                               <a class="btn btn-dark btn-block" href="mailto:{{ group.email }}">
-                                       {{ _("Email %s") % group }}
-                               </a>
-                       </div>
-               {% end %}
-
-               {% if len(group) > 0 %}
-                       <div class="card-body">
-                               <div class="row">
-                                       <div class="col">
-                                               <h6 class="mb-0">{{ _("Members") }}</h6>
-                                       </div>
-                               </div>
-                       </div>
-
-                       <div class="list-group list-group-flush">
-                               {% for account in group %}
-                                       <a class="list-group-item list-group-item-active" href="/users/{{ account.uid }}">
-                                               <div class="row">
-                                                       <div class="col-1 text-center">
-                                                               <img class="img-fluid rounded-circle" src="{{ account.avatar_url(64) }}" alt="{{ account }}" style="height: 24px" />
-                                                       </div>
-                                                       <div class="col-11">
-                                                               {{ account }}
-                                                       </div>
-                                               </div>
-                                       </a>
-                               {% end %}
-                       </div>
-               {% end %}
-       </div>
-{% end block %}
diff --git a/src/templates/people/groups.html b/src/templates/people/groups.html
deleted file mode 100644 (file)
index 48de1b8..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Groups") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center">
-               <div class="col col-md-8">
-                       <h1>{{ _("Groups") }}</h1>
-
-                       <div class="row">
-                               {% for group in backend.groups %}
-                                       <div class="col-12 col-lg-6 mb-3">
-                                               <div class="card">
-                                                       <div class="card-body">
-                                                               <h6 class="card-title mb-0">
-                                                                       <a href="/groups/{{ group.gid }}">{{ group }}</a>
-                                                               </h6>
-
-                                                               <small class="text-muted">
-                                                                       {{ _("One member", "%(num)s members", len(group)) % { "num" : len(group) } }}
-                                                               </small>
-                                                       </div>
-                                               </div>
-                                       </div>
-                               {% end %}
-                       </div>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/index.html b/src/templates/people/index.html
deleted file mode 100644 (file)
index ad461e1..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Home") }}{% end block %}
-
-{% block content %}
-       <section>
-               <div class="row mb-5">
-                       <div class="col col-lg-6">
-                               <h1>{{ _("Hello, %s!") % current_user.first_name }}</h1>
-
-                               <p>
-                                       {{ _("Welcome to the IPFire People Portal where you can manage your account and get in touch with others") }}
-                               </p>
-                       </div>
-               </div>
-
-               {% if not current_user.consents_to_promotional_emails %}
-                       <div class="card glow-primary border-primary">
-                               <div class="card-body">
-                                       <h6>{{ _("You are currently not subscribed to important updates from the IPFire Project") }}</h6>
-
-                       <div class="row">
-                           <div class="col-2 d-flex align-items-center justify-content-center">
-                               <i class="fas fa-envelope-open-text fa-3x text-primary"></i>
-                           </div>
-
-                           <div class="col-10 col-lg-6 mb-3 mb-lg-0">
-                               <p class="card-text">
-                                   {{ _("Subscribe to receive notifications about important security updates of IPFire and other news from inside the project") }}
-                               </p>
-                           </div>
-
-                           <div class="col-12 col-lg-4">
-                                                       <form action="/subscribe" method="POST">
-                                                               {% raw xsrf_form_html() %}
-
-                                                               <button type="submit" class="btn btn-success btn-block">
-                                                                       {{ _("Subscribe Now") }}
-                                                               </button>
-                                                       </form>
-                           </div>
-                       </div>
-                               </div>
-                       </div>
-               {% end %}
-
-               {% if hints %}
-                       <div class="card my-5">
-                               <div class="card-body">
-                                       <div class="row">
-                                               <div class="col-12 col-md-2 text-center">
-                                                       <i class="far fa-smile-wink fa-4x my-3"></i>
-                                               </div>
-
-                                               <div class="col-12 col-md-10">
-                                                       <h4 class="mb-0">{{ _("Tell us more about you!") }}</h4>
-                                                       <p>
-                                                               {{ _("Become a part of our community and allow other people to connect with you!") }}
-                                                       </p>
-                                               </div>
-                                       </div>
-                               </div>
-
-                               <div class="list-group list-group-flush">
-                                       {% for hint in hints %}
-                                               <a class="list-group-item list-group-item-action" href="/users/{{ current_user.uid }}/edit">
-                                                       {% if hint == "avatar" %}
-                                                               <h6 class="mb-0">{{ _("Upload an avatar!") }}</h6>
-
-                                                               <p class="card-text">
-                                                                       {{ _("A picture says more than a thousand words.") }}
-                                                               </p>
-                                                       {% elif hint == "description" %}
-                                                               <h6 class="mb-0">{{ _("Tell Us Who You Are!" ) }}</h6>
-
-                                                               <p class="card-text">
-                                                                       {{ _("Add a couple of lines about yourself to your profile so that others get to know you better!") }}
-                                                               </p>
-                                                       {% end %}
-                                               </a>
-                                       {% end %}
-                               </div>
-                       </div>
-               {% end %}
-       </section>
-{% end block %}
index 9b9daacd37617b4e87851778d700681fc91b0cc4..e7a13e88350974fe1ae67ff9ac1da7d26c83c161 100644 (file)
@@ -12,4 +12,4 @@ Subject: {{ _("New Account Registered: %s") % account }}
 
 {{ _("More can be found here:") }}
 
-  https://people.ipfire.org/users/{{ account.uid }}
+  https://www.ipfire.org/users/{{ account.uid }}
diff --git a/src/templates/people/modules/accounts-list.html b/src/templates/people/modules/accounts-list.html
deleted file mode 100644 (file)
index 0c8fe03..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-{% for group in grouper(accounts, 2) %}
-       <div class="row">
-               {% for account in group %}
-                       <div class="col-12 col-lg-6 mb-3">
-                               <div class="card">
-                                       <div class="card-body">
-                                               <h6 class="card-title mb-0">
-                                                       <img class="img-fluid rounded-circle" src="{{ account.avatar_url(64) }}" alt="{{ account }}" style="height: 24px" />
-
-                                                       <a href="/users/{{ account.uid }}">{{ account }}</a>
-                                               </h6>
-                                       </div>
-                               </div>
-                       </div>
-               {% end %}
-       </div>
-{% end %}
diff --git a/src/templates/people/modules/accounts-new.html b/src/templates/people/modules/accounts-new.html
deleted file mode 100644 (file)
index 5898cb7..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-{% if accounts %}
-    <h3>{{ _("Recently Created Accounts") }}</h3>
-
-    {% module AccountsList(accounts) %}
-{% end %}
diff --git a/src/templates/people/modules/agent.html b/src/templates/people/modules/agent.html
deleted file mode 100644 (file)
index db3e2d2..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-{% set status = account.agent_status %}
-
-{% if status %}
-       <div class="card mb-3">
-               <div class="card-body ">
-                       <h6 class="card-title">{{ _("Agent Status") }}</h6>
-
-                       <p class="card-text">
-                               {% if status == "Available" or status == "Available (On Demand)" %}
-                                       <span class="text-success">
-                                               <i class="fas fa-phone"></i>
-                                               {{ _("This agent is currently available") }}
-                                       </span>
-                               {% elif status == "Logged Out" %}
-                                       <span class="text-danger">
-                                               <i class="fas fa-phone-slash"></i>
-                                               {{ _("This agent is currently logged out") }}
-                                       </span>
-                               {% elif status == "On Break" %}
-                                       <span class="text-warning">
-                                               <i class="fas fa-coffee"></i>
-                                               {{ _("This agent is currently on a break") }}
-                                       </span>
-                               {% else %}
-                                       {{ _("Unknown status: %s") % status }}
-                               {% end %}
-                       </p>
-               </div>
-       </div>
-{% end %}
diff --git a/src/templates/people/modules/cdr.html b/src/templates/people/modules/cdr.html
deleted file mode 100644 (file)
index 456f8f2..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-{% if cdr %}
-       <table class="table table-sm mb-0">
-               <thead>
-                       <tr>
-                               <th></th>
-                               <th class="text-right">{{ _("Duration") }}</th>
-                       </tr>
-               </thead>
-
-               <tbody>
-                       {% for c in cdr %}
-                               <tr>
-                                       <td>
-                                               {% if c.direction == "inbound" %}
-                                                       <span class="fas fa-arrow-right text-danger"></span>
-
-                                                       <a href="/users/{{ account.uid }}/calls/{{ c.uuid }}">
-                                                               {% if c.callee %}
-                                                                       {{ c.callee }}
-                                                               {% else %}
-                                                                       {{ format_phone_number(c.callee_number) }}
-                                                               {% end %}
-                                                       </a>
-                                               {% elif c.direction == "outbound" %}
-                                                       <span class="fas fa-arrow-left text-success"></span>
-
-                                                       <a href="/users/{{ account.uid }}/calls/{{ c.uuid }}">
-                                                               {% if c.caller %}
-                                                                       {{ c.caller }}
-                                                               {% else %}
-                                                                       {{ format_phone_number(c.caller_number) }}
-                                                               {% end %}
-                                                       </a>
-                                               {% end %}
-
-                                               <br>
-
-                                               {% module MOS(c) %}
-                                       </td>
-
-                                       <td class="text-right">
-                                               {{ locale.format_date(c.time_start, relative=False) }} <br>
-
-                                               {% if c.time_answered %}
-                                                       <span class="text-muted">{{ format_time(c.duration) }}</span>
-                                               {% else %}
-                                                       <span class="text-danger">{{ _("Not Answered") }}</span>
-                                               {% end %}
-                                       </td>
-                               </tr>
-                       {% end %}
-               </tbody>
-       </table>
-{% else %}
-       <p class="text-muted text-center my-5">
-               {{ _("There are no calls") }}
-       </p>
-{% end %}
diff --git a/src/templates/people/modules/channels.html b/src/templates/people/modules/channels.html
deleted file mode 100644 (file)
index 3a23443..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-{% if channels %}
-       <div class="card mb-3">
-               <div class="card-body">
-                       <h6 class="card-title">{{ _("Ongoing Calls") }}</h6>
-
-                       <table class="table mb-0">
-                               <tbody>
-                                       {% for chan in channels %}
-                                               <tr>
-                                                       <td>
-                                                               {% if chan.direction == "inbound" %}
-                                                                       <span class="fas fa-arrow-right text-danger"></span>
-
-                                                                       {% if chan.conference %}
-                                                                               <a href="/conferences#{{ chan.conference.handle }}">{{ _("Conference Room %s") % chan.conference.number }}</a>
-
-                                                                               <span class="text-muted">
-                                                                                       ({{ _("One Participant", "%(num)s Participants", len(chan.conference)) % { "num" : len(chan.conference) } }})
-                                                                               </span>
-                                                                       {% elif chan.application == "echo" %}
-                                                                               {{ _("Echo Test") }}
-
-                                                                       {% elif chan.application == "voicemail" %}
-                                                                               {{ _("Voicemail") }}
-
-                                                                       {% elif chan.callee %}
-                                                                               {% if chan.callee %}
-                                                                                       <a href="/users/{{ chan.callee.uid }}">{{ chan.callee }}</a>
-                                                                               {% else %}
-                                                                                       {{ chan.callee_name }}
-                                                                               {% end %}
-               
-                                                                               <span class="text-muted">({{ format_phone_number(chan.callee_number) }})</span>
-                                                                       {% else %}
-                                                                               {{ format_phone_number(chan.called_number) }}
-                                                                       {% end %}
-                                                               {% elif chan.direction == "outbound" %}
-                                                                       <span class="fas fa-arrow-right text-success"></span>
-
-                                                                       {% if chan.caller %}
-                                                                               <a href="/users/{{ chan.caller.uid }}">{{ chan.caller }}</a>
-                                                                       {% else %}
-                                                                               {{ chan.caller_name }}
-                                                                       {% end %}
-       
-                                                                       <span class="text-muted">({{ format_phone_number(chan.caller_number) }})</span>
-                                                               {% end %}
-               
-                                                               <br>
-               
-                                                               <span class="text-muted small">
-                                                                       {% if chan.is_secure() %}
-                                                                               <span class="fas fa-lock" title="{{ chan.secure }}"></span>
-                                                                       {% end %}
-
-                                                                       {{ chan.codec }}
-                                                               </span>
-                                                       </td>
-
-                                                       <td class="text-right">
-                                                               {% if chan.state == "ACTIVE" %}
-                                                                       {# Don't show anything #}
-                                                               {% elif chan.state == "HELD" %}
-                                                                       <span class="badge badge-warning mr-2">{{ _("On Hold") }}</span>
-                                                               {% elif chan.state == "RINGING" %}
-                                                                       <span class="badge badge-info mr-2">{{ _("Ringing") }}</span>
-                                                               {% else %}
-                                                                       {{ _("Unknown State: %s") % chan.state }}
-                                                               {% end %}
-
-                                                               {{ format_time(chan.duration) }}
-                                                       </td>
-                                               </tr>
-                                       {% end %}
-                               </tbody>
-                       </table>
-               </div>
-       </div>
-{% end %}
diff --git a/src/templates/people/modules/mos.html b/src/templates/people/modules/mos.html
deleted file mode 100644 (file)
index 5ae9ddb..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-{% if call.mos %}
-       <span class="small text-muted" title="{{ "%.2f" % call.mos }}/5">
-               {% for i in range(5) %}
-                       {% if call.mos > (i + 0.5) %}
-                               <span class="fas fa-star"></span>
-                       {% elif call.mos > i %}
-                               <span class="fas fa-star-half-alt"></span>
-                       {% else %}
-                               <span class="far fa-star"></span>
-                       {% end %}
-               {% end %}
-       </span>
-{% end %}
diff --git a/src/templates/people/modules/password.html b/src/templates/people/modules/password.html
deleted file mode 100644 (file)
index f5d273b..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-<fieldset>
-       <div class="form-group">
-               <label>{{ _("New Password") }}</label>
-
-               <input type="password" class="form-control" name="password1"
-                       id="password1" placeholder="{{ _("New Password") }}" required
-                       data-user-input="{% if account %}{{ " ".join((account.first_name, account.last_name)) }}{% end %}">
-       </div>
-
-       <div class="form-group">
-               <input type="password" class="form-control" name="password2"
-                       id="password2" placeholder="{{ _("Repeat Password") }}" required>
-
-               <div id="password-mismatch" class="invalid-feedback">
-                       {{ _("Passwords do not match") }}
-               </div>
-       </div>
-
-       <div class="form-group">
-               <div class="progress">
-                       <div class="progress-bar" id="password-strength" role="progressbar"></div>
-               </div>
-       </div>
-
-       <div class="form-group text-muted">
-               <p class="mb-0" id="password-warning"></p>
-
-               <small class="form-text">
-                       <ul id="password-feedback"></ul>
-               </small>
-       </div>
-</fieldset>
diff --git a/src/templates/people/modules/registrations.html b/src/templates/people/modules/registrations.html
deleted file mode 100644 (file)
index 2b3799e..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-{% if account.sip_registrations %}
-       <div class="card mb-3">
-               <div class="card-body">
-                       <h6 class="card-title">{{ _("Active Registrations") }}</h6>
-
-                       <table class="table table-sm mb-0">
-                               <tbody>
-                                       {% for reg in account.sip_registrations %}
-                                               <tr>
-                                                       <td>{{ reg.user_agent }}</td>
-                                                       <td>{{ reg.protocol }}/{{ reg.network_ip }}:{{ reg.network_port }}</td>
-                                                       <td class="text-right">
-                                                               {% if reg.is_reachable() and reg.latency %}
-                                                                       {{ "%.2f ms" % reg.latency }}
-                                                               {% else %}
-                                                                       <span class="text-muted">{{ _("N/A") }}</span>
-                                                               {% end %}
-                                                       </td>
-                                               </tr>
-                                       {% end %}
-                               </tbody>
-                       </table>
-               </div>
-       </div>
-{% end %}
diff --git a/src/templates/people/modules/sip-status.html b/src/templates/people/modules/sip-status.html
deleted file mode 100644 (file)
index 1b2228a..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-{% if account.sip_channels %}
-    {% if account.can_be_managed_by(current_user) %}
-        <a class="text-warning" href="/users/{{ account.uid }}/sip">
-            {{ _("On The Phone") }}
-        </a>
-    {% else %}
-        <span class="text-warning">{{ _("On The Phone") }}</span>
-    {% end %}
-{% elif account.sip_registrations %}
-    {% if account.can_be_managed_by(current_user) %}
-        <a class="text-success" href="/users/{{ account.uid }}/sip">
-            {{ _("Online") }} ({{ len(account.sip_registrations) }})
-        </a>
-    {% else %}
-        <span class="text-success">{{ _("Online") }}</span>
-    {% end %}
-{% elif account.uses_sip_forwarding() %}
-    {% if account.can_be_managed_by(current_user) %}
-        <a class="text-success" href="/users/{{ account.uid }}/sip">
-            {{ _("Forwarded") }}
-        </a>
-    {% else %}
-        <span class="text-success">{{ _("Forwarded") }}</span>
-    {% end %}
-{% else %}
-    {% if account.can_be_managed_by(current_user) %}
-        <a class="text-danger" href="/users/{{ account.uid }}/sip">
-            {{ _("Offline") }}
-        </a>
-    {% else %}
-        <span class="text-danger">{{ _("Offline") }}</span>
-    {% end %}
-{% end %}
diff --git a/src/templates/people/passwd.html b/src/templates/people/passwd.html
deleted file mode 100644 (file)
index 8285e71..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ account }} - {{ _("Change Password") }}{% end block %}
-
-{% block main %}
-       <div class="row justify-content-center">
-               <div class="col col-md-8">
-                       <h4 class="mb-4">{{ _("Change Password") }}</h4>
-
-                       <form method="POST" action="">
-                               {% raw xsrf_form_html() %}
-
-                               <div class="form-group">
-                                       <label>{{ _("Current Password") }}</label>
-
-                                       <input type="password" class="form-control" name="password"
-                                               placeholder="{{ _("Current Password") }}">
-                               </div>
-
-                               {% module Password(account) %}
-
-                               <input class="btn btn-primary btn-block" type="submit" value="{{ _("Change Password") }}">
-                       </form>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/search.html b/src/templates/people/search.html
deleted file mode 100644 (file)
index d15b767..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Search Results for \"%s\"") % q }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center">
-               <div class="col col-md-8">
-                       <h1>{{ _("Search Results for \"%s\"") % q }}</h1>
-
-                       {% if accounts %}
-                               {% module AccountsList(accounts) %}
-                       {% else %}
-                               <p class="text-muted text-center my-5">
-                                       {{ _("There are no results for \"%s\"") % q }}
-                               </p>
-                       {% end %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/sip.html b/src/templates/people/sip.html
deleted file mode 100644 (file)
index 9379f32..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ account }} - {{ _("SIP Status") }}{% end block %}
-
-{% block main %}
-       <h1>{{ _("SIP Status") }}</h1>
-
-       {% module Channels(account) %}
-
-       {% module Agent(account) %}
-
-       {% module Registrations(account) %}
-
-       {% if account.uses_sip_forwarding() %}
-               <div class="card">
-                       <div class="card-body">
-                               <h6 class="card-title">{{ _("SIP Forwarding Enabled") }}</h6>
-
-                               <p class="card-text">
-                                       {{ _("All calls to %s will be forwarded to %s") % (account.sip_id, format_phone_number(account.sip_routing_address)) }}
-                               </p>
-                       </div>
-               </div>
-       {% else %}
-               <div class="card">
-                       <div class="card-body">
-                               <h6 class="card-title">{{ _("SIP Credentials") }}</h6>
-
-                               <p>
-                                       {{ _("Use these credentials to connect a SIP client to our SIP server") }}
-                               </p>
-
-                               <div class="row justify-content-center">
-                                       <div class="col-12 col-sm-8 col-md-6">
-                                               <dl class="row mb-0">
-                                                       <dt class="col-sm-6">{{ _("SIP Server") }}</dt>
-                                                       <dd class="col-sm-6">ipfire.org</dd>
-
-                                                       <dt class="col-sm-6">{{ _("Username") }}</dt>
-                                                       <dd class="col-sm-6">{{ account.sip_id }}</dd>
-
-                                                       <dt class="col-sm-6">{{ _("Password") }}</dt>
-                                                       <dd class="col-sm-6 text-monospace text-nowrap">{{ account.sip_password }}</dd>
-                                               </dl>
-                                       </div>
-                               </div>
-                       </div>
-               </div>
-       {% end %}
-{% end block %}
diff --git a/src/templates/people/stats.html b/src/templates/people/stats.html
deleted file mode 100644 (file)
index 3a1d2b6..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Statistics") }}{% end block %}
-
-{% block content %}
-       <h1>{{ _("Statistics") }}</h1>
-
-       {% set total_accounts = len(backend.accounts) %}
-
-       <div class="row">
-               <div class="col-12 col-lg-3">
-                       <div class="card mb-3">
-                               <div class="card-body text-center">
-                                       <h1>{{ total_accounts }}</h1>
-                                       <h5>{{ _("Total Accounts") }}</h5>
-
-                                       <hr>
-
-                                       {% set t = now - datetime.timedelta(days=7) %}
-
-                                       <h1>{{ backend.accounts.count_created_after(t) }}</h1>
-                                       <h5 class="mb-0">{{ _("Created This Week") }}</h5>
-
-                                       <hr>
-
-                                       {% set t = now - datetime.timedelta(days=30) %}
-
-                                       <h1>{{ backend.accounts.count_created_after(t) }}</h1>
-                                       <h5 class="mb-0">{{ _("Created This Month") }}</h5>
-
-                                       {% set pending_registrations = backend.accounts.pending_registrations %}
-                                       {% if pending_registrations %}
-                                               <hr>
-
-                                               <h1>{{ pending_registrations }}</h1>
-                                               <h5 class="mb-0">{{ _("Pending Registrations") }}</h5>
-                                       {% end %}
-                               </div>
-                       </div>
-               </div>
-
-               <div class="col-12 col-lg-9">
-                       <div class="card">
-                               <div class="card-body">
-                                       <h4 class="mb-0">{{ _("By Country") }}</h4>
-                               </div>
-
-                               <ul class="list-group list-group-flush">
-                                       {% set countries = backend.accounts.countries %}
-
-                                       {% for country in sorted(countries, key=lambda c: countries[c], reverse=True) %}
-                                               <li class="list-group-item d-flex justify-content-between align-items-center">
-                                                       <span>
-                                                               <span class="flag-icon flag-icon-{{ country.alpha2.lower() }} small mr-1"></span>
-                                                               {{ country.apolitical_name }}
-                                                       </span>
-
-                                                       <span class="badge badge-secondary" title="{{ countries[country] }}">
-                                                               {{ "%.1f%%" % (countries[country] * 100 / total_accounts) }}
-                                                       </span>
-                                               </li>
-                                       {% end %}
-                               </ul>
-                       </div>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/subscribed.html b/src/templates/people/subscribed.html
deleted file mode 100644 (file)
index 6710f8d..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Thank You") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col-12 col-md-6">
-                       <div class="card bg-success text-white p-md-5 mb-3">
-                               <div class="card-body text-center">
-                                       <span class="fas fa-check fa-5x my-4"></span>
-
-                                       <p class="lead">
-                                               {{ _("You have been subscribed and will now receive updates from the IPFire Project.") }}
-                                       </p>
-                               </div>
-                       </div>
-
-                       <a class="btn btn-light btn-block" href="/">
-                               {{ _("Back") }}
-                       </a>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/unsubscribed.html b/src/templates/people/unsubscribed.html
deleted file mode 100644 (file)
index c85a180..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Thank You") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col-12 col-md-6">
-                       <div class="card bg-danger text-white p-md-5 mb-3">
-                               <div class="card-body text-center">
-                                       <span class="fas fa-check fa-5x my-4"></span>
-
-                                       <p class="lead">
-                                               {{ _("You have been unsubscribed and will no longer receive important updates from the IPFire Project.") }}
-                                       </p>
-                               </div>
-                       </div>
-
-                       <form action="/subscribe" method="POST">
-                               {% raw xsrf_form_html() %}
-
-                               <button type="submit" class="btn btn-success btn-block">
-                                       {{ _("Continue Receiving Important Updates") }}
-                               </button>
-                       </form>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/people/user-edit.html b/src/templates/people/user-edit.html
deleted file mode 100644 (file)
index c9322bf..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ account }} - {{ _("Edit") }}{% end block %}
-
-{% block main %}
-       <h4 class="mb-4">{{ _("Edit") }}</h4>
-
-       <form method="POST" action="" enctype="multipart/form-data">
-               {% raw xsrf_form_html() %}
-
-               <div class="form-row mb-3">
-                       <div class="col">
-                               <label>{{ _("First Name") }}</label>
-
-                               <input type="text" class="form-control" name="first_name"
-                                       placeholder="{{ _("First Name") }}" value="{{ account.first_name }}" required>
-                       </div>
-
-                       <div class="col">
-                               <label>{{ _("Last Name") }}</label>
-
-                               <input type="text" class="form-control" name="last_name"
-                                       placeholder="{{ _("Last Name") }}" value="{{ account.last_name }}" required>
-                       </div>
-               </div>
-
-               <div class="form-group">
-                       <label>{{ _("Nickname") }} ({{ _("optional") }})</label>
-
-                       <input type="text" class="form-control" name="nickname"
-                               placeholder="{{ _("Nickname") }}" value="{{ account.nickname or "" }}">
-               </div>
-
-               <div class="form-group">
-                       <label>{{ _("Address") }}</label>
-
-                       <textarea type="text" class="form-control" name="street" rows="3"
-                               placeholder="{{ _("Address") }}">{{ account.street or "" }}</textarea>
-               </div>
-
-               <div class="form-row mb-3">
-                       <div class="col">
-                               <label>{{ _("City") }}</label>
-
-                               <input type="text" class="form-control" name="city"
-                                       placeholder="{{ _("City") }}" value="{{ account.city }}">
-                       </div>
-
-                       <div class="col">
-                               <label>{{ _("Postal Code") }}</label>
-
-                               <input type="text" class="form-control" name="postal_code"
-                                       placeholder="{{ _("Postal Code") }}" value="{{ account.postal_code }}">
-                       </div>
-               </div>
-
-               <div class="form-group">
-                       <label>{{ _("Country") }}</label>
-
-                       <select class="form-control" name="country_code">
-                               <option value="">{{ _("- Please Select -") }}</option>
-
-                               {% for c in countries %}
-                                       <option value="{{ c.alpha2 }}" {% if account.country_code == c.alpha2 %}selected{% end %}>{{ c.name }}</option>
-                               {% end %}
-                       </select>
-               </div>
-
-               <fieldset>
-                       <legend>{{ _("Tell Us Who You Are") }}</legend>
-
-                       <div class="form-group">
-                               <textarea type="text" class="form-control" name="description" rows="5"
-                                       placeholder="{{ _("Tell Us Who You Are") }}">{{ account.description or "" }}</textarea>
-
-                               <small class="form-text text-muted">
-                                       {{ _("You can use Markdown syntax as you know it from the IPFire Wiki") }}
-                               </small>
-                       </div>
-
-                       <div class="form-group">
-                               <label>{{ _("Avatar") }}</label>
-
-                               <input type="file" class="form-control-file" name="avatar">
-
-                               <small class="form-text text-muted">
-                                       {{ _("Upload a new avatar") }}
-                               </small>
-                       </div>
-               </fieldset>
-
-               {% if account.has_mail() %}
-                       <fieldset>
-                               <legend>{{ _("Email") }}</legend>
-
-                               <div class="form-group">
-                                       <label>{{ _("Forward Emails") }}</label>
-
-                                       <input type="mail" class="form-control" name="mail_routing_address"
-                                               placeholder="{{ _("Email Address") }}" value="{{ account.mail_routing_address or "" }}">
-
-                                       <small class="form-text text-muted">
-                                               {{ _("All emails will be forwarded to this email address") }}
-                                       </small>
-                               </div>
-                       </fieldset>
-               {% end %}
-
-               <fieldset>
-                       <legend>{{ _("Telephone") }}</legend>
-
-                       <div class="form-group">
-                               <label>{{ _("Phone Numbers") }}</label>
-
-                               <textarea type="text" class="form-control" name="phone_numbers" rows="3"
-                                       placeholder="{{ _("Phone Numbers") }}">{{ "\n".join((format_phone_number_to_e164(n) for n in account.phone_numbers)) }}</textarea>
-
-                               <small class="form-text text-muted">
-                                       {{ _("Enter your landline and mobile phone numbers") }}
-                               </small>
-                       </div>
-
-                       {% if account.has_sip() %}
-                               <div class="form-group">
-                                       <label>{{ _("Forward Calls") }}</label>
-
-                                       <input type="text" class="form-control" name="sip_routing_address"
-                                               placeholder="{{ _("SIP URI or Phone Number") }}" value="{{ account.sip_routing_address or "" }}">
-
-                                       <small class="form-text text-muted">
-                                               {{ _("All calls will be forwarded to this phone number or SIP URI") }}
-                                       </small>
-                               </div>
-                       {% end %}
-               </fieldset>
-
-               <input class="btn btn-primary btn-block" type="submit" value="{{ _("Save") }}">
-       </form>
-{% end block %}
diff --git a/src/templates/people/user.html b/src/templates/people/user.html
deleted file mode 100644 (file)
index a89ec8d..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-       {% import phonenumbers %}
-
-       <div class="card mb-3">
-               <div class="card-body">
-                       {% if account.description %}
-                               <div class="row">
-                                       <div class="col">
-                                               {% module Markdown(account.description) %}
-                                       </div>
-                               </div>
-                       {% end %}
-
-                       <div class="row">
-                               <div class="col">
-                                       <a class="btn btn-dark btn-block" href="mailto:{{ account.email }}">
-                                               {{ _("Email %s") % account.first_name }}
-                                       </a>
-                               </div>
-                       </div>
-
-                       {% if current_user == account or current_user.is_staff() %}
-                               <div class="row">
-                                       {% if account.address %}
-                                               <div class="col-md-6 mt-5">
-                                                       <h6>{{ _("Postal Address") }}</h6>
-
-                                                       <address>
-                                                               <strong>{{ account.name }}</strong>
-                                                               <br>
-                                                               {% for line in account.address %}
-                                                                       {{ line }}<br>
-                                                               {% end %}
-                                                       </address>
-                                               </div>
-                                       {% end %}
-
-                                       <div class="col-md-6 mt-5">
-                                               {% if account.phone_number or account.fax_number %}
-                                                       <h6 class="mb-2">{{ _("Phone Numbers") }}</h6>
-
-                                                       <ul class="list-unstyled">
-                                                               {% if account.phone_number %}
-                                                                       <li>
-                                                                               <span class="fas fa-phone"></span>
-
-                                                                               <a href="tel:{{ format_phone_number_to_e164(account.phone_number) }}">
-                                                                                       {{ format_phone_number(account.phone_number) }}
-                                                                               </a>
-                                                                       </li>
-                                                               {% end %}
-
-                                                               {% if account.fax_number %}
-                                                                       <li>
-                                                                               <span class="fas fa-fax"></span>
-
-                                                                               <a href="fax:{{ format_phone_number_to_e164(account.fax_number) }}">
-                                                                                       {{ format_phone_number(account.fax_number) }}
-                                                                               </a>
-                                                                       </li>
-                                                               {% end %}
-                                                       </ul>
-                                               {% end %}
-
-                                               {% if account.phone_numbers %}
-                                                       <h6 class="mb-2">{{ _("External Phone Numbers") }}</h6>
-
-                                                       <ul class="list-unstyled">
-                                                               {% for number in account.phone_numbers %}
-                                                                       <li>
-                                                                               {% if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE %}
-                                                                                       <span class="fas fa-mobile" title="{{ _("Mobile") }}"></span>
-                                                                               {% else %}
-                                                                                       <span class="fas fa-phone"></span>
-                                                                               {% end %}
-
-                                                                               <a href="tel:{{ format_phone_number_to_e164(number) }}"
-                                                                                       title="{{ format_phone_number_location(number) }}">{{ format_phone_number(number) }}</a>
-                                                                       </li>
-                                                               {% end %}
-                                                       </ul>
-                                               {% end %}
-                                       </div>
-                               </div>
-                       {% end %}
-               </div>
-
-               {% if current_user.is_staff() and account.groups %}
-                       <div class="card-body">
-                               <div class="row">
-                                       <div class="col">
-                                               <h6 class="mb-0">{{ _("Groups") }}</h6>
-                                       </div>
-                               </div>
-                       </div>
-
-                       <div class="list-group list-group-flush">
-                               {% for g in account.groups %}
-                                       <a class="list-group-item list-group-item-active" href="/groups/{{ g.gid }}">
-                                               <i class="fas fa-users mr-2"></i> {{ g }}
-                                       </a>
-                               {% end %}
-                       </div>
-               {% end %}
-       </div>
-
-       <ul class="list-unstyled small text-muted">
-               <li>
-                       {{ _("Joined %s") % locale.format_date(account.created_at, shorter=True) }}
-               </li>
-
-               {% if current_user.is_admin() %}
-                       <li>
-                               {{ _("Last Modified %s") % locale.format_date(account.modified_at) }}
-                       </li>
-
-                       {% if account.last_successful_authentication %}
-                               <li>
-                                       {{ _("Last successful authentication: %s") % locale.format_date(account.last_successful_authentication) }}
-                               </li>
-                       {% end %}
-
-                       {% if account.failed_login_count %}
-                               <li class="text-warning">
-                                       {{ _("One unsuccessful authentication attempt.", "%(num)s unsuccessful authentication attempts.", account.failed_login_count) % { "num" : account.failed_login_count } }}
-
-                                       {% if account.last_failed_authentication %}
-                                               {{ _("Last attempt: %s") % locale.format_date(account.last_failed_authentication) }}
-                                       {% end %}
-                               </li>
-                       {% end %}
-               {% end %}
-       </ul>
-{% end block %}
diff --git a/src/templates/people/users.html b/src/templates/people/users.html
deleted file mode 100644 (file)
index f884102..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Users") }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center">
-               <div class="col col-md-8">
-                       <h1>{{ _("Users") }}</h1>
-
-                       {% module NewAccounts() %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/static/about.html b/src/templates/static/about.html
new file mode 100644 (file)
index 0000000..e063399
--- /dev/null
@@ -0,0 +1,523 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("About IPFire") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">About</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h1 class="title">
+                                       About IPFire<span class="has-text-primary">_</span>
+                               </h1>
+                               <h6 class="subtitle">The Open Source Firewall</h6>
+                       </div>
+               </div>
+       </section>
+
+       <div class="container">
+               <section class="section">
+                       <div class="block">
+                               <p class="is-size-4">
+                                       <strong>IPFire<span class="has-text-primary">_</span></strong>
+                                       is the world's leading Open Source firewall distribution.
+                                       Businesses across the world have chosen to put their trust
+                                       in our versatile, feature-rich solution with its easy-to-use
+                                       web management console. Why not join them today?
+                               </p>
+                       </div>
+               </section>
+
+               <section class="section">
+                       <div class="block">
+                               <div class="columns is-multiline">
+                                       <div class="column is-half">
+                                               <div class="columns is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <i class="fas fa-shield-halved fa-5x"></i>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">Security by Design</p>
+
+                                                               <p>
+                                                                       Network segmentation is the key to a secure network.
+                                                                       IPFire sets up a DMZ for your local infrastructure or a
+                                                                       guest network for any visitors separating and protecting
+                                                                       other parts of your network.
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-half">
+                                               <div class="columns is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <i class="fas fa-rocket fa-5x"></i>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">Industry-Leading Firewall Engine</p>
+
+                                                               <p>
+                                                                       Our stateful packet inspection firewall engine analyses
+                                                                       traffic for the latest threats and performs
+                                                                       deep packet inspection in real time.
+                                                                       Due to our smart user interface, creating even complex
+                                                                       setups is quick and straight-forward.
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-half">
+                                               <div class="columns is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <i class="fas fa-network-wired fa-5x"></i>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">We Connect the World</p>
+
+                                                               <p>
+                                                                       We securely connect your employees to their desks at home,
+                                                                       your global business partners and the infrastructure in your data centre,
+                                                                       giving you maximum flexibility so that you can focus on what really matters.
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-half">
+                                               <div class="columns is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <span class="fas fa-thumbs-up fa-5x"></span>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">Easy to Use</p>
+
+                                                               <p>
+                                                                       IPFire is managed over a web-based console which
+                                                                       is powerful, yet easy to use.
+                                                                       Each feature is just one click away.
+                                                                       Advanced reporting and real time graphs give you
+                                                                       detailed insight into your network.
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-half">
+                                               <div class="columns is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <i class="fa-solid fa-earth-europe fa-5x"></i>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">Supporting Global Standards</p>
+
+                                                               <p>
+                                                                       Commonly deployed in businesses and educational organisations
+                                                                       of all sizes, IPFire interoperates perfectly with solutions
+                                                                       from other vendors making it an ideal drop-in replacement.
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-half">
+                                               <div class="columns is-vcentered">
+                                                       <div class="column is-3 has-text-centered">
+                                                               <i class="fas fa-terminal fa-5x"></i>
+                                                       </div>
+
+                                                       <div class="column">
+                                                               <p class="title is-5">Free As In Freedom</p>
+
+                                                               <p>
+                                                                       IPFire is free software.
+                                                                       Our community develops and reviews all changes going
+                                                                       into the code base and IPFire is regularly audited by
+                                                                       independent third parties.
+                                                                       Become a part of the community and help us
+                                                                       to continue improving IPFire!
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </section>
+
+               <section class="section">
+                       <a class="button is-primary is-large is-fullwidth" href="/download">
+                               <span class="is-hidden-touch">{{ _("DOWNLOAD IPFIRE NOW. IT'S FREE!") }}</span>
+                               <span class="is-hidden-desktop">{{ _("DOWNLOAD NOW") }}</span>
+                       </a>
+
+                       <!-- any screenshots go here -->
+               </section>
+
+               <section class="section">
+                       <h3 class="title is-3">{{ _("Meet The Team") }}</h3>
+
+                       <div class="block">
+                               <p class="is-size-5">
+                                       IPFire is built by a group of experts from various backgrounds and places
+                                       and we could not do it without our great community around us.
+
+                                       <a href="/donate">Support our work with your donation!</a>
+                               </p>
+                       </div>
+
+                       {% set core_team = backend.groups.get_by_gid("core-team") %}
+
+                       <div class="block">
+                               <div class="columns is-multiline is-mobile">
+                                       {% for account in sorted(core_team, key=lambda a: a.created_at) %}
+                                               <div class="column has-text-centered">
+                                                       <figure class="image is-128x128 is-inline-block">
+                                                               <img class="is-rounded" src="{{ account.avatar_url(size=256) }}">
+                                                       </figure>
+
+                                                       <h4 class="title is-4 has-text-weight-bold">{{ account.name or account.nickname }}</h5>
+                                               </div>
+                                       {% end %}
+                               </div>
+                       </div>
+
+                       {% set team = [
+                               a for a in backend.groups.get_by_gid("contributors") if not a in core_team
+                       ] %}
+
+                       {% if team %}
+                               <div class="block">
+                                       <div class="columns is-multiline is-mobile">
+                                               {% for account in sorted(team, key=lambda a: a.created_at) %}
+                                                       <div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop is-one-fifth-widescreen is-one-fifth-fullhd">
+                                                               <div class="columns is-vcentered is-mobile">
+                                                                       <div class="column is-narrow">
+                                                                               <figure class="image is-48x48">
+                                                                                       <img class="is-rounded" src="{{ account.avatar_url(size=96) }}">
+                                                                               </figure>
+                                                                       </div>
+                                                                       <div class="column">
+                                                                               <h6 class="title is-6 has-text-weight-bold">{{ account.name or account.nickname }}</h6>
+                                                                       </div>
+                                                               </div>
+                                                       </div>
+                                               {% end %}
+                                       </div>
+                               </div>
+                       {% end %}
+
+                       <!-- Talk about funding. Donations, how LWL supports the project -->
+               </section>
+       </div>
+
+       <div class="container">
+               <section class="section">
+                       <div class="block">
+                               <h3 class="title is-3">{{ _("Under The Hood") }}</h3>
+
+                               <div class="columns">
+                                       <div class="column is-one-fourth">
+                                               IPFire is not only an app that you install, it is a whole operating
+                                               system based on Linux, hardened and tuned to the maximum to serve
+                                               as a firewall.
+                                               Regular updates help keeping even the hardest kind of hacker out.
+                                       </div>
+
+                                       <div class="column">
+                                               The stateful inspection firewall that is working inside IPFire
+                                               is one of the fastest of its kind.
+                                               Configuration of even complex rulesets becomes easy with
+                                               groups for hosts and services on the network and help you
+                                               to keep things in order, even when it gets complicated.
+                                       </div>
+                               </div>
+                       </div>
+               </section>
+
+               <section class="section">
+                       <div class="block">
+                               <div class="columns">
+                                       <div class="column is-one-third content">
+                                               <h6>Network Security</h6>
+
+                                               <ul>
+                                                       <li>Stateful inspection firewall</li>
+                                                       <li>
+                                                               Builtin network segmentation
+                                                               <ul>
+                                                                       <li>Demilitarized Zone (DMZ)</li>
+                                                                       <li>Separate network for wireless devices/guest network</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>Flexible rule creating with groups and visual aids</li>
+                                                       <li>Intrusion Prevention System</li>
+                                                       <li>
+                                                               Rate Limiting to Protect Servers from DoS attacks
+                                                               and Maximum Connection Limits
+                                                       </li>
+                                                       <li>SYN-flood Protection</li>
+                                                       <li>Country-based Firewall Rules</li>
+                                                       <li>Source and Destination NAT Rules</li>
+                                                       <li>Time-based Firewall Rules</li>
+                                                       <li>MAC address-based Firewall Rules</li>
+                                                       <li>Blocking of P2P Networks</li>
+                                                       <li>Connection Logging</li>
+                                               </ul>
+
+                                               <h6>Network Features</h6>
+
+                                               <ul>
+                                                       <li>VLAN (802.1q)</li>
+                                                       <li>Port Bridging</li>
+                                                       <li>Spanning Tree Protocol Support</li>
+                                                       <li>Wireless Access Point</li>
+                                                       <li>Live Connection Tracking</li>
+                                                       <li>Static Routes</li>
+                                                       <li>Dynamic Routing with Bird or FRR using BGP/OSPF</li>
+                                                       <li>
+                                                               DHCP Server
+                                                               <ul>
+                                                                       <li>Static Leases</li>
+                                                                       <li>DNS Update (RFC2136)</li>
+                                                                       <li>Support for DHCP Options</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>Network Time Server (NTP)</li>
+                                                       <li>Dynamic DNS Client with support for many providers</li>
+                                                       <li>
+                                                               Captive Portal
+                                                               <ul>
+                                                                       <li>Terms &amp; Conditions or Coupon</li>
+                                                                       <li>Customizable to your corporate design</li>
+                                                                       <li>Coupon Code Export in PDF Format</li>
+                                                                       <li>Flexible Coupon Expiry Times</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>Wake-on-LAN (WOL)</li>
+                                               </ul>
+
+                                               <h6>Web Proxy</h6>
+
+                                               <ul>
+                                                       <li>Transparent Mode</li>
+                                                       <li>Support for Upstream Proxies with Authentication</li>
+                                                       <li>Advanced Logging</li>
+                                                       <li>In Memory and on Disk Cache</li>
+                                                       <li>
+                                                               Network-based Access Control (ACL)
+                                                               <ul>
+                                                                       <li>By IP Address</li>
+                                                                       <li>By MAC Address</li>
+                                                                       <li>Ban/Allow List</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>Time-based Rules</li>
+                                                       <li>Transfer Limits based on File Size</li>
+                                                       <li>Download Throttling per Network Zone or Host</li>
+                                                       <li>Anomaly Detection based on AS Information</li>
+                                                       <li>MIME Type Filter</li>
+                                                       <li>Classroom Extensions</li>
+                                                       <li>Web Proxy Auto-Discovery Protocol (WPAD)</li>
+                                                       <li>Proxy Auto-Config (PAC)</li>
+                                                       <li>
+                                                               Authentication
+                                                               <ul>
+                                                                       <li>Local User Database</li>
+                                                                       <li>Microsoft Windows Active Directory</li>
+                                                                       <li>LDAP</li>
+                                                                       <li>RADIUS</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>
+                                                               Advanced Content Filtering
+                                                               <ul>
+                                                                       <li>Blocklist-based Access Blocking</li>
+                                                                       <li>Support for Various Blocklist Providers</li>
+                                                                       <li>Automatic List Update</li>
+                                                                       <li>Custom Blocklists</li>
+                                                                       <li>Custom Allowlists</li>
+                                                                       <li>Custom Expression Lists</li>
+                                                                       <li>Filter by File Extension</li>
+                                                                       <li>Custom Error Page</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>
+                                                               Advanced Update Caching
+                                                               <ul>
+                                                                       <li>Microsoft Windows</li>
+                                                                       <li>Apple Operating Systems</li>
+                                                                       <li>Adobe</li>
+                                                                       <li>Mozilla</li>
+                                                                       <li>
+                                                                               Various Anti-Virus Signatures including
+                                                                               Avast,
+                                                                               Avira,
+                                                                               AVG,
+                                                                               McAffee,
+                                                                               Trend Micro,
+                                                                               and Symantec
+                                                                       </li>
+                                                               </ul>
+                                                       </li>
+                                               </ul>
+                                       </div>
+
+                                       <div class="column is-one-third content">
+                                               <h6>WAN Features</h6>
+
+                                               <ul>
+                                                       <li>Support for Fibre, DSL, Cable and 5G/4G/3G</li>
+                                                       <li>Multiple Public IP Addresses</li>
+                                                       <li>Automatic failover for dialup connections</li>
+                                                       <li>User-Assignable MAC Address</li>
+                                               </ul>
+
+                                               <h6>VPN</h6>
+
+                                               <ul>
+                                                       <li>
+                                                               IPsec
+                                                               <ul>
+                                                                       <li>Net-to-Net and Net-to-Host Mode</li>
+                                                                       <li>Support for IKEv2 and IKEv1</li>
+                                                                       <li>Public Key and Pre-Shared-Secret Authentication</li>
+                                                                       <li>
+                                                                               Encryption
+                                                                               <ul>
+                                                                                       <li>AES (CBC, GCM)</li>
+                                                                                       <li>ChaCha20-Poly1305</li>
+                                                                                       <li>Camellia</li>
+                                                                                       <li>3DES</li>
+                                                                               </ul>
+                                                                       </li>
+                                                                       <li>
+                                                                               Integrity
+                                                                               <ul>
+                                                                                       <li>SHA2 512/384/256 Bit</li>
+                                                                                       <li>AES XCBC</li>
+                                                                                       <li>SHA1</li>
+                                                                                       <li>MD5</li>
+                                                                               </ul>
+                                                                       </li>
+                                                                       <li>
+                                                                               Key Exchange
+                                                                               <ul>
+                                                                                       <li>Curve-25519, Curve-448</li>
+                                                                                       <li>NIST ECP-521, 384, 256, 224, or 192 Bit</li>
+                                                                                       <li>Brainpool ECP-512, 384, 256, or 224 Bit</li>
+                                                                                       <li>RSA 8192, 6144, 4096, 3072, 2048, 1536, 1024, or 768 Bit</li>
+                                                                               </ul>
+                                                                       </li>
+                                                                       <li>Hardware-accelerated Encryption</li>
+                                                                       <li>Tunnel and Transport Mode</li>
+                                                                       <li>Encapsulation with GRE and VTI</li>
+                                                                       <li>Dead Peer Detection</li>
+                                                                       <li>Perfect Forward Secrecy</li>
+                                                                       <li>MOBIKE</li>
+                                                                       <li>On-demand mode</li>
+                                                                       <li>Payload Compression</li>
+                                                                       <li>Easy connection export to Apple Mac OS/iOS devices</li>
+                                                               </ul>
+                                                       </li>
+                                                       <li>
+                                                               OpenVPN
+                                                               <ul>
+                                                                       <li>Net-to-Net and Net-to-Host Mode</li>
+                                                                       <li>Public Key Authentication</li>
+                                                                       <li>
+                                                                               Encryption
+                                                                               <ul>
+                                                                                       <li>AES (CBC, GCM)</li>
+                                                                                       <li>Camellia</li>
+                                                                                       <li>SEED</li>
+                                                                                       <li>DES/3DES</li>
+                                                                                       <li>Blowfish</li>
+                                                                                       <li>CAST5</li>
+                                                                               </ul>
+                                                                       </li>
+                                                                       <li>
+                                                                               Integrity
+                                                                               <ul>
+                                                                                       <li>SHA2 512, 384, or 256 Bit</li>
+                                                                                       <li>Whirpool</li>
+                                                                                       <li>SHA1</li>
+                                                                               </ul>
+                                                                       </li>
+                                                                       <li>TLS Authentication</li>
+                                                                       <li>TLS Channel Protection</li>
+                                                                       <li>LZO Compression</li>
+                                                                       <li>Configuration Export/Import in ZIP Format</li>
+                                                               </ul>
+                                                       </li>
+                                               </ul>
+
+                                               <h6>Quality of Service (QoS)</h6>
+
+                                               <ul>
+                                                       <li>Inbound &amp; Outbound Traffic Shaping</li>
+                                                       <li>Latency Minimization</li>
+                                                       <li>Classify Traffic by IP Address, Protocol, or Ports</li>
+                                                       <li>Layer7 Protocol Detection</li>
+                                               </ul>
+                                       </div>
+
+                                       <div class="column is-one-third content">
+                                               <h6>Intrusion Prevention System</h6>
+
+                                               <ul>
+                                                       <li>Live Deep Packet Analysis</li>
+                                                       <li>Graphical Rule Editor</li>
+                                                       <li>Support for Various Rule Providers</li>
+                                                       <li>Automatic Ruleset Updates</li>
+                                               </ul>
+
+                                               <h6>DNS</h6>
+
+                                               <ul>
+                                                       <li>Internal DNSSEC-validating DNS proxy</li>
+                                                       <li>Caching for faster DNS response times</li>
+                                                       <li>Local hostnames</li>
+                                                       <li>DNS Forwarding for Zones</li>
+                                                       <li>Configuration of multiple upstream DNS recursors</li>
+                                                       <li>Recursor/Standalone Mode</li>
+                                                       <li>DNS-over-TLS, TCP or UDP</li>
+                                                       <li>Agressive NSEC</li>
+                                                       <li>SafeSearch</li>
+                                                       <li>QNAME Minimization</li>
+                                               </ul>
+
+                                               <h6>Operating System</h6>
+
+                                               <ul>
+                                                       <li>Comfortable Web User Interface in various languages</li>
+                                                       <li>Simple One-Click Updates</li>
+                                                       <li>Configuration Backup and Restore</li>
+                                                       <li>Detailed System Health Reports and Graphs</li>
+                                                       <li>Console Access with SSH</li>
+                                                       <li>Serial Console</li>
+                                                       <li>Hardware Vulnerability Reporting</li>
+                                                       <li>Email Notifications</li>
+                                                       <li>Remote Syslog</li>
+                                                       <li>SNMP/Zabbix/Observium Monitoring</li>
+                                               </ul>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/static/features.html b/src/templates/static/features.html
deleted file mode 100644 (file)
index 68dee8c..0000000
+++ /dev/null
@@ -1,280 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Features") }}{% end block %}
-
-{% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1 class="display-2">{{ _("Features") }}</h1>
-
-                                       <p>
-                                               {{ _("IPFire is a powerful and professional Open Source firewall solution") }}
-                                               <br>
-                                               {{ _("Learn what it can do") }}
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-start">
-                                       <span class="fas fa-shield-alt icon-large text-primary my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-9">
-                                       <h1>{{ _("Security") }}</h1>
-
-                                       <p>
-                                               The primary objective of IPFire is security.
-                                               Its easy to configure firewall engine and Intrusion Detection System
-                                               prevent any attackers from breaking into your network.
-                                               In the default configuration, the network is split into various zones
-                                               with different security policies such as a LAN and DMZ to manage
-                                               risks inside the network and have custom configuration for the specific
-                                               needs of each segment of the network.
-                                       </p>
-
-                                       <p>
-                                               But even the firewall needs to protect itself.
-                                               IPFire is built from scratch and not based on any other distribution.
-                                               This allows the developers to harden IPFire better than any other
-                                               server operating system and build all components specifically for use
-                                               as a firewall.
-                                       </p>
-
-                                       <p>
-                                               Frequent updates keep IPFire strong against security vulnerabilities
-                                               and new attack vectors.
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <div class="row flex-md-row-reverse">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-end">
-                                       <span class="fas fa-fire icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-9">
-                                       <h1>{{ _("Firewall") }}</h1>
-
-                                       <p>
-                                               IPFire employs a Stateful Packet Inspection (SPI) firewall,
-                                               which is built on top of Netfilter, the Linux packet filtering framework.
-                                               It filters packets fast and achieves throughputs of up to multiple
-                                               tens of Gigabit per second.
-                                       </p>
-
-                                       <p>
-                                               Its intuitive web user interface allows to create groups of hosts and
-                                               networks which can be used to keep large set of rules short and tidy -
-                                               something very important in complex environments with strict access control.
-                                               Logging and graphical reports give great insight.
-                                       </p>
-
-                                       <p>
-                                               Various settings are available to mitigate and block Denial-of-Service
-                                               attacks by filtering them directly at the firewall and not allowing them
-                                               to take down your servers.
-                                       </p>
-
-                                       <p class="mb-5">
-                                               <a class="btn btn-secondary" href="https://wiki.ipfire.org/configuration/firewall">
-                                                       Firewall Documentation
-                                               </a>
-                                       </p>
-
-                                       <h4>Intrusion Detection/Prevention System</h4>
-
-                                       <p>
-                                               IPFire's Intrusion Detection System (IDS) analyzes network traffic and tries to
-                                               detect exploits, leaking data and any other suspicious activity.
-                                               Upon detection, alerts are raised and the attacker is immediately blocked.
-                                       </p>
-
-                                       <p>
-                                               <a class="btn btn-secondary" href="https://wiki.ipfire.org/configuration/services/ids">
-                                                       Documentation
-                                               </a>
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-start">
-                                       <span class="fas fa-network-wired icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-9">
-                                       <h1>{{ _("Connecting the World") }}</h1>
-
-                                       <p>
-                                               Virtual Private Networks (VPNs) connect remote locations like data centers,
-                                               branch offices or outsourced infrastructure via an encrypted link.
-                                               IPFire allows staff to work remotely as if they would be sitting in the office
-                                               and allowing them to access all resources that they need - fast and securely.
-                                       </p>
-
-                                       <p>
-                                               IPFire supports industry standards like IPsec and OpenVPN and interoperates with
-                                               equipment from various vendors like Cisco & Juniper.
-                                               VPNs are quickly and easily set up with IPFire and employ latest cryptography.
-                                       </p>
-
-                                       <div class="row">
-                                               <div class="col-12 col-md-4 mb-3">
-                                                       <a class="btn btn-secondary btn-block" href="https://wiki.ipfire.org/configuration/services/ipsec">
-                                                               VPN with IPsec
-                                                       </a>
-                                               </div>
-
-                                               <div class="col-12 col-md-4 mb-3">
-                                                       <a class="btn btn-secondary btn-block" href="https://wiki.ipfire.org/configuration/services/openvpn">
-                                                               VPN with OpenVPN
-                                                       </a>
-                                               </div>
-                                       </div>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <div class="row flex-md-row-reverse">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-end">
-                                       <span class="fas fa-box-open icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-9">
-                                       <h1>{{ _("Add-ons") }}</h1>
-
-                                       <p>
-                                               From a technical point of view, IPFire is a minimalistic, hardened operating system.
-                                               To provide more functionality, it can be extended by add-ons which are installed with
-                                               IPFire's own package management system called <em>Pakfire</em>.
-                                       </p>
-
-                                       <p>
-                                               Add-ons can be handy command line tools for administrators or can extend the system
-                                               to provide additional functionality. Those include:
-
-                                               <ul>
-                                                       <li>
-                                                               Turning IPFire into a <a href="https://wiki.ipfire.org/addons/wireless">Wireless Access Point</a>
-                                                       </li>
-
-                                                       <li>
-                                                               Tools for Monitoring and System Health Management
-                                                       </li>
-
-                                                       <li>
-                                                               Backup, File and Print Services
-                                                       </li>
-
-                                                       <li>
-                                                               Running a <a href="https://wiki.ipfire.org/addons/tor">Tor</a> node
-                                                       </li>
-
-                                                       <li>
-                                                               Proxies and Relays for various protocols
-                                                       </li>
-
-                                                       <li>
-                                                               and many more...
-                                                       </li>
-                                               </ul>
-
-                                               <a class="btn btn-secondary" href="https://wiki.ipfire.org/addons">
-                                                       List of all Add-ons
-                                               </a>
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-start">
-                                       <span class="fas fa-angle-double-right icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-9">
-                                       <h1>{{ _("Making Your Internet Faster") }}</h1>
-
-                                       <p>
-                                               The IPFire Quality of Service (QoS) categorizes network traffic and sends it out
-                                               prioritized by how important it is to ensure a good service.
-                                               For example, a Voice-over-IP call will always have priority over a large download
-                                               to ensure that words will never get lost and call quality is always the best it can be.
-                                       </p>
-
-                                       <p>
-                                               Even on very busy links, IPFire will make sure that websites load fast and that the
-                                               network is quick and responsive by using smart queueing algorithms and getting the
-                                               most out of your bandwidth.
-                                       </p>
-
-                                       <p>
-                                               <a class="btn btn-secondary" href="https://wiki.ipfire.org/configuration/services/qos">
-                                                       Documentation
-                                               </a>
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <div class="row flex-md-row-reverse">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-end">
-                                       <span class="fas fa-server icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-9">
-                                       <h1>{{ _("Web proxy") }}</h1>
-
-                                       <p>
-                                               One of the most commonly used features of IPFire is the full-fledged web proxy.
-                                               It delivers and filters web content and can only allow Internet access for some
-                                               users.
-                                       </p>
-
-                                       <p>
-                                               Caching content on the firewalls disk makes websites load faster.
-                                               External regularly updated blacklists allow banning browsing on various websites
-                                               when they are for example not suitable for students.
-                                               Optionally, the IPFire web proxy can transparently scan for viruses and block
-                                               them straight away.
-                                       </p>
-
-                                       <p>
-                                               The web proxy makes IPFire perfect for schools and universities where
-                                               access control and logging is required.
-                                       </p>
-
-                                       <p>
-                                               <a class="btn btn-secondary" href="https://wiki.ipfire.org/configuration/network/proxy">
-                                                       Documentation
-                                               </a>
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-{% end block %}
diff --git a/src/templates/static/help.html b/src/templates/static/help.html
new file mode 100644 (file)
index 0000000..05c70e6
--- /dev/null
@@ -0,0 +1,155 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Help") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">Help</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">
+                                       {{ _("Get Help with IPFire") }}
+                               </h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="hero is-lwl">
+               <div class="hero-body">
+                       <div class="container">
+                               <div class="columns is-mobile is-vcentered">
+                                       <div class="column">
+                                               <div class="block">
+                                                       <h3 class="title is-3">
+                                                               {{ _("Professional Support from Lightning Wire Labs") }}
+                                                       </h3>
+
+                                                       <p>
+                                                               If you require dedicated assistance, Lightning Wire Labs
+                                                               offers professional support services to ensure your
+                                                               IPFire deployment runs smoothly.
+                                                               Their experienced team is ready to provide expert guidance,
+                                                               troubleshooting, and tailored solutions for your specific needs.
+                                                       </p>
+                                               </div>
+
+                                               <div lcass="block">
+                                                       <a class="button is-white has-text-lwl has-text-weight-bold"
+                                                                       href="https://store.lightningwirelabs.com/products/groups/support">
+                                                               {{ _("Learn More") }}
+                                                       </a>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-narrow is-hidden-mobile has-text-centered">
+                                               <figure class="image is-128x128">
+                                                       <img src="{{ static_url("img/lightningwirelabs-logo.svg") }}"
+                                                               alt="{{ _("Lightning Wire Labs") }}">
+                                               </figure>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-multiline">
+                               <div class="column is-half p-5">
+                                       <div class="block">
+                                               <h3 class="title is-3">Documentation</h3>
+
+                                               <p>
+                                                       Before reaching out for assistance, explore our comprehensive
+                                                       <a href="/docs">documentation</a> to find answers to common questions,
+                                                       step-by-step guides, and configuration tips.
+                                                       The documentation is a valuable resource designed to empower you
+                                                       with the knowledge to manage and optimize your IPFire installation.
+                                               </p>
+                                       </div>
+
+                                       <div class="block">
+                                               <a class="button is-primary" href="/docs">
+                                                       {{ _("Go to Documentation") }}
+                                               </a>
+                                       </div>
+                               </div>
+
+                               <div class="column is-half p-5">
+                                       <div class="block">
+                                               <h3 class="title is-3">
+                                                       Engage with the IPFire<span class="has-text-primary">_</span> Community
+                                               </h3>
+
+                                               <p>
+                                                       Join the vibrant IPFire community where users, developers, and
+                                                       enthusiasts share knowledge and experiences.
+                                                       Participate in discussions, ask questions, and learn from others
+                                                       who have faced similar challenges.
+                                                       Our Community Forums and Mailing Lists are excellent platforms
+                                                       to connect with like-minded individuals.
+                                               </p>
+                                       </div>
+
+                                       <div class="block">
+                                               <a class="button is-primary" href="https://community.ipfire.org/">
+                                                       {{ _("Go to Community") }}
+                                               </a>
+                                       </div>
+                               </div>
+
+                               <div class="column is-half p-5">
+                                       <div class="block">
+                                               <h4 class="title is-4">Report Bugs and Request Features</h4>
+
+                                               <p>
+                                                       If you encounter a bug or have a feature request, please visit
+                                                       our Bugtracker.
+                                                       Your feedback is crucial for improving IPFire, and our
+                                                       development team appreciates your contributions to enhancing
+                                                       the system.
+                                               </p>
+                                       </div>
+
+                                       <div class="block">
+                                               <a class="button is-primary" href="https://bugzilla.ipfire.org/">
+                                                       {{ _("Go to Bugtracker") }}
+                                               </a>
+                                       </div>
+                               </div>
+
+                               <div class="column is-half p-5">
+                                       <div class="block">
+                                               <h4 class="title is-4">Stay Informed</h4>
+
+                                               <p>
+                                                       Subscribe to our newsletter and follow us on social media to stay
+                                                       informed about the latest updates, security advisories, and
+                                                       community events.
+                                                       Connect with us on Twitter and LinkedIn to be a part of the
+                                                       broader IPFire network.
+                                               </p>
+                                       </div>
+
+                                       {% if not current_user or not current_user.consents_to_promotional_emails %}
+                                               <div class="block">
+                                                       <a class="button is-primary" href="/subscribe">
+                                                               {{ _("Subscribe To Our Newsletter") }}
+                                                       </a>
+                                               </div>
+                                       {% end %}
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
index 7c175dd9ecb615745a27036c4fabe0ec326dc74f..09aa31c26863e65f931d2dc4e5c6df2d7e3367fd 100644 (file)
@@ -2,33 +2,52 @@
 
 {% block title %}{{ _("Legal") }}{% end block %}
 
-{% block content %}
-       <h1 class="text-center mt-5 mb-5">{{ _("Legal") }}</h1>
-
-       <div class="row justify-content-center">
-               <div class="col-12 col-md-6">
-                       <div class="card">
-                               <div class="card-body">
-                                       <p>
-                                               Information in accordance with section 5 TMG and
-                                               persons responsible for content in accordance with 55 Section. 2 RStV
-                                       </p>
-
-                                       <address>
-                                               <strong>The IPFire Project</strong>
-                                               <br>
-                                               c/o Lightning Wire Labs GmbH
-                                               <br>
-                                               Gerhardstraße 8
-                                               <br>
-                                               45711 Datteln
-                                               <br>
-                                               GERMANY
-                                       </address>
+{% block container %}
+       <section class="hero is-dark">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">Legal</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h1 class="title">{{ _("Legal") }}</h1>
+                       </div>
+               </div>
+       </section>
+       <div class="container">
+               <section class="section">
+                       <div class="block">
+                               <p class="is-size-5">
+                                       Information in accordance with section 5 TMG and
+                                       persons responsible for content in accordance with 55 Section. 2 RStV
+                               </p>
+                       </div>
 
-                                       <h3>Disclaimer</h3>
+                       <div class="block">
+                               <address>
+                                       <strong>The IPFire Project</strong>
+                                       <br>
+                                       c/o Lightning Wire Labs GmbH
+                                       <br>
+                                       Gerhardstraße 8
+                                       <br>
+                                       45711 Datteln
+                                       <br>
+                                       GERMANY
+                               </address>
+                       </div>
+               </section>
 
-                                       <h5>Accountability for content</h5>
+               <section class="section">
+                       <div class="block">
+                               <h3 class="title is-3 has-text-weight-semibold">Disclaimer</h3>
+                               <h5 class="title is-5">Accountability for content</h5>
                                        <p>
                                                The contents of our pages have been created with the utmost care. However,
                                                we cannot guarantee the contents' accuracy, completeness or topicality.
                                                applicable laws remain unaffected by this as per §§ 8 to 10 of the Telemedia
                                                Act (TMG).
                                        </p>
+                       </div>
 
-                                       <h5>Accountability for links</h5>
+                       <div class="block">
+                               <h5 class="title is-5">Accountability for links</h5>
                                        <p>
                                                Responsibility for the content of external links (to web pages of third parties)
                                                lies solely with the operators of the linked pages. No violations were evident
                                                to us at the time of linking. Should any legal infringement become known to us,
                                                we will remove the respective link immediately.
                                        </p>
+                       </div>
 
-                                       <h5>Copyright</h5>
+                       <div class="block">
+                               <h5 class="title is-5">Copyright</h5>
                                        <p>
                                                Our web pages and their contents are subject to German copyright law. Unless
                                                expressly permitted by law (§ 44a et seq. of the copyright law), every form
                                                not serve either directly or indirectly for earnings. Unauthorized utilization
                                                of copyrighted works is punishable (§ 106 of the copyright law).
                                        </p>
+                       </div>
+               </section>
+               <section class="section">
 
-                                       <h3>Privacy Statement</h3>
-
-                                       <h5>General</h5>
+                       <div class="block">
+                               <h3 class="title is-3">Privacy Statement</h3>
+                               <h5 class="title is-5">General</h5>
                                        <p>
                                                Your personal data (e.g. title, name, house address, e-mail address, phone number,
                                                bank details, credit card number) are processed by us only in accordance with the
                                                This data privacy policy applies only to our web pages. If links on our pages route
                                                you to other pages, please inquire there about how your data are handled in such cases.
                                        </p>
+                       </div>
 
-                                       <h5>Inventory data</h5>
-
-                                       <ol>
-                                               <li>
-                                                       Your personal data, insofar as these are necessary for this contractual
-                                                       relationship (inventory data) in terms of its establishment, organization of
-                                                       content and modifications, are used exclusively for fulfilling the contract.
-                                                       For goods to be delivered, for instance, your name and address must be relayed
-                                                       to the supplier of the goods.
-                                               </li>
-                                               <li>
-                                                       Without your explicit consent or a legal basis, your personal data are not passed
-                                                       on to third parties outside the scope of fulfilling this contract.
-                                                       After completion of the contract, your data are blocked against further use.
-                                                       After expiry of deadlines as per tax-related and commercial regulations, these data
-                                                       are deleted unless you have expressly consented to their further use.
-                                               </li>
-                                       </ol>
-
-                                       <h5>Information about cookies</h5>
+                       <div class="block">
+                               <h5 class="title is-5">Inventory data</h5>
+                                               <ul>
+                                                       <li>
+                                                               1. Your personal data, insofar as these are necessary for this contractual
+                                                               relationship (inventory data) in terms of its establishment, organization of
+                                                               content and modifications, are used exclusively for fulfilling the contract.
+                                                               For goods to be delivered, for instance, your name and address must be relayed
+                                                               to the supplier of the goods.
+                                                       </li>
+                                                       <li>
+                                                               2. Without your explicit consent or a legal basis, your personal data are not passed
+                                                               on to third parties outside the scope of fulfilling this contract.
+                                                               After completion of the contract, your data are blocked against further use.
+                                                               After expiry of deadlines as per tax-related and commercial regulations, these data
+                                                               are deleted unless you have expressly consented to their further use.
+                                                       </li>
+                                               </ul>
+                       </div>
 
-                                       <ol>
+                       <div class="block">
+                               <h5 class="title is-5">Information about cookies</h5>
+                                       <ul>
                                                <li>
-                                                       To optimize our web presence, we use cookies. These are small text files stored
+                                                       1. To optimize our web presence, we use cookies. These are small text files stored
                                                        in your computer's main memory. These cookies are deleted after you close the browser.
                                                        Other cookies remain on your computer (long-term cookies) and permit its recognition
                                                        on your next visit. This allows us to improve your access to our site.
                                                </li>
                                                <li>
-                                                       You can prevent storage of cookies by choosing a "disable cookies" option in your
+                                                       2. You can prevent storage of cookies by choosing a "disable cookies" option in your
                                                        browser settings. But this can limit the functionality of our Internet offers as a result.
                                                </li>
-                                       </ol>
+                                       </ul>
+                       </div>
 
-                                       <h5>Newsletter</h5>
+                       <div class="block">
+                               <h5 class="title is-5">Newsletter</h5>
                                        <p>
                                                Following subscription to the newsletter, your e-mail address is used for our own
                                                advertising purposes until you cancel the newsletter again.
                                                You may revoke your consent at any time with future effect.
                                                If you no longer want to receive the newsletter, then unsubscribe.
                                        </p>
+                       </div>
 
-                                       <h5>Disclosure</h5>
+                       <div class="block">
+                               <h5 class="title is-5">Disclosure</h5>
                                        <p>
                                                According to the Federal Data Protection Act, you have a right to free-of-charge
                                                information about your stored data, and possibly entitlement to correction, blocking
                                                or deletion of such data.
-                                               Inquiries can be directed to <a href="mailto:legal@ipfire.org">legal@ipfire.org</a>.
+                                               Inquiries can be directed to <a class="has-text-primary" href="mailto:legal@ipfire.org">legal@ipfire.org</a>.
                                        </p>
-                               </div>
                        </div>
-               </div>
+               </section>
        </div>
 {% end block %}
diff --git a/src/templates/static/partners.html b/src/templates/static/partners.html
new file mode 100644 (file)
index 0000000..6571cec
--- /dev/null
@@ -0,0 +1,71 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Our Partners") }}{% end block %}
+
+{% block container %}
+       <section class="hero">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">Home</a>
+                                               </li>
+
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Our Partners") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+                               <h1 class="title">
+                                       Our Partners
+                               </h1>
+                       </div>
+               </div>
+       </section>
+
+       {# Lightning Wire Labs #}
+       <section class="hero is-lwl is-medium">
+               <div class="hero-body">
+                       <div class="container">
+                               <div class="columns is-vcentered">
+                                       <div class="column">
+                                               <h1 class="title is-1">
+                                                       Lightning Wire Labs
+                                               </h1>
+
+                                               <a class="button is-lwl-inverted" href="https://www.lightningwirelabs.com/">
+                                                       {{ _("Go To Website") }}
+                                               </a>
+                                       </div>
+
+                                       <div class="column is-2">
+                                               <figure class="image">
+                                                       <img src="{{ static_url("img/lightningwirelabs-logo.svg") }}"
+                                                               alt="{{ _("Lightning Wire Labs Logo") }}">
+                                               </figure>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="hero">
+               <div class="hero-body">
+                       <div class="container">
+                               <h3 class="title is-3">{{ _("Our Hosting Partners") }}</h3>
+
+                               <div class="columns is-centered">
+                                       <div class="column is-one-quarter">
+                                               <a href="https://www.kyberio.com/">
+                                                       <figure class="image">
+                                                               <img src="{{ static_url("img/kyberio-logo.svg") }}"
+                                                                       alt="{{ _("kyberio Logo") }}">
+                                                       </figure>
+                                               </a>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/static/sitemap.html b/src/templates/static/sitemap.html
new file mode 100644 (file)
index 0000000..30fd1e0
--- /dev/null
@@ -0,0 +1,246 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Sitemap") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-primary is-medium">
+               <div class="hero-body">
+                       <div class="container">
+                               <p class="title is-2 has-text-centered">{{ _("Projects") }}</p>
+
+                               <div class="columns is-centered is-multiline">
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">Geo Location Database</p>
+                                                       <p class="title">
+                                                               <a href="/location">
+                                                                       {{ _("IPFire Location") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">Hardware Information</p>
+                                                       <p class="title">
+                                                               <a href="/fireinfo">
+                                                                       {{ _("Fireinfo") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="hero is-medium">
+               <div class="hero-body">
+                       <div class="container">
+                               <p class="title is-2 has-text-centered">{{ _("Support") }}</p>
+
+                               <div class="columns is-centered is-multiline">
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">Read The Fascinating Manual</p>
+                                                       <p class="title">
+                                                               <a href="/docs">
+                                                                       {{ _("Documentation") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">
+                                                               <span class="tag is-lwl">By Our Team Of Experts</span>
+                                                       </p>
+                                                       <p class="title">
+                                                               <a class="has-text-lwl" href="https://store.lightningwirelabs.com/products/groups/support?utm_source={{ hostname }}&amp;utm_medium=sitemap">
+                                                                       {{ _("Professional Support") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">By Our Community</p>
+                                                       <p class="title">
+                                                               <a href="https://community.ipfire.org">
+                                                                       {{ _("IPFire Community") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">Together Is Stronger</p>
+                                                       <p class="title">
+                                                               <a href="/partners">
+                                                                       {{ _("Our Partners") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="hero is-light is-medium">
+               <div class="hero-body">
+                       <div class="container">
+                               <p class="title is-2 has-text-centered">{{ _("Development") }}</p>
+
+                               <div class="columns is-multiline">
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">pakfire.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://pakfire.ipfire.org">
+                                                                       {{ _("Pakfire Build Service") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">git.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://git.ipfire.org">
+                                                                       {{ _("Source") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">lists.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://lists.ipfire.org">
+                                                                       {{ _("Mailing Lists") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">bugzilla.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://bugzilla.ipfire.org">
+                                                                       {{ _("Bugtracker") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">patchwork.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://patchwork.ipfire.org">
+                                                                       {{ _("Patches") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">translate.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://translate.ipfire.org">
+                                                                       {{ _("Translate") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">man-pages.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://man-pages.ipfire.org">
+                                                                       {{ _("Man Pages") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">nightly.ipfire.org</p>
+                                                       <p class="title">
+                                                               <a href="https://nightly.ipfire.org">
+                                                                       {{ _("Nightly Builds") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <p class="title is-2 has-text-centered">{{ _("Infrastructure") }}</p>
+
+                       <div class="columns is-centered is-multiline">
+                               <div class="column is-one-third has-text-centered">
+                                       <div>
+                                               <p class="heading">mail.ipfire.org</p>
+                                               <p class="title">
+                                                       <a href="https://mail.ipfire.org">
+                                                               {{ _("Web Mail") }}
+                                                       </a>
+                                               </p>
+                                       </div>
+                               </div>
+
+                               <div class="column is-one-third has-text-centered">
+                                       <div>
+                                               <p class="heading">social.ipfire.org</p>
+                                               <p class="title">
+                                                       <a href="https://social.ipfire.org">
+                                                               {{ _("Mastodon") }}
+                                                       </a>
+                                               </p>
+                                       </div>
+                               </div>
+
+                               <div class="column is-one-third has-text-centered">
+                                       <div>
+                                               <p class="heading">{{ _("Around the Globe") }}</p>
+                                               <p class="title">
+                                                       <a href="/downloads/mirrors">
+                                                               {{ _("Mirrors") }}
+                                                       </a>
+                                               </p>
+                                       </div>
+                               </div>
+
+                               {% if current_user and current_user.is_staff() %}
+                                       <div class="column is-one-third has-text-centered">
+                                               <div>
+                                                       <p class="heading">Telephony</p>
+                                                       <p class="title">
+                                                               <a href="/voip">
+                                                                       {{ _("VoIP") }}
+                                                               </a>
+                                                       </p>
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
+               </div>
+       </section>
+{% end block %}
+
diff --git a/src/templates/static/support.html b/src/templates/static/support.html
deleted file mode 100644 (file)
index e5fdb68..0000000
+++ /dev/null
@@ -1,132 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Support") }}{% end block %}
-
-{% block container %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1 class="display-2">{{ _("Support") }}</h1>
-
-                                       <p>
-                                               If you have any questions, IPFire has an active support community
-                                               and is also backed by a professional development team
-                                       </p>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row justify-content-between flex-md-row-reverse">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-end">
-                                       <i class="fas fa-pen-alt icon-large my-5"></i>
-                               </div>
-
-                               <div class="col-12 col-md-7">
-                                       <h1>{{ _("Wiki") }}</h1>
-
-                                       <p>
-                                               The IPFire Wiki is the prime resource for everything
-                                               you need to know about IPFire.
-                                               There is almost nothing that cannot be found there.
-                                       </p>
-
-                                       <p>
-                                               The wiki is written by the community.
-                                               Everyone can join and improve it.
-                                       </p>
-
-                                       <a class="btn btn-primary" href="https://wiki.ipfire.org/">
-                                               {{ _("Go to Wiki") }}
-                                       </a>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <div class="row justify-content-between">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-start">
-                                       <span class="fas fa-hands-helping icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-7">
-                                       <h1>{{ _("Community") }}</h1>
-
-                                       <p>
-                                               Talk to the IPFire Community on our community portal.
-                                       </p>
-
-                                       <p>
-                                               Ask about how to get started with IPFire, how you can
-                                               contribute to the project, and what else you always
-                                               wanted to know about IPFire...
-                                       </p>
-
-                                       <a class="btn btn-secondary" href="https://community.ipfire.org/">
-                                               {{ _("Go to Community") }}
-                                       </a>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section class="inverse">
-               <div class="container">
-                       <div class="row justify-content-between flex-md-row-reverse">
-                               <div class="col-12 col-md-4 text-center text-md-right">
-                                       <img class="img-fluid w-100 my-5" src="{{ static_url("img/lightningwirelabs-logo.svg") }}"
-                                               alt="{{ _("Lightning Wire Labs") }}">
-                               </div>
-
-                               <div class="col-12 col-md-7">
-                                       <h1>{{ _("Get Professional Support") }}</h1>
-
-                                       <p>
-                                               <a class="text-lwl" href="https://www.lightningwirelabs.com/">Lightning Wire Labs</a>
-                                               provides professional support services for companies that use IPFire.
-                                       </p>
-
-                                       <p>
-                                               The team of developers implements custom solutions based on IPFire and
-                                               carries out development of new features.
-                                               They will help you with designing a secure network that is tailored to
-                                               the specific needs of your business and support you with the integration
-                                               of IPFire.
-                                       </p>
-
-                                       <a class="btn btn-lwl mb-5" href="https://www.lightningwirelabs.com/">
-                                               {{ _("Go to Website") }}
-                                       </a>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <section>
-               <div class="container">
-                       <div class="row justify-content-between">
-                               <div class="col-12 col-md-3 d-flex align-items-center justify-content-center justify-content-md-start">
-                                       <span class="fas fa-comments icon-large my-5"></span>
-                               </div>
-
-                               <div class="col-12 col-md-7">
-                                       <h1>{{ _("Chat") }}</h1>
-
-                                       <p>
-                                               You can chat live with other IPFire users and developers.
-                                               To join our chat room, point your XMPP client to:
-                                       </p>
-
-                                       <a class="btn btn-light" href="xmpp:ipfire@conference.ipfire.org?join">
-                                               ipfire@conference.ipfire.org
-                                       </a>
-                               </div>
-                       </div>
-               </div>
-       </section>
-{% end block %}
diff --git a/src/templates/users/delete.html b/src/templates/users/delete.html
new file mode 100644 (file)
index 0000000..5681e69
--- /dev/null
@@ -0,0 +1,44 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Delete %s") % account }}{% end block %}
+
+{% block container %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-one-third">
+                                       <h1 class="title">
+                                               {{ _("Delete User") }}
+                                       </h1>
+                                       <h4 class="subtitle">{{ account }}</h4>
+
+                                       <div class="block has-text-danger">
+                                               <form action="" method="POST">
+                                                       {% raw xsrf_form_html() %}
+
+                                                       {% if next %}<input type="hidden" name="next" value="{{ next }}">{% end %}
+
+                                                       <div class="field">
+                                                               <p>
+                                                                       {{ _("Are you sure you want to delete %s?") % account }}
+                                                               </p>
+
+                                                               <p>
+                                                                       {{ _("This cannot be undone.") }}
+                                                               </p>
+                                                       </div>
+
+                                                       <div class="field">
+                                                               <div class="control">
+                                                                       <button class="button is-danger is-fullwidth">
+                                                                               {{ _("Delete %s") % account }}
+                                                                       </button>
+                                                               </div>
+                                                       </div>
+                                               </form>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/users/deleted.html b/src/templates/users/deleted.html
new file mode 100644 (file)
index 0000000..d277928
--- /dev/null
@@ -0,0 +1,19 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("%s Deleted") % account }}{% end block %}
+
+{% block container %}
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-half">
+                                       <div class="notification is-danger has-text-centered">
+                                               <p class="is-size-5">
+                                                       {{ _("%s has been successfully deleted") % account }}
+                                               </p>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/users/edit.html b/src/templates/users/edit.html
new file mode 100644 (file)
index 0000000..b6ebdd7
--- /dev/null
@@ -0,0 +1,211 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ account }} - {{ _("Edit") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-dark">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/users">
+                                                               {{ _("Users") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/users/{{ account.uid }}">
+                                                               {{ account }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">
+                                                               Edit
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Edit Profile") }}</h1>
+                               <h6 class="subtitle">{{ account }} | {{ account.uid }}</h6>
+                       </div>
+               </div>
+       </section>
+
+       <div class="container">
+               <section class="section">
+                       <form method="POST" action="" enctype="multipart/form-data">
+                               {% raw xsrf_form_html() %}
+
+                               <div class="block">
+                                       <div class="columns is-5">
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("First Name") }}</label>
+
+                                                       <input type="text" class="input" name="first_name"
+                                                               placeholder="{{ _("First Name") }}" value="{{ account.first_name }}" required>
+                                               </div>
+
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("Last Name") }}</label>
+
+                                                       <input type="text" class="input" name="last_name"
+                                                               placeholder="{{ _("Last Name") }}" value="{{ account.last_name }}" required>
+                                               </div>
+
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("Nickname") }} ({{ _("optional") }})</label>
+
+                                                       <input type="text" class="input" name="nickname"
+                                                               placeholder="{{ _("Nickname") }}" value="{{ account.nickname or "" }}">
+                                               </div>
+                                       </div>
+                               </div>
+
+                               <div class="block">
+                                       <div class="columns">
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("Address") }}</label>
+
+                                                       <textarea type="text" class="textarea" name="street" rows="3"
+                                                               placeholder="{{ _("Address") }}">{{ account.street or "" }}</textarea>
+                                               </div>
+                                       </div>
+
+                                       <div class="columns">
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("City") }}</label>
+
+                                                       <input type="text" class="input" name="city"
+                                                               placeholder="{{ _("City") }}" value="{{ account.city }}">
+                                               </div>
+
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("Postal Code") }}</label>
+
+                                                       <input type="text" class="input" name="postal_code"
+                                                               placeholder="{{ _("Postal Code") }}" value="{{ account.postal_code }}">
+                                               </div>
+                                       </div>
+                               </div>
+
+                               <div class="block">
+                                       <div class="columns">
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("Country") }}</label>
+
+                                                       <div class="select">
+                                                               <select name="country_code" required>
+                                                                       <option>{{ _("- Please Select -") }}</option>
+
+                                                                       {% for c in countries %}
+                                                                               <option value="{{ c.alpha2 }}" {% if account.country_code == c.alpha2 %}selected{% end %}>{{ c.name }}</option>
+                                                                       {% end %}
+                                                               </select>
+                                                         </div>
+                                               </div>
+                                       </div>
+                               </div>
+
+                               <div class="block">
+                                       <h4 class="title is-4">{{ _("Tell Us Who You Are") }}</h4>
+
+                                       <div class="columns" id="description">
+                                               <div class="column is-4">
+                                                       <textarea type="text" class="textarea" name="description" rows="5"
+                                                       placeholder="{{ _("Tell Us Who You Are") }}">{{ account.description or "" }}</textarea>
+
+                                                       <p class="help">
+                                                               {{ _("You can use Markdown syntax as you know it from the IPFire Documentation") }}
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                               <div class="block">
+                                       <div class="columns" id="avatar">
+                                               <div class="column is-4">
+                                                       <div class="file">
+                                                               <label class="file-label">
+                                                                       <input class="file-input" type="file" name="avatar">
+                                                                       <span class="file-cta">
+                                                                               <span class="file-icon">
+                                                                                       <i class="fas fa-upload"></i>
+                                                                               </span>
+                                                                               <span class="file-label">
+                                                                                       {{ _("Upload a new avatar") }}
+                                                                               </span>
+                                                                       </span>
+                                                               </label>
+                                                         </div>
+                                               </div>
+                                       </div>
+                               </div>
+
+                               {% if account.has_mail() %}
+                                       <div class="block">
+                                               <div class="columns">
+                                                       <div class="column is-4">
+                                                               <label class="label">{{ _("Email") }}</label>
+
+                                                               <div class="field-body">
+                                                                       <div class="field">
+                                                                               <p class="control">
+                                                                                       <input type="mail" class="input" name="mail_routing_address"
+                                                                                       placeholder="{{ _("Email Address") }}" value="{{ account.mail_routing_address or "" }}">
+                                                                               </p>
+                                                                       </div>
+                                                               </div>
+
+                                                               <p class="help">
+                                                                       {{ _("All emails will be forwarded to this email address") }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               {% end %}
+
+                               <div class="block">
+                                       <div class="columns">
+                                               <div class="column is-4">
+                                                       <label class="label">{{ _("Telephone") }}</label>
+
+                                                       <textarea type="text" class="textarea" name="phone_numbers" rows="3"
+                                                               placeholder="{{ _("Phone Numbers") }}">{{ "\n".join((format_phone_number_to_e164(n) for n in account.phone_numbers)) }}</textarea>
+
+
+                                                       <p class="help">
+                                                               {{ _("Enter your landline and mobile phone numbers") }}
+                                                       </p>
+                                               </div>
+
+                                               {% if account.has_sip() %}
+                                                       <div class="column is-4">
+                                                               <label class="label">{{ _("Forward Calls") }}</label>
+
+                                                               <input type="text" class="input" name="sip_routing_address"
+                                                                       placeholder="{{ _("SIP URI or Phone Number") }}" value="{{ account.sip_routing_address or "" }}">
+
+                                                               <p class="help">
+                                                                       {{ _("All calls will be forwarded to this phone number or SIP URI") }}
+                                                               </p>
+                                                       </div>
+                                               {% end %}
+                                       </div>
+
+                               <div class="block">
+                                       <div class="columns">
+                                               <div class="column is-4">
+                                                       <input class="button is-primary is-fullwidth is-outlined is-medium has-text-weight-bold" type="submit" value="{{ _("SAVE") }}">
+                                               </div>
+                                       </div>
+
+                               </div>
+                       </form>
+               </section>
+       </div>
+{% end block %}
diff --git a/src/templates/users/groups/index.html b/src/templates/users/groups/index.html
new file mode 100644 (file)
index 0000000..eb695fb
--- /dev/null
@@ -0,0 +1,51 @@
+{% extends "../../base.html" %}
+
+{% block title %}{{ _("Groups") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-dark">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/users">
+                                                               {{ _("Users") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Groups") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Groups") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-multiline">
+                               {% for group in backend.groups %}
+                                       <div class="column is-half">
+                                               <div class="box">
+                                                       <h5 class="title is-5">
+                                                               <span class="tag is-pulled-right">
+                                                                       {{ len(group) }}
+                                                               </span>
+
+                                                               <a href="/users/groups/{{ group.gid }}">{{ group }}</a>
+                                                       </h5>
+                                               </div>
+                                       </div>
+                               {% end %}
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/users/groups/show.html b/src/templates/users/groups/show.html
new file mode 100644 (file)
index 0000000..f17a639
--- /dev/null
@@ -0,0 +1,66 @@
+{% extends "../../base.html" %}
+
+{% block title %}{{ group }}{% end block %}
+
+{% block container %}
+       <section class="hero is-dark">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/users">
+                                                               {{ _("Users") }}
+                                                       </a>
+                                               </li>
+                                               <li>
+                                                       <a href="/users/groups">
+                                                               {{ _("Groups") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ group }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ group }}</h1>
+                               <h6 class="subtitle">{{ group.gid }}</h6>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="level">
+                               <div class="level-item has-text-centered">
+                                       <div>
+                                               <p class="heading">{{ _("Members") }}</p>
+                                               <p class="title">{{ len(group) }}</p>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       {% if group.email %}
+                               <div class="block">
+                                       <a class="button is-light is-fullwidth" href="mailto:{{ group.email }}">
+                                               {{ _("Email Group") }}
+                                       </a>
+                               </div>
+                       {% end %}
+
+                       <div class="block">
+                               {% module UsersList(group) %}
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/users/index.html b/src/templates/users/index.html
new file mode 100644 (file)
index 0000000..499a172
--- /dev/null
@@ -0,0 +1,166 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Users") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-dark">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("Users") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("Users") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       {# Search #}
+
+       <section class="section">
+               <div class="container">
+                       <div class="columns is-centered">
+                               <div class="column is-one-third">
+                                       <form action="" method="GET">
+                                               <div class="field">
+                                                       <div class="control has-icons-left">
+                                                               <input class="input is-medium" type="text"
+                                                                       name="q" {% if q %}value="{{ q }}"{% end %}
+                                                                       placeholder="{{ _("Search...") }}">
+                                                               <span class="icon is-small is-left">
+                                                                       <i class="fas fa-search"></i>
+                                                               </span>
+                                                       </div>
+                                               </div>
+                                       </form>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       {# Search Results #}
+
+       {% if q %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">{{ _("Search Results for '%s'") % q }}</h4>
+
+                               {% if results %}
+                                       {% module UsersList(results) %}
+                               {% else %}
+                                       <div class="notification has-text-centered">
+                                               {{ _("Nothing found for '%s'") % q }}
+                                       </div>
+                               {% end %}
+                       </div>
+               </section>
+
+       {% else %}
+               {# Recently Joined #}
+
+               {% set recently_registered = backend.accounts.get_recently_registered(limit=4) %}
+               {% if recently_registered %}
+                       <section class="section">
+                               <div class="container">
+                                       <h4 class="title is-4">{{ _("Recently Joined") }}</h4>
+
+                                       {% module UsersList(recently_registered, show_created_at=True) %}
+                               </div>
+                       </section>
+               {% end %}
+
+               {% if current_user and current_user.is_staff() %}
+                       {% set total_accounts = len(backend.accounts) %}
+                       {% set countries = backend.accounts.countries %}
+
+                       {# Stats #}
+
+                       <section class="section">
+                               <div class="container">
+                                       <div class="level">
+                                               <div class="level-item has-text-centered">
+                                                       <div>
+                                                               <p class="heading">{{ _("Total Accounts") }}</p>
+                                                               <p class="title">
+                                                                       {{ total_accounts }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               {% set t = now - datetime.timedelta(days=7) %}
+
+                                               <div class="level-item has-text-centered">
+                                                       <div>
+                                                               <p class="heading">{{ _("Created This Week") }}</p>
+                                                               <p class="title">
+                                                                       {{ backend.accounts.count_created_after(t) }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               {% set t = now - datetime.timedelta(days=30) %}
+
+                                               <div class="level-item has-text-centered">
+                                                       <div>
+                                                               <p class="heading">{{ _("Created This Month") }}</p>
+                                                               <p class="title">
+                                                                       {{ backend.accounts.count_created_after(t) }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+
+                                               {% set pending_registrations = backend.accounts.pending_registrations %}
+                                               {% if pending_registrations %}
+                                                       <div class="level-item has-text-centered">
+                                                               <div>
+                                                                       <p class="heading">{{ _("Pending Registrations") }}</p>
+                                                                       <p class="title">
+                                                                               {{ pending_registrations }}
+                                                                       </p>
+                                                               </div>
+                                                       </div>
+                                               {% end %}
+                                       </div>
+                               </div>
+                       </section>
+
+                       {# Countries #}
+
+                       <section class="section">
+                               <div class="container">
+                                       <h4 class="title is-4">{{ _("Origin") }}</h4>
+
+                                       <nav class="panel">
+                                               {% for country in sorted(countries, key=lambda c: countries[c], reverse=True) %}
+                                                       {% set percentage = countries[country] / total_accounts %}
+
+                                                       <div class="panel-block is-justify-content-space-between">
+                                                               <span>
+                                                                       <span class="panel-icon">
+                                                                               <i class="flag-icon flag-icon-{{ country.alpha2.lower() }}"
+                                                                                       aria-hidden="true"></i>
+                                                                       </span>
+
+                                                                       {{ country.apolitical_name }}
+                                                               </span>
+
+                                                               <span class="tag">
+                                                                       {{ "%.1f%%" % (percentage * 100) }}
+                                                               </span>
+                                                       </div>
+                                               {% end %}
+                                       </div>
+                               </div>
+                       </section>
+               {% end %}
+       {% end %}
+{% end block %}
diff --git a/src/templates/users/modules/list.html b/src/templates/users/modules/list.html
new file mode 100644 (file)
index 0000000..bb2f60a
--- /dev/null
@@ -0,0 +1,29 @@
+<div class="block">
+       <div class="columns is-multiline">
+               {% for account in accounts %}
+                       <div class="column is-half">
+                               <div class="box">
+                                       <div class="columns is-mobile">
+                                               <div class="column is-narrow">
+                                                       <figure class="image is-64x64">
+                                                               <img class="is-rounded" src="{{ account.avatar_url(64) }}">
+                                                       </figure>
+                                               </div>
+
+                                               <div class="column">
+                                                       <h5 class="title is-5">
+                                                               <a href="/users/{{ account.uid }}">{{ account }}</a>
+                                                       </h5>
+
+                                                       {% if show_created_at %}
+                                                               <p>
+                                                                       {{ _("Joined %s") % locale.format_date(account.created_at, shorter=True) }}
+                                                               </p>
+                                                       {% end %}
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               {% end %}
+       </div>
+</div>
diff --git a/src/templates/users/passwd.html b/src/templates/users/passwd.html
new file mode 100644 (file)
index 0000000..1171196
--- /dev/null
@@ -0,0 +1,40 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ account }} - {{ _("Change Password") }}{% end block %}
+
+{% block content %}
+       <section class="hero is-fullheight-with-navbar">
+               <div class="hero-body">
+                       <div class="container">
+                               <div class="columns is-centered">
+                                       <div class="column is-one-third">
+                                               <h1 class="title">{{ _("Change Password") }}</h1>
+
+                                               <div class="block">
+                                                       <form method="POST" action="">
+                                                               {% raw xsrf_form_html() %}
+
+                                                               <div class="field">
+                                                                       <div class="control">
+                                                                               <label class="label">{{ _("Current Password") }}</label>
+
+                                                                               <input type="password" class="input" name="password"
+                                                                                       placeholder="{{ _("Current Password") }}">
+                                                                       </div>
+                                                               </div>
+
+                                                               {% module Password(account) %}
+
+                                                               <div class="field">
+                                                                       <div class="control">
+                                                                               <input class="button is-primary is-fullwidth" type="submit" value="{{ _("Change Password") }}">
+                                                                       </div>
+                                                               </div>
+                                                       </form>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+{% end block %}
diff --git a/src/templates/users/show.html b/src/templates/users/show.html
new file mode 100644 (file)
index 0000000..71ba2c7
--- /dev/null
@@ -0,0 +1,278 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ account }}{% end block %}
+
+{% block container %}
+       {% import phonenumbers %}
+
+       <section class="hero {% if account.is_lwl() %}is-lwl{% else %}is-dark{% end %}">
+               <div class="hero-body">
+                       <div class="container">
+                               <div class="columns is-vcentered">
+                                       <div class="column">
+                                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                                       <ul>
+                                                               <li>
+                                                                       <a href="/">
+                                                                               {{ _("Home") }}
+                                                                       </a>
+                                                               </li>
+                                                               <li>
+                                                                       <a href="/users">
+                                                                               {{ _("Users") }}
+                                                                       </a>
+                                                               </li>
+                                                               <li class="is-active">
+                                                                       <a href="#" aria-current="page">{{ account }}</a>
+                                                               </li>
+                                                       </ul>
+                                               </nav>
+
+                                               <h1 class="title">{{ account }}</h1>
+                                               <h6 class="subtitle">{{ account.uid }}</h6>
+
+                                               {# Description #}
+                                               {% if account.description %}
+                                                       <div class="block">
+                                                               <div class="content">
+                                                                       {% module Markdown(account.description) %}
+                                                               </div>
+                                                       </div>
+                                               {% end %}
+                                       </div>
+
+                                       <div class="column is-narrow is-hidden-mobile">
+                                               <figure class="image is-192x192">
+                                                       <img class="is-rounded" src="{{ account.avatar_url(512) }}">
+                                               </figure>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       </section>
+
+       <section class="section">
+               <div class="container">
+                       <div class="buttons">
+                               <a class="button is-light" href="mailto:{{ account.email }}">
+                                       <span class="icon-text">
+                                               <span class="icon">
+                                                       <i class="fas fa-envelope"></i>
+                                               </span>
+                                               <span>{{ account.email }}</span>
+                                       </span>
+                               </a>
+
+                               <a class="button is-light" href="https://community.ipfire.org/u/{{ account.uid }}">
+                                       <span class="icon-text">
+                                               <span class="icon">
+                                                       <i class="fas fa-users"></i>
+                                               </span>
+                                               <span>{{ _("Community Profile") }}</span>
+                                       </span>
+                               </a>
+
+                               {% if account.has_shell() %}
+                                       <a class="button is-dark" href="https://people.ipfire.org/~{{ account.uid }}/">
+                                               <span class="icon-text">
+                                                       <span class="icon">
+                                                               <i class="fas fa-home"></i>
+                                                       </span>
+                                                       <span>{{ _("Home Directory") }}</span>
+                                               </span>
+                                       </a>
+                               {% end %}
+
+                               {% if account.can_be_managed_by(current_user) %}
+                                       <a class="button is-warning" href="/users/{{ account.uid }}/edit">
+                                               {{ _("Edit") }}
+                                       </a>
+
+                                       <a class="button is-danger" href="/users/{{ account.uid }}/delete"
+                                                               {% if not account.can_be_deleted_by(current_user) %}disabled{% end %}>
+                                               {{ _("Delete") }}
+                                       </a>
+                               {% end %}
+                       </div>
+
+                       {% if account == current_user %}
+                               {# Suggest adding a description #}
+                               {% if not current_user.description %}
+                                       <div class="notification is-info">
+                                               <strong>{{ _("Tell Us About Yourself!") }}</strong>
+
+                                               {{ _("Add a couple of lines about yourself to your profile so that others get to know you better") }}
+
+                                               <a href="/users/{{ account.uid }}/edit#description">{{ _("Edit Profile") }}</a>
+                                       </div>
+                               {% end %}
+                       {% end %}
+               </div>
+       </section>
+
+       {% if current_user == account or current_user.is_staff() %}
+
+               {# SIP Channels #}
+
+               {% if sip_channels %}
+                       <section class="section">
+                               <div class="container">
+                                       {% for channel in sip_channels %}
+                                               <div class="notification
+                                                               {% if channel.is_ringing() %}is-warning
+                                                               {% elif channel.is_connected() %}is-success
+                                                               {% end %}">
+                                                       <span class="icon-text">
+                                                               <span class="icon">
+                                                                       <i class="fa-solid fa-phone-volume"></i>
+                                                               </span>
+                                                               <span>{{ channel }}</span>
+                                                       </span>
+
+                                                       {% if channel.duration %}
+                                                               <span class="is-pulled-right">
+                                                                       {{ format_time(channel.duration) }}
+                                                               </span>
+                                                       {% end %}
+                                               </div>
+                                       {% end %}
+                               </div>
+                       </section>
+               {% end %}
+
+               <section class="section">
+                       <div class="container">
+                               <div class="columns">
+                                       {# Address #}
+                                       {% if account.address %}
+                                               <div class="column">
+                                                       <h6 class="title is-6">{{ _("Address") }}</h6>
+
+                                                       <address>
+                                                               {{ account.name }}<br>
+                                                               {% for line in account.address %}
+                                                                       {{ line }}<br>
+                                                               {% end %}
+                                                       </address>
+                                               </div>
+                                       {% end %}
+
+                                       {# Phone Numbers #}
+                                       {% if account.phone_number or account.fax_number %}
+                                               <div class="column">
+                                                       <h6 class="title is-6">{{ _("Phone Numbers") }}</h6>
+
+                                                       <ul>
+                                                               {% if account.has_sip() %}
+                                                                       <li>
+                                                                               {% if sip_channels %}
+                                                                                       <i class="fas fa-phone-volume has-text-warning fa-fw"></i>
+                                                                               {% elif account.uses_sip_forwarding() %}
+                                                                                       <i class="fas fa-phone-square has-text-warning fa-fw"></i>
+                                                                               {% else %}
+                                                                                       <i class="fas fa-phone-square has-text-danger fa-fw"></i>
+                                                                               {% end %}
+
+                                                                               <a href="sip:{{ account.sip_url }}">{{ account.sip_id }}</a>
+                                                                       </li>
+                                                               {% end %}
+
+                                                               {% if account.phone_number %}
+                                                                       <li>
+                                                                               <i class="fas fa-phone fa-fw"></i>
+
+                                                                               <a href="tel:{{ format_phone_number_to_e164(account.phone_number) }}">
+                                                                                       {{ format_phone_number(account.phone_number) }}
+                                                                               </a>
+                                                                       </li>
+                                                               {% end %}
+
+                                                               {% if account.fax_number %}
+                                                                       <li>
+                                                                               <i class="fas fa-fax fa-fw"></i>
+
+                                                                               <a href="fax:{{ format_phone_number_to_e164(account.fax_number) }}">
+                                                                                       {{ format_phone_number(account.fax_number) }}
+                                                                               </a>
+                                                                       </li>
+                                                               {% end %}
+                                                       </ul>
+                                               </div>
+                                       {% end %}
+
+                                       {% if current_user.is_staff() %}
+                                               {% if account.groups %}
+                                                       <div class="column is-half">
+                                                               <h6 class="title is-6">{{ _("Groups") }}</h6>
+
+                                                               <nav class="panel">
+                                                                       {% for g in account.groups %}
+                                                                               <a class="panel-block" href="/users/groups/{{ g.gid }}">
+                                                                                       <span class="panel-icon">
+                                                                                               <i class="fas fa-users" aria-hidden="true"></i>
+                                                                                       </span>
+                                                                                       {{ g }}
+                                                                               </a>
+                                                                       {% end %}
+                                                               </nav>
+                                                       </div>
+                                               {% end %}
+                                       {% end %}
+                               </div>
+                       </div>
+               </section>
+
+               <section class="section">
+                       <div class="container">
+                               <nav class="level">
+                                       <div class="level-item has-text-centered">
+                                               <div>
+                                                       <p class="heading">{{ _("Joined") }}</p>
+                                                       <p>
+                                                               {{ locale.format_date(account.created_at, shorter=True) }}
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       <div class="level-item has-text-centered">
+                                               <div>
+                                                       <p class="heading">{{ _("Last Modified") }}</p>
+                                                       <p>
+                                                               {{ locale.format_date(account.modified_at) }}
+                                                       </p>
+                                               </div>
+                                       </div>
+
+                                       {% if account.last_successful_authentication %}
+                                               <div class="level-item has-text-centered">
+                                                       <div>
+                                                               <p class="heading">{{ _("Last Successful Authentication") }}</p>
+                                                               <p>
+                                                                       {{ locale.format_date(account.last_successful_authentication) }}
+                                                               </p>
+                                                       </div>
+                                               </div>
+                                       {% end %}
+
+                                       {% if account.failed_login_count %}
+                                               <div class="level-item has-text-warning has-text-centered">
+                                                       <div>
+                                                               <p class="heading">{{ _("Failed Authentication Attempts") }}</p>
+                                                               <p>{{ account.failed_login_count }}</p>
+                                                       </div>
+                                               </div>
+
+                                               {% if account.last_failed_authentication %}
+                                                       <div class="level-item has-text-warning has-text-centered">
+                                                               <div>
+                                                                       <p class="heading">{{ _("Last Failed Authentication Attempt") }}</p>
+                                                                       <p>{{ locale.format_date(account.last_failed_authentication) }}</p>
+                                                               </div>
+                                                       </div>
+                                               {% end %}
+                                       {% end %}
+                               </nav>
+                       </div>
+               </section>
+       {% end %}
+{% end block %}
diff --git a/src/templates/users/subscribe.html b/src/templates/users/subscribe.html
new file mode 100644 (file)
index 0000000..06a5420
--- /dev/null
@@ -0,0 +1,35 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Subscribe") }}{% end block %}
+
+{% block container %}
+       <div class="columns is-centered">
+               <div class="column is-one-third-desktop">
+                       <div class="notification my-auto">
+                               <h5 class="title is-5">{{ _("Subscribe Now To Stay Up To Date") }}</h5>
+
+                               <div class="content">
+                                       <p>
+                                               {{ _("Subscribe and you will receive important emails from the IPFire Project:") }}
+                                       </p>
+
+                                       <ul>
+                                               <li>{{ _("Important release announcements including notificiations about security updates") }}</li>
+                                               <li>{{ _("News from our blog and inside the project") }}</li>
+                                               <li>{{ _("Information about our fundraising efforts") }}</li>
+                                       </ul>
+                               </div>
+
+                               <div class="block">
+                                       <form method="POST" action="/subscribe">
+                                               {% raw xsrf_form_html() %}
+
+                                               <button type="submit" class="button is-success is-large is-fullwidth">
+                                                       {{ _("Subscribe") }}
+                                               </button>
+                                       </form>
+                               </div>
+                       </div>
+               </div>
+       </div>
+{% end block %}
diff --git a/src/templates/users/subscribed.html b/src/templates/users/subscribed.html
new file mode 100644 (file)
index 0000000..a58f02d
--- /dev/null
@@ -0,0 +1,19 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Thank You") }}{% end block %}
+
+{% block container %}
+       <div class="columns is-centered">
+               <div class="column is-one-third-desktop">
+                       <div class="notification is-success my-auto has-text-centered">
+                               <div class="block">
+                                       <span class="fa-solid fa-check fa-5x my-4"></span>
+                               </div>
+
+                               <p>
+                                       {{ _("You have been subscribed and will now receive updates from the IPFire Project") }}
+                               </p>
+                       </div>
+               </div>
+       </div>
+{% end block %}
similarity index 59%
rename from src/templates/people/unsubscribe.html
rename to src/templates/users/unsubscribe.html
index 6578f58c8c0862a4072bd04d6ee979b5ad5a89c4..631239a1615f069b65ace7cc1c0678a17d12421e 100644 (file)
@@ -2,13 +2,13 @@
 
 {% block title %}{{ _("Unsubscribe") }}{% end block %}
 
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-8 col-lg-6">
-                       <div class="card border-danger">
-                               <div class="card-body">
-                                       <h5>{{ _("We'd Be Sorry To See You Go") }}</h5>
+{% block container %}
+       <div class="columns is-centered">
+               <div class="column is-one-third-desktop">
+                       <div class="notification my-auto">
+                               <h5 class="title is-5">{{ _("We'd Be Sorry To See You Go") }}</h5>
 
+                               <div class="content">
                                        <p>
                                                {{ _("If you unsubscribe, you will no longer receive important emails from the IPFire Project.") }}
                                                {{ _("There are plenty of benefits to remain subscribed:") }}
                                                <li>{{ _("News from our blog and inside the project") }}</li>
                                                <li>{{ _("Information about our fundraising efforts") }}</li>
                                        </ul>
+                               </div>
 
-                                       <form action="/subscribe" method="POST">
+                               <div class="block">
+                                       <form method="POST" action="/subscribe">
                                                {% raw xsrf_form_html() %}
 
-                                               <button type="submit" class="btn btn-success btn-block mb-3">
+                                               <button type="submit" class="button is-success is-large is-fullwidth">
                                                        {{ _("Continue Receiving Important Updates") }}
                                                </button>
                                        </form>
+                               </div>
 
-                                       <form action="/unsubscribe" method="POST">
+                               <div class="block">
+                                       <form method="POST" action="/unsubscribe">
                                                {% raw xsrf_form_html() %}
 
-                                               <button type="submit" class="btn btn-danger btn-block">
+                                               <button type="submit" class="button is-danger is-fullwidth">
                                                        {{ _("Unsubscribe") }}
                                                </button>
                                        </form>
diff --git a/src/templates/users/unsubscribed.html b/src/templates/users/unsubscribed.html
new file mode 100644 (file)
index 0000000..bf1c0aa
--- /dev/null
@@ -0,0 +1,31 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Thank You") }}{% end block %}
+
+{% block container %}
+       <div class="columns is-centered">
+               <div class="column is-one-third-desktop my-auto">
+                       <div class="block">
+                               <div class="notification is-danger has-text-centered">
+                                       <div class="block">
+                                               <span class="fa-solid fa-check fa-5x my-4"></span>
+                                       </div>
+
+                                       <p>
+                                               {{ _("You have been unsubscribed and will no longer receive important updates from the IPFire Project") }}
+                                       </p>
+                               </div>
+                       </div>
+
+                       <div class="block">
+                               <form action="/subscribe" method="POST">
+                                       {% raw xsrf_form_html() %}
+
+                                       <button type="submit" class="button is-success is-fullwidth">
+                                               {{ _("Continue Receiving Important Updates") }}
+                                       </button>
+                               </form>
+                       </div>
+               </div>
+       </div>
+{% end block %}
diff --git a/src/templates/voip/index.html b/src/templates/voip/index.html
new file mode 100644 (file)
index 0000000..6e61d24
--- /dev/null
@@ -0,0 +1,78 @@
+{% extends "../base.html" %}
+
+{% block title %}{{ _("VoIP") }}{% end block %}
+
+{% block container %}
+       <section class="hero is-dark">
+               <div class="hero-body">
+                       <div class="container">
+                               <nav class="breadcrumb" aria-label="breadcrumbs">
+                                       <ul>
+                                               <li>
+                                                       <a href="/">
+                                                               {{ _("Home") }}
+                                                       </a>
+                                               </li>
+                                               <li class="is-active">
+                                                       <a href="#" aria-current="page">{{ _("VoIP") }}</a>
+                                               </li>
+                                       </ul>
+                               </nav>
+
+                               <h1 class="title">{{ _("VoIP") }}</h1>
+                       </div>
+               </div>
+       </section>
+
+       {% if registrations %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">
+                                       {{ _("Registrations") }}
+                                       <span class="tag">{{ len(registrations) }}</span>
+                               </h4>
+
+                               {% module VoIPRegistrations(registrations) %}
+                       </div>
+               </section>
+       {% end %}
+
+       {% if conferences %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">
+                                       {{ _("Conferences") }}
+                                       <span class="tag">{{ len(conferences) }}</span>
+                               </h4>
+
+                               {% module VoIPConferences(conferences) %}
+                       </div>
+               </section>
+       {% end %}
+
+       {% if queues %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">
+                                       {{ _("Queues") }}
+                                       <span class="tag">{{ len(queues) }}</span>
+                               </h4>
+
+                               {% module VoIPQueues(queues) %}
+                       </div>
+               </section>
+       {% end %}
+
+       {% if outbound_registrations %}
+               <section class="section">
+                       <div class="container">
+                               <h4 class="title is-4">
+                                       {{ _("Outbound Registrations") }}
+                                       <span class="tag">{{ len(outbound_registrations) }}</span>
+                               </h4>
+
+                               {% module VoIPOutboundRegistrations(outbound_registrations) %}
+                       </div>
+               </section>
+       {% end %}
+{% end block %}
diff --git a/src/templates/voip/modules/conferences.html b/src/templates/voip/modules/conferences.html
new file mode 100644 (file)
index 0000000..cc26b74
--- /dev/null
@@ -0,0 +1,19 @@
+{% for c in conferences %}
+       <div class="notification">
+               <h5 class="title is-5">{{ _("Conference Room %s") % c }}</h5>
+
+               {% if c.members %}
+                       <ul>
+                               {% for m in sorted(c.members) %}
+                                       <li>
+                                               {{ m }}
+
+                                               <span class="is-pulled-right">
+                                                       {{ format_time(m.duration) }}
+                                               </span>
+                                       </li>
+                               {% end %}
+                       </ul>
+               {% end %}
+       </div>
+{% end %}
diff --git a/src/templates/voip/modules/outbound-registrations.html b/src/templates/voip/modules/outbound-registrations.html
new file mode 100644 (file)
index 0000000..96d8a84
--- /dev/null
@@ -0,0 +1,30 @@
+<table class="table is-striped is-fullwidth">
+       <thead>
+               <tr>
+                       <th>{{ _("Server") }}</th>
+                       <th>{{ _("Username") }}</th>
+                       <th>{{ _("Status") }}</th>
+               </tr>
+       </thead>
+
+       <tbody>
+               {% for r in sorted(registrations) %}
+                       <tr>
+                               {# Server #}
+                               <th scope="row">
+                                       {{ r.server }}
+                               </th>
+
+                               {# Username #}
+                               <td>
+                                       {{ r.username }}
+                               </td>
+
+                               {# Status #}
+                               <td>
+                                       {{ r.status }}
+                               </td>
+                       </tr>
+               {% end %}
+       </tbody>
+</table>
diff --git a/src/templates/voip/modules/queues.html b/src/templates/voip/modules/queues.html
new file mode 100644 (file)
index 0000000..9bce7b2
--- /dev/null
@@ -0,0 +1,23 @@
+{% for q in queues %}
+       <div class="notification">
+               <h5 class="title is-5">{{ _("Queue %s") % q }}</h5>
+
+               {% if q.members %}
+                       <ul>
+                               {% for m in q.members %}
+                                       <li>
+                                               {{ m }}
+
+                                               <small class="has-text-grey">
+                                                       {{ _("Logged in %s") % locale.format_date(m.logged_in_at, shorter=True) }}
+                                               </small>
+                                       </li>
+                               {% end %}
+                       </ul>
+               {% else %}
+                       <p class="has-text-grey">
+                               {{ _("No Members") }}
+                       </p>
+               {% end %}
+       </div>
+{% end %}
diff --git a/src/templates/voip/modules/registrations.html b/src/templates/voip/modules/registrations.html
new file mode 100644 (file)
index 0000000..771c446
--- /dev/null
@@ -0,0 +1,52 @@
+{% from ipfire.accounts import Account %}
+
+<table class="table is-striped is-fullwidth">
+       <thead>
+               <tr>
+                       <th>{{ _("User") }}</th>
+                       <th>{{ _("Transport") }}</th>
+                       <th>{{ _("Address") }}</th>
+                       <th>{{ _("User Agent") }}</th>
+                       <th>{{ _("Latency") }}</th>
+               </tr>
+       </thead>
+
+       <tbody>
+               {% for r in sorted(registrations) %}
+                       <tr>
+                               {# User #}
+                               <th scope="row">
+                                       {% if isinstance(r.user, Account) %}
+                                               <a href="/users/{{ r.user.uid }}">{{ r.user }}</a>
+                                       {% else %}
+                                               {{ r.user }}
+                                       {% end %}
+                               </th>
+
+                               {# Transport #}
+                               <td class="has-text-centered">
+                                       {{ r.transport or _("Unknown") }}
+                               </td>
+
+                               {# Address #}
+                               <td>
+                                       {{ r.address }}
+                               </td>
+
+                               {# User Agent #}
+                               <td>
+                                       {{ r.user_agent or _("N/A") }}
+                               </td>
+
+                               {# Latency #}
+                               <td class="has-text-right">
+                                       {% if r.roundtrip %}
+                                               {{ _("%.2fms") % r.roundtrip }}
+                                       {% else %}
+                                               {{ _("N/A") }}
+                                       {% end %}
+                               </th>
+                       </tr>
+               {% end %}
+       </tbody>
+</table>
diff --git a/src/templates/wiki/404.html b/src/templates/wiki/404.html
deleted file mode 100644 (file)
index c410a7a..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Page Not Found") }}{% end block %}
-
-{% block container %}
-    {% import os.path %}
-
-    <section>
-        <div class="container">
-            <div class="row justify-content-center mt-5">
-                <div class="col col-md-7">
-                    <h5 class="mb-0">{{ _("Error 404") }}</h5>
-
-                    <h2>{{ _("This Page Does Not Exist") }}</h2>
-
-                    <p>
-                        {{ _("This wiki page does not exist, yet.") }}
-                    </p>
-
-                    <a class="btn btn-primary btn-block" href="{{ os.path.join(request.path, "_edit") }}">
-                        {{ _("Create Now") }}
-                    </a>
-                </div>
-            </div>
-        </div>
-    </section>
-{% end block %}
diff --git a/src/templates/wiki/confirm-delete.html b/src/templates/wiki/confirm-delete.html
deleted file mode 100644 (file)
index 031b917..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Delete %s") % file.filename }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-6">
-                       <div class="card card-body">
-                               <h5 class=" mb-4">{{ _("Delete %s") % file.filename }}</h5>
-
-                               <p>
-                                       {{ _("Do you really want to delete %(filename)s in %(path)s?") % { "filename" : file.filename, "path" : file.path } }}
-                               </p>
-
-                               <form action="" method="POST">
-                                       {% raw xsrf_form_html() %}
-
-                                       <button type="submit" class="btn btn-primary btn-block">
-                                               {{ _("Delete") }}
-                                       </button>
-                               </form>
-                       </div>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/confirm-restore.html b/src/templates/wiki/confirm-restore.html
deleted file mode 100644 (file)
index 7bebd42..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Restore %s") % page.page }}{% end block %}
-
-{% block content %}
-       <div class="row justify-content-center my-5">
-               <div class="col col-md-6">
-                       <div class="card card-body">
-                               <h5 class=" mb-4">{{ _("Restore %s") % page.page }}</h5>
-
-                               <p>
-                                       {{ _("Do you really want to restore this page to its revision from %s?") % locale.format_date(page.timestamp) }}
-                               </p>
-
-                               <form action="/actions/restore" method="POST">
-                                       {% raw xsrf_form_html() %}
-
-                                       <input type="hidden" name="path" value="{{ page.page }}">
-                                       <input type="hidden" name="revision" value="{{ page.timestamp.isoformat() }}">
-
-                                       <div class="form-group">
-                                               <input class="form-control" type="text" name="comment"
-                                                       placeholder="{{ _("Comment") }}">
-                                       </div>
-
-                                       <button type="submit" class="btn btn-warning btn-block">
-                                               {{ _("Restore") }}
-                                       </button>
-                               </form>
-                       </div>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/edit.html b/src/templates/wiki/edit.html
deleted file mode 100644 (file)
index c4ffa56..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{% if page %}{{ _("Edit %s") % page.title }}{% else %}{{ _("Create A New Page") }}{% end %}{% end block %}
-
-{% block sidebar %}
-       {% set help = backend.wiki.get_page("/wiki/edit") %}
-
-       {% if help %}
-               {% raw help.html %}
-       {% end %}
-{% end block %}
-
-{% block main %}
-       {% import os.path %}
-
-       <div class="card mb-4">
-               <div class="card-body">
-                       <h4 class="card-title">
-                               {% if page %}{{ _("Edit %s") % page.title }}{% else %}{{ _("Create A New Page") }}{% end %}
-                       </h4>
-
-                       <form action="" method="POST" class="editor" data-render_url="{{ os.path.join(path, "_render") }}">
-                               {% raw xsrf_form_html() %}
-
-                               <div class="form-group">
-                                       <div class="btn-toolbar mb-3" role="toolbar">
-                                               <div class="btn-group btn-group-sm mr-2" role="group">
-                                                       <button type="button" class="btn btn-secondary"
-                                                                       id="bold" title="{{ _("Bold") }} [{{ _("Ctrl") }}-B]">
-                                                               <i class="fas fa-bold"></i>
-                                                       </button>
-                                                       <button type="button" class="btn btn-secondary"
-                                                                       id="italic" title="{{ _("Italic") }} [{{ _("Ctrl") }}-I]">
-                                                               <i class="fas fa-italic"></i>
-                                                       </button>
-                                                       <button type="button" class="btn btn-secondary"
-                                                                       id="code" title="{{ _("Code") }} [{{ _("Ctrl") }}-C]">
-                                                               <i class="fas fa-code"></i>
-                                                       </button>
-                                               </div>
-
-                                               <div class="btn-group btn-group-sm mr-2" role="group">
-                                                       <button type="button" class="btn btn-secondary"
-                                                                       id="headline-up" title="{{ _("Headline one level up") }}">
-                                                               <i class="fas fa-chevron-left"></i>
-                                                       </button>
-                                                       <button type="button" class="btn btn-secondary"
-                                                                       id="headline" title="{{ _("Headline") }} [{{ _("Ctrl") }}-H]">
-                                                               <i class="fas fa-heading"></i>
-                                                       </button>
-                                                       <button type="button" class="btn btn-secondary"
-                                                                       id="headline-down" title="{{ _("Headline one level down") }}">
-                                                               <i class="fas fa-chevron-right"></i>
-                                                       </button>
-                                               </div>
-
-                                               <button type="button" class="btn btn-sm btn-secondary mr-2"
-                                                               id="link" title="{{ _("Link") }} [{{ _("Ctrl") }}-L]">
-                                                       <i class="fas fa-link"></i>
-                                               </button>
-
-                                               <a class="btn btn-sm btn-secondary" href="{{ path }}/_files"
-                                                               target="_blank" title="{{ _("Files") }}">
-                                                       <i class="fas fa-images"></i>
-                                               </a>
-                                       </div>
-
-                                       <textarea class="form-control" rows="16" name="content" id="content" placeholder="{{ _("Text") }}"
-                                               >{% if page and page.markdown %}{{ page.markdown }}{% end %}</textarea>
-                               </div>
-
-                               <div class="form-group row">
-                                       <label class="col-sm-4 col-form-label">{{ _("What has changed?") }}</label>
-                                       <div class="col-sm-8">
-                                               <input type="text" class="form-control" name="changes" required>
-                                       </div>
-                               </div>
-
-                               {% if page and not page.is_watched_by(current_user) %}
-                                       <div class="form-group form-check">
-                                               <div class="custom-control custom-checkbox">
-                                                       <input type="checkbox" class="custom-control-input" name="watch" id="watch" checked>
-                                                       <label class="custom-control-label" for="watch">{{ _("Watch this page") }}</label>
-                                               </div>
-
-                                               <small class="form-text text-muted">
-                                                       {{ _("Get notified when this page is changed") }}
-                                               </small>
-                                       </div>
-                               {% end %}
-
-                               <button type="submit" class="btn btn-primary btn-block">
-                                       {% if page %}{{ _("Save Page") }}{% else %}{{ _("Create Page") }}{% end %}
-                               </button>
-                       </form>
-               </div>
-       </div>
-
-       <div id="preview" class="fade show">
-               <div class="d-flex align-items-center mb-4">
-                       <h4 class="mb-0">{{ _("Preview") }}</h4>
-                       <div id="spinner" class="spinner-border ml-auto" role="status" aria-hidden="true"></div>
-               </div>
-
-               <div class="card">
-                       <div class="card-body mb-0">
-                               <div id="preview-content" class="wiki-content mb-0">
-                                       {{ _("Loading...") }}
-                               </div>
-                       </div>
-               </div>
-       </div>
-{% end block %}
-
-{% block javascript %}
-       <script src="{{ static_url("js/editor.js") }}"></script>
-{% end block %}
diff --git a/src/templates/wiki/files/detail.html b/src/templates/wiki/files/detail.html
deleted file mode 100644 (file)
index 45b165e..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-{% extends "../page.html" %}
-
-{% block title %}{{ file.filename }}{% end block %}
-
-{% block head %}{% end block %}
-
-{% block main %}
-       <div class="card mb-4">
-               <div class="card-body">
-                       {% if file.is_image() %}
-                               <p class="text-center">
-                                       <img class="img-fluid img-thumbnail" src="{{ file.url }}?revision={{ file.created_at.isoformat() }}&s=768" alt="{{ file.filename }}">
-                               </p>
-                       {% elif file.is_pdf() %}
-                               <object class="pdf-viewer" data="{{ file.url }}?revision={{ file.created_at.isoformat() }}"
-                                               title="{{ file.filename }}" type="{{ file.mimetype }}">
-                                       <p>
-                                               {{ _("This PDF attachment could not be displayed.") }}
-                                               <a href="{{ file.url }}?revision={{ file.created_at.isoformat() }}">{{ _("Click here to download") }}</a>
-                                       </p>
-                               </object>
-                       {% end %}
-
-                       <a class="btn btn-primary btn-lg btn-block my-3" href="{{ file.url }}?revision={{ file.created_at.isoformat() }}">
-                               <span class="fas fa-file-download"></span>
-                               {{ _("Download") }} ({{ format_size(file.size) }})
-                       </a>
-
-                       {% if file.is_image() %}
-                               <h6>{{ _("Usage") }}</h6>
-
-                               <pre><code>![](./{{ file.filename }})</code></pre>
-
-                               <p>{{ _("Or with an optional caption") }}</p>
-
-                               <pre><code>![](./{{ file.filename }} "{{ _("Caption") }}")</code></pre>
-                       {% end %}
-
-                       <h6 class="my-3">{{ _("Details") }}</h6>
-
-                       <dl class="row">
-                               <dt class="col-sm-3">{{ _("Filename") }}</dt>
-                               <dd class="col-sm-9">{{ file.filename }}</dd>
-
-                               {% if file.author %}
-                                       <dt class="col-sm-3">{{ _("Author") }}</dt>
-                                       <dd class="col-sm-9">
-                                               <a href="/users/{{ file.author.uid }}">{{ file.author }}</a>
-                                       </dd>
-                               {% end %}
-
-                               <dt class="col-sm-3">{{ _("Uploaded at") }}</dt>
-                               <dd class="col-sm-9">{{ locale.format_date(file.created_at) }}</dd>
-
-                               {% if file.deleted_at %}
-                                       <dt class="col-sm-3">{{ _("Deleted at") }}</dt>
-                                       <dd class="col-sm-9">{{ locale.format_date(file.deleted_at) }}</dd>
-                               {% end %}
-
-                               {% set revisions = file.get_revisions() %}
-                               {% if len(revisions) > 1 %}
-                                       <dt class="col-sm-3">{{ _("Other Revisions") }}</dt>
-                                       <dd class="col-sm-9">
-                                               <ul class="list-inline">
-                                                       {% for r in revisions %}
-                                                               <li>
-                                                                       <a href="{{ r.url }}?action=detail&revision={{ r.created_at.isoformat() }}">
-                                                                               {{ _("Uploaded %(time)s by %(author)s") % { "time" : locale.format_date(r.created_at), "author" : r.author } }}
-                                                                       </a>
-                                                               </li>
-                                                       {% end %}
-                                               </ul>
-                                       </dd>
-                               {% end %}
-                       </dl>
-
-                       <h6>{{ _("Delete") }}</h6>
-
-                       <a class="btn btn-danger btn-block mb-5" href="{{ file.url }}/_delete">
-                               {{ _("Delete") }}
-                       </a>
-
-                       <h6>{{ _("Upload Newer Revision") }}</h6>
-
-                       <form method="POST" action="/actions/upload" enctype="multipart/form-data">
-                               {% raw xsrf_form_html() %}
-
-                               <input type="hidden" name="path" value="{{ file.path }}">
-                               <input type="hidden" name="filename" value="{{ file.filename }}">
-
-                               <div class="form-group">
-                                       <div class="custom-file">
-                                               <input type="file" class="custom-file-input" name="file" required>
-                                               <label class="custom-file-label" for="customFile">{{ _("Choose a file to upload") }}</label>
-                                       </div>
-
-                                       <small class="form-text text-muted">
-                                               {{ _("Uploading a new file to replaces this one to fix any errata in the current version") }}
-                                       </small>
-                               </div>
-
-                               <input class="btn btn-primary btn-block" type="submit" value="{{ _("Upload") }}">
-                       </form>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/files/index.html b/src/templates/wiki/files/index.html
deleted file mode 100644 (file)
index 6c259f2..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Files") }}{% end block %}
-
-{% block sidebar %}
-       {% set help = backend.wiki.get_page("/wiki/media") %}
-
-       {% if help %}
-               {% raw help.html %}
-       {% end %}
-{% end block %}
-
-{% block main %}
-       {% if files %}
-               <div class="card mb-4">
-                       <div class="card-body">
-                               <h4 class="card-title">{{ _("Files") }}</h4>
-
-                               <div class="row">
-                                       {% for f in files %}
-                                               {% if f.is_image() %}
-                                                       <div class="col-sm-6 col-md-4">
-                                                               <figure class="figure">
-                                                                       <a href="{{ f.url }}?action=detail">
-                                                                               <img class="figure-img img-fluid img-thumbnail" src="{{ f.url }}?s=256" alt="{{ f.filename }}">
-                                                                               <figcaption class="figure-caption">{{ f.filename }}</figcaption>
-                                                                       </a>
-                                                               </figure>
-                                                       </div>
-                                               {% end %}
-                                       {% end %}
-                               </div>
-
-                               <ul class="list-inline">
-                                       {% for f in files %}
-                                               {% if not f.is_image() %}
-                                                       <li class="list-inline-item">
-                                                               {% if "pdf" in f.mimetype %}
-                                                                       <span class="fas fa-file-pdf fa-fw"></span>
-                                                               {% else %}
-                                                                       <span class="fas fa-file fa-fw"></span>
-                                                               {% end %}
-
-                                                               <a href="{{ f.url }}?action=detail">{{ f.filename }}</a>
-                                                       </li>
-                                               {% end %}
-                                       {% end %}
-                               </ul>
-                       </div>
-               </div>
-       {% end %}
-
-       <div class="card">
-               <div class="card-body">
-                       <h6 class="card-title">{{ _("Upload File") }}</h6>
-
-                       <form method="POST" action="/actions/upload" enctype="multipart/form-data">
-                               {% raw xsrf_form_html() %}
-
-                               <input type="hidden" name="path" value="{{ path }}">
-
-                               <div class="form-group">
-                                       <div class="custom-file">
-                                               <input type="file" class="custom-file-input" name="file" required>
-                                               <label class="custom-file-label" for="customFile">{{ _("Choose a file to upload") }}</label>
-                                       </div>
-                               </div>
-
-                               <input class="btn btn-primary btn-block" type="submit" value="{{ _("Upload") }}">
-                       </form>
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/modules/diff.html b/src/templates/wiki/modules/diff.html
deleted file mode 100644 (file)
index 341a5ee..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<table class="table table-sm small text-monospace">
-       <tbody>
-               {% for line in diff %}
-                       {% if not line.startswith("?") %}
-                               <tr class="{% if line.startswith("+") %}table-success{% elif line.startswith("-") %}table-danger{% end %}">
-                                       <td>{% if line[2:] %}{{ line[2:] }}{% else %}&nbsp;{% end %}</td>
-                               </tr>
-                       {% end %}
-               {% end %}
-       </tbody>
-</table>
diff --git a/src/templates/wiki/modules/list.html b/src/templates/wiki/modules/list.html
deleted file mode 100644 (file)
index 3891a29..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-{% for page in pages %}
-       <strong class="mb-0">
-               {% if show_breadcrumbs %}
-                       {% for url, title in page.breadcrumbs %}
-                               <a href="{{ url }}">{{ title }}</a> /
-                       {% end %}
-               {% end %}
-
-               <a href="{{ page.url }}{% if link_revision %}?revision={{ page.timestamp.isoformat() }}{% end %}">{{ page.title }}</a>
-       </strong>
-
-       <p class="text-muted small">
-               {% if show_author %}
-                       {{ _("Last edited %s") % locale.format_date(page.timestamp, shorter=True, relative=False) }}
-
-                       {% if page.author %}
-                               {{ _("by") }}
-                               <a href="/users/{{ page.author.uid }}">{{ page.author }}</a>
-                       {% end %}
-               {% end %}
-
-               {% if show_changes %}
-                       {% if page.changes %}
-                               &bull; {{ page.changes }}
-                       {% end %}
-
-                       {% if page.previous_revision %}
-                               <a href="{{ page.url }}?action=diff&a={{ page.previous_revision.timestamp.isoformat() }}&b={{ page.timestamp.isoformat() }}">
-                                       <span class="fas fa-exchange-alt ml-1"></span>
-                               </a>
-                       {% end %}
-               {% end %}
-
-               {% if not page.is_latest_revision() %}
-                       <a href="{{ page.url }}?action=restore&revision={{ page.timestamp.isoformat() }}" title="{{ _("Restore") }}">
-                               <span class="fas fa-undo ml-1"></span>
-                       </a>
-               {% end %}
-       </p>
-{% end %}
diff --git a/src/templates/wiki/modules/navbar.html b/src/templates/wiki/modules/navbar.html
deleted file mode 100644 (file)
index c8c1c3f..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-{% if len(breadcrumbs) >= 1 %}
-       <ol class="breadcrumb">
-               {% for p, title in breadcrumbs %}
-                       <li class="breadcrumb-item">
-                               <a href="{{ p }}">{{ title }}</a>
-                       </li>
-               {% end %}
-
-               <li class="breadcrumb-item {% if not suffix %}active{% end %}">
-                       <a href="{{ page }}">{{ page_title }}</a>
-               </li>
-
-               {% if suffix %}
-                       <li class="breadcrumb-item active">{{ suffix }}</li>
-               {% end %}
-       </ol>
-{% end %}
diff --git a/src/templates/wiki/page.html b/src/templates/wiki/page.html
deleted file mode 100644 (file)
index ebcd552..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ page.title }}{% end block %}
-
-{% block sidebar %}
-       {% if page and page.sidebar %}
-               <div class="wiki-content small">
-                       {% raw page.sidebar.html %}
-               </div>
-       {% end %}
-{% end block %}
-
-{% block head %}
-       <!-- Facebook OpenGraph -->
-       <meta property="og:site_name" content="IPFire Wiki" />
-       <meta property="og:title" content="{{ page.title }} - The IPFire Wiki" />
-       <meta property="og:url" content="{{ page.full_url }}" />
-       <meta property="og:image" content="https://wiki.ipfire.org/{{ static_url("img/ipfire-tux.png") }}" />
-
-       <meta property="og:type" content="article" />
-       <meta property="og:article:modified_time" content="{{ page.timestamp.isoformat() }}" />
-
-       <!-- Twitter -->
-       <meta property="twitter:site" content="@ipfire" />
-       <meta property="twitter:card" content="summary_large_image" />
-       <meta property="twitter:title" content="{{ page.title }} - The IPFire Wiki" />
-       <meta property="twitter:image" content="https://wiki.ipfire.org/{{ static_url("img/ipfire-tux.png") }}" />
-{% end block %}
-
-{% block main %}
-       {% import os.path %}
-
-       <div class="card mb-3">
-               <div class="card-body wiki-content">
-                       {% raw page.html %}
-               </div>
-       </div>
-
-       <a class="btn btn-primary btn-block mb-3" href="{{ os.path.join(request.path, "_edit") }}">
-               <span class="fas fa-edit mr-2"></span> {{ _("Edit Page") }}
-               {% if not current_user %}&dash; {{ _("Yes, you can edit!") }}{% end %}
-       </a>
-
-       <p class="small text-muted">
-               {% if current_user %}
-                       {% if page.is_watched_by(current_user) %}
-                               <a href="{{ os.path.join(page.url, "_unwatch") }}"><span class="fas fa-star" title="{{ _("Stop watching this page") }}"></span></a>
-                       {% else %}
-                               <a href="{{ os.path.join(page.url, "_watch") }}"><span class="far fa-star" title="{{ _("Watch this page") }}"></span></a>
-                       {% end %} &bull;
-               {% end %}
-
-               <a href="{{ request.path }}?action=revisions">
-                       {{ _("Older Revisions") }}
-               </a>
-
-               &bull;
-
-               {{ locale.format_date(page.timestamp) }}
-
-               {% if page.author %}
-                       &bull;
-
-                       <a href="/users/{{ page.author.uid }}">
-                               {{ page.author }}
-                       </a>
-               {% end %}
-       </p>
-{% end block %}
diff --git a/src/templates/wiki/recent-changes.html b/src/templates/wiki/recent-changes.html
deleted file mode 100644 (file)
index 9cb939b..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Recent Changes") }}{% end block %}
-
-{% block content %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1>{{ _("Recent Changes") }}</h1>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <div class="card">
-               <div class="card-body">
-                       {% module WikiList(recent_changes, show_changes=True) %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/revisions.html b/src/templates/wiki/revisions.html
deleted file mode 100644 (file)
index 5b80d31..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-{% extends "page.html" %}
-
-{% block title %}{{ _("Revisions of %s") % page.title }}{% end block %}
-
-{% block main %}
-       <div class="card mb-3">
-               <div class="card-body">
-            <h4 class="card-title">{{ _("Revisions of %s") % page.title }}</h4>
-
-                       {% module WikiList(page.get_revisions(), show_breadcrumbs=False, link_revision=True, show_changes=True) %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/search-results.html b/src/templates/wiki/search-results.html
deleted file mode 100644 (file)
index 8689b83..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-{% extends "../base.html" %}
-
-{% block title %}{{ _("Search results for '%s'") % q }}{% end block %}
-
-{% block content %}
-       <div class="card">
-               <div class="card-body">
-                       <h5>{{ _("Search results for '%s'") % q }}</h5>
-
-                       {% module WikiList(pages, show_author=False) %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/tree.html b/src/templates/wiki/tree.html
deleted file mode 100644 (file)
index a2d1bb2..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Tree") }}{% end block %}
-
-{% block main %}
-       <section>
-               <div class="container">
-                       <div class="row">
-                               <div class="col col-lg-6">
-                                       <h1>{{ _("Tree") }}</h1>
-                               </div>
-                       </div>
-               </div>
-       </section>
-
-       <div class="card">
-               <div class="list-group list-group-flush">
-                       {% for page in pages %}
-                               {% if page.check_acl(current_user) %}
-                                       <div class="list-group-item d-flex flex-column">
-                                               <p class="mb-0">
-                                                       {% for p, title in backend.wiki.make_breadcrumbs(page.page) %}
-                                                               <a href="{{ p }}">{{ title }}</a> /
-                                                       {% end %}
-
-                                                       <a href="{{ page.url }}">{{ page.title or _("- No Title -") }}</a>
-                                               </p>
-
-                                               <small class="text-muted">
-                                                       {{ page.page }} &dash;
-                                                       {{ _("Last edited %s") % locale.format_date(page.timestamp, shorter=True) }}
-                                               </small>
-                                       </div>
-                               {% end %}
-                       {% end %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/templates/wiki/watchlist.html b/src/templates/wiki/watchlist.html
deleted file mode 100644 (file)
index 2234fec..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ _("Your Watchlist") }}{% end block %}
-
-{% block sidebar %}
-       {% set help = backend.wiki.get_page("/wiki/watchlist") %}
-
-       {% if help %}
-               {% raw help.html %}
-       {% end %}
-{% end block %}
-
-{% block main %}
-       <div class="card">
-               <div class="card-body">
-                       <h5>{{ _("Your Watchlist") }}</h5>
-
-                       {% if pages %}
-                               {% module WikiList(pages) %}
-                       {% else %}
-                               <div class="alert alert-light mb-0">
-                                       {{ _("You do not have any pages on your watchlist") }}
-                               </div>
-                       {% end %}
-               </div>
-       </div>
-{% end block %}
diff --git a/src/third-party/bulma b/src/third-party/bulma
new file mode 160000 (submodule)
index 0000000..538e83f
--- /dev/null
@@ -0,0 +1 @@
+Subproject commit 538e83f00190639814a78f904fb00bd170357e03
index 676e6edb8a60fc3dcd64283401c438fdb4eb5684..dfc72eaa1212e30f246913f2dc7d6bc99ebb17d1 100644 (file)
@@ -1,7 +1,6 @@
 #/usr/bin/python
 
 import logging
-import itertools
 import os.path
 import phonenumbers
 import phonenumbers.geocoder
@@ -15,19 +14,21 @@ from .. import util
 
 from .handlers import *
 
+from . import analytics
 from . import auth
 from . import blog
 from . import boot
+from . import docs
 from . import donate
-from . import download
+from . import downloads
 from . import fireinfo
 from . import iuse
+from . import lists
 from . import location
-from . import mirrors
 from . import nopaste
-from . import people
 from . import ui_modules
-from . import wiki
+from . import users
+from . import voip
 
 class Application(tornado.web.Application):
        def __init__(self, config, **kwargs):
@@ -40,6 +41,9 @@ class Application(tornado.web.Application):
 
                        # Enable XSRF cookies
                        "xsrf_cookies" : True,
+                       "xsrf_cookie_kwargs" : {
+                               "secure" : True,
+                       },
 
                        # Login
                        "login_url" : "/login",
@@ -56,32 +60,29 @@ class Application(tornado.web.Application):
                                "format_phone_number"          : self.format_phone_number,
                                "format_phone_number_to_e164"  : self.format_phone_number_to_e164,
                                "format_phone_number_location" : self.format_phone_number_location,
-                               "grouper"                      : grouper,
                        },
 
                        # UI Modules
                        "ui_modules" : {
+                               # Analytics
+                               "AnalyticsSummary"     : analytics.SummaryModule,
+
+                               # Auth
+                               "Password"             : auth.PasswordModule,
+
                                # Blog
                                "BlogHistoryNavigation": blog.HistoryNavigationModule,
                                "BlogList"             : blog.ListModule,
-                               "BlogPost"             : blog.PostModule,
-                               "BlogPosts"            : blog.PostsModule,
 
                                # Boot
                                "BootMenuConfig"       : boot.MenuConfigModule,
                                "BootMenuHeader"       : boot.MenuHeaderModule,
                                "BootMenuSeparator"    : boot.MenuSeparatorModule,
 
-                               # People
-                               "AccountsList"         : people.AccountsListModule,
-                               "Agent"                : people.AgentModule,
-                               "CDR"                  : people.CDRModule,
-                               "Channels"             : people.ChannelsModule,
-                               "MOS"                  : people.MOSModule,
-                               "NewAccounts"          : people.NewAccountsModule,
-                               "Password"             : people.PasswordModule,
-                               "Registrations"        : people.RegistrationsModule,
-                               "SIPStatus"            : people.SIPStatusModule,
+                               # Docs
+                               "DocsDiff"             : docs.DiffModule,
+                               "DocsHeader"           : docs.HeaderModule,
+                               "DocsList"             : docs.ListModule,
 
                                # Nopaste
                                "Code"                 : nopaste.CodeModule,
@@ -91,13 +92,18 @@ class Application(tornado.web.Application):
                                "FireinfoDeviceAndGroupsTable"
                                                       : fireinfo.DeviceAndGroupsTableModule,
 
-                               # Wiki
-                               "WikiDiff"             : wiki.WikiDiffModule,
-                               "WikiNavbar"           : wiki.WikiNavbarModule,
-                               "WikiList"             : wiki.WikiListModule,
+                               # Users
+                               "UsersList"            : users.ListModule,
+
+                               # VoIP
+                               "VoIPConferences"      : voip.ConferencesModule,
+                               "VoIPOutboundRegistrations" :
+                                       voip.OutboundRegistrationsModule,
+                               "VoIPQueues"           : voip.QueuesModule,
+                               "VoIPRegistrations"    : voip.RegistrationsModule,
 
                                # Misc
-                               "ChristmasBanner"      : ui_modules.ChristmasBannerModule,
+                               "IPFireLogo"           : ui_modules.IPFireLogoModule,
                                "Markdown"             : ui_modules.MarkdownModule,
                                "Map"                  : ui_modules.MapModule,
                                "ProgressBar"          : ui_modules.ProgressBarModule,
@@ -115,76 +121,172 @@ class Application(tornado.web.Application):
                        (r"/logout", auth.LogoutHandler),
                ]
 
-               self.add_handlers(r"(dev|www)\.ipfire\.org", [
+               self.add_handlers(r"www\.([a-z]+\.dev\.)?ipfire\.org", [
                        # Entry site that lead the user to index
                        (r"/", IndexHandler),
 
-                       # Download sites
-                       (r"/downloads", tornado.web.RedirectHandler, { "url" : "/download" }),
-                       (r"/download", download.IndexHandler),
-                       (r"/download/([0-9a-z\-\.]+)", download.ReleaseHandler),
+                       # Analytics
+                       (r"/analytics", analytics.IndexHandler),
+                       (r"/analytics/docs", analytics.DocsHandler),
+
+                       # Authentication
+                       (r"/join", auth.JoinHandler),
+                       (r"/login", auth.LoginHandler),
+                       (r"/logout", auth.LogoutHandler),
+                       (r"/activate/([a-z_][a-z0-9_-]{0,31})/(\w+)", auth.ActivateHandler),
+
+                       # Blog
+                       (r"/blog", blog.IndexHandler),
+                       (r"/blog/drafts", blog.DraftsHandler),
+                       (r"/blog/feed.xml", blog.FeedHandler),
+                       (r"/blog/write", blog.WriteHandler),
+                       (r"/blog/years/([0-9]{4})", blog.YearHandler),
+                       (r"/blog/([0-9a-z\-\._]+)", blog.PostHandler),
+                       (r"/blog/([0-9a-z\-\._]+)/delete", blog.DeleteHandler),
+                       (r"/blog/([0-9a-z\-\._]+)/edit", blog.EditHandler),
+                       (r"/blog/([0-9a-z\-\._]+)/publish", blog.PublishHandler),
+                       (r"/blog/([0-9a-z\-\._]+)/debug/email", blog.DebugEmailHandler),
+
+                       # Docs
+                       (r"/docs/recent\-changes", docs.RecentChangesHandler),
+                       (r"/docs/search", docs.SearchHandler),
+                       (r"/docs/tree", docs.TreeHandler),
+                       (r"/docs/watchlist", docs.WatchlistHandler),
+                       (r"/docs/_restore", docs.RestoreHandler),
+                       (r"/docs/_upload", docs.UploadHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]+)?/_edit", docs.EditHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]+)?/_render", docs.RenderHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]+)?/_(watch|unwatch)", docs.WatchHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]+)?/_files", docs.FilesHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]+(?:.*)\.(?:\w+))/_delete", docs.DeleteFileHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]+(?:.*)\.(?:\w+))$", docs.FileHandler),
+                       (r"/docs(/[A-Za-z0-9\-_\/]*)?", docs.PageHandler),
+
+                       # Downloads
+                       (r"/downloads", downloads.IndexHandler),
+                       (r"/downloads/cloud", StaticHandler, { "template" : "downloads/cloud.html" }),
+                       (r"/downloads/mirrors", downloads.MirrorsHandler),
+                       (r"/downloads/thank-you", downloads.ThankYouHandler),
+                       (r"/downloads/([0-9a-z\-\.]+)", downloads.ReleaseHandler),
 
                        # Donate
                        (r"/donate", donate.DonateHandler),
                        (r"/donate/thank-you", donate.ThankYouHandler),
                        (r"/donate/error", donate.ErrorHandler),
-                       (r"/donation", tornado.web.RedirectHandler, { "url" : "/donate" }),
+                       (r"/donate/check-vat-number", donate.CheckVATNumberHandler),
+
+                       # Fireinfo
+                       (r"/fireinfo/?", fireinfo.IndexHandler),
+                       (r"/fireinfo/admin", fireinfo.AdminIndexHandler),
+                       (r"/fireinfo/vendors", fireinfo.VendorsHandler),
+                       (r"/fireinfo/vendors/(pci|usb)/([0-9a-f]{4})", fireinfo.VendorHandler),
+                       (r"/fireinfo/drivers/(.*)", fireinfo.DriverDetail),
+                       (r"/fireinfo/profile/random", fireinfo.RandomProfileHandler),
+                       (r"/fireinfo/profile/([a-z0-9]{40})", fireinfo.ProfileHandler),
+                       (r"/fireinfo/processors", fireinfo.ProcessorsHandler),
+                       (r"/fireinfo/releases", fireinfo.ReleasesHandler),
+
+                       # Lists
+                       (r"/lists", lists.IndexHandler),
 
-                       # RSS feed
-                       (r"/news.rss", tornado.web.RedirectHandler, { "url" : "https://blog.ipfire.org/feed.xml" }),
+                       # Password Reset
+                       (r"/password\-reset", auth.PasswordResetInitiationHandler),
+                       (r"/password\-reset/([a-z_][a-z0-9_-]{0,31})/(\w+)", auth.PasswordResetHandler),
+                       (r"/.well-known/change-password", auth.WellKnownChangePasswordHandler),
+
+                       # Location
+                       (r"/location/?", StaticHandler, { "template" : "location/index.html" }),
+                       (r"/location/download", tornado.web.RedirectHandler, { "url" : "/location/install" }),
+                       (r"/location/how\-to\-use", StaticHandler, { "template" : "location/how-to-use/index.html" }),
+                       (r"/location/how\-to\-use/cli", StaticHandler, { "template" : "location/how-to-use/cli.html" }),
+                       (r"/location/how\-to\-use/dns", StaticHandler, { "template" : "location/how-to-use/dns.html" }),
+                       (r"/location/how\-to\-use/python", StaticHandler, { "template" : "location/how-to-use/python.html" }),
+                       (r"/location/install", StaticHandler, { "template" : "location/install.html" }),
+                       (r"/location/report\-a\-problem", StaticHandler, { "template" : "location/report-a-problem.html" }),
+                       (r"/location/lookup/(.+)", location.LookupHandler),
+
+                       # Single-Sign-On for Discourse
+                       (r"/sso/discourse", auth.SSODiscourse),
+
+                       # User Groups
+                       (r"/users/groups", users.GroupIndexHandler),
+                       (r"/users/groups/([a-z_][a-z0-9_-]{0,31})", users.GroupShowHandler),
+
+                       # Users
+                       (r"/users", users.IndexHandler),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})", users.ShowHandler),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})\.jpg", users.AvatarHandler),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})/delete", users.DeleteHandler),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})/edit", users.EditHandler),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})/passwd", users.PasswdHandler),
+
+                       # Promotional Consent Stuff
+                       (r"/subscribe", users.SubscribeHandler),
+                       (r"/unsubscribe", users.UnsubscribeHandler),
 
-                       # Redirect news articles to blog
-                       (r"/news/(.*)", handlers.NewsHandler),
+                       # VoIP
+                       (r"/voip", voip.IndexHandler),
 
                        # Static Pages
-                       (r"/features", StaticHandler, { "template" : "features.html" }),
-                       (r"/legal", StaticHandler, { "template" : "legal.html" }),
-                       (r"/support", StaticHandler, { "template" : "support.html" }),
+                       (r"/about",  StaticHandler, { "template" : "static/about.html" }),
+                       (r"/legal", StaticHandler, { "template" : "static/legal.html" }),
+                       (r"/help", StaticHandler, { "template" : "static/help.html" }),
+                       (r"/partners", StaticHandler, { "template" : "static/partners.html" }),
+                       (r"/sitemap",  StaticHandler, { "template" : "static/sitemap.html" }),
+
+                       # API
+                       (r"/api/check/email", auth.APICheckEmail),
+                       (r"/api/check/uid", auth.APICheckUID),
 
                        # Handle old pages that have moved elsewhere
+                       (r"/blog/authors/(\w+)", tornado.web.RedirectHandler, { "url" : "/users/{0}" }),
+                       (r"/donation", tornado.web.RedirectHandler, { "url" : "/donate" }),
+                       (r"/download", tornado.web.RedirectHandler, { "url" : "/downloads" }),
+                       (r"/download/([0-9a-z\-\.]+)", tornado.web.RedirectHandler, { "url" : "/downloads/{0}" }),
+                       (r"/features", tornado.web.RedirectHandler, { "url" : "/about" }),
                        (r"/imprint", tornado.web.RedirectHandler, { "url" : "/legal" }),
-                       (r"/(de|en)/(.*)", LangCompatHandler),
+                       (r"/news.rss", tornado.web.RedirectHandler, { "url" : "/blog/feed.xml" }),
+                       (r"/news/(.*)", tornado.web.RedirectHandler, { "url" : "/blog/{0}" }),
+                       (r"/projects(/.*)", tornado.web.RedirectHandler, { "url" : "{0}" }),
+                       (r"/support", tornado.web.RedirectHandler, { "url" : "/help"}),
+                       (r"/(de|en)/(.*)", tornado.web.RedirectHandler, { "url" : "/{0}"}),
 
                        # Export arbitrary error pages
                        (r"/error/([45][0-9]{2})", base.ErrorHandler),
+
+                       # Serve any static files
+                       (r"/static/(.*)", tornado.web.StaticFileHandler, { "path" : self.settings.get("static_path") }),
                ])
 
-               # blog.ipfire.org
-               self.add_handlers(r"blog\.ipfire\.org", [
-                       (r"/", blog.IndexHandler),
-                       (r"/authors/(\w+)", blog.AuthorHandler),
-                       (r"/compose", blog.ComposeHandler),
-                       (r"/drafts", blog.DraftsHandler),
-                       (r"/post/([0-9a-z\-\._]+)", blog.PostHandler),
-                       (r"/post/([0-9a-z\-\._]+)/delete", blog.DeleteHandler),
-                       (r"/post/([0-9a-z\-\._]+)/edit", blog.EditHandler),
-                       (r"/post/([0-9a-z\-\._]+)/publish", blog.PublishHandler),
-                       (r"/search", blog.SearchHandler),
-                       (r"/tags/([0-9a-z\-\.]+)", blog.TagHandler),
-                       (r"/years/([0-9]+)", blog.YearHandler),
+               # blog.ipfire.org - LEGACY REDIRECTION
+               self.add_handlers(r"blog\.([a-z]+\.dev\.)?ipfire\.org", [
+                       (r"/", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog" }),
+                       (r"/authors/(\w+)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog/authors/{0}" }),
+                       (r"/post/([0-9a-z\-\._]+)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog/{0}" }),
+                       (r"/tags/([0-9a-z\-\.]+)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog/tags/{0}" }),
 
                        # RSS Feed
-                       (r"/feed.xml", blog.FeedHandler),
-               ] + authentication_handlers)
+                       (r"/feed.xml", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog/feed.xml" }),
+               ])
 
                # downloads.ipfire.org
-               self.add_handlers(r"downloads?\.ipfire\.org", [
-                       (r"/", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/" }),
-                       (r"/release/(.*)", download.ReleaseRedirectHandler),
-                       (r"/(.*)", download.FileHandler),
+               self.add_handlers(r"downloads\.([a-z]+\.dev\.)?ipfire\.org", [
+                       (r"/", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/download" }),
+                       (r"/release/(.*)",  tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/download/{0}" }),
+                       (r"/(.*)", downloads.FileHandler),
                ])
 
                # mirrors.ipfire.org
-               self.add_handlers(r"^mirrors\.ipfire\.org", [
-                       (r"/", mirrors.IndexHandler),
-                       (r"/mirrors/(.*)", mirrors.MirrorHandler),
+               self.add_handlers(r"mirrors\.([a-z]+\.dev\.)?ipfire\.org", [
+                       (r"/", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/download/mirrors" }),
+                       (r"/mirrors/(.*)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/download/mirrors/{0}" }),
                ])
 
                # planet.ipfire.org
-               self.add_handlers(r"planet\.ipfire\.org", [
+               self.add_handlers(r"planet\.([a-z]+\.dev\.)?ipfire\.org", [
                        (r"/", tornado.web.RedirectHandler, { "url" : "https://blog.ipfire.org/" }),
-                       (r"/post/([A-Za-z0-9_-]+)", handlers.PlanetPostHandler),
-                       (r"/user/([a-z0-9_-]+)", handlers.PlanetUserHandler),
+                       (r"/post/([A-Za-z0-9_-]+)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog/{0}" }),
+                       (r"/user/([a-z0-9_-]+)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/blog/authors/{0}" }),
 
                        # RSS
                        (r"/rss", tornado.web.RedirectHandler, { "url" : "https://blog.ipfire.org/feed.xml" }),
@@ -193,40 +295,23 @@ class Application(tornado.web.Application):
                ])
 
                # fireinfo.ipfire.org
-               self.add_handlers(r"fireinfo\.ipfire\.org", [
-                       (r"/", fireinfo.IndexHandler),
-
-                       # Admin
-                       (r"/admin", fireinfo.AdminIndexHandler),
-
-                       # Vendors
-                       (r"/vendors", fireinfo.VendorsHandler),
-                       (r"/vendors/(pci|usb)/([0-9a-f]{4})", fireinfo.VendorHandler),
-
-                       # Driver
-                       (r"/drivers/(.*)", fireinfo.DriverDetail),
-
-                       # Show profiles
-                       (r"/profile/random", fireinfo.RandomProfileHandler),
-                       (r"/profile/([a-z0-9]{40})", fireinfo.ProfileHandler),
-
-                       # Stats
-                       (r"/processors", fireinfo.ProcessorsHandler),
-                       (r"/releases", fireinfo.ReleasesHandler),
-
-                       # Send profiles
+               self.add_handlers(r"fireinfo\.([a-z]+\.dev\.)?ipfire\.org", [
+                       # Handle profiles
                        (r"/send/([a-z0-9]+)", fireinfo.ProfileSendHandler),
-               ] + authentication_handlers)
+
+                       # Redirect anything else
+                       (r"(.*)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/fireinfo{0}" }),
+               ])
 
                # i-use.ipfire.org
-               self.add_handlers(r"i-use\.ipfire\.org", [
+               self.add_handlers(r"i-use\.([a-z]+\.dev\.)?ipfire\.org", [
                        (r"/", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/" }),
                        (r"/profile/([a-f0-9]{40})/([0-9]+).png", iuse.ImageHandler),
                ])
 
                # boot.ipfire.org
                BOOT_STATIC_PATH = os.path.join(self.settings["static_path"], "netboot")
-               self.add_handlers(r"boot(\.dev)?\.ipfire\.org", [
+               self.add_handlers(r"boot\.([a-z]+\.dev\.)?ipfire\.org", [
                        (r"/", tornado.web.RedirectHandler, { "url" : "https://wiki.ipfire.org/installation/pxe" }),
 
                        # Configurations
@@ -240,97 +325,50 @@ class Application(tornado.web.Application):
                ])
 
                # nopaste.ipfire.org
-               self.add_handlers(r"nopaste\.ipfire\.org", [
+               self.add_handlers(r"nopaste\.([a-z]+\.dev\.)?ipfire\.org", [
                        (r"/", nopaste.CreateHandler),
+                       (r"/upload", nopaste.UploadHandler),
+
+                       # View
                        (r"/raw/(.*)", nopaste.RawHandler),
                        (r"/view/(.*)", nopaste.ViewHandler),
+
+                       # Serve any static files
+                       (r"/static/(.*)", tornado.web.StaticFileHandler, { "path" : self.settings.get("static_path") }),
                ] + authentication_handlers)
 
                # location.ipfire.org
-               self.add_handlers(r"location\.ipfire\.org", [
-                       (r"/", location.IndexHandler),
-                       (r"/download", StaticHandler, { "template" : "../location/download.html" }),
-                       (r"/how\-to\-use", StaticHandler, { "template" : "../location/how-to-use.html" }),
-                       (r"/lookup/(.+)/blacklists", location.BlacklistsHandler),
-                       (r"/lookup/(.+)", location.LookupHandler),
+               self.add_handlers(r"location\.([a-z]+\.dev\.)?ipfire\.org", [
+                       (r"(.*)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/location{0}" }),
                ])
 
                # geoip.ipfire.org
-               self.add_handlers(r"geoip\.ipfire\.org", [
+               self.add_handlers(r"geoip\.([a-z]+\.dev\.)?ipfire\.org", [
                        (r"/", tornado.web.RedirectHandler, { "url" : "https://location.ipfire.org/" }),
                ])
 
                # talk.ipfire.org
-               self.add_handlers(r"talk\.ipfire\.org", [
+               self.add_handlers(r"talk\.([a-z]+\.dev\.)?ipfire\.org", [
                        (r"/", tornado.web.RedirectHandler, { "url" : "https://people.ipfire.org/" }),
                ])
 
                # people.ipfire.org
-               self.add_handlers(r"people\.ipfire\.org", [
-                       (r"/", people.IndexHandler),
-                       (r"/activate/([a-z_][a-z0-9_-]{0,31})/(\w+)", auth.ActivateHandler),
-                       (r"/conferences", people.ConferencesHandler),
-                       (r"/groups", people.GroupsHandler),
-                       (r"/groups/([a-z_][a-z0-9_-]{0,31})", people.GroupHandler),
-                       (r"/register", auth.RegisterHandler),
-                       (r"/search", people.SearchHandler),
-                       (r"/users", people.UsersHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})", people.UserHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})\.jpg", people.AvatarHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})/calls/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", people.CallHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})/calls(?:/(\d{4}-\d{2}-\d{2}))?", people.CallsHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})/edit", people.UserEditHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})/passwd", people.UserPasswdHandler),
-                       (r"/users/([a-z_][a-z0-9_-]{0,31})/sip", people.SIPHandler),
-
-                       # Promotional Consent Stuff
-                       (r"/subscribe", people.SubscribeHandler),
-                       (r"/unsubscribe", people.UnsubscribeHandler),
-
-                       # Single-Sign-On for Discourse
-                       (r"/sso/discourse", people.SSODiscourse),
-
-                       # Password Reset
-                       (r"/password\-reset", auth.PasswordResetInitiationHandler),
-                       (r"/password\-reset/([a-z_][a-z0-9_-]{0,31})/(\w+)", auth.PasswordResetHandler),
-
-                       # Stats
-                       (r"/stats", people.StatsHandler),
-
-                       # API
-                       (r"/api/check/email", auth.APICheckEmail),
-                       (r"/api/check/uid", auth.APICheckUID),
-               ]  + authentication_handlers)
+               self.add_handlers(r"people\.([a-z]+\.dev\.)?ipfire\.org", [
+                       (r"/", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/users" }),
+                       (r"/register", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/join" }),
+                       (r"/users", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/users" }),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/users/{0}" }),
+                       (r"/users/([a-z_][a-z0-9_-]{0,31})\.jpg", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/users/{0}.jpg" }),
+               ])
 
                # wiki.ipfire.org
-               self.add_handlers(r"wiki\.ipfire\.org",
-                       authentication_handlers + [
-
-                       # Actions
-                       (r"((?:[A-Za-z0-9\-_\/]+)?(?:.*)\.(?:\w+))/_delete", wiki.ActionDeleteHandler),
-                       (r"([A-Za-z0-9\-_\/]+)?/_edit", wiki.ActionEditHandler),
-                       (r"([A-Za-z0-9\-_\/]+)?/_render", wiki.ActionRenderHandler),
-                       (r"([A-Za-z0-9\-_\/]+)?/_(watch|unwatch)", wiki.ActionWatchHandler),
-                       (r"/actions/restore", wiki.ActionRestoreHandler),
-                       (r"/actions/upload", wiki.ActionUploadHandler),
-
-                       # Handlers
-                       (r"/recent\-changes", wiki.RecentChangesHandler),
-                       (r"/search", wiki.SearchHandler),
-                       (r"/tree", wiki.TreeHandler),
-                       (r"/watchlist", wiki.WatchlistHandler),
-
-                       # Media
-                       (r"([A-Za-z0-9\-_\/]+)?/_files", wiki.FilesHandler),
-                       (r"((?!/static)(?:[A-Za-z0-9\-_\/]+)?(?:.*)\.(?:\w+))$", wiki.FileHandler),
-
-                       # Render pages
-                       (r"([A-Za-z0-9\-_\/]+)?", wiki.PageHandler),
+               self.add_handlers(r"wiki\.([a-z]+\.dev\.)?ipfire\.org", [
+                       (r"(.*)", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/docs{0}" }),
                ])
 
                # ipfire.org
-               self.add_handlers(r"ipfire\.org", [
-                       (r".*", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org" })
+               self.add_handlers(r"([a-z]+\.dev\.)?ipfire\.org", [
+                       (r".*", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org/" })
                ])
 
                logging.info("Successfully initialied application")
@@ -413,17 +451,3 @@ class Application(tornado.web.Application):
                ]
 
                return ", ".join((e for e in s if e))
-
-
-def grouper(handler, iterator, n):
-       """
-               Returns groups of n from the iterator
-       """
-       i = iter(iterator)
-
-       while True:
-               ret = list(itertools.islice(i, 0, n))
-               if not ret:
-                       break
-
-               yield ret
diff --git a/src/web/analytics.py b/src/web/analytics.py
new file mode 100644 (file)
index 0000000..83a7d96
--- /dev/null
@@ -0,0 +1,54 @@
+#!/usr/bin/python3
+
+import datetime
+import tornado.web
+
+from . import base
+from . import ui_modules
+
+class IndexHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               # Check access permissions
+               if not self.current_user.is_admin():
+                       raise tornado.web.HTTPError(403)
+
+               self.render("analytics/index.html")
+
+
+class DocsHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               # Check access permissions
+               if not self.current_user.is_admin():
+                       raise tornado.web.HTTPError(403)
+
+               # Most Popular Pages
+               popular_pages = self.backend.analytics.get_most_popular_docs_pages(
+                       self.request.host, since=datetime.timedelta(hours=24 * 365), limit=50)
+
+               # Popular Searches
+               popular_searches = self.backend.analytics.get_search_queries(
+                       self.request.host, "/docs/search", limit=25)
+
+               self.render("analytics/docs.html",
+                       popular_pages=popular_pages, popular_searches=popular_searches)
+
+
+class SummaryModule(ui_modules.UIModule):
+       def render(self, host=None, uri=None):
+               if host is None:
+                       host = self.request.host
+
+               if uri is None:
+                       uri = self.request.path
+
+               # Fetch the total number of page views
+               total_page_views = self.backend.analytics.get_page_views(host, uri)
+
+               # Fetch the total number of page views in the last 24h
+               total_page_views_24h = self.backend.analytics.get_page_views(host, uri,
+                       since=datetime.timedelta(hours=24))
+
+               return self.render_string("analytics/modules/summary.html", host=host, uri=uri,
+                       total_page_views=total_page_views, total_page_views_24h=total_page_views_24h)
index d9bb703ec8aff8d2fb3574003e66a9b9f27eb81c..4a4c88a4c2858f8b864fb584223b328e615590bf 100644 (file)
@@ -2,19 +2,12 @@
 
 import logging
 import tornado.web
+import urllib.parse
 
 from . import base
+from . import ui_modules
 
-class CacheMixin(object):
-       def prepare(self):
-               # Mark this as private when someone is logged in
-               if self.current_user:
-                       self.add_header("Cache-Control", "private")
-
-               self.add_header("Cache-Control", "no-store")
-
-
-class AuthenticationMixin(CacheMixin):
+class AuthenticationMixin(object):
        def login(self, account):
                # User has logged in, create a session
                session_id, session_expires = self.backend.accounts.create_session(
@@ -26,7 +19,7 @@ class AuthenticationMixin(CacheMixin):
 
                # Send session cookie to the client
                self.set_cookie("session_id", session_id,
-                       domain=self.request.host, expires=session_expires)
+                       domain=self.request.host, expires=session_expires, secure=True)
 
        def logout(self):
                session_id = self.get_cookie("session_id")
@@ -38,7 +31,7 @@ class AuthenticationMixin(CacheMixin):
                        self.clear_cookie("session_id")
 
 
-class LoginHandler(AuthenticationMixin, base.BaseHandler):
+class LoginHandler(base.AnalyticsMixin, AuthenticationMixin, base.BaseHandler):
        def get(self):
                next = self.get_argument("next", None)
 
@@ -81,14 +74,14 @@ class LogoutHandler(AuthenticationMixin, base.BaseHandler):
                self.redirect("/")
 
 
-class RegisterHandler(CacheMixin, base.BaseHandler):
+class JoinHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
                # Redirect logged in users away
                if self.current_user:
                        self.redirect("/")
                        return
 
-               self.render("auth/register.html")
+               self.render("auth/join.html")
 
        @base.ratelimit(minutes=15, requests=5)
        async def post(self):
@@ -98,24 +91,16 @@ class RegisterHandler(CacheMixin, base.BaseHandler):
                first_name = self.get_argument("first_name")
                last_name  = self.get_argument("last_name")
 
-               # Check if this is a spam account
-               is_spam = await self.backend.accounts.check_spam(email,
-                       address=self.get_remote_ip())
-
-               if is_spam:
-                       self.render("auth/register-spam.html")
-                       return
-
                # Register account
                try:
                        with self.db.transaction():
-                               self.backend.accounts.register(uid, email,
+                               self.backend.accounts.join(uid, email,
                                        first_name=first_name, last_name=last_name,
                                        country_code=self.current_country_code)
                except ValueError as e:
                        raise tornado.web.HTTPError(400, "%s" % e) from e
 
-               self.render("auth/register-success.html")
+               self.render("auth/join-success.html")
 
 
 class ActivateHandler(AuthenticationMixin, base.BaseHandler):
@@ -144,7 +129,7 @@ class ActivateHandler(AuthenticationMixin, base.BaseHandler):
                self.render("auth/activated.html", account=account)
 
 
-class PasswordResetInitiationHandler(CacheMixin, base.BaseHandler):
+class PasswordResetInitiationHandler(base.BaseHandler):
        def get(self):
                username = self.get_argument("username", None)
 
@@ -193,6 +178,81 @@ class PasswordResetHandler(AuthenticationMixin, base.BaseHandler):
                self.redirect("/")
 
 
+class WellKnownChangePasswordHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               """
+                       Implements https://web.dev/articles/change-password-url
+               """
+               self.redirect("/users/%s/passwd" % self.current_user.uid)
+
+
+class SSODiscourse(base.BaseHandler):
+       @base.ratelimit(minutes=24*60, requests=100)
+       @tornado.web.authenticated
+       def get(self):
+               # Fetch Discourse's parameters
+               sso = self.get_argument("sso")
+               sig = self.get_argument("sig")
+
+               # Decode payload
+               try:
+                       params = self.accounts.decode_discourse_payload(sso, sig)
+
+               # Raise bad request if the signature is invalid
+               except ValueError:
+                       raise tornado.web.HTTPError(400)
+
+               # Redirect back if user is already logged in
+               args = {
+                       "nonce" : params.get("nonce"),
+                       "external_id" : self.current_user.uid,
+
+                       # Pass email address
+                       "email" : self.current_user.email,
+                       "require_activation" : "false",
+
+                       # More details about the user
+                       "username" : self.current_user.uid,
+                       "name" : "%s" % self.current_user,
+                       "bio" : self.current_user.description or "",
+
+                       # Avatar
+                       "avatar_url" : self.current_user.avatar_url(absolute=True),
+                       "avatar_force_update" : "true",
+
+                       # Send a welcome message
+                       "suppress_welcome_message" : "false",
+
+                       # Group memberships
+                       "admin" : "true" if self.current_user.is_admin() else "false",
+                       "moderator" : "true" if self.current_user.is_moderator() else "false",
+               }
+
+               # Format payload and sign it
+               payload = self.accounts.encode_discourse_payload(**args)
+               signature = self.accounts.sign_discourse_payload(payload)
+
+               qs = urllib.parse.urlencode({
+                       "sso" : payload,
+                       "sig" : signature,
+               })
+
+               # Redirect user
+               self.redirect("%s?%s" % (params.get("return_sso_url"), qs))
+
+
+class PasswordModule(ui_modules.UIModule):
+       def render(self, account=None):
+               return self.render_string("auth/modules/password.html", account=account)
+
+       def javascript_files(self):
+               return "js/zxcvbn.js"
+
+       def embedded_javascript(self):
+               return self.render_string("auth/modules/password.js")
+
+
 class APICheckUID(base.APIHandler):
        @base.ratelimit(minutes=1, requests=100)
        def get(self):
index 6c5031d80cf021c7b935ca4fd24ff88b22667584..efc47d7fe751b5dfce6806b0357b7af64ed9e96f 100644 (file)
@@ -1,11 +1,15 @@
 #!/usr/bin/python
 
+import asyncio
+import base64
 import datetime
 import dateutil.parser
 import functools
 import http.client
 import ipaddress
 import logging
+import magic
+import mimetypes
 import time
 import tornado.locale
 import tornado.web
@@ -13,36 +17,58 @@ import tornado.web
 from ..decorators import *
 from .. import util
 
+# Setup logging
+log = logging.getLogger(__name__)
+
 class ratelimit(object):
-       def __init__(self, minutes=15, requests=180):
-               self.minutes = minutes
+       """
+               A decorator class which limits how often a function can be called
+       """
+       def __init__(self, *, minutes, requests):
+               self.minutes  = minutes
                self.requests = requests
 
        def __call__(self, method):
                @functools.wraps(method)
-               def wrapper(handler, *args, **kwargs):
+               async def wrapper(handler, *args, **kwargs):
                        # Pass the request to the rate limiter and get a request object
                        req = handler.backend.ratelimiter.handle_request(handler.request,
                                handler, minutes=self.minutes, limit=self.requests)
 
                        # If the rate limit has been reached, we won't allow
                        # processing the request and therefore send HTTP error code 429.
-                       if req.is_ratelimited():
+                       if await req.is_ratelimited():
                                raise tornado.web.HTTPError(429, "Rate limit exceeded")
 
-                       return method(handler, *args, **kwargs)
+                       # Call the wrapped method
+                       result = method(handler, *args, **kwargs)
+
+                       # Await it if it is a coroutine
+                       if asyncio.iscoroutine(result):
+                               return await result
+
+                       # Return the result
+                       return result
 
                return wrapper
 
 
 class BaseHandler(tornado.web.RequestHandler):
+       def prepare(self):
+               # Mark this as private when someone is logged in
+               if self.current_user:
+                       self.set_header("Cache-Control", "private")
+
+               # Always send Vary: Cookie
+               self.set_header("Vary", "Cookie")
+
        def set_expires(self, seconds):
                # For HTTP/1.1
                self.add_header("Cache-Control", "max-age=%s, must-revalidate" % seconds)
 
                # For HTTP/1.0
                expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds)
-               self.add_header("Expires", expires)
+               self.set_header("Expires", expires)
 
        def write_error(self, status_code, **kwargs):
                # Translate code into message
@@ -53,16 +79,34 @@ class BaseHandler(tornado.web.RequestHandler):
 
                self.render("error.html", status_code=status_code, message=message, **kwargs)
 
-       def xsrf_form_html(self, *args, **kwargs):
-               # Set Vary: Cookie header
-               self.add_header("Vary", "Cookie")
+       def browser_accepts(self, t):
+               """
+                       Checks if type is in Accept: header
+               """
+               accepts = []
+
+               for elem in self.request.headers.get("Accept", "").split(","):
+                       # Remove q=N
+                       type, delim, q = elem.partition(";")
+
+                       accepts.append(type)
 
-               return super().xsrf_form_html(*args, **kwargs)
+               # Check if the filetype is in the list of accepted ones
+               return t in accepts
 
        @property
        def hostname(self):
+               # Return hostname in production
+               if self.request.host.endswith("ipfire.org"):
+                       return self.request.host
+
                # Remove the development prefix
-               return self.request.host.replace(".dev.", ".")
+               subdomain, delimier, domain = self.request.host.partition(".")
+               if subdomain:
+                       return "%s.ipfire.org" % subdomain
+
+               # Return whatever it is
+               return self.request.host
 
        def get_template_namespace(self):
                ns = tornado.web.RequestHandler.get_template_namespace(self)
@@ -70,13 +114,14 @@ class BaseHandler(tornado.web.RequestHandler):
                now = datetime.date.today()
 
                ns.update({
-                       "backend" : self.backend,
-                       "debug" : self.application.settings.get("debug", False),
+                       "backend"     : self.backend,
+                       "debug"       : self.application.settings.get("debug", False),
                        "format_size" : util.format_size,
                        "format_time" : util.format_time,
-                       "hostname" : self.hostname,
-                       "now" : now,
-                       "year" : now.year,
+                       "hostname"    : self.hostname,
+                       "now"         : now,
+                       "q"           : None,
+                       "year"        : now.year,
                })
 
                return ns
@@ -114,6 +159,68 @@ class BaseHandler(tornado.web.RequestHandler):
                if self.current_address:
                        return self.current_address.country_code
 
+       @property
+       def user_agent(self):
+               """
+                       Returns the HTTP user agent
+               """
+               return self.request.headers.get("User-Agent", None)
+
+       @property
+       def referrer(self):
+               return self.request.headers.get("Referer", None)
+
+       def _request_basic_authentication(self):
+               """
+                       Called to ask the client to perform HTTP Basic authentication
+               """
+               # Ask for authentication
+               self.set_status(401)
+
+               # Say that we support Basic
+               self.set_header("WWW-Authenticate", "Basic realm=Restricted")
+
+               self.finish()
+
+       def perform_basic_authentication(self):
+               """
+                       This handles HTTP Basic authentication.
+               """
+               # Fetch credentials
+               cred = self.request.headers.get("Authorization", None)
+               if not cred:
+                       return self._request_basic_authentication()
+
+               # No basic auth? We cannot handle that
+               if not cred.startswith("Basic "):
+                       return self._request_basic_authentication()
+
+               # Decode the credentials
+               try:
+                       # Convert into bytes()
+                       cred = cred[6:].encode()
+
+                       # Decode base64
+                       cred = base64.b64decode(cred).decode()
+
+                       username, password = cred.split(":", 1)
+
+               # Fail if any of those steps failed
+               except:
+                       raise e
+                       raise tornado.web.HTTPError(400, "Authorization data was malformed")
+
+               # Find the user in the database
+               return self.backend.accounts.auth(username, password)
+
+               # Log something
+               if account:
+                       log.info("%s authenticated successfully using HTTP Basic authentication" % account.uid)
+               else:
+                       log.warning("Could not authenticate %s" % username)
+
+               return account
+
        def get_argument_int(self, *args, **kwargs):
                arg = self.get_argument(*args, **kwargs)
 
@@ -154,6 +261,36 @@ class BaseHandler(tornado.web.RequestHandler):
                except KeyError:
                        return None
 
+       # Initialize libmagic
+       magic = magic.Magic(mime=True, uncompress=True)
+
+       # File
+
+       def _deliver_file(self, data, filename=None, prefix=None):
+               # Guess content type
+               mimetype = self.magic.from_buffer(data)
+
+               # Send the mimetype
+               self.set_header("Content-Type", mimetype or "application/octet-stream")
+
+               # Fetch the file extension
+               if not filename and prefix:
+                       ext = mimetypes.guess_extension(mimetype)
+
+                       # Compose a new filename
+                       filename = "%s%s" % (prefix, ext)
+
+               # Set filename
+               if filename:
+                       self.set_header("Content-Disposition", "inline; filename=\"%s\"" % filename)
+
+               # Set size
+               if data:
+                       self.set_header("Content-Length", len(data))
+
+               # Deliver payload
+               self.finish(data)
+
        # Login stuff
 
        def get_current_user(self):
@@ -195,10 +332,6 @@ class BaseHandler(tornado.web.RequestHandler):
        def iuse(self):
                return self.backend.iuse
 
-       @property
-       def memcached(self):
-               return self.backend.memcache
-
        @property
        def mirrors(self):
                return self.backend.mirrors
@@ -211,9 +344,60 @@ class BaseHandler(tornado.web.RequestHandler):
        def releases(self):
                return self.backend.releases
 
-       @property
-       def talk(self):
-               return self.backend.talk
+
+class AnalyticsMixin(object):
+       def on_finish(self):
+               """
+                       Collect some data about this request
+               """
+               # Log something
+               log.debug("Analytics for %s:" % self)
+               log.debug("  User-Agent: %s" % self.user_agent)
+               log.debug("  Referrer  : %s" % self.referrer)
+
+               # Do nothing if this requst should be ignored
+               if self._ignore_analytics():
+                       return
+
+               with self.db.transaction():
+                       # Log unique visits
+                       self.backend.analytics.log_unique_visit(
+                               address=self.current_address,
+                               referrer=self.referrer,
+                               country_code=self.current_country_code,
+                               user_agent=self.user_agent,
+                               host=self.request.host,
+                               uri=self.request.uri,
+
+                               # UTMs
+                               source=self.get_argument("utm_source", None),
+                               medium=self.get_argument("utm_medium", None),
+                               campaign=self.get_argument("utm_campaign", None),
+                               content=self.get_argument("utm_content", None),
+                               term=self.get_argument("utm_term", None),
+
+                               # Search queries
+                               q=self.get_argument("q", None),
+                       )
+
+       def _ignore_analytics(self):
+               """
+                       Checks if this request should be ignored
+               """
+               ignored_user_agents = (
+                       "LWP::Simple",
+                       "check_http",
+               )
+
+               # Only log GET requests
+               if not self.request.method == "GET":
+                       return True
+
+               # Ignore everything from matching user agents
+               if self.user_agent:
+                       for ignored_user_agent in ignored_user_agents:
+                               if self.user_agent.startswith(ignored_user_agent):
+                                       return True
 
 
 class APIHandler(BaseHandler):
@@ -223,6 +407,10 @@ class APIHandler(BaseHandler):
                """
                pass
 
+       def prepare(self):
+               # Do not cache any API communication
+               self.set_header("Cache-Control", "no-cache")
+
 
 class NotFoundHandler(BaseHandler):
        def prepare(self):
index ff90bdd009b8f18ba320ebeac53543958f1786d7..9c2bb07189f86878585f977d75226b2882576d0b 100644 (file)
@@ -5,40 +5,31 @@ import dateutil
 import email.utils
 import tornado.web
 
-from . import auth
 from . import base
 from . import ui_modules
 
-class IndexHandler(auth.CacheMixin, base.BaseHandler):
+class IndexHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
-               posts = self.backend.blog.get_newest(limit=3)
+               latest_post = None
 
-               # Allow this to be cached for 5 minutes
-               if not self.current_user:
-                       self.set_expires(300)
+               # Fetch the search query
+               q = self.get_argument("q", None)
 
-               self.render("blog/index.html", posts=posts)
+               # If the user is searching, perform the search
+               if q:
+                       posts = self.backend.blog.search(q)
 
+               # Otherwise fetch the latest posts
+               else:
+                       posts = self.backend.blog.get_newest(limit=10)
 
-class AuthorHandler(auth.CacheMixin, base.BaseHandler):
-       def get(self, uid):
-               author = self.accounts.get_by_uid(uid)
-               if not author:
-                       raise tornado.web.HTTPError(404, "User is unknown")
+                       # Extract the latest post
+                       latest_post = posts.pop(0)
 
-               # Get all posts from this author
-               posts = self.backend.blog.get_by_author(author)
-               if not posts:
-                       raise tornado.web.HTTPError(404, "User has no posts")
-
-               # Allow this to be cached for 10 minutes
-               if not self.current_user:
-                       self.set_expires(600)
-
-               self.render("blog/author.html", author=author, posts=posts)
+               self.render("blog/index.html", q=q, posts=posts, latest_post=latest_post)
 
 
-class FeedHandler(base.BaseHandler):
+class FeedHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
                posts = self.backend.blog.get_newest(limit=10)
                if not posts:
@@ -55,23 +46,25 @@ class FeedHandler(base.BaseHandler):
                        now=datetime.datetime.now())
 
 
-class PostHandler(auth.CacheMixin, base.BaseHandler):
+class PostHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=not self.current_user)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
                        raise tornado.web.HTTPError(404)
 
-               # Allow this to be cached for 10 minutes
-               if post.is_published():
-                       self.set_expires(600)
-
                self.render("blog/post.html", post=post)
 
 
-class PublishHandler(auth.CacheMixin, base.BaseHandler):
+class PublishHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def prepare(self):
+               # Check if the user has permissions
+               if not self.current_user.is_blog_author():
+                       raise tornado.web.HTTPError(403)
+
        @tornado.web.authenticated
        def get(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=False)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
                        raise tornado.web.HTTPError(404)
 
@@ -87,7 +80,7 @@ class PublishHandler(auth.CacheMixin, base.BaseHandler):
 
        @tornado.web.authenticated
        def post(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=False)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
                        raise tornado.web.HTTPError(404)
 
@@ -107,10 +100,16 @@ class PublishHandler(auth.CacheMixin, base.BaseHandler):
                with self.db.transaction():
                        post.publish(when)
 
-               self.redirect("/post/%s" % post.slug)
+               self.redirect("/blog/%s" % post.slug)
 
 
-class DraftsHandler(auth.CacheMixin, base.BaseHandler):
+class DraftsHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def prepare(self):
+               # Check if the user has permissions
+               if not self.current_user.is_blog_author():
+                       raise tornado.web.HTTPError(403)
+
        @tornado.web.authenticated
        def get(self):
                drafts = self.backend.blog.get_drafts(author=self.current_user)
@@ -118,46 +117,25 @@ class DraftsHandler(auth.CacheMixin, base.BaseHandler):
                self.render("blog/drafts.html", drafts=drafts)
 
 
-class SearchHandler(auth.CacheMixin, base.BaseHandler):
-       @base.ratelimit(minutes=5, requests=25)
-       def get(self):
-               q = self.get_argument("q")
-
-               posts = self.backend.blog.search(q, limit=50)
-               if not posts:
-                       raise tornado.web.HTTPError(404, "Nothing found")
-
-               self.render("blog/search-results.html", q=q, posts=posts)
-
-
-class TagHandler(auth.CacheMixin, base.BaseHandler):
-       def get(self, tag):
-               posts = self.backend.blog.get_by_tag(tag)
-               if not posts:
-                       raise tornado.web.HTTPError(404, "There are no posts with tag: %s" % tag)
-
-               # Allow this to be cached for 10 minutes
-               self.set_expires(600)
-
-               self.render("blog/tag.html", posts=list(posts), tag=tag)
-
-
-class YearHandler(auth.CacheMixin, base.BaseHandler):
+class YearHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self, year):
                posts = self.backend.blog.get_by_year(year)
                if not posts:
                        raise tornado.web.HTTPError(404, "There are no posts in %s" % year)
 
-               # Allow this to be cached for 10 minutes
-               self.set_expires(600)
-
                self.render("blog/year.html", posts=posts, year=year)
 
 
-class ComposeHandler(auth.CacheMixin, base.BaseHandler):
+class WriteHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def prepare(self):
+               # Check if the user has permissions
+               if not self.current_user.is_blog_author():
+                       raise tornado.web.HTTPError(403)
+
        @tornado.web.authenticated
        def get(self):
-               self.render("blog/compose.html", post=None)
+               self.render("blog/write.html", post=None)
 
        @tornado.web.authenticated
        def post(self):
@@ -169,13 +147,14 @@ class ComposeHandler(auth.CacheMixin, base.BaseHandler):
                        post = self.backend.blog.create_post(title, text,
                                author=self.current_user, tags=tags)
 
-               self.redirect("/drafts")
+               # Redirect to the new post
+               self.redirect("/blog/%s" % post.slug)
 
 
-class EditHandler(auth.CacheMixin, base.BaseHandler):
+class EditHandler(base.BaseHandler):
        @tornado.web.authenticated
        def get(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=False)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
                        raise tornado.web.HTTPError(404)
 
@@ -183,13 +162,13 @@ class EditHandler(auth.CacheMixin, base.BaseHandler):
                if not post.is_editable(self.current_user):
                        raise tornado.web.HTTPError(403, "%s cannot edit %s" % (self.current_user, post))
 
-               self.render("blog/compose.html", post=post)
+               self.render("blog/write.html", post=post)
 
        @tornado.web.authenticated
        def post(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=False)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
-                       raise tornado.web.HTTPError(404)
+                       raise tornado.web.HTTPError(404, "Could not find post %s" % slug)
 
                # Check if post is editable
                if not post.is_editable(self.current_user):
@@ -203,19 +182,14 @@ class EditHandler(auth.CacheMixin, base.BaseHandler):
                                tags  = self.get_argument("tags", "").split(" "),
                        )
 
-               # Return to blog if the post is already published
-               if post.is_published():
-                       self.redirect("/post/%s" % post.slug)
-                       return
-
-               # Otherwise return to drafts
-               self.redirect("/drafts")
+               # Redirect to the post
+               self.redirect("/blog/%s" % post.slug)
 
 
-class DeleteHandler(auth.CacheMixin, base.BaseHandler):
+class DeleteHandler(base.BaseHandler):
        @tornado.web.authenticated
        def get(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=False)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
                        raise tornado.web.HTTPError(404)
 
@@ -227,7 +201,7 @@ class DeleteHandler(auth.CacheMixin, base.BaseHandler):
 
        @tornado.web.authenticated
        def post(self, slug):
-               post = self.backend.blog.get_by_slug(slug, published=False)
+               post = self.backend.blog.get_by_slug(slug)
                if not post:
                        raise tornado.web.HTTPError(404)
 
@@ -242,6 +216,20 @@ class DeleteHandler(auth.CacheMixin, base.BaseHandler):
                self.redirect("/drafts")
 
 
+class DebugEmailHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, slug):
+               if not self.current_user.is_staff():
+                       raise tornado.web.HTTPError(403)
+
+               # Fetch the post
+               post = self.backend.blog.get_by_slug(slug)
+               if not post:
+                       raise tornado.web.HTTPError(404, "Could not find post %s" % slug)
+
+               self.render("blog/messages/announcement.html", account=self.current_user, post=post)
+
+
 class HistoryNavigationModule(ui_modules.UIModule):
        def render(self):
                return self.render_string("blog/modules/history-navigation.html",
@@ -249,15 +237,6 @@ class HistoryNavigationModule(ui_modules.UIModule):
 
 
 class ListModule(ui_modules.UIModule):
-       def render(self, posts):
-               return self.render_string("blog/modules/list.html", posts=posts)
-
-
-class PostModule(ui_modules.UIModule):
-       def render(self, post):
-               return self.render_string("blog/modules/post.html", post=post)
-
-
-class PostsModule(ui_modules.UIModule):
-       def render(self, posts):
-               return self.render_string("blog/modules/posts.html", posts=list(posts))
+       def render(self, posts, relative=False, show_author=True):
+               return self.render_string("blog/modules/list.html",
+                       posts=posts, relative=relative, show_author=show_author)
similarity index 73%
rename from src/web/wiki.py
rename to src/web/docs.py
index c2bdd43f334653230c756496c88fbc2f197c1abc..90b0c42327d6b77fbded428b48941628a07bae9a 100644 (file)
 import difflib
 import tornado.web
 
-from . import auth
 from . import base
 from . import ui_modules
 
-class ActionEditHandler(auth.CacheMixin, base.BaseHandler):
+class PageHandler(base.AnalyticsMixin, base.BaseHandler):
+       @property
+       def action(self):
+               return self.get_argument("action", None)
+
+       def write_error(self, status_code, **kwargs):
+               # Render a custom page for 404
+               if status_code == 404:
+                       self.render("docs/404.html", **kwargs)
+                       return
+
+               # Otherwise raise this to one layer above
+               super().write_error(status_code, **kwargs)
+
+       @tornado.web.removeslash
+       def get(self, path):
+               if path is None:
+                       path = "/"
+
+               # Check permissions
+               if not self.backend.wiki.check_acl(path, self.current_user):
+                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
+
+               # Check if we are asked to render a certain revision
+               revision = self.get_argument("revision", None)
+
+               # Fetch the wiki page
+               page = self.backend.wiki.get_page(path, revision=revision)
+
+               # Diff
+               if self.action == "diff":
+                       # Get both revisions
+                       a = self.get_argument("a")
+                       b = self.get_argument("b")
+
+                       # Fetch both versions of the page
+                       a = self.backend.wiki.get_page(path, revision=a)
+                       b = self.backend.wiki.get_page(path, revision=b)
+                       if not a or not b:
+                               raise tornado.web.HTTPError(404)
+
+                       # Cannot render a diff for the identical page
+                       if a == b:
+                               raise tornado.web.HTTPError(400)
+
+                       # Make sure that b is newer than a
+                       if a > b:
+                               a, b = b, a
+
+                       self.render("docs/diff.html", page=page, a=a, b=b)
+                       return
+
+               # Restore
+               elif self.action == "restore":
+                       self.render("docs/confirm-restore.html", page=page)
+                       return
+
+               # Revisions
+               elif self.action == "revisions":
+                       self.render("docs/revisions.html", page=page)
+                       return
+
+               # If the page does not exist, we send 404
+               if not page or page.was_deleted():
+                       # Handle /start links which were in the format of DokuWiki
+                       if path.endswith("/start"):
+                               # Strip /start from path
+                               path = path[:-6] or "/"
+
+                               # Redirect user to page if it exists
+                               page = self.backend.wiki.get_page(path)
+                               if page and not page.was_deleted():
+                                       self.redirect(page.url)
+
+                       raise tornado.web.HTTPError(404)
+
+               # Fetch the latest revision
+               latest_revision = page.get_latest_revision()
+
+               # Render page
+               self.render("docs/page.html", page=page, latest_revision=latest_revision)
+
+
+class FilesHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, path):
+               if path is None:
+                       path = "/"
+
+               # Check permissions
+               if not self.backend.wiki.check_acl(path, self.current_user):
+                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
+
+               files = self.backend.wiki.get_files(path)
+
+               self.render("docs/files/index.html", path=path, files=files)
+
+
+class FileHandler(base.AnalyticsMixin, base.BaseHandler):
+       @property
+       def action(self):
+               return self.get_argument("action", None)
+
+       async def get(self, path):
+               # Check permissions
+               if not self.backend.wiki.check_acl(path, self.current_user):
+                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
+
+               # Check if we are asked to render a certain revision
+               revision = self.get_argument("revision", None)
+
+               # Fetch the file
+               file = self.backend.wiki.get_file_by_path(path, revision=revision)
+               if not file:
+                       raise tornado.web.HTTPError(404, "Could not find %s" % path)
+
+               # Render detail page
+               if self.action == "detail":
+                       page = None
+
+                       for breadcrumb, title in self.backend.wiki.make_breadcrumbs(path):
+                               page = self.backend.wiki.get_page(breadcrumb)
+                               if page:
+                                       break
+
+                       self.render("docs/files/detail.html", page=page, file=file)
+                       return
+
+               # Get image size
+               size = self.get_argument_int("s", None)
+
+               # Check if image should be resized
+               if size and file.is_bitmap_image():
+                       # Send WEBP if the browser supports
+                       if self.browser_accepts("image/webp"):
+                               format = "WEBP"
+
+                       # Fall back to the native format
+                       else:
+                               format = None
+
+                       blob = await file.get_thumbnail(size, format=format)
+               else:
+                       blob = file.blob
+
+               # Allow downstream to cache this for a year
+               if revision:
+                       self.set_expires(31536000)
+
+               # Send the payload
+               self._deliver_file(blob, filename=file.filename)
+
+
+class EditHandler(base.BaseHandler):
        @tornado.web.authenticated
        def get(self, path):
                if path is None:
@@ -25,7 +177,7 @@ class ActionEditHandler(auth.CacheMixin, base.BaseHandler):
                        page = None
 
                # Render page
-               self.render("wiki/edit.html", page=page, path=path)
+               self.render("docs/edit.html", page=page, path=path)
 
        @tornado.web.authenticated
        def post(self, path):
@@ -65,68 +217,25 @@ class ActionEditHandler(auth.CacheMixin, base.BaseHandler):
                        self.backend.wiki.refresh()
 
 
-class ActionUploadHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       @base.ratelimit(minutes=60, requests=24)
-       def post(self):
-               path = self.get_argument("path")
-
-               # Check permissions
-               if not self.backend.wiki.check_acl(path, self.current_user):
-                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
-
-               try:
-                       filename, data, mimetype = self.get_file("file")
-
-                       # Use filename from request if any
-                       filename = self.get_argument("filename", filename)
-
-                       # XXX check valid mimetypes
-
-                       with self.db.transaction():
-                               file = self.backend.wiki.upload(path, filename, data,
-                                       mimetype=mimetype, author=self.current_user,
-                                       address=self.get_remote_ip())
-
-               except TypeError as e:
-                       raise e
-
-               self.redirect("%s/_files" % path)
-
-
-class ActionDeleteHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, path):
-               # Check permissions
-               if not self.backend.wiki.check_acl(path, self.current_user):
-                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
-
-               # Fetch the file
-               file = self.backend.wiki.get_file_by_path(path)
-               if not file:
-                       raise tornado.web.HTTPError(404, "Could not find %s" % path)
-
-               self.render("wiki/confirm-delete.html", file=file)
+class RenderHandler(base.BaseHandler):
+       def check_xsrf_cookie(self):
+               pass # disabled
 
        @tornado.web.authenticated
-       @base.ratelimit(minutes=60, requests=24)
+       @base.ratelimit(minutes=5, requests=180)
        def post(self, path):
-               # Check permissions
-               if not self.backend.wiki.check_acl(path, self.current_user):
-                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
+               if path is None:
+                       path = "/"
 
-               # Fetch the file
-               file = self.backend.wiki.get_file_by_path(path)
-               if not file:
-                       raise tornado.web.HTTPError(404, "Could not find %s" % path)
+               content = self.get_argument("content")
 
-               with self.db.transaction():
-                       file.delete(self.current_user)
+               # Render the content
+               renderer = self.backend.wiki.render(path, content)
 
-               self.redirect("%s/_files" % file.path)
+               self.finish(renderer.html)
 
 
-class ActionRestoreHandler(auth.CacheMixin, base.BaseHandler):
+class RestoreHandler(base.BaseHandler):
        @tornado.web.authenticated
        @base.ratelimit(minutes=60, requests=24)
        def post(self):
@@ -154,7 +263,36 @@ class ActionRestoreHandler(auth.CacheMixin, base.BaseHandler):
                self.redirect(page.page)
 
 
-class ActionWatchHandler(auth.CacheMixin, base.BaseHandler):
+class UploadHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       @base.ratelimit(minutes=60, requests=24)
+       def post(self):
+               path = self.get_argument("path")
+
+               # Check permissions
+               if not self.backend.wiki.check_acl(path, self.current_user):
+                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
+
+               try:
+                       filename, data, mimetype = self.get_file("file")
+
+                       # Use filename from request if any
+                       filename = self.get_argument("filename", filename)
+
+                       # XXX check valid mimetypes
+
+                       with self.db.transaction():
+                               file = self.backend.wiki.upload(path, filename, data,
+                                       mimetype=mimetype, author=self.current_user,
+                                       address=self.get_remote_ip())
+
+               except TypeError as e:
+                       raise e
+
+               self.redirect("/docs%s/_files" % path)
+
+
+class WatchHandler(base.BaseHandler):
        @tornado.web.authenticated
        @base.ratelimit(minutes=60, requests=180)
        def get(self, path, action):
@@ -179,253 +317,118 @@ class ActionWatchHandler(auth.CacheMixin, base.BaseHandler):
                self.redirect(page.url)
 
 
-class ActionRenderHandler(auth.CacheMixin, base.BaseHandler):
-       def check_xsrf_cookie(self):
-               pass # disabled
-
-       @tornado.web.authenticated
-       @base.ratelimit(minutes=5, requests=180)
-       def post(self, path):
-               if path is None:
-                       path = "/"
-
-               content = self.get_argument("content")
-
-               # Render the content
-               html = self.backend.wiki.render(path, content)
-
-               self.finish(html)
-
-
-class FilesHandler(auth.CacheMixin, base.BaseHandler):
+class DeleteFileHandler(base.BaseHandler):
        @tornado.web.authenticated
        def get(self, path):
-               if path is None:
-                       path = "/"
-
                # Check permissions
                if not self.backend.wiki.check_acl(path, self.current_user):
                        raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
 
-               files = self.backend.wiki.get_files(path)
-
-               self.render("wiki/files/index.html", path=path, files=files)
-
-
-class FileHandler(base.BaseHandler):
-       @property
-       def action(self):
-               return self.get_argument("action", None)
-
-       def get(self, path):
-               # Check permissions
-               if not self.backend.wiki.check_acl(path, self.current_user):
-                       raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
-
-               # Check if we are asked to render a certain revision
-               revision = self.get_argument("revision", None)
-
                # Fetch the file
-               file = self.backend.wiki.get_file_by_path(path, revision=revision)
+               file = self.backend.wiki.get_file_by_path(path)
                if not file:
                        raise tornado.web.HTTPError(404, "Could not find %s" % path)
 
-               # Render detail page
-               if self.action == "detail":
-                       page = None
-
-                       for breadcrumb, title in self.backend.wiki.make_breadcrumbs(path):
-                               page = self.backend.wiki.get_page(breadcrumb)
-                               if page:
-                                       break
-
-                       self.render("wiki/files/detail.html", page=page, file=file)
-                       return
-
-               size = self.get_argument_int("s", None)
-
-               # Check if image should be resized
-               if size and file.is_bitmap_image():
-                       blob = file.get_thumbnail(size)
-               else:
-                       blob = file.blob
-
-               # Set headers
-               self.set_header("Content-Type", file.mimetype or "application/octet-stream")
-               self.set_header("Content-Length", len(blob))
-
-               # Set expires
-               self.set_expires(3600)
-
-               # Deliver content
-               self.finish(blob)
-
-
-class PageHandler(auth.CacheMixin, base.BaseHandler):
-       @property
-       def action(self):
-               return self.get_argument("action", None)
-
-       def write_error(self, status_code, **kwargs):
-               # Render a custom page for 404
-               if status_code == 404:
-                       self.render("wiki/404.html", **kwargs)
-                       return
-
-               # Otherwise raise this to one layer above
-               super().write_error(status_code, **kwargs)
+               # Check if this can be deleted
+               if not file.can_be_deleted():
+                       raise tornado.web.HTTPError(400, "%s cannot be deleted" % file)
 
-       @tornado.web.removeslash
-       def get(self, path):
-               if path is None:
-                       path = "/"
+               self.render("docs/confirm-delete.html", file=file)
 
+       @tornado.web.authenticated
+       @base.ratelimit(minutes=60, requests=24)
+       def post(self, path):
                # Check permissions
                if not self.backend.wiki.check_acl(path, self.current_user):
                        raise tornado.web.HTTPError(403, "Access to %s not allowed for %s" % (path, self.current_user))
 
-               # Check if we are asked to render a certain revision
-               revision = self.get_argument("revision", None)
-
-               # Fetch the wiki page
-               page = self.backend.wiki.get_page(path, revision=revision)
-
-               # Diff
-               if self.action == "diff":
-                       # Get both revisions
-                       a = self.get_argument("a")
-                       b = self.get_argument("b")
-
-                       # Fetch both versions of the page
-                       a = self.backend.wiki.get_page(path, revision=a)
-                       b = self.backend.wiki.get_page(path, revision=b)
-                       if not a or not b:
-                               raise tornado.web.HTTPError(404)
-
-                       # Cannot render a diff for the identical page
-                       if a == b:
-                               raise tornado.web.HTTPError(400)
-
-                       # Make sure that b is newer than a
-                       if a > b:
-                               a, b = b, a
-
-                       self.render("wiki/diff.html", page=page, a=a, b=b)
-                       return
-
-               # Restore
-               elif self.action == "restore":
-                       self.render("wiki/confirm-restore.html", page=page)
-                       return
-
-               # Revisions
-               elif self.action == "revisions":
-                       self.render("wiki/revisions.html", page=page)
-                       return
-
-               # If the page does not exist, we send 404
-               if not page or page.was_deleted():
-                       # Handle /start links which were in the format of DokuWiki
-                       if path.endswith("/start"):
-                               # Strip /start from path
-                               path = path[:-6] or "/"
-
-                               # Redirect user to page if it exists
-                               page = self.backend.wiki.page_exists(path)
-                               if page:
-                                       self.redirect(path)
+               # Fetch the file
+               file = self.backend.wiki.get_file_by_path(path)
+               if not file:
+                       raise tornado.web.HTTPError(404, "Could not find %s" % path)
 
-                       raise tornado.web.HTTPError(404)
+               # Check if this can be deleted
+               if not file.can_be_deleted():
+                       raise tornado.web.HTTPError(400, "%s cannot be deleted" % file)
 
-               # Fetch the latest revision
-               latest_revision = page.get_latest_revision()
+               with self.db.transaction():
+                       file.delete(self.current_user)
 
-               # Render page
-               self.render("wiki/page.html", page=page, latest_revision=latest_revision)
+               self.redirect("/docs%s/_files" % file.path)
 
 
-class SearchHandler(auth.CacheMixin, base.BaseHandler):
+class SearchHandler(base.AnalyticsMixin, base.BaseHandler):
        @base.ratelimit(minutes=5, requests=25)
        def get(self):
                q = self.get_argument("q")
 
-               pages = self.backend.wiki.search(q, account=self.current_user, limit=50)
+               # Perform search
+               with self.db.transaction():
+                       pages = self.backend.wiki.search(q, account=self.current_user, limit=50)
 
-               self.render("wiki/search-results.html", q=q, pages=pages)
+               self.render("docs/search-results.html", q=q, pages=pages)
 
 
-class RecentChangesHandler(auth.CacheMixin, base.BaseHandler):
+class RecentChangesHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
                recent_changes = self.backend.wiki.get_recent_changes(self.current_user, limit=50)
 
-               self.render("wiki/recent-changes.html", recent_changes=recent_changes)
+               self.render("docs/recent-changes.html", recent_changes=recent_changes)
 
 
-class TreeHandler(auth.CacheMixin, base.BaseHandler):
+class TreeHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
-               self.render("wiki/tree.html", pages=self.backend.wiki)
+               self.render("docs/tree.html", pages=self.backend.wiki)
 
 
-class WatchlistHandler(auth.CacheMixin, base.BaseHandler):
+class WatchlistHandler(base.AnalyticsMixin, base.BaseHandler):
        @tornado.web.authenticated
        def get(self):
                pages = self.backend.wiki.get_watchlist(self.current_user)
 
-               self.render("wiki/watchlist.html", pages=pages)
+               self.render("docs/watchlist.html", pages=pages)
 
 
-class WikiDiffModule(ui_modules.UIModule):
-       differ = difflib.Differ()
-
-       def render(self, a, b):
-               diff = self.differ.compare(
-                       a.markdown.splitlines(),
-                       b.markdown.splitlines(),
-               )
-
-               return self.render_string("wiki/modules/diff.html", diff=diff)
-
-
-class WikiListModule(ui_modules.UIModule):
+class ListModule(ui_modules.UIModule):
        def render(self, pages, link_revision=False, show_breadcrumbs=True,
                        show_author=True, show_changes=False):
-               return self.render_string("wiki/modules/list.html", link_revision=link_revision,
+               return self.render_string("docs/modules/list.html", link_revision=link_revision,
                        pages=pages, show_breadcrumbs=show_breadcrumbs,
                        show_author=show_author, show_changes=show_changes)
 
 
-class WikiNavbarModule(ui_modules.UIModule):
+class HeaderModule(ui_modules.UIModule):
        @property
-       def path(self):
+       def page(self):
                """
                        Returns the path of the page (without any actions)
                """
-               path = self.request.path.split("/")
-
-               if path and path[-1].startswith("_"):
-                       path.pop()
+               path = self.request.path.removeprefix("/docs")
 
-               return "/".join(path)
+               return "/".join((p for p in path.split("/") if not p.startswith("_")))
 
        def render(self, suffix=None):
                _ = self.locale.translate
 
-               # Make the path
-               page = self.request.path.split("/")
-
-               # Drop the action bit
-               if page and page[-1].startswith("_"):
-                       page.pop()
-
-               page = "/".join(page)
-
-               breadcrumbs = self.backend.wiki.make_breadcrumbs(page)
-               title = self.backend.wiki.get_page_title(page)
+               breadcrumbs = self.backend.wiki.make_breadcrumbs(self.page)
+               title = self.backend.wiki.get_page_title(self.page)
 
                if self.request.path.endswith("/_edit"):
                        suffix = _("Edit")
                elif self.request.path.endswith("/_files"):
                        suffix = _("Files")
 
-               return self.render_string("wiki/modules/navbar.html",
-                       breadcrumbs=breadcrumbs, page=page, page_title=title, suffix=suffix)
+               return self.render_string("docs/modules/header.html",
+                       breadcrumbs=breadcrumbs, page=self.page, page_title=title, suffix=suffix)
+
+
+class DiffModule(ui_modules.UIModule):
+       differ = difflib.Differ()
+
+       def render(self, a, b):
+               diff = self.differ.compare(
+                       a.markdown.splitlines(),
+                       b.markdown.splitlines(),
+               )
+
+               return self.render_string("docs/modules/diff.html", diff=diff)
index bda1ee5bb8ee2ad78f6781f33e28f1207de29752..a6e9d405b9a5ab982695c352448e8031b75474fb 100644 (file)
@@ -3,16 +3,23 @@
 import iso3166
 import tornado.web
 
-from . import auth
 from . import base
 
-class DonateHandler(auth.CacheMixin, base.BaseHandler):
+SKUS = {
+       "monthly"   : "IPFIRE-DONATION-MONTHLY",
+       "quarterly" : "IPFIRE-DONATION-QUARTERLY",
+       "yearly"    : "IPFIRE-DONATION-YEARLY",
+}
+DEFAULT_SKU = "IPFIRE-DONATION"
+
+class DonateHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
-               country = self.current_country_code
+               if self.current_user:
+                       country = self.current_user.country_code
+               else:
+                       country = self.current_country_code
 
                # Get defaults
-               first_name = self.get_argument("first_name", None)
-               last_name = self.get_argument("last_name", None)
                amount    = self.get_argument_float("amount", None)
                currency  = self.get_argument("currency", None)
                frequency = self.get_argument("frequency", None)
@@ -30,21 +37,35 @@ class DonateHandler(auth.CacheMixin, base.BaseHandler):
                        frequency = "one-time"
 
                self.render("donate/donate.html", countries=iso3166.countries,
-                       country=country, first_name=first_name, last_name=last_name,
-                       amount=amount, currency=currency, frequency=frequency)
+                       country=country, amount=amount, currency=currency, frequency=frequency)
 
        @base.ratelimit(minutes=15, requests=5)
        async def post(self):
+               type      = self.get_argument("type")
+               if not type in ("individual", "organization"):
+                       raise tornado.web.HTTPError(400, "type is of an invalid value: %s" % type)
+
                amount    = self.get_argument("amount")
                currency  = self.get_argument("currency", "EUR")
                frequency = self.get_argument("frequency")
 
-               # Collect donor information
-               donor = {
+               organization = None
+               locale = self.get_browser_locale()
+
+               # Get organization information
+               if type == "organization":
+                       organization = {
+                               "name"         : self.get_argument("organization"),
+                               "vat_number"   : self.get_argument("vat_number", None),
+                       }
+
+               # Collect person information
+               person = {
                        "email"        : self.get_argument("email"),
                        "title"        : self.get_argument("title"),
                        "first_name"   : self.get_argument("first_name"),
                        "last_name"    : self.get_argument("last_name"),
+                       "locale"       : locale.code,
                }
 
                # Collect address information
@@ -59,41 +80,30 @@ class DonateHandler(auth.CacheMixin, base.BaseHandler):
 
                # Send everything to Zeiterfassung
                try:
-                       # Search for person or create a new one
-                       response = await self.backend.zeiterfassung.send_request(
-                               "/api/v1/persons/search", **donor
-                       )
+                       # Create a new organization
+                       if organization:
+                               organization = await self._create_organization(organization, address)
 
-                       if not response:
-                               response = await self.backend.zeiterfassung.send_request(
-                                       "/api/v1/persons/create", **donor, **address
-                               )
-
-                       person = response.get("number")
-
-                       donation = {
-                               "person"       : person,
+                       # Create a person
+                       person = await self._create_person(person, address, organization)
 
-                               # $$$
-                               "amount"       : amount,
-                               "currency"     : currency,
+                       # Create a new order
+                       order = await self._create_order(person=person, currency=currency)
 
-                               # Is this a recurring donation?
-                               "recurring"    : frequency == "monthly",
+                       # Add donation to the order
+                       await self._create_donation(order, frequency, amount, currency,
+                               vat_included=(type == "individual"))
 
-                               # Add URLs to redirect the user back
-                               "success_url"  : "https://%s/donate/thank-you" % self.request.host,
-                               "error_url"    : "https://%s/donate/error" % self.request.host,
-                               "back_url"     : "https://%s/donate?amount=%s&currency=%s&frequency=%s" %
-                                       (self.request.host, amount, currency, frequency),
-                       }
+                       # Submit the order
+                       needs_payment = await self._submit_order(order)
 
-                       # Create donation
-                       response = await self.backend.zeiterfassung.send_request(
-                               "/api/v1/donations/create/ipfire-project", **donation, **address)
+                       # Pay the order
+                       if needs_payment:
+                               redirect_url = await self._pay_order(order)
+                       else:
+                               redirect_url = "https://%s/donate/thank-you" % self.request.host
 
                        # Redirect the user to the payment page
-                       redirect_url = response.get("redirect_url")
                        if not redirect_url:
                                raise tornado.web.HTTPError(500, "Did not receive a redirect URL")
 
@@ -103,6 +113,139 @@ class DonateHandler(auth.CacheMixin, base.BaseHandler):
                except Exception:
                        raise
 
+       async def _create_organization(self, organization, address):
+               # Check if we have an existing organization
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/organizations/search", **organization,
+               )
+
+               # Update details if we found a match
+               if response:
+                       number = response.get("number")
+
+                       # Update name
+                       await self.backend.zeiterfassung.send_request(
+                               "/api/v1/organizations/%s/name" % number, **organization
+                       )
+
+                       # Update VAT number
+                       vat_number = organization.get("vat_number", None)
+                       if vat_number:
+                               await self.backend.zeiterfassung.send_request(
+                                       "/api/v1/organizations/%s/vat-number" % number, vat_number=vat_number,
+                               )
+
+                       # Update address
+                       await self.backend.zeiterfassung.send_request(
+                               "/api/v1/organizations/%s/address" % number, **address,
+                       )
+
+                       return number
+
+               # Otherwise we will create a new one
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/organizations/create", **organization, **address,
+               )
+
+               # Return the organization's number
+               return response.get("number")
+
+       async def _create_person(self, person, address, organization=None):
+               """
+                       Searches for a matching person or creates a new one
+               """
+               # Check if we have an existing person
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/persons/search", **person
+               )
+
+               # Update details if we found a match
+               if response:
+                       number = response.get("number")
+
+                       # Update name
+                       await self.backend.zeiterfassung.send_request(
+                               "/api/v1/persons/%s/name" % number, **person,
+                       )
+
+                       # Update address
+                       await self.backend.zeiterfassung.send_request(
+                               "/api/v1/persons/%s/address" % number, **address,
+                       )
+
+                       return number
+
+               # If not, we will create a new one
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/persons/create", organization=organization, **person, **address
+               )
+
+               # Return the persons's number
+               return response.get("number")
+
+       async def _create_order(self, person, currency=None):
+               """
+                       Creates a new order and returns its ID
+               """
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/orders/create", person=person, currency=currency,
+               )
+
+               # Return the order number
+               return response.get("number")
+
+       async def _create_donation(self, order, frequency, amount, currency,
+                       vat_included=False):
+               """
+                       Creates a new donation
+               """
+               # Select the correct product
+               try:
+                       sku = SKUS[frequency]
+               except KeyError:
+                       sku = DEFAULT_SKU
+
+               # Add it to the order
+               await self.backend.zeiterfassung.send_request(
+                       "/api/v1/orders/%s/products/add" % order, sku=sku, quantity=1,
+               )
+
+               # Set the price
+               await self.backend.zeiterfassung.send_request(
+                       "/api/v1/orders/%s/products/%s/price" % (order, sku),
+                       price=amount, currency=currency, vat_included=vat_included,
+               )
+
+       async def _submit_order(self, order):
+               """
+                       Submits the order
+               """
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/orders/%s/submit" % order,
+               )
+
+               # Return whether this needs payment
+               return not response.get("is_authorized")
+
+       async def _pay_order(self, order):
+               """
+                       Pay the order
+               """
+               # Add URLs to redirect the user back
+               urls = {
+                       "success_url" : "https://%s/donate/thank-you" % self.request.host,
+                       "error_url"   : "https://%s/donate/error" % self.request.host,
+                       "back_url"    : "https://%s/donate" % self.request.host,
+               }
+
+               # Send request
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/orders/%s/pay" % order, **urls,
+               )
+
+               # Return redirect URL
+               return response.get("redirect_url", None)
+
 
 class ThankYouHandler(base.BaseHandler):
     def get(self):
@@ -112,3 +255,16 @@ class ThankYouHandler(base.BaseHandler):
 class ErrorHandler(base.BaseHandler):
     def get(self):
         self.render("donate/error.html")
+
+
+class CheckVATNumberHandler(base.APIHandler):
+       @base.ratelimit(minutes=5, requests=25)
+       async def get(self):
+               vat_number = self.get_argument("vat_number")
+
+               # Send request
+               response = await self.backend.zeiterfassung.send_request(
+                       "/api/v1/organizations/check-vat-number", vat_number=vat_number)
+
+               # Forward the response
+               self.finish(response)
similarity index 66%
rename from src/web/download.py
rename to src/web/downloads.py
index 6827d132eecaf6039df4e354c96afc423635f5bc..de1c79a18844e6ce82f945f880195753fe612d31 100644 (file)
@@ -5,37 +5,40 @@ import tornado.web
 
 from . import base
 
-class IndexHandler(base.BaseHandler):
+class IndexHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self):
                release = self.backend.releases.get_latest()
                if not release:
                        raise tornado.web.HTTPError(404)
 
-               # Cache this response for one minute
-               self.set_expires(60)
-
                # Redirect to latest release page
-               self.redirect("/download/%s" % release.slug)
+               self.redirect("/downloads/%s" % release.slug)
+
+
+class MirrorsHandler(base.AnalyticsMixin, base.BaseHandler):
+       def get(self):
+               mirrors = self.backend.mirrors.get_by_countries()
+               if not mirrors:
+                       raise tornado.web.HTTPError(404)
+
+               self.render("downloads/mirrors.html", mirrors=mirrors)
 
 
-class ReleaseHandler(base.BaseHandler):
+class ReleaseHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self, slug):
                release = self.backend.releases.get_by_sname(slug)
                if not release:
                        raise tornado.web.HTTPError(404)
 
-               # Cache this response for ten minutes
-               self.set_expires(600)
+               self.render("downloads/release.html", release=release)
 
-               self.render("download/release.html", release=release)
 
-
-class ReleaseRedirectHandler(base.BaseHandler):
-       def get(self, release):
-               self.redirect("https://www.ipfire.org/download/%s" % release, permanent=True)
+class ThankYouHandler(base.AnalyticsMixin, base.BaseHandler):
+       def get(self):
+               self.render("downloads/thank-you.html")
 
 
-class FileHandler(base.BaseHandler):
+class FileHandler(base.AnalyticsMixin, base.BaseHandler):
        def prepare(self):
                self.set_header("Pragma", "no-cache")
 
index 2662ed4667b8a5ef3b642111ef37d551e9c05410..415f47423affccbddcc2be58dbb074e4c9431d4a 100644 (file)
 #!/usr/bin/python
 
-import datetime
-import logging
-import re
 import json
 import tornado.web
 
-from .. import fireinfo
-
-from . import auth
 from . import base
 from . import ui_modules
 
-class BaseHandler(auth.CacheMixin, base.BaseHandler):
+class BaseHandler(base.BaseHandler):
        @property
        def when(self):
                return self.get_argument_date("when", None)
 
 
-MIN_PROFILE_VERSION = 0
-MAX_PROFILE_VERSION = 0
-
-class Profile(dict):
-       def __getattr__(self, key):
-               try:
-                       return self[key]
-               except KeyError:
-                       raise AttributeError(key)
-
-       def __setattr__(self, key, val):
-               self[key] = val
-
-
 class ProfileSendHandler(BaseHandler):
        def check_xsrf_cookie(self):
                # This cookie is not required here.
                pass
 
-       def prepare(self):
-               # Create an empty profile.
-               self.profile = Profile()
-
-       def __check_attributes(self, profile):
-               """
-                       Check for attributes that must be provided,
-               """
-               attributes = (
-                       "private_id",
-                       "profile_version",
-                       "public_id",
-               )
-               for attr in attributes:
-                       if attr not in profile:
-                               raise tornado.web.HTTPError(400, "Profile lacks '%s' attribute: %s" % (attr, profile))
-
-       def __check_valid_ids(self, profile):
-               """
-                       Check if IDs contain valid data.
-               """
-               for id in ("public_id", "private_id"):
-                       if re.match(r"^([a-f0-9]{40})$", "%s" % profile[id]) is None:
-                               raise tornado.web.HTTPError(400, "ID '%s' has wrong format: %s" % (id, profile))
-
-       def __check_equal_ids(self, profile):
-               """
-                       Check if public_id and private_id are equal.
-               """
-               if profile.public_id == profile.private_id:
-                       raise tornado.web.HTTPError(400, "Public and private IDs are equal: %s" % profile)
-
-       def __check_matching_ids(self, profile):
-               """
-                       Check if a profile with the given public_id is already in the
-                       database. If so we need to check if the private_id matches.
-               """
-               p = self.profiles.find_one({ "public_id" : profile["public_id"]})
-               if not p:
-                       return
-
-               p = Profile(p)
-               if p.private_id != profile.private_id:
-                       raise tornado.web.HTTPError(400, "Mismatch of private_id: %s" % profile)
-
-       def __check_profile_version(self, profile):
-               """
-                       Check if this version of the server software does support the
-                       received profile.
-               """
-               version = profile.profile_version
-
-               if version < MIN_PROFILE_VERSION or version > MAX_PROFILE_VERSION:
-                       raise tornado.web.HTTPError(400,
-                               "Profile version is not supported: %s" % version)
-
-       def check_profile_blob(self, profile):
-               """
-                       This method checks if the blob is sane.
-               """
-               checks = (
-                       self.__check_attributes,
-                       self.__check_valid_ids,
-                       self.__check_equal_ids,
-                       self.__check_profile_version,
-                       # These checks require at least one database query and should be done
-                       # at last.
-                       self.__check_matching_ids,
-               )
-
-               for check in checks:
-                       check(profile)
-
-               # If we got here, everything is okay and we can go on...
-
        def get_profile_blob(self):
                profile = self.get_argument("profile", None)
 
@@ -119,44 +24,41 @@ class ProfileSendHandler(BaseHandler):
                if not profile:
                        raise tornado.web.HTTPError(400, "No profile received")
 
-               # Try to decode the profile.
+               # Try to decode the profile
                try:
                        return json.loads(profile)
                except json.decoder.JSONDecodeError as e:
                        raise tornado.web.HTTPError(400, "Profile could not be decoded: %s" % e)
 
-       # The GET method is only allowed in debugging mode.
-       def get(self, public_id):
-               if not self.application.settings["debug"]:
-                       raise tornado.web.HTTPError(405)
-
-               return self.post(public_id)
-
        def post(self, public_id):
                profile_blob = self.get_profile_blob()
-               #self.check_profile_blob(profile_blob)
 
                # Handle the profile.
                with self.db.transaction():
                        try:
                                self.fireinfo.handle_profile(public_id, profile_blob,
-                                       country_code=self.current_country_code)
+                                       country_code=self.current_country_code,
+                                       asn=self.current_address.asn if self.current_address else None)
 
-                       except fireinfo.ProfileParserError as e:
-                               raise tornado.web.HTTPError(400, "Could not parse profile: %s" % e)
+                       except ValueError as e:
+                               raise tornado.web.HTTPError(400, "Could not process profile: %s" % e)
 
                self.finish("Your profile was successfully saved to the database.")
 
 
-class IndexHandler(BaseHandler):
+class IndexHandler(base.AnalyticsMixin, BaseHandler):
        def get(self):
                data = {
+                       "when"           : self.when,
+
                        # Release
                        "latest_release" : self.backend.releases.get_latest(),
 
                        # Hardware
                        "arches"         : self.fireinfo.get_arch_map(when=self.when),
                        "cpu_vendors"    : self.fireinfo.get_cpu_vendors_map(when=self.when),
+
+                       # Memory
                        "memory_avg"     : self.backend.fireinfo.get_average_memory_amount(when=self.when),
 
                        # Virtualization
@@ -170,13 +72,14 @@ class IndexHandler(BaseHandler):
                self.render("fireinfo/index.html", **data)
 
 
-class DriverDetail(BaseHandler):
+class DriverDetail(base.AnalyticsMixin, BaseHandler):
        def get(self, driver):
-               self.render("fireinfo/driver.html", driver=driver,
-                       driver_map=self.fireinfo.get_driver_map(driver, when=self.when))
+               devices = self.fireinfo.get_devices_by_driver(driver, when=self.when)
 
+               self.render("fireinfo/driver.html", driver=driver, devices=devices)
 
-class ProfileHandler(BaseHandler):
+
+class ProfileHandler(base.AnalyticsMixin, BaseHandler):
        def get(self, profile_id):
                profile = self.fireinfo.get_profile(profile_id, when=self.when)
 
@@ -186,16 +89,16 @@ class ProfileHandler(BaseHandler):
                self.render("fireinfo/profile.html", profile=profile)
 
 
-class RandomProfileHandler(BaseHandler):
+class RandomProfileHandler(base.AnalyticsMixin, BaseHandler):
        def get(self):
-               profile_id = self.fireinfo.get_random_profile(when=self.when)
-               if profile_id is None:
-                       raise tornado.web.HTTPError(404)
+               profile = self.fireinfo.get_random_profile(when=self.when)
+               if not profile:
+                       raise tornado.web.HTTPError(404, "Could not find a random profile")
 
-               self.redirect("/profile/%s" % profile_id)
+               self.redirect("/fireinfo/profile/%s" % profile.profile_id)
 
 
-class ReleasesHandler(BaseHandler):
+class ReleasesHandler(base.AnalyticsMixin, BaseHandler):
        def get(self):
                data = {
                        "releases" : self.fireinfo.get_releases_map(when=self.when),
@@ -205,25 +108,19 @@ class ReleasesHandler(BaseHandler):
                return self.render("fireinfo/releases.html", **data)
 
 
-class ProcessorsHandler(BaseHandler):
+class ProcessorsHandler(base.AnalyticsMixin, BaseHandler):
        def get(self):
-               flags = {}
-
-               for platform in ("arm", "x86"):
-                       flags[platform] = \
-                               self.fireinfo.get_common_cpu_flags_by_platform(platform, when=self.when)
+               return self.render("fireinfo/processors.html", when=self.when)
 
-               return self.render("fireinfo/processors.html", flags=flags)
 
-
-class VendorsHandler(BaseHandler):
+class VendorsHandler(base.AnalyticsMixin, BaseHandler):
        def get(self):
                vendors = self.fireinfo.get_vendor_list(when=self.when)
 
                self.render("fireinfo/vendors.html", vendors=vendors)
 
 
-class VendorHandler(BaseHandler):
+class VendorHandler(base.AnalyticsMixin, BaseHandler):
        def get(self, subsystem, vendor_id):
                devices = self.fireinfo.get_devices_by_vendor(subsystem, vendor_id, when=self.when)
                if not devices:
@@ -274,4 +171,8 @@ class AdminIndexHandler(BaseHandler):
        def get(self):
                with_data, total = self.backend.fireinfo.get_active_profiles()
 
-               self.render("fireinfo/admin.html", with_data=with_data, total=total)
+               # Fetch the ASN map
+               asn_map = self.backend.fireinfo.get_asn_map()
+
+               self.render("fireinfo/admin.html", with_data=with_data, total=total,
+                       asn_map=asn_map)
index 2c785b22580d456b8108c60f884d5717d039e3ea..4372dc8f9c63defa5d0aa3a110c05a2052fbec91 100644 (file)
@@ -2,17 +2,7 @@
 
 from . import base
 
-class LangCompatHandler(base.BaseHandler):
-       """
-               Redirect links in the old format to current site:
-
-               E.g. /en/index -> /index
-       """
-       def get(self, lang, page):
-               self.redirect("/%s" % page)
-
-
-class IndexHandler(base.BaseHandler):
+class IndexHandler(base.AnalyticsMixin, base.BaseHandler):
        """
                This handler displays the welcome page.
        """
@@ -20,33 +10,12 @@ class IndexHandler(base.BaseHandler):
                # Get the latest release.
                latest_release = self.releases.get_latest()
 
-               # Cache page for 5 minutes
-               self.set_expires(300)
-
                return self.render("index.html", latest_release=latest_release)
 
 
-class NewsHandler(base.BaseHandler):
-       def get(self, post):
-               self.redirect("https://blog.ipfire.org/post/%s" % post, permanent=True)
-
-
-class PlanetPostHandler(base.BaseHandler):
-       def get(self, post):
-               self.redirect("https://blog.ipfire.org/post/%s" % post, permanent=True)
-
-
-class PlanetUserHandler(base.BaseHandler):
-       def get(self, user):
-               self.redirect("https://blog.ipfire.org/authors/%s" % user, permanent=True)
-
-
-class StaticHandler(base.BaseHandler):
+class StaticHandler(base.AnalyticsMixin, base.BaseHandler):
        def initialize(self, template):
                self._template = template
 
        def get(self):
-               # Cache page for 60 minutes
-               self.set_expires(3600)
-
-               self.render("static/%s" % self._template)
+               self.render("%s" % self._template)
index c7dc319090f496452b14b0a7f1fdc3102291b329..c3ab4927fc6a1132d3904ce7ccd803d6ef05ac10 100644 (file)
@@ -5,7 +5,7 @@ import tornado.web
 
 from . import base
 
-class ImageHandler(base.BaseHandler):
+class ImageHandler(base.AnalyticsMixin, base.BaseHandler):
        def write_error(self, status_code, **kwargs):
                """
                        Select a random image from the errors directory
@@ -19,7 +19,7 @@ class ImageHandler(base.BaseHandler):
        def get(self, profile_id, image_id):
                when = self.get_argument_date("when", None)
 
-               profile = self.fireinfo.get_profile_with_data(profile_id, when=when)
+               profile = self.fireinfo.get_profile(profile_id, when=when)
                if not profile:
                        raise tornado.web.HTTPError(404, "Profile '%s' was not found." % profile_id)
 
diff --git a/src/web/lists.py b/src/web/lists.py
new file mode 100644 (file)
index 0000000..1425603
--- /dev/null
@@ -0,0 +1,16 @@
+#!/usr/bin/python3
+
+import tornado.web
+
+from . import base
+
+class IndexHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       async def get(self):
+               # Fetch all available lists
+               lists = await self.backend.lists.get_lists()
+
+               # Fetch all subscribed lists
+               subscribed_lists = await self.current_user.get_lists()
+
+               self.render("lists/index.html", lists=lists, subscribed_lists=subscribed_lists)
index 8e0fb8bfcf1b5d5ce484e19e0408b8d188edab94..90fd45b4cf62a6811da05586e0962361960f07a4 100644 (file)
@@ -1,38 +1,11 @@
-
-#!/usr/bin/python
-
-import logging
-import tornado.web
+#!/usr/bin/python3
 
 from .. import util
-
-from . import auth
 from . import base
 
-class IndexHandler(auth.CacheMixin, base.BaseHandler):
-       def get(self):
-               posts = self.backend.blog.get_by_tag("location", limit=1)
-
-               self.render("location/index.html",
-                       address=self.current_address, posts=posts,
-               )
-
-
-class LookupHandler(base.BaseHandler):
+class LookupHandler(base.AnalyticsMixin, base.BaseHandler):
        async def get(self, address):
                # Lookup address
                address = util.Address(self.backend, address)
 
                self.render("location/lookup.html", address=address)
-
-
-class BlacklistsHandler(base.BaseHandler):
-       async def get(self, address):
-               # Lookup address
-               address = util.Address(self.backend, address)
-
-               # Lookup blacklists
-               blacklists = await address.get_blacklists()
-
-               self.render("location/blacklists.html",
-                       address=address, blacklists=blacklists)
diff --git a/src/web/mirrors.py b/src/web/mirrors.py
deleted file mode 100644 (file)
index 6c7c273..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/usr/bin/python
-
-import tornado.web
-
-from . import base
-
-class IndexHandler(base.BaseHandler):
-       def get(self):
-               mirrors = self.backend.mirrors.get_by_countries()
-               if not mirrors:
-                       raise tornado.web.HTTPError(404)
-
-               self.render("mirrors/index.html", mirrors=mirrors)
-
-
-class MirrorHandler(base.BaseHandler):
-       def get(self, hostname):
-               mirror = self.mirrors.get_by_hostname(hostname)
-               if not mirror:
-                       raise tornado.web.HTTPError(404, "Could not find %s" % hostname)
-
-               self.render("mirrors/mirror.html", mirror=mirror)
index 759a5beb0c0fee7d51406d3cb8e7d9d4fe4821c7..c25fe60639f5a74782d13545daab92a3cc29c379 100644 (file)
 
 import tornado.web
 
-from . import auth
 from . import base
 from . import ui_modules
 
-class CreateHandler(auth.CacheMixin, base.BaseHandler):
-       MODES = ("paste", "upload")
-
+class CreateHandler(base.AnalyticsMixin, base.BaseHandler):
+       @tornado.web.authenticated
        def get(self):
-               mode = self.get_argument("mode", "paste")
-               if not mode in self.MODES:
-                       raise tornado.web.HTTPError(400)
-
-               self.render("nopaste/create.html", mode=mode,
-                       max_size=self._max_size)
+               self.render("nopaste/create.html")
 
+       @tornado.web.authenticated
        @base.ratelimit(minutes=15, requests=5)
        def post(self):
-               mode = self.get_argument("mode")
-               if not mode in self.MODES:
-                       raise tornado.web.HTTPError(400)
+               subject = self.get_argument("subject", None)
+               content = self.get_argument("content")
 
-               mimetype = "text/plain"
+               # Fetch expires time
+               expires = self.get_argument_int("expires", "0")
 
-               if mode == "paste":
-                       subject = self.get_argument("subject", None)
-                       content = self.get_argument("content", "").encode("utf-8")
+               with self.db.transaction():
+                       paste = self.backend.nopaste.create(content, subject=subject, expires=expires,
+                               account=self.current_user, address=self.get_remote_ip())
 
-               elif mode == "upload":
-                       for f in self.request.files.get("file"):
-                               subject  = f.filename
-                               content  = f.body
-                               mimetype = f.content_type
-                               break
+               # Redirect to the paste
+               return self.redirect("/view/%s" % paste.uuid)
+
+       # cURL Interface
+
+       def get_current_user(self):
+               if self.request.method == "PUT":
+                       return self.perform_basic_authentication()
+
+               # Perform the usual authentication
+               return super().get_current_user()
+
+       def check_xsrf_cookie(self):
+               # Skip the check on PUT
+               if self.request.method == "PUT":
+                       return
+
+               # Perform the check as usual
+               super().check_xsrf_cookie()
+
+       def write_error(self, *args, **kwargs):
+               if self.request.method == "PUT":
+                       return
+
+               # Write errors as usual
+               return super().write_error(*args, **kwargs)
+
+       @tornado.web.authenticated
+       @base.ratelimit(minutes=15, requests=5)
+       def put(self):
+               with self.db.transaction():
+                       paste = self.backend.nopaste.create(
+                               self.request.body, account=self.current_user, address=self.get_remote_ip())
+
+               # Send a message to the client
+               self.write("https://%s/view/%s\n" % (self.request.host, paste.uuid))
+
+               # Tell the user when this expires
+               if paste.expires_at:
+                       self.write("  This paste will expire at %s\n" % self.locale.format_date(paste.expires_at))
 
-               # Check maximum size
-               if len(content) > self._max_size:
-                       raise tornado.web.HTTPError(400,
-                               "You cannot upload more than %s bytes" % self._max_size)
+               # All done
+               self.finish()
 
-               expires = self.get_argument("expires", "0")
-               try:
-                       expires = int(expires)
-               except (TypeError, ValueError):
-                       expires = None
 
-               uid = self.backend.nopaste.create(subject, content, mimetype=mimetype,
-                       expires=expires, account=self.current_user, address=self.get_remote_ip())
+class UploadHandler(base.AnalyticsMixin, base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               self.render("nopaste/upload.html")
 
-               if uid:
-                       return self.redirect("/view/%s" % uid)
+       @tornado.web.authenticated
+       def post(self):
+               subject = self.get_argument("subject", None)
 
-               raise tornado.web.HTTPError(500)
+               # Fetch expires time
+               expires = self.get_argument_int("expires", "0")
 
-       @property
-       def _max_size(self):
-               # Authenticated users are allowed to upload up to 25MB
-               if self.current_user:
-                       return 25 * (1024 ** 2)
+               with self.db.transaction():
+                       for f in self.request.files.get("file", []):
+                               paste = self.backend.nopaste.create(f.body, subject=subject, expires=expires,
+                                       account=self.current_user, address=self.get_remote_ip())
 
-               # The rest is only allowed to upload up to 2MB
-               return 2 * (1024 ** 2)
+                               # Only accept one file
+                               break
 
+                       # Complain if no file was selected
+                       else:
+                               raise tornado.web.HTTPError(400, "No file uploaded")
 
-class RawHandler(base.BaseHandler):
+               # Redirect to the paste
+               return self.redirect("/view/%s" % paste.uuid)
+
+
+class RawHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self, uid):
-               entry = self.backend.nopaste.get(uid)
-               if not entry:
-                       raise tornado.web.HTTPError(404)
+               with self.db.transaction():
+                       paste = self.backend.nopaste.get(uid)
+                       if not paste:
+                               raise tornado.web.HTTPError(404)
+
+                       # This has received a view
+                       paste.viewed()
 
                # Set the filename
-               self.set_header("Content-Disposition", "inline; filename=\"%s\"" % entry.subject)
+               self.set_header("Content-Disposition", "inline; filename=\"%s\"" % paste.subject)
 
                # Set mimetype
-               self.set_header("Content-Type", entry.mimetype)
-
-               # Set expiry headers
-               self.set_expires(3600)
+               self.set_header("Content-Type", paste.mimetype)
 
                # Send content
-               content = self.backend.nopaste.get_content(entry.uuid)
-               self.finish(content)
+               self.finish(paste.blob)
 
 
-class ViewHandler(auth.CacheMixin, base.BaseHandler):
+class ViewHandler(base.AnalyticsMixin, base.BaseHandler):
        def get(self, uid):
-               entry = self.backend.nopaste.get(uid)
-               if not entry:
-                       raise tornado.web.HTTPError(404)
-
-               # Fetch the content if the output should be displayed
-               if entry.mimetype.startswith("text/"):
-                       content = self.backend.nopaste.get_content(entry.uuid)
-               else:
-                       content = None
+               with self.db.transaction():
+                       paste = self.backend.nopaste.get(uid)
+                       if not paste:
+                               raise tornado.web.HTTPError(404)
 
-               # Set expiry headers
-               self.set_expires(3600)
+                       # This has received a view
+                       paste.viewed()
 
-               self.render("nopaste/view.html", entry=entry, content=content)
+               self.render("nopaste/view.html", paste=paste)
 
 
 class CodeModule(ui_modules.UIModule):
diff --git a/src/web/people.py b/src/web/people.py
deleted file mode 100644 (file)
index 8f6a858..0000000
+++ /dev/null
@@ -1,516 +0,0 @@
-#!/usr/bin/python
-
-import PIL
-import datetime
-import imghdr
-import io
-import ldap
-import logging
-import os.path
-import tornado.web
-import urllib.parse
-
-from .. import countries
-
-from . import auth
-from . import base
-from . import ui_modules
-
-COLOUR_LIGHT = (237,232,232)
-COLOUR_DARK  = (49,53,60)
-
-class IndexHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               hints = []
-
-               # Suggest uploading an avatar if this user does not have one
-               if not self.current_user.has_avatar():
-                       hints.append("avatar")
-
-               # Suggest adding a description
-               if not self.current_user.description:
-                       hints.append("description")
-
-               self.render("people/index.html", hints=hints)
-
-
-class AvatarHandler(base.BaseHandler):
-       def get(self, uid):
-               # Get the desired size of the avatar file
-               size = self.get_argument("size", None)
-
-               try:
-                       size = int(size)
-               except (TypeError, ValueError):
-                       size = None
-
-               logging.debug("Querying for avatar of %s" % uid)
-
-               # Fetch user account
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Allow downstream to cache this for a year
-               self.set_expires(31536000)
-
-               # Resize avatar
-               avatar = account.get_avatar(size)
-
-               # If there is no avatar, we serve a default image
-               if not avatar:
-                       logging.debug("No avatar uploaded for %s" % account)
-
-                       # Generate a random avatar with only one letter
-                       avatar = self._get_avatar(account, size=size)
-
-               # Guess content type
-               type = imghdr.what(None, avatar)
-
-               # Set headers about content
-               self.set_header("Content-Disposition", "inline; filename=\"%s.%s\"" % (account.uid, type))
-               self.set_header("Content-Type", "image/%s" % type)
-
-               # Deliver payload
-               self.finish(avatar)
-
-       def _get_avatar(self, account, size=None, **args):
-               letter = ("%s" % account)[0].upper()
-
-               if size is None:
-                       size = 256
-
-               # The generated avatar cannot be larger than 1024px
-               if size >= 2048:
-                       size = 2048
-
-               # Cache key
-               key = "avatar:letter:%s:%s" % (letter, size)
-
-               # Fetch avatar from the cache
-               avatar = self.memcached.get(key)
-               if not avatar:
-                       avatar = self._make_avatar(letter, size=size, **args)
-
-                       # Cache for forever
-                       self.memcached.set(key, avatar)
-
-               return avatar
-
-       def _make_avatar(self, letter, format="PNG", size=None, **args):
-               # Generate an image of the correct size
-               image = PIL.Image.new("RGBA", (size, size), COLOUR_LIGHT)
-
-               # Have a canvas
-               draw = PIL.ImageDraw.Draw(image)
-
-               # Load font
-               font = PIL.ImageFont.truetype(os.path.join(
-                       self.application.settings.get("static_path", ""),
-                       "fonts/Mukta-Bold.ttf"
-               ), size, encoding="unic")
-
-               # Determine size of the printed letter
-               w, h = font.getsize(letter)
-
-               # Mukta seems to be very broken and the height needs to be corrected
-               h //= 0.7
-
-               # Draw the letter in the center
-               draw.text(((size - w) / 2, (size - h) / 2), letter,
-                       font=font, fill=COLOUR_DARK)
-
-               with io.BytesIO() as f:
-                       # If writing out the image does not work with optimization,
-                       # we try to write it out without any optimization.
-                       try:
-                               image.save(f, format, optimize=True, **args)
-                       except:
-                               image.save(f, format, **args)
-
-                       return f.getvalue()
-
-
-class CallsHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, uid, date=None):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               if date:
-                       try:
-                               date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
-                       except ValueError:
-                               raise tornado.web.HTTPError(400, "Invalid date: %s" % date)
-               else:
-                       date = datetime.date.today()
-
-               self.render("people/calls.html", account=account, date=date)
-
-
-class CallHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, uid, uuid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               call = self.backend.talk.freeswitch.get_call_by_uuid(uuid)
-               if not call:
-                       raise tornado.web.HTTPError(404, "Could not find call %s" % uuid)
-
-               # XXX limit
-
-               self.render("people/call.html", account=account, call=call)
-
-
-class ConferencesHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               self.render("people/conferences.html", conferences=self.backend.talk.conferences)
-
-
-class GroupsHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               # Only staff can see other groups
-               if not self.current_user.is_staff():
-                       raise tornado.web.HTTPError(403)
-
-               self.render("people/groups.html")
-
-
-class GroupHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, gid):
-               # Only staff can see other groups
-               if not self.current_user.is_staff():
-                       raise tornado.web.HTTPError(403)
-
-               # Fetch group
-               group = self.backend.groups.get_by_gid(gid)
-               if not group:
-                       raise tornado.web.HTTPError(404, "Could not find group %s" % gid)
-
-               self.render("people/group.html", group=group)
-
-
-class SearchHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               q = self.get_argument("q")
-
-               # Perform the search
-               accounts = self.backend.accounts.search(q)
-
-               # Redirect when only one result was found
-               if len(accounts) == 1:
-                       self.redirect("/users/%s" % accounts[0].uid)
-                       return
-
-               self.render("people/search.html", q=q, accounts=accounts)
-
-
-class StatsHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               # Only staff can see stats
-               if not self.current_user.is_staff():
-                       raise tornado.web.HTTPError(403)
-
-               self.render("people/stats.html")
-
-
-class SubscribeHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def post(self):
-               # Give consent
-               self.current_user.consents_to_promotional_emails = True
-
-               self.render("people/subscribed.html")
-
-
-class UnsubscribeHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               if self.current_user.consents_to_promotional_emails:
-                       return self.render("people/unsubscribe.html")
-
-               self.render("people/unsubscribed.html")
-
-       @tornado.web.authenticated
-       def post(self):
-               # Withdraw consent
-               self.current_user.consents_to_promotional_emails = False
-
-               self.render("people/unsubscribed.html")
-
-
-class SIPHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, uid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               self.render("people/sip.html", account=account)
-
-
-class UsersHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self):
-               # Only staff can see other users
-               if not self.current_user.is_staff():
-                       raise tornado.web.HTTPError(403)
-
-               self.render("people/users.html")
-
-
-class UserHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, uid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               self.render("people/user.html", account=account)
-
-
-class UserEditHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, uid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               self.render("people/user-edit.html", account=account, countries=countries.get_all())
-
-       @tornado.web.authenticated
-       def post(self, uid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               # Unfortunately this cannot be wrapped into a transaction
-               try:
-                       account.first_name   = self.get_argument("first_name")
-                       account.last_name    = self.get_argument("last_name")
-                       account.nickname     = self.get_argument("nickname", None)
-                       account.street       = self.get_argument("street", None)
-                       account.city         = self.get_argument("city", None)
-                       account.postal_code  = self.get_argument("postal_code", None)
-                       account.country_code = self.get_argument("country_code", None)
-                       account.description  = self.get_argument("description", None)
-
-                       # Avatar
-                       try:
-                               filename, data, mimetype = self.get_file("avatar")
-
-                               if not mimetype.startswith("image/"):
-                                       raise tornado.web.HTTPError(400, "Avatar is not an image file: %s" % mimetype)
-
-                               account.upload_avatar(data)
-                       except TypeError:
-                               pass
-
-                       # Email
-                       account.mail_routing_address = self.get_argument("mail_routing_address", None)
-
-                       # Telephone
-                       account.phone_numbers = self.get_argument("phone_numbers", "").splitlines()
-                       account.sip_routing_address = self.get_argument("sip_routing_address", None)
-               except ldap.STRONG_AUTH_REQUIRED as e:
-                       raise tornado.web.HTTPError(403, "%s" % e) from e
-
-               # Redirect back to user page
-               self.redirect("/users/%s" % account.uid)
-
-
-class UserPasswdHandler(auth.CacheMixin, base.BaseHandler):
-       @tornado.web.authenticated
-       def get(self, uid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               self.render("people/passwd.html", account=account)
-
-       @tornado.web.authenticated
-       def post(self, uid):
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
-
-               # Check for permissions
-               if not account.can_be_managed_by(self.current_user):
-                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
-
-               # Get current password
-               password = self.get_argument("password")
-
-               # Get new password
-               password1 = self.get_argument("password1")
-               password2 = self.get_argument("password2")
-
-               # Passwords must match
-               if not password1 == password2:
-                       raise tornado.web.HTTPError(400, "Passwords do not match")
-
-               # XXX Check password complexity
-
-               # Check if old password matches
-               if not account.check_password(password):
-                       raise tornado.web.HTTPError(403, "Incorrect password for %s" % account)
-
-               # Save new password
-               account.passwd(password1)
-
-               # Redirect back to user's page
-               self.redirect("/users/%s" % account.uid)
-
-
-class SSODiscourse(auth.CacheMixin, base.BaseHandler):
-       @base.ratelimit(minutes=24*60, requests=100)
-       @tornado.web.authenticated
-       def get(self):
-               # Fetch Discourse's parameters
-               sso = self.get_argument("sso")
-               sig = self.get_argument("sig")
-
-               # Decode payload
-               try:
-                       params = self.accounts.decode_discourse_payload(sso, sig)
-
-               # Raise bad request if the signature is invalid
-               except ValueError:
-                       raise tornado.web.HTTPError(400)
-
-               # Redirect back if user is already logged in
-               args = {
-                       "nonce" : params.get("nonce"),
-                       "external_id" : self.current_user.uid,
-
-                       # Pass email address
-                       "email" : self.current_user.email,
-                       "require_activation" : "false",
-
-                       # More details about the user
-                       "username" : self.current_user.uid,
-                       "name" : "%s" % self.current_user,
-                       "bio" : self.current_user.description or "",
-
-                       # Avatar
-                       "avatar_url" : self.current_user.avatar_url(),
-                       "avatar_force_update" : "true",
-
-                       # Send a welcome message
-                       "suppress_welcome_message" : "false",
-
-                       # Group memberships
-                       "admin" : "true" if self.current_user.is_admin() else "false",
-                       "moderator" : "true" if self.current_user.is_moderator() else "false",
-               }
-
-               # Format payload and sign it
-               payload = self.accounts.encode_discourse_payload(**args)
-               signature = self.accounts.sign_discourse_payload(payload)
-
-               qs = urllib.parse.urlencode({
-                       "sso" : payload,
-                       "sig" : signature,
-               })
-
-               # Redirect user
-               self.redirect("%s?%s" % (params.get("return_sso_url"), qs))
-
-
-class NewAccountsModule(ui_modules.UIModule):
-       def render(self, days=14):
-               t = datetime.datetime.utcnow() - datetime.timedelta(days=days)
-
-               # Fetch all accounts created after t
-               accounts = self.backend.accounts.get_created_after(t)
-
-               accounts.sort(key=lambda a: a.created_at, reverse=True)
-
-               return self.render_string("people/modules/accounts-new.html",
-                       accounts=accounts, t=t)
-
-
-class AccountsListModule(ui_modules.UIModule):
-       def render(self, accounts=None):
-               if accounts is None:
-                       accounts = self.backend.accounts
-
-               return self.render_string("people/modules/accounts-list.html", accounts=accounts)
-
-
-class AgentModule(ui_modules.UIModule):
-       def render(self, account):
-               return self.render_string("people/modules/agent.html", account=account)
-
-
-class CDRModule(ui_modules.UIModule):
-       def render(self, account, date=None, limit=None):
-               cdr = account.get_cdr(date=date, limit=limit)
-
-               return self.render_string("people/modules/cdr.html",
-                       account=account, cdr=list(cdr))
-
-
-class ChannelsModule(ui_modules.UIModule):
-       def render(self, account):
-               return self.render_string("people/modules/channels.html",
-                       account=account, channels=account.sip_channels)
-
-
-class MOSModule(ui_modules.UIModule):
-       def render(self, call):
-               return self.render_string("people/modules/mos.html", call=call)
-
-
-class PasswordModule(ui_modules.UIModule):
-       def render(self, account=None):
-               return self.render_string("people/modules/password.html", account=account)
-
-       def javascript_files(self):
-               return "js/zxcvbn.js"
-
-       def embedded_javascript(self):
-               return self.render_string("people/modules/password.js")
-
-
-class RegistrationsModule(ui_modules.UIModule):
-       def render(self, account):
-               return self.render_string("people/modules/registrations.html", account=account)
-
-
-class SIPStatusModule(ui_modules.UIModule):
-       def render(self, account):
-               return self.render_string("people/modules/sip-status.html", account=account)
index 43cfd9a6b1a3df13bf3bb3e18239a061c31aad8a..a41ab371fc1aef56dd1b71b2ca2ef7af696fd1ef 100644 (file)
@@ -10,9 +10,9 @@ class UIModule(tornado.web.UIModule):
                return self.handler.backend
 
 
-class ChristmasBannerModule(UIModule):
-       def render(self):
-               return self.render_string("modules/christmas-banner.html")
+class IPFireLogoModule(UIModule):
+       def render(self, suffix=None):
+               return self.render_string("modules/ipfire-logo.html", suffix=suffix)
 
 
 class MarkdownModule(UIModule):
diff --git a/src/web/users.py b/src/web/users.py
new file mode 100644 (file)
index 0000000..bc4e57f
--- /dev/null
@@ -0,0 +1,348 @@
+#!/usr/bin/python
+
+import PIL
+import io
+import ldap
+import logging
+import os.path
+import tornado.web
+
+from .. import countries
+from .. import util
+
+from . import base
+from . import ui_modules
+
+COLOUR_LIGHT = (237,232,232)
+COLOUR_DARK  = (49,53,60)
+
+class IndexHandler(base.AnalyticsMixin, base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               results = None
+
+               # Query Term
+               q = self.get_argument("q", None)
+
+               # Peform search
+               if q:
+                       results = self.backend.accounts.search(q)
+
+               self.render("users/index.html", q=q, results=results)
+
+
+class ShowHandler(base.AnalyticsMixin, base.BaseHandler):
+       @tornado.web.authenticated
+       async def get(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Fetch SIP channels
+               sip_channels = await account.get_sip_channels()
+
+               self.render("users/show.html", account=account, sip_channels=sip_channels)
+
+
+class AvatarHandler(base.BaseHandler):
+       async def get(self, uid):
+               if self.browser_accepts("image/webp"):
+                       format = "WEBP"
+               else:
+                       format = "JPEG"
+
+               # Get the desired size of the avatar file
+               size = self.get_argument("size", None)
+
+               try:
+                       size = int(size)
+               except (TypeError, ValueError):
+                       size = None
+
+               logging.debug("Querying for avatar of %s" % uid)
+
+               # Fetch user account
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Allow downstream to cache this for a year
+               self.set_expires(31536000)
+
+               # Resize avatar
+               avatar = await account.get_avatar(size, format=format)
+
+               # If there is no avatar, we serve a default image
+               if not avatar:
+                       logging.debug("No avatar uploaded for %s" % account)
+
+                       # Generate a random avatar with only one letter
+                       avatar = await self._get_avatar(account, size=size, format=format)
+
+               # Deliver the data
+               self._deliver_file(avatar, prefix=account.uid)
+
+       async def _get_avatar(self, account, size=None, format=None, **args):
+               letters = account.initials
+
+               if size is None:
+                       size = 256
+
+               # The generated avatar cannot be larger than 1024px
+               if size >= 2048:
+                       size = 2048
+
+               # Cache key
+               cache_key = "avatar:letter:%s:%s:%s" % ("".join(letters), format or "N/A", size)
+
+               # Try to fetch the data from the cache
+               async with await self.backend.cache.pipeline() as p:
+                       # Fetch the key
+                       await p.get(cache_key)
+
+                       # Reset the TTL
+                       await p.expire(cache_key, 86400)
+
+                       # Execute the pipeline
+                       avatar, _ = await p.execute()
+
+               if not avatar:
+                       avatar = self._make_avatar(letters, size=size, **args)
+
+                       # Cache for forever
+                       await self.backend.cache.set(cache_key, avatar, 86400)
+
+               return avatar
+
+       def _make_avatar(self, letters, format="PNG", size=None, **args):
+               # Load font
+               font = PIL.ImageFont.truetype(os.path.join(
+                       self.application.settings.get("static_path", ""),
+                       "fonts/Prompt-Bold.ttf"
+               ), size, encoding="unic")
+
+               width = 0
+               height = 0
+
+               for letter in letters:
+                       # Determine size of the printed letter
+                       w, h = font.getsize(letter)
+
+                       # Store the maximum height
+                       height = max(h, height)
+
+                       # Add up the width
+                       width += w
+
+               # Add the margin
+               width  = int(width  * 1.75)
+               height = int(height * 1.75)
+
+               # Generate an image of the correct size
+               image = PIL.Image.new("RGBA", (width, height), COLOUR_LIGHT)
+
+               # Have a canvas
+               draw = PIL.ImageDraw.Draw(image)
+
+               # Draw the letter in the center
+               draw.text((width // 2, height // 2), "".join(letters),
+                       font=font, anchor="mm", fill=COLOUR_DARK)
+
+               return util.generate_thumbnail(image, size, square=True, format=format)
+
+
+class EditHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_managed_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
+
+               self.render("users/edit.html", account=account, countries=countries.get_all())
+
+       @tornado.web.authenticated
+       async def post(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_managed_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
+
+               # Unfortunately this cannot be wrapped into a transaction
+               try:
+                       account.first_name   = self.get_argument("first_name")
+                       account.last_name    = self.get_argument("last_name")
+                       account.nickname     = self.get_argument("nickname", None)
+                       account.street       = self.get_argument("street", None)
+                       account.city         = self.get_argument("city", None)
+                       account.postal_code  = self.get_argument("postal_code", None)
+                       account.country_code = self.get_argument("country_code")
+                       account.description  = self.get_argument("description", None)
+
+                       # Avatar
+                       try:
+                               filename, data, mimetype = self.get_file("avatar")
+
+                               if not mimetype.startswith("image/"):
+                                       raise tornado.web.HTTPError(400, "Avatar is not an image file: %s" % mimetype)
+
+                               await account.upload_avatar(data)
+                       except TypeError:
+                               pass
+
+                       # Email
+                       account.mail_routing_address = self.get_argument("mail_routing_address", None)
+
+                       # Telephone
+                       account.phone_numbers = self.get_argument("phone_numbers", "").splitlines()
+                       account.sip_routing_address = self.get_argument("sip_routing_address", None)
+               except ldap.STRONG_AUTH_REQUIRED as e:
+                       raise tornado.web.HTTPError(403, "%s" % e) from e
+
+               # Redirect back to user page
+               self.redirect("/users/%s" % account.uid)
+
+
+class DeleteHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_deleted_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot delete %s" % (self.current_user, account))
+
+               self.render("users/delete.html", account=account)
+
+       @tornado.web.authenticated
+       async def post(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_deleted_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot delete %s" % (self.current_user, account))
+
+               # Delete!
+               with self.db.transaction():
+                       await account.delete(self.current_user)
+
+               self.render("users/deleted.html", account=account)
+
+
+class PasswdHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_managed_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
+
+               self.render("users/passwd.html", account=account)
+
+       @tornado.web.authenticated
+       def post(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_managed_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
+
+               # Get current password
+               password = self.get_argument("password")
+
+               # Get new password
+               password1 = self.get_argument("password1")
+               password2 = self.get_argument("password2")
+
+               # Passwords must match
+               if not password1 == password2:
+                       raise tornado.web.HTTPError(400, "Passwords do not match")
+
+               # XXX Check password complexity
+
+               # Check if old password matches
+               if not account.check_password(password):
+                       raise tornado.web.HTTPError(403, "Incorrect password for %s" % account)
+
+               # Save new password
+               account.passwd(password1)
+
+               # Redirect back to user's page
+               self.redirect("/users/%s" % account.uid)
+
+
+class GroupIndexHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               # Only staff can see other groups
+               if not self.current_user.is_staff():
+                       raise tornado.web.HTTPError(403)
+
+               self.render("users/groups/index.html")
+
+
+class GroupShowHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, gid):
+               # Only staff can see other groups
+               if not self.current_user.is_staff():
+                       raise tornado.web.HTTPError(403)
+
+               # Fetch group
+               group = self.backend.groups.get_by_gid(gid)
+               if not group:
+                       raise tornado.web.HTTPError(404, "Could not find group %s" % gid)
+
+               self.render("users/groups/show.html", group=group)
+
+
+class SubscribeHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               self.render("users/subscribe.html")
+
+       @tornado.web.authenticated
+       def post(self):
+               # Give consent
+               with self.db.transaction():
+                       self.current_user.consents_to_promotional_emails = True
+
+               self.render("users/subscribed.html")
+
+
+class UnsubscribeHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self):
+               if self.current_user.consents_to_promotional_emails:
+                       return self.render("users/unsubscribe.html")
+
+               self.render("users/unsubscribed.html")
+
+       @tornado.web.authenticated
+       def post(self):
+               # Withdraw consent
+               with self.db.transaction():
+                       self.current_user.consents_to_promotional_emails = False
+
+               self.render("users/unsubscribed.html")
+
+
+class ListModule(ui_modules.UIModule):
+       def render(self, accounts, show_created_at=False):
+               return self.render_string("users/modules/list.html", accounts=accounts,
+                       show_created_at=show_created_at)
diff --git a/src/web/voip.py b/src/web/voip.py
new file mode 100644 (file)
index 0000000..ed8d339
--- /dev/null
@@ -0,0 +1,48 @@
+#!/usr/bin/python3
+
+import asyncio
+import tornado.web
+
+from . import base
+from . import ui_modules
+
+class IndexHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       async def get(self):
+               # Only staff can view this page
+               if not self.current_user.is_staff():
+                       raise tornado.web.HTTPError(403)
+
+               # Fetch everything
+               registrations, outbound_registrations, queues, conferences, = \
+                       await asyncio.gather(
+                               self.backend.asterisk.get_registrations(),
+                               self.backend.asterisk.get_outbound_registrations(),
+                               self.backend.asterisk.get_queues(),
+                               self.backend.asterisk.get_conferences(),
+                       )
+
+               self.render("voip/index.html", registrations=registrations, queues=queues,
+                       outbound_registrations=outbound_registrations, conferences=conferences)
+
+
+class OutboundRegistrationsModule(ui_modules.UIModule):
+       def render(self, registrations):
+               return self.render_string("voip/modules/outbound-registrations.html",
+                       registrations=registrations)
+
+
+class RegistrationsModule(ui_modules.UIModule):
+       def render(self, registrations):
+               return self.render_string("voip/modules/registrations.html",
+                       registrations=registrations)
+
+
+class QueuesModule(ui_modules.UIModule):
+       def render(self, queues):
+               return self.render_string("voip/modules/queues.html", queues=queues)
+
+
+class ConferencesModule(ui_modules.UIModule):
+       def render(self, conferences):
+               return self.render_string("voip/modules/conferences.html", conferences=conferences)