From: Michael Tremer Date: Fri, 26 Apr 2024 14:35:48 +0000 (+0000) Subject: wiki: Only match usernames when a word starts with @ X-Git-Url: http://git.ipfire.org/?p=ipfire.org.git;a=commitdiff_plain;h=HEAD;hp=8e246bfa21a1b2103b1bd620fb101ec48896d5e8 wiki: Only match usernames when a word starts with @ Signed-off-by: Michael Tremer --- diff --git a/.gitignore b/.gitignore index 777242a8..6868503f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules index 2933dfd5..eb73fcb7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 index c59ddb03..00000000 --- a/Dockerfile +++ /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"] diff --git a/Makefile.am b/Makefile.am index e59e9c7a..95220c2e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -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)" . diff --git a/configure.ac b/configure.ac index b71677ca..17ef1294 100644 --- a/configure.ac +++ b/configure.ac @@ -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 index 00000000..55eaa803 --- /dev/null +++ b/m4/.gitignore @@ -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 index 00000000..f0f873d1 --- /dev/null +++ b/m4/ax_python_module.m4 @@ -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 index 00000000..78a41788 --- /dev/null +++ b/migrate.sql @@ -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 index 5158c37c..00000000 --- a/requirements.txt +++ /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 diff --git a/src/backend/accounts.py b/src/backend/accounts.py index a3c8297b..397f217c 100644 --- a/src/backend/accounts.py +++ b/src/backend/accounts.py @@ -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 index 00000000..34da983a --- /dev/null +++ b/src/backend/analytics.py @@ -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 index 00000000..f182776c --- /dev/null +++ b/src/backend/asterisk.py @@ -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)) diff --git a/src/backend/base.py b/src/backend/base.py index 00497a29..a3daa358 100644 --- a/src/backend/base.py +++ b/src/backend/base.py @@ -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() diff --git a/src/backend/blog.py b/src/backend/blog.py index b92b0276..45621b9e 100644 --- a/src/backend/blog.py +++ b/src/backend/blog.py @@ -3,14 +3,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 +28,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 +47,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 +54,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) @@ -130,9 +115,10 @@ class Blog(misc.Object): def _render_text(self, text, lang="markdown"): if lang == "markdown": - return markdown.markdown(text, + renderer = wiki.Markdown( + self.backend, extensions=[ - PrettyLinksExtension(), + wiki.PrettyLinksExtension(), "codehilite", "fenced_code", "footnotes", @@ -140,19 +126,23 @@ class Blog(misc.Object): "sane_lists", "tables", "toc", - ]) + ], + ) + + return renderer.convert(text) 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 +341,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 +406,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 index 00000000..3e494ef0 --- /dev/null +++ b/src/backend/bugzilla.py @@ -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 index 00000000..54bb5271 --- /dev/null +++ b/src/backend/cache.py @@ -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) diff --git a/src/backend/database.py b/src/backend/database.py index f79cf128..d45031ca 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -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 diff --git a/src/backend/fireinfo.py b/src/backend/fireinfo.py index d7f78456..702739bd 100644 --- a/src/backend/fireinfo.py +++ b/src/backend/fireinfo.py @@ -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 index 00000000..c8150ed7 --- /dev/null +++ b/src/backend/httpclient.py @@ -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 . # +# # +############################################################################### + +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) diff --git a/src/backend/hwdata.py b/src/backend/hwdata.py index afcf4e10..911ee050 100644 --- a/src/backend/hwdata.py +++ b/src/backend/hwdata.py @@ -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): diff --git a/src/backend/iuse.py b/src/backend/iuse.py index 99c9d042..be28b790 100644 --- a/src/backend/iuse.py +++ b/src/backend/iuse.py @@ -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 index 00000000..d2adff40 --- /dev/null +++ b/src/backend/lists.py @@ -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 "" % 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 index 56a8cc80..00000000 --- a/src/backend/memcached.py +++ /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(" ", "-") diff --git a/src/backend/messages.py b/src/backend/messages.py index 817a6f10..53f62baa 100644 --- a/src/backend/messages.py +++ b/src/backend/messages.py @@ -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 diff --git a/src/backend/misc.py b/src/backend/misc.py index f2f2e753..12474435 100644 --- a/src/backend/misc.py +++ b/src/backend/misc.py @@ -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") diff --git a/src/backend/nopaste.py b/src/backend/nopaste.py index db143d5b..43f5552a 100644 --- a/src/backend/nopaste.py +++ b/src/backend/nopaste.py @@ -1,59 +1,261 @@ #!/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, + ) diff --git a/src/backend/ratelimit.py b/src/backend/ratelimit.py index ec99cc51..f4b2b4c1 100644 --- a/src/backend/ratelimit.py +++ b/src/backend/ratelimit.py @@ -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() diff --git a/src/backend/releases.py b/src/backend/releases.py index a0a8cf93..bed051d0 100644 --- a/src/backend/releases.py +++ b/src/backend/releases.py @@ -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 index 4215e7d5..00000000 --- a/src/backend/talk.py +++ /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 index 00000000..271d4be3 --- /dev/null +++ b/src/backend/toots.py @@ -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 index e5fc5422..00000000 --- a/src/backend/tweets.py +++ /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) diff --git a/src/backend/util.py b/src/backend/util.py index 2252818e..baf6dcb3 100644 --- a/src/backend/util.py +++ b/src/backend/util.py @@ -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: diff --git a/src/backend/wiki.py b/src/backend/wiki.py index c8a8ff8d..8edfb4e6 100644 --- a/src/backend/wiki.py +++ b/src/backend/wiki.py @@ -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"(.*?)") + _links = re.compile(r"(.*?)") # Images - images = re.compile(r"") + _images = re.compile(r"") - 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 """%s""" % (url, text or url) + + # External Links + for schema in self.schemas: + if url.startswith(schema): + return """%s""" % \ + (url, text or url) + # Emails if "@" in url: # Strip mailto: @@ -595,26 +837,50 @@ class WikiRenderer(misc.Object): return """%s""" % \ (url, text or url) - # External Links - for schema in self.schemas: - if url.startswith(schema): - return """%s""" % \ - (url, text or url) - # Everything else must be an internal link path = self.backend.wiki.make_path(self.path, url) - return """%s""" % \ + return """%s""" % \ (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 """
%s -
%s
- """ % (url, alt_text, caption or "") + # Compute a hash over the URL + h = hashlib.new("md5") + h.update(url.encode()) + id = h.hexdigest() + + html = """ +
+
+ + + +
+
+ """ # 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 "" % (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 """
%s -
%s
- """ % (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
- 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"\b@(\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) diff --git a/src/backend/zeiterfassung.py b/src/backend/zeiterfassung.py index c999a0ca..c156a0a3 100644 --- a/src/backend/zeiterfassung.py +++ b/src/backend/zeiterfassung.py @@ -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 index 7a6da5e3..00000000 --- a/src/bootstrap +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a6da5e3e7ad7c749dde806546a35d4d4259d965 diff --git a/src/crontab/ipfire b/src/crontab/ipfire index 4122f187..e93d81da 100644 --- a/src/crontab/ipfire +++ b/src/crontab/ipfire @@ -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 index 00000000..d8c03cdc --- /dev/null +++ b/src/error-pages/.gitignore @@ -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 index 00000000..c0881ad6 --- /dev/null +++ b/src/error-pages/500.markdown @@ -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 index 00000000..61dd04c8 --- /dev/null +++ b/src/error-pages/502.markdown @@ -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 index 00000000..b7af89e6 --- /dev/null +++ b/src/error-pages/503.markdown @@ -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 index 00000000..b4213eda --- /dev/null +++ b/src/error-pages/504.markdown @@ -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 index 00000000..1bccf29a --- /dev/null +++ b/src/error-pages/Gemfile @@ -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 index 00000000..0dd36812 --- /dev/null +++ b/src/error-pages/Gemfile.lock @@ -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 index 00000000..2fb6eae8 --- /dev/null +++ b/src/error-pages/_config.yml @@ -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 index 00000000..45f9d382 --- /dev/null +++ b/src/error-pages/_layouts/error.html @@ -0,0 +1,49 @@ +HTTP/1.1 {{ page.error-code }} {{ page.error-description }} +Cache-Control: no-cache +Connection: close +Content-Type: text/html + + + + + + + + + {{ site.title }} - {% if page.title %}{{ page.title }}{% endif %} + + + + + + + + + +
+
+
+

Oops, Something Went Wrong

+ + {% if page.error-description %} +

{{ page.error-code }} ‐ {{ page.error-description }}

+ {% endif %} + +
+ {{ content }} +
+
+
+
+ + diff --git a/src/error-pages/assets/fonts/Prompt-Black.ttf b/src/error-pages/assets/fonts/Prompt-Black.ttf new file mode 100644 index 00000000..624fe85b 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 index 00000000..f08773f2 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 index 00000000..49ebb167 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 index 00000000..da28bfcf 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 index 00000000..6cdd89b4 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 index 00000000..20f5632d 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 index 00000000..a9effd7f 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 index 00000000..5f04318d 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 index 00000000..ded930f2 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 index 00000000..bd931f5f 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 index 00000000..6c076ed2 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 index 00000000..343cc3b1 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 index 00000000..7f946761 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 index 00000000..a2899f6c 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 index 00000000..4ca72e3a 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 index 00000000..0f605a95 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 index 00000000..05457347 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 index 00000000..6b182c19 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 index 00000000..0c5d1eb5 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 index 00000000..90e58c91 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 index 00000000..8bb339a5 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 index 00000000..f43243ef 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 index 00000000..adf72c9f 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 index 00000000..53b5d920 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 index 00000000..7b87fdb6 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 index 00000000..47d1bd86 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 index 00000000..2a736dbb 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 index 00000000..03c10acc 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 index 00000000..011a0507 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 index 00000000..0404c799 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 index 00000000..3bec3f77 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 index 00000000..d41593b6 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 index 00000000..a3b80b44 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 index 00000000..77826247 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 index 00000000..f64319a5 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 index 00000000..edfaa873 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 index 00000000..19e9c7a2 --- /dev/null +++ b/src/error-pages/assets/main.sass @@ -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" diff --git a/src/font-awesome b/src/font-awesome index e8bec4b3..f0c25837 160000 --- a/src/font-awesome +++ b/src/font-awesome @@ -1 +1 @@ -Subproject commit e8bec4b362ca23832aed6087be398c998da27cf4 +Subproject commit f0c25837a3fe0e03783b939559e088abcbfb3c4b diff --git a/src/fonts b/src/fonts deleted file mode 160000 index 3ca591da..00000000 --- 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 index 00000000..7ebe058b --- /dev/null +++ b/src/sass/_code-highlighting.sass @@ -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 index 00000000..a7dc361d --- /dev/null +++ b/src/sass/_fonts.sass @@ -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 index 00000000..31f38c0c --- /dev/null +++ b/src/sass/_icons.sass @@ -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 index 00000000..533cbf9c --- /dev/null +++ b/src/sass/_variables.sass @@ -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 index 00000000..67ec60ba --- /dev/null +++ b/src/sass/listing.sass @@ -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 index 00000000..ffcef4c5 --- /dev/null +++ b/src/sass/main.sass @@ -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; diff --git a/src/scripts/ipfire.org-webapp.in b/src/scripts/ipfire.org-webapp.in index 77271cf8..6c57ecf2 100755 --- a/src/scripts/ipfire.org-webapp.in +++ b/src/scripts/ipfire.org-webapp.in @@ -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()) diff --git a/src/scripts/ipfire.org.in b/src/scripts/ipfire.org.in index f74e0816..fa817061 100644 --- a/src/scripts/ipfire.org.in +++ b/src/scripts/ipfire.org.in @@ -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 index feaf754a..00000000 --- a/src/scss/_code-highlighting.scss +++ /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 index 69a0b10e..00000000 --- a/src/scss/_fonts.scss +++ /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 index 4fc42067..00000000 --- a/src/scss/_icons.scss +++ /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 index 772a14d5..00000000 --- a/src/scss/_variables.scss +++ /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 index c40a5b57..00000000 --- a/src/scss/listing.scss +++ /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 index ba8d6ce1..00000000 --- a/src/scss/style.scss +++ /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 index 00000000..a214c1e5 --- /dev/null +++ b/src/static/fonts/prompt/DESCRIPTION.en_us.html @@ -0,0 +1,15 @@ +

+Prompt in Thai means “ready,” 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. +

+

+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. +

+

+The Prompt project is led by Cadson Demak, a type foundry in Thailand. +To contribute, see github.com/cadsondemak/prompt +

\ 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 index 00000000..a5d90fd2 --- /dev/null +++ b/src/static/fonts/prompt/METADATA.pb @@ -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 index 00000000..c760a319 --- /dev/null +++ b/src/static/fonts/prompt/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2015, Cadson Demak (info@cadsondemak.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/static/fonts/prompt/Prompt-Black.ttf b/src/static/fonts/prompt/Prompt-Black.ttf new file mode 100644 index 00000000..624fe85b 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 index 00000000..49ebb167 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 index 00000000..6cdd89b4 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 index 00000000..a9effd7f 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 index 00000000..ded930f2 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 index 00000000..6c076ed2 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 index 00000000..7f946761 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 index 00000000..4ca72e3a 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 index 00000000..05457347 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 index 00000000..0c5d1eb5 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 index 00000000..8bb339a5 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 index 00000000..adf72c9f 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 index 00000000..7b87fdb6 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 index 00000000..2a736dbb 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 index 00000000..011a0507 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 index 00000000..3bec3f77 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 index 00000000..a3b80b44 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 index 00000000..f64319a5 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 index 00000000..d4220690 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 index 00000000..214e1414 --- /dev/null +++ b/src/static/img/downloads/cloud/aws.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + diff --git a/src/static/img/downloads/cloud/exoscale.svg b/src/static/img/downloads/cloud/exoscale.svg new file mode 100644 index 00000000..1a353271 --- /dev/null +++ b/src/static/img/downloads/cloud/exoscale.svg @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/src/static/img/downloads/cloud/hetzner.svg b/src/static/img/downloads/cloud/hetzner.svg new file mode 100644 index 00000000..97bf32c4 --- /dev/null +++ b/src/static/img/downloads/cloud/hetzner.svg @@ -0,0 +1,19 @@ + + + + + + Element 1 + + + + + + + + + + + diff --git a/src/static/img/fdroid-logo.svg b/src/static/img/fdroid-logo.svg new file mode 100644 index 00000000..ca7cc29e --- /dev/null +++ b/src/static/img/fdroid-logo.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 index 00000000..e858548b --- /dev/null +++ b/src/static/img/kyberio-logo.svg @@ -0,0 +1 @@ + \ 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 index 4d9b3a25..00000000 --- a/src/static/js/jquery-3.3.1.min.js +++ /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&&n0&&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+~]|"+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="",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="";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=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r1?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-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}];u1&&xe(p),u>1&&ve(e.slice(0,u-1).concat({value:" "===e[u-2].type?"*":""})).replace(B,"$1"),n,u0,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="","#"===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="",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;t1?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-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-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&&(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(;s1,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\x20\t\r\n\f]+)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_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-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="",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=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-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\x20\t\r\n\f]*)[^>]*)\/>/gi,Ae=/\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;n1&&"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")},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;r0&&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=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;a1)}});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;o1)},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-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(" +{% end block %} diff --git a/src/templates/auth/login.html b/src/templates/auth/login.html index d359dd43..5e660af6 100644 --- a/src/templates/auth/login.html +++ b/src/templates/auth/login.html @@ -2,53 +2,64 @@ {% block title %}{{ _("Log In") }}{% end block %} -{% block content %} -
-
-
-
-
{{ _("Log In") }}
- - {% if incorrect %} -
- {{ _("You entered an invalid username or password") }} -
- {% end %} - -
- {% raw xsrf_form_html() %} - - {% if next %}{% end %} - -
- -
+{% block container %} +
+
+
+
+
+

{{ _("Log In") }}

-
- -
+
+ + {% raw xsrf_form_html() %} - - + {% if next %}{% end %} -

- - {{ _("Did you forget your password?") }} - -

-
-
+
+
+ +
+
+ +
+
+ +
+
-
{{ _("New to IPFire?") }}
+
+
+ +
+
- - {{ _("Register") }} - + {% if request.host.startswith("www.") %} + + {% end %} + +
+ + {% if request.host.startswith("www.") %} + + {% end %} +
+
+
-
+ {% end block %} diff --git a/src/templates/auth/messages/donation-reminder.html b/src/templates/auth/messages/donation-reminder.html index 22be8b0b..b4d7de54 100644 --- a/src/templates/auth/messages/donation-reminder.html +++ b/src/templates/auth/messages/donation-reminder.html @@ -1,62 +1,56 @@ {% extends "../../messages/base-promo.html" %} {% block content %} -

- {{ _("Hey again, %s,") % account.first_name }} -

- -

- {{ _("IPFire runs on supporters' donations, people like you!") }} -

- -

- {{ _("Why do we need you donations?") }} -

- -
    -
  • {{ _("Your money ensures the longevity and long-term success of this project.") }}
  • -
  • {{ _("It helps us fund developers and extend our skills") }}
  • -
  • {{ _("It will aid us to promote IPFire to more people around the world") }}
  • -
  • {{ _("This funds conferences, where we focus on future projects") }}
  • -
  • {{ _("It pays for our hosting") }}
  • -
- -

- {{ _("All this, as you would understand, requires money. Every single donation counts.") }} -

- -

- {{ _("If you want to see IPFire thrive, we need your support.") }} -

- -

- {{ _("The best way to do this is by setting up a monthly donation which you can do here:") }} -

- - - - - - - - - -

- {{ _("We also have other ways to donate. Please go to https://www.ipfire.org/donate for details.") }} -

- -

- {{ _("Thank you so much for your support,") }} -
{{ _("-Michael") }} -

+ + +

{{ _("Hey again, %s,") % account.first_name }}

+ +

+ {{ _("IPFire runs on supporters' donations, people like you!") }} +

+ +

+ {{ _("Why do we need you donations?") }} +

+ +
    +
  • {{ _("Your money ensures the longevity and long-term success of this project.") }}
  • +
  • {{ _("It helps us fund developers and extend our skills") }}
  • +
  • {{ _("It will aid us to promote IPFire to more people around the world") }}
  • +
  • {{ _("This funds conferences, where we focus on future projects") }}
  • +
  • {{ _("It pays for our hosting") }}
  • +
+ +

+ {{ _("All this, as you would understand, requires money. Every single donation counts.") }} +

+ +

+ {{ _("If you want to see IPFire thrive, we need your support.") }} +

+ +

+ {{ _("The best way to do this is by setting up a monthly donation which you can do here:") }} +

+ + + + + +
+ + {{ _("Donate Now") }} + +
+ +

+ {{ _("We also have other ways to donate. Please go to https://www.ipfire.org/donate for details.") }} +

+ +

+ {{ _("Thank you so much for your support,") }} +
{{ _("-Michael") }} +

+ + {% end block %} diff --git a/src/templates/auth/messages/donation-reminder.txt b/src/templates/auth/messages/donation-reminder.txt index b7e35b90..a4454c42 100644 --- a/src/templates/auth/messages/donation-reminder.txt +++ b/src/templates/auth/messages/donation-reminder.txt @@ -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 index 00000000..145bcc72 --- /dev/null +++ b/src/templates/auth/messages/join.html @@ -0,0 +1,39 @@ +{% extends "../../messages/base.html" %} + +{% block hero %} + + + IPFire + + +{% end block %} + +{% block content %} + + +

{{ _("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:") }} +

+ + + + + +
+ + {{ _("Activate Now") }} + +
+ +

+ {{ _("We look forward to seeing you actively participate in our community.") }} +

+ + +{% end block %} diff --git a/src/templates/auth/messages/join.txt b/src/templates/auth/messages/join.txt new file mode 100644 index 00000000..d6674b4d --- /dev/null +++ b/src/templates/auth/messages/join.txt @@ -0,0 +1,16 @@ +From: IPFire Project +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.") }} diff --git a/src/templates/auth/messages/password-reset.html b/src/templates/auth/messages/password-reset.html index 8309d4ac..a9f1abb7 100644 --- a/src/templates/auth/messages/password-reset.html +++ b/src/templates/auth/messages/password-reset.html @@ -1,29 +1,24 @@ {% extends "../../messages/base.html" %} {% block content %} -

- {{ _("Hello %s!") % account.first_name }} -

+ + +

{{ _("Hello %s!") % account.first_name }}

-

- {{ _("You, or somebody else on your behalf, has requested to change your password.") }} {{ _("If this was not you, please notify a team member.") }} -

+

+ {{ _("You, or somebody else on your behalf, has requested to change your password.") }} + {{ _("If this was not you, please notify a team member.") }} +

- - - - - - - + + + + +
+ + {{ _("Reset Password") }} + +
+ + {% end block %} diff --git a/src/templates/auth/messages/password-reset.txt b/src/templates/auth/messages/password-reset.txt index c6f81bc8..e149add5 100644 --- a/src/templates/auth/messages/password-reset.txt +++ b/src/templates/auth/messages/password-reset.txt @@ -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 }} diff --git a/src/templates/auth/messages/profile-setup-2.html b/src/templates/auth/messages/profile-setup-2.html index 1c9fa0fa..4505f4f2 100644 --- a/src/templates/auth/messages/profile-setup-2.html +++ b/src/templates/auth/messages/profile-setup-2.html @@ -1,43 +1,37 @@ {% extends "../../messages/base.html" %} {% block content %} -

- {{ _("Hello once again, %s,") % account.first_name }} -

+ + +

{{ _("Hello once again, %s,") % account.first_name }}

-

- {{ _("we hope you are enjoying using IPFire.") }} -

+

+ {{ _("we hope you are enjoying using IPFire.") }} +

-

- {{ _("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.") }} -

+

+ {{ _("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.") }} +

-

- {{ _("But we also rely on you donations. Please consider helping us by setting up a small monthly donation:") }} -

+

+ {{ _("But we also rely on you donations. Please consider helping us by setting up a small monthly donation:") }} +

- - - - - - - + + + + +
+ + {{ _("Donate Now") }} + +
-

- {{ _("Thank you, we really appreciate your support,") }} -
{{ _("-Arne")}} -

+

+ {{ _("Thank you, we really appreciate your support,") }} +
{{ _("-Arne")}} +

+ + {% end block %} diff --git a/src/templates/auth/messages/profile-setup.html b/src/templates/auth/messages/profile-setup.html index 3ab3275a..4b17f9c2 100644 --- a/src/templates/auth/messages/profile-setup.html +++ b/src/templates/auth/messages/profile-setup.html @@ -1,47 +1,41 @@ {% extends "../../messages/base.html" %} {% block content %} -

- {{ _("Hello %s!") % account.first_name }} -

+ + +

{{ _("Hello %s!") % account.first_name }}

-

- {{ _("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!") }} -

+

+ {{ _("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!") }} +

-

- {{ _("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.") }} -

+

+ {{ _("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.") }} +

-

- {{ _("I would also like to invite you to join our community at https://community.ipfire.org, if you haven't already done so.") }} -

+

+ {{ _("I would also like to invite you to join our community at https://community.ipfire.org, if you haven't already done so.") }} +

-

- {{ _("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.") }} -

+

+ {{ _("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.") }} +

- - - - - - - + + + + +
+ + {{ _("Donate Now") }} + +
-

- {{ _("Thank you,") }} -
{{ _("-Michael") }} -

+

+ {{ _("Thank you,") }} +
{{ _("-Michael") }} +

+ + {% end block %} diff --git a/src/templates/auth/messages/register.html b/src/templates/auth/messages/register.html deleted file mode 100644 index 75e0429d..00000000 --- a/src/templates/auth/messages/register.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "../../messages/base.html" %} - -{% block content %} -

- {{ _("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.") }} -

- - - - - - - - -{% end block %} diff --git a/src/templates/auth/messages/register.txt b/src/templates/auth/messages/register.txt deleted file mode 100644 index 72e63e5e..00000000 --- a/src/templates/auth/messages/register.txt +++ /dev/null @@ -1,14 +0,0 @@ -From: IPFire Project -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 index 00000000..f96869ad --- /dev/null +++ b/src/templates/auth/modules/password.html @@ -0,0 +1,22 @@ +
+ + +
+ +
+
+ +
+
+ +
+ +
    +
    + +
    + +
    diff --git a/src/templates/people/modules/password.js b/src/templates/auth/modules/password.js similarity index 76% rename from src/templates/people/modules/password.js rename to src/templates/auth/modules/password.js index 8235fc73..562c83ef 100644 --- a/src/templates/people/modules/password.js +++ b/src/templates/auth/modules/password.js @@ -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("
  • {{ _("Passwords do not match") }}
  • "); + } + if (quality.feedback.warning) { - warning.html(quality.feedback.warning); + feedback.append("
  • " + quality.feedback.warning + "
  • "); } $.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; diff --git a/src/templates/auth/password-reset-initiation.html b/src/templates/auth/password-reset-initiation.html index c1306d7f..d8d223cc 100644 --- a/src/templates/auth/password-reset-initiation.html +++ b/src/templates/auth/password-reset-initiation.html @@ -2,28 +2,39 @@ {% block title %}{{ _("Password Reset") }}{% end block %} -{% block content %} -
    -
    -
    -
    -
    {{ _("Password Reset") }}
    +{% block container %} +
    +
    +
    +
    +

    + IPFire_ +

    +

    {{ _("Reset Your Password") }}

    -
    - {% raw xsrf_form_html() %} +
    + + {% raw xsrf_form_html() %} -
    - -
    +
    +
    + +
    +
    - - +
    +
    + +
    +
    + +
    -
    -{% end block %} + +{% end block %} \ No newline at end of file diff --git a/src/templates/auth/password-reset-successful.html b/src/templates/auth/password-reset-successful.html index ff45d61a..f11a5342 100644 --- a/src/templates/auth/password-reset-successful.html +++ b/src/templates/auth/password-reset-successful.html @@ -3,17 +3,18 @@ {% block title %}{{ _("Password Reset") }}{% end block %} {% block content %} -
    -
    -
    -
    - +
    +
    +
    +
    +

    + IPFire_ +

    +

    {{ _("Reset Your Password") }}

    -

    - {{ _("You will shortly receive an email with instructions on how to reset your password.") }} -

    +

    {{ _("You will shortly receive an email with instructions on how to reset your password.") }}

    -
    + {% end block %} diff --git a/src/templates/auth/password-reset.html b/src/templates/auth/password-reset.html index 3a26a170..f7cfc2f5 100644 --- a/src/templates/auth/password-reset.html +++ b/src/templates/auth/password-reset.html @@ -2,20 +2,33 @@ {% block title %}{{ _("Password Reset") }}{% end block %} -{% block content %} -
    -
    -
    {{ _("Reset Your Password") }}
    +{% block container %} +
    +
    +
    +
    +

    + IPFire_ +

    +

    {{ _("Reset Your Password") }}

    -
    - {% raw xsrf_form_html() %} +
    + + {% raw xsrf_form_html() %} - {% module Password(account) %} + {% module Password(account) %} - - +
    +
    + +
    +
    + +
    +
    +
    -
    -{% end block %} + +{% 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 index 6b44690d..00000000 --- a/src/templates/auth/register-spam.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "../base.html" %} - -{% block title %}{{ _("Oops!") }}{% end block %} - -{% block content %} -
    -
    -
    -
    - - -

    - {{ _("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.") }} -

    -
    -
    -
    -
    -{% end block %} diff --git a/src/templates/auth/register-success.html b/src/templates/auth/register-success.html deleted file mode 100644 index e8b1b204..00000000 --- a/src/templates/auth/register-success.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "../base.html" %} - -{% block title %}{{ _("Thank You") }}{% end block %} - -{% block content %} -
    -
    -
    -
    - - -

    - {{ _("Your account has been created.") }} - {{ _("Please check your email for next steps.") }} -

    -
    -
    -
    -
    -{% end block %} diff --git a/src/templates/auth/register.html b/src/templates/auth/register.html deleted file mode 100644 index cf95ac69..00000000 --- a/src/templates/auth/register.html +++ /dev/null @@ -1,184 +0,0 @@ -{% extends "../base.html" %} - -{% block title %}{{ _("Register") }}{% end block %} - -{% block content %} -
    -
    -

    {{ _("Register A New Account") }}

    - -

    - {{ _("Become a part of our community by registering an account!") }} -

    - -
    - {% raw xsrf_form_html() %} - - - -
    -
    -
    - @ -
    - -
    -
    - {{ _("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") }} -
    -
    - {{ _("This username is not available") }} -
    -
    - -
    -
    - - - -
    - -
    - - - -
    -
    - -
    - -
    - {{ _("This email address is invalid") }} -
    -
    - {{ _("This email address cannot be used") }} -
    -
    - {{ _("This email address is already in use") }} -
    -
    - - -
    -
    -
    -{% end block %} - -{% block javascript %} - -{% end block %} diff --git a/src/templates/base.html b/src/templates/base.html index d3f995d1..87f7242f 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -20,392 +20,263 @@ {% block head %}{% end block %} - -
    + {% elif request.path.startswith("/fireinfo") %} + + {% elif hostname.startswith("nopaste.") %} + {% end %} - {% elif hostname == "wiki.ipfire.org" %} - - - - {% end %} - {% end block %} + {% end block %} +
    - {% block container %} -
    - {% block content %}{% end block %} -
    - {% end block %} +
    + {% block container %} +
    + {% block content %}{% end block %} +
    + {% end block %} +
    {% block footer %} -