commit 2f7deaae2d165b2905d2fc80b0ab6fda576cd4d4 Author: Alice Date: Thu Jul 14 02:56:24 2016 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce5b7a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +ccvpn/local_settings.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed4438f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 PacketImpact + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6da84c8 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +CCrypto VPN +=========== + +CCVPN is the software we use at CCrypto to provide our VPN. +You can see it live at https://vpn.ccrypto.org/ + +It handles user management, support tickets, billing and is used as a backend +for VPN authentication. +It communicates with an external service, lambdacore, that manages VPN servers +and sessions. + +CCrypto's commercial support *does not* include this product and will not help you set it up. +Feel free to contact us about ccvpn, but with no guarantee. +[PacketImpact](https://packetimpact.net/) however may provide you commercial support +and more services about ccvpn and lambdacore. + + +Getting Started +--------------- + +```bash + git clone https://github.com/CCrypto/ccvpn3.git + cd ccvpn3/ + + ./manage.py createsuperuser + ./manage.py runserver + +``` + +CRON +---- + +For bitcoin payments, you will need to run a script regularly to check for +verified transaction. Another to delete old cancelled payments. +And another to send expiration emails. + + */5 * * * * /home/vpn/ccvpn3/manage.py check_btc_payments + 0 0 * * * /home/vpn/ccvpn3/manage.py expire_payments + 0 */6 * * * /home/vpn/ccvpn3/manage.py expire_notify + diff --git a/ccvpn/__init__.py b/ccvpn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ccvpn/ca.crt b/ccvpn/ca.crt new file mode 100644 index 0000000..1260895 --- /dev/null +++ b/ccvpn/ca.crt @@ -0,0 +1,39 @@ +-----BEGIN CERTIFICATE----- +MIIG0zCCBLugAwIBAgIJAOOv2BdszSOVMA0GCSqGSIb3DQEBBQUAMIGhMQswCQYD +VQQGEwJGUjERMA8GA1UEBxMIU29tZUNpdHkxHzAdBgNVBAoTFkNvZ25pdGl2ZSBD +cnlwdG9ncmFwaHkxEzARBgNVBAsTCkNDcnlwdG9WUE4xEzARBgNVBAMTCkNDcnlw +dG9WUE4xEzARBgNVBCkTCkNDcnlwdG9WUE4xHzAdBgkqhkiG9w0BCQEWEGNlcnRA +Y2NyeXB0by5vcmcwHhcNMTMwODEzMTgxOTQ4WhcNMjMwODExMTgxOTQ4WjCBoTEL +MAkGA1UEBhMCRlIxETAPBgNVBAcTCFNvbWVDaXR5MR8wHQYDVQQKExZDb2duaXRp +dmUgQ3J5cHRvZ3JhcGh5MRMwEQYDVQQLEwpDQ3J5cHRvVlBOMRMwEQYDVQQDEwpD +Q3J5cHRvVlBOMRMwEQYDVQQpEwpDQ3J5cHRvVlBOMR8wHQYJKoZIhvcNAQkBFhBj +ZXJ0QGNjcnlwdG8ub3JnMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +xvkZj62nUvSjEPs1qBokLd8bBpBlLj6RGgJpfPqS/kKF0s1HpcYZynIcqP6Dw/Pi +LFcTE1STzgFfcEdKLmZAH+JCFVpc9mRTXEifouBk+2j3MG9+j2GTXHCK5FMkcJWQ +o4YihO2UOLz8qz4yn3dmy0zP1UmqxB2SayYXhwT2+pDSTkBCP6YtRURVVNIVRM7A +72hBUJ2dUgKHMTsBJSQj/11rRJ6wW6yUt0NtEcDUbdgrq0BibHq8zzCkl3vGl20M +2UCXPNKavP1aapoDGWLSxZgJ9nkFUfWbjWjHuuBw8cjQ7OV7SLiBXqLNJdcCshDA +OlwaPS4atao73rliE8bWAsGiwJZ+WXnGNJAr/6BwEtOizc6hr92S8lHOrlEWz3/F +h2+L/GI97KMM+pfxlTd8j4dbBpDIXv2vlpYOQ97EbYSbWv7fmYZ2BxxgljATBPfA +hA/y7GEfTodC/mkyZO8R2joBxcbQRu7AjsL30AOiE0GepUQNqhlbhEePvm28C9Rn +OHm4zaqQLo3BJzlP23N9sn4cMZYMiPnx+eCDv+UW7Y+xHVz0GSjlO1IkZ5lTu2fR +IbONfLYDGcByOBaLmo3oD60grw552COTAYDMlu2h8zQV0gPhJziO/txDapoBuNeX +XUdzzp1l6GFTsqIKs6ATleJ4n/S4OQe/kicZkCdYZ3kCAwEAAaOCAQowggEGMB0G +A1UdDgQWBBTWsE0h2/8fw8SbrhvXny8aBL/KTTCB1gYDVR0jBIHOMIHLgBTWsE0h +2/8fw8SbrhvXny8aBL/KTaGBp6SBpDCBoTELMAkGA1UEBhMCRlIxETAPBgNVBAcT +CFNvbWVDaXR5MR8wHQYDVQQKExZDb2duaXRpdmUgQ3J5cHRvZ3JhcGh5MRMwEQYD +VQQLEwpDQ3J5cHRvVlBOMRMwEQYDVQQDEwpDQ3J5cHRvVlBOMRMwEQYDVQQpEwpD +Q3J5cHRvVlBOMR8wHQYJKoZIhvcNAQkBFhBjZXJ0QGNjcnlwdG8ub3JnggkA46/Y +F2zNI5UwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAgEAMQsLBf8TPFkh +/cnsh+E2N5YCqoeiYwLfPlqo3DwVaDoD8w5NovbIj+O6Z31FkTbt2Zotky6BQMCz +5wfuPa1WPuU2kARqS+4LA8FOd1tH4+xqGE6lgkqQuKGJEhi4J4wBg0yKvS4GxZ0+ +ZXw+fWZSGDPR8WlKVfAR6DEuPIgJqIo0hIWbMgKsmqDzxevWg+Y8C9X0VjLwaSX+ +dcC9N+ztYFKkzP4yOba8vFjYBmAYzaxwloUmm+iaTD/jrik9kqjd6grNh8bQa/0N +hzoa0UL8qgnorusvzo85B/I+cavS37djAeoZ1Oilerp39HS5yjvtbI/ijKW6Wp0l +aYCQ6e9f6Ka9cMfVwDJ+aow3rp0MJG0ilP+mS+ouE/R4tnAfVXrlh5X7tsGanByu +LtXIbRrLGcCmmNLtdZ2lRVi3BRRKQu5G/6WkvjslBU27CcvKnTtT7FmtKg9564rB +NCRawVf3veelwmVdnmkKd6Ka05ymJrm3ZU03+41ilSiqecFRFqz/+urdYkmynJtq +E4hNy013h6rooKnNXrMDMuTJqPTa0xJTM+74+JQwEZETSW7TdAGVrqRnJXdXn8Zi +17Gr351BJNOBWvyk8MPBKLM24e0qX+4NykQCT8Whhj4YQ/wzvwx2ayKAfoJYd74s +9ZCg4dyZMObBjkw08am0H6W6pe9umaY= +-----END CERTIFICATE----- diff --git a/ccvpn/context_processors.py b/ccvpn/context_processors.py new file mode 100644 index 0000000..0bcb08d --- /dev/null +++ b/ccvpn/context_processors.py @@ -0,0 +1,9 @@ +from django.conf import settings + + +def some_settings(request): + return { + 'ROOT_URL': settings.ROOT_URL, + 'ADDITIONAL_HTML': settings.ADDITIONAL_HTML, + 'ADDITIONAL_HEADER_HTML': settings.ADDITIONAL_HEADER_HTML, + } diff --git a/ccvpn/forms.py b/ccvpn/forms.py new file mode 100644 index 0000000..e69de29 diff --git a/ccvpn/passwords.py b/ccvpn/passwords.py new file mode 100644 index 0000000..6c17538 --- /dev/null +++ b/ccvpn/passwords.py @@ -0,0 +1,48 @@ +import hashlib +import binascii +from collections import OrderedDict + +from django.contrib.auth.hashers import BasePasswordHasher +from django.utils.translation import ugettext as _ + + +class LegacyPasswordHasher(BasePasswordHasher): + """ Legacy password hasher. + Single SHA512 iteration with a 32 bytes salt. + It's wrong and should not be used except for backward compatibility. + CCVPN2 had it in a binary form, it must be base64-encoded and appened + to "legacy_sha512$" during the migration. + """ + algorithm = "legacy_sha512" + + def encode(self, password, salt): + assert password is not None + if isinstance(password, str): + password = bytes(password, 'utf-8') + if isinstance(salt, str): + salt = bytes(salt, 'utf-8') + hash = hashlib.sha512(salt + password) + return "%s$%s%s" % (self.algorithm, binascii.b2a_hex(salt).decode('utf-8'), + hash.hexdigest()) + + def verify(self, password, encoded): + algorithm, rest = encoded.split('$', 1) + assert algorithm == self.algorithm + + binary = binascii.a2b_hex(rest) + + encoded_2 = self.encode(password, binary[:32]) + return encoded == encoded_2 + + def safe_summary(self, encoded): + algorithm, hash = encoded.split('$', 1) + assert algorithm == self.algorithm + return OrderedDict([ + (_('algorithm'), algorithm), + (_('salt'), hash[0:8]), + (_('hash'), hash[64:72]), + ]) + + def must_update(self, encoded): + return True # "legacy" + diff --git a/ccvpn/settings.py b/ccvpn/settings.py new file mode 100644 index 0000000..282b4df --- /dev/null +++ b/ccvpn/settings.py @@ -0,0 +1,173 @@ +""" +Django settings for ccvpn project. + +Generated by 'django-admin startproject' using Django 1.8. + +For more information on this file, see +https://docs.djangoproject.com/en/1.8/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.8/ref/settings/ +""" + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '@4+zlzju0(wymvatr%8uguuc-aeap8yaz$269ftloqhd&vm%c4' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_countries', + 'lambdainst', + 'payments', + 'tickets', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.common.BrokenLinkEmailsMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'lambdainst.middleware.ReferrerMiddleware', +) + +ROOT_URLCONF = 'ccvpn.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'templates/'), + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.template.context_processors.i18n', + 'django.template.context_processors.static', + 'django.template.context_processors.csrf', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'ccvpn.context_processors.some_settings', + ], + }, + }, +] + +WSGI_APPLICATION = 'ccvpn.wsgi.application' + +LOGIN_URL = 'account:login' +LOGOUT_URL = 'account:logout' + +LOGIN_REDIRECT_URL = 'ccvpn.views.index' +LOGOUT_REDIRECT_URL = 'ccvpn.views.index' + +PAGES_DIR = BASE_DIR + '/pages/' + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'ccvpn.passwords.LegacyPasswordHasher', +] + +# Database +# https://docs.djangoproject.com/en/1.8/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ + +LANGUAGE_CODE = 'en' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +LANGUAGES = ( + ('fr', "French"), + ('en', "English"), +) + +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale/'), +) + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.8/howto/static-files/ + +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, 'static'), +) + +# Security + +X_FRAME_OPTIONS = 'SAMEORIGIN' + +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False +CSRF_COOKIE_HTTPONLY = True + +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_BROWSER_XSS_FILTER = True + +SECURE_SSL_REDIRECT = False +SECURE_HSTS_SECONDS = 3600 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True + + +# OpenVPN CA Certificate + +with open(BASE_DIR + '/ccvpn/ca.crt') as ca_file: + OPENVPN_CA = ca_file.read() + + +ADDITIONAL_HEADER_HTML = '' +ADDITIONAL_HTML = '' + +LAMBDAINST_CLUSTER_MESSAGES = {} + +# Local settings +try: + from .local_settings import * # noqa +except ImportError: + pass + diff --git a/ccvpn/urls.py b/ccvpn/urls.py new file mode 100644 index 0000000..d4731de --- /dev/null +++ b/ccvpn/urls.py @@ -0,0 +1,38 @@ +from django.conf.urls import include, url +from django.contrib import admin +from django.contrib.auth import views as auth_views + +from . import views + +from lambdainst import urls as account_urls, views as account_views +from payments import urls as payments_urls +from tickets import urls as tickets_urls + +urlpatterns = [ + url(r'^admin/status$', account_views.admin_status, name='admin_status'), + url(r'^admin/referrers$', account_views.admin_ref, name='admin_ref'), + url(r'^admin/', include(admin.site.urls)), + + url(r'^api/auth$', account_views.api_auth), + + url(r'^$', views.index, name='index'), + url(r'^ca.crt$', account_views.ca_crt), + url(r'^setlang$', views.set_lang, name='set_lang'), + url(r'^chat$', views.chat, name='chat'), + url(r'^page/(?P[a-zA-Z0-9_-]+)$', views.page, name='page'), + url(r'^status$', account_views.status), + + url(r'^account/forgot$', auth_views.password_reset, + {}, name='password_reset'), + url(r'^account/forgot_done$', auth_views.password_reset_done, + name='password_reset_done'), + url(r'^account/reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + auth_views.password_reset_confirm, name='password_reset_confirm'), + url(r'^account/reset/done/$', auth_views.password_reset_complete, + name='password_reset_complete'), + + url(r'^account/', include(account_urls, namespace='account')), + url(r'^payments/', include(payments_urls, namespace='payments')), + url(r'^tickets/', include(tickets_urls, namespace='tickets')), + +] diff --git a/ccvpn/views.py b/ccvpn/views.py new file mode 100644 index 0000000..7b801ae --- /dev/null +++ b/ccvpn/views.py @@ -0,0 +1,82 @@ +import os.path + +import markdown +from django.http import HttpResponseNotFound +from django.shortcuts import render +from django.conf import settings +from django.utils.translation import ugettext as _, get_language +from django import http +from django.utils.http import is_safe_url +from django.utils.translation import ( + LANGUAGE_SESSION_KEY, check_for_language, +) + + +md = markdown.Markdown(extensions=['toc', 'meta', 'codehilite(noclasses=True)']) + + +def index(request): + eur = '%.2f' % (settings.PAYMENTS_MONTHLY_PRICE / 100) + return render(request, 'ccvpn/index.html', dict(eur_price=eur)) + + +def chat(request): + if request.user.is_authenticated(): + username = request.user.username + '|cc' + else: + username = "cc?" + return render(request, 'ccvpn/chat.html', dict(username=username)) + + +def set_lang(request): + """ django.views.i18n.set_language() with GET """ + + next = request.GET.get('next', request.GET.get('next')) + if not is_safe_url(url=next, host=request.get_host()): + next = request.META.get('HTTP_REFERER') + if not is_safe_url(url=next, host=request.get_host()): + next = '/' + response = http.HttpResponseRedirect(next) + lang_code = request.GET.get('lang', None) + if lang_code and check_for_language(lang_code): + if hasattr(request, 'session'): + request.session[LANGUAGE_SESSION_KEY] = lang_code + else: + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, lang_code, + max_age=settings.LANGUAGE_COOKIE_AGE, + path=settings.LANGUAGE_COOKIE_PATH, + domain=settings.LANGUAGE_COOKIE_DOMAIN) + return response + + +def page(request, name): + basename = settings.PAGES_DIR + '/' + name + + username = request.user.username + + page_replace = { + 'USERNAME': username or '[username]', + } + + files = [ + basename + '.' + get_language() + '.md', + basename + '.en.md', + basename + '.md', + ] + + for file in files: + if not os.path.isfile(file): + continue + + with open(file, encoding='utf8') as fh: + page = fh.read() + for s, r in page_replace.items(): + page = page.replace('{' + s + '}', r) + page = md.convert(page) + + title = md.Meta.get('title', [None])[0] + ctx = dict(content=page, title=title) + return render(request, 'ccvpn/page.html', ctx) + + return HttpResponseNotFound() + diff --git a/ccvpn/wsgi.py b/ccvpn/wsgi.py new file mode 100644 index 0000000..33961b6 --- /dev/null +++ b/ccvpn/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for ccvpn project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ccvpn.settings") + +application = get_wsgi_application() diff --git a/lambdainst/__init__.py b/lambdainst/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambdainst/admin.py b/lambdainst/admin.py new file mode 100644 index 0000000..c3bfdc6 --- /dev/null +++ b/lambdainst/admin.py @@ -0,0 +1,132 @@ +import string +from django.shortcuts import resolve_url +from django import forms +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ + +from lambdainst.models import VPNUser, GiftCode, GiftCodeUser + + +def make_user_link(user): + change_url = resolve_url('admin:auth_user_change', user.id) + return '%s' % (change_url, user.username) + + +class GiftCodeAdminForm(forms.ModelForm): + def clean(self): + input_code = self.cleaned_data.get('code', '') + code_charset = string.ascii_letters + string.digits + if any(c not in code_charset for c in input_code): + raise forms.ValidationError(_("Code must be [a-zA-Z0-9]")) + if not 1 <= len(input_code) <= 32: + raise forms.ValidationError(_("Code must be between 1 and 32 characters")) + return self.cleaned_data + + +class VPNUserInline(admin.StackedInline): + model = VPNUser + can_delete = False + fk_name = 'user' + + fields = ('notes', 'expiration', 'last_expiry_notice', 'notify_expiration', + 'trial_periods_given', 'referrer_a', 'last_vpn_auth') + readonly_fields = ('referrer_a', 'last_vpn_auth') + + def referrer_a(self, object): + if not object.referrer: + return "-" + + s = make_user_link(object.referrer) + " " + if object.referrer_used: + s += _("(rewarded)") + else: + s += _("(not rewarded)") + return s + referrer_a.allow_tags = True + referrer_a.short_description = _("Referrer") + + def is_paid(self, object): + return object.is_paid + is_paid.boolean = True + is_paid.short_description = _("Is paid?") + + +class GiftCodeUserAdmin(admin.TabularInline): + model = GiftCodeUser + fields = ('user_link', 'code_link', 'date') + readonly_fields = ('user_link', 'code_link', 'date') + list_display = ('user', ) + original = False + + def user_link(self, object): + return make_user_link(object.user) + user_link.allow_tags = True + user_link.short_description = 'User' + + def code_link(self, object): + change_url = resolve_url('admin:lambdainst_giftcode_change', object.code.id) + return '%s' % (change_url, object.code.code) + code_link.allow_tags = True + code_link.short_description = 'Code' + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +class UserAdmin(UserAdmin): + inlines = (VPNUserInline, GiftCodeUserAdmin) + list_display = ('username', 'email', 'is_staff', 'date_joined', 'is_paid') + ordering = ('-date_joined', ) + fieldsets = ( + (None, {'fields': ('username', 'password', 'email', 'links')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', + 'groups', 'user_permissions')}), + ) + readonly_fields = ('last_login', 'date_joined', 'links') + + def is_paid(self, object): + return object.vpnuser.is_paid + is_paid.boolean = True + is_paid.short_description = _("Is paid?") + + def links(self, object): + fmt = '%s' + payments_url = resolve_url('admin:payments_payment_changelist') + tickets_url = resolve_url('admin:tickets_ticket_changelist') + s = fmt % (payments_url, object.id, "Payments") + s += ' - ' + fmt % (tickets_url, object.id, "Tickets") + return s + links.allow_tags = True + + +class GiftCodeAdmin(admin.ModelAdmin): + fields = ('code', 'time', 'created', 'created_by', 'single_use', 'free_only', + 'available', 'comment') + readonly_fields = ('created', 'created_by') + list_display = ('code', 'time', 'comment_head', 'available') + search_fields = ('code', 'comment', 'users__username') + inlines = (GiftCodeUserAdmin,) + list_filter = ('available', 'time') + form = GiftCodeAdminForm + + def comment_head(self, object): + return object.comment_head + comment_head.short_description = _("Comment") + + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + obj.save() + + +admin.site.unregister(User) +admin.site.register(User, UserAdmin) + +admin.site.register(GiftCode, GiftCodeAdmin) + diff --git a/lambdainst/core.py b/lambdainst/core.py new file mode 100644 index 0000000..dab98de --- /dev/null +++ b/lambdainst/core.py @@ -0,0 +1,99 @@ +from datetime import timedelta, datetime +import lcoreapi +from django.conf import settings +from django.core.mail import mail_admins +import logging + +cluster_messages = settings.LAMBDAINST_CLUSTER_MESSAGES + +lcore_settings = settings.LCORE + +LCORE_BASE_URL = lcore_settings.get('BASE_URL') +LCORE_API_KEY = lcore_settings['API_KEY'] +LCORE_API_SECRET = lcore_settings['API_SECRET'] +LCORE_SOURCE_ADDR = lcore_settings.get('SOURCE_ADDRESS') +LCORE_INST_SECRET = lcore_settings['INST_SECRET'] + +# The default is to log the exception and only raise it if we cannot show +# the previous value or a default value instead. +LCORE_RAISE_ERRORS = bool(lcore_settings.get('RAISE_ERRORS', False)) + +LCORE_CACHE_TTL = lcore_settings.get('CACHE_TTL', 60) +if isinstance(LCORE_CACHE_TTL, int): + LCORE_CACHE_TTL = timedelta(seconds=LCORE_CACHE_TTL) +assert isinstance(LCORE_CACHE_TTL, timedelta) + +core_api = lcoreapi.API(LCORE_API_KEY, LCORE_API_SECRET, LCORE_BASE_URL) + + +class APICache: + """ Cache data for a time, try to update and silence errors. + Outdated data is not a problem. + """ + def __init__(self, ttl=None, initial=None): + self.cache_date = datetime.fromtimestamp(0) + self.ttl = ttl or LCORE_CACHE_TTL + + self.has_cached_value = initial is not None + self.cached = initial() if initial else None + + def query(self, wrapped, *args, **kwargs): + try: + return wrapped(*args, **kwargs) + except lcoreapi.APIError: + logger = logging.getLogger('django.request') + logger.exception("core api error") + + if LCORE_RAISE_ERRORS: + raise + + if not self.has_cached_value: + # We only return a default value if we were given one. + # Prevents returning an unexpected None. + raise + + # Return previous value + return self.cached + + def __call__(self, wrapped): + def wrapper(*args, **kwargs): + if self.cache_date > (datetime.now() - self.ttl): + return self.cached + + self.cached = self.query(wrapped, *args, **kwargs) + + # New results *and* errors are cached + self.cache_date = datetime.now() + return self.cached + return wrapper + + +@APICache(initial=lambda: 0) +def current_active_sessions(): + return core_api.get(core_api.info['current_instance'] + '/sessions', active=True)['total_count'] + + +@APICache(initial=lambda: []) +def get_locations(): + gateways = core_api.get('/gateways/') + locations = {} + + for gw in gateways.list_iter(): + cc = gw['cluster_name'] + + if cc not in locations: + locations[cc] = dict( + servers=0, + bandwidth=0, + hostname='gw.' + cc + '.204vpn.net', + country_code=cc, + message=cluster_messages.get(cc), + ) + + locations[cc]['servers'] += 1 + locations[cc]['bandwidth'] += gw['bandwidth'] + + locations = sorted(locations.items(), key=lambda x: x[1]['country_code']) + return locations + + diff --git a/lambdainst/forms.py b/lambdainst/forms.py new file mode 100644 index 0000000..f552dc0 --- /dev/null +++ b/lambdainst/forms.py @@ -0,0 +1,56 @@ +from django import forms +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ +from django.utils.safestring import mark_safe + + +class FormPureRender: + def as_pure_aligned(self): + html = '' + for f in self: + html += '
\n' + html += str(f.label_tag()) + '\n' + html += str(f) + '\n' + if f.errors: + html += str(f.errors) + '\n' + html += '
\n' + return mark_safe(html) + + +class UserField(forms.RegexField): + def clean(self, value): + super(UserField, self).clean(value) + try: + User.objects.get(username=value) + raise forms.ValidationError(_("Username taken.")) + except User.DoesNotExist: + return value + + +class SignupForm(forms.Form, FormPureRender): + username = UserField( + label=_("Username"), min_length=2, max_length=16, regex='^[a-zA-Z0-9_-]+$', + widget=forms.TextInput(attrs={'required': 'true', + 'pattern': '[a-zA-Z0-9_-]{2,32}', + 'placeholder': _("Username"), + 'autofocus': 'true'}) + ) + password = forms.CharField( + label=_("Password"), + widget=forms.PasswordInput(attrs={'placeholder': _("Anything")}) + ) + password2 = forms.CharField( + label=_("Repeat"), + widget=forms.PasswordInput(attrs={'placeholder': _("Same Anything")}) + ) + email = forms.EmailField( + label=_("E-Mail"), + widget=forms.EmailInput(attrs={'placeholder': _("E-Mail")}), + required=False, + ) + + def clean_password(self): + if self.data['password'] != self.data['password2']: + raise forms.ValidationError(_("Passwords are not the same")) + return self.data['password'] + diff --git a/lambdainst/graphs.py b/lambdainst/graphs.py new file mode 100644 index 0000000..52ff026 --- /dev/null +++ b/lambdainst/graphs.py @@ -0,0 +1,126 @@ +from datetime import timedelta, date + +import pygal + +from .models import User +from payments.models import BACKENDS +from payments.models import Payment + + +PERIOD_VERBOSE_NAME = { + 'y': "per month", + 'm': "per day", +} + + +def monthdelta(date, delta): + m = (date.month + delta) % 12 + y = date.year + (date.month + delta - 1) // 12 + if not m: + m = 12 + d = min(date.day, [31, 29 if y % 4 == 0 and not y % 400 == 0 + else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 + ][m - 1]) + return date.replace(day=d, month=m, year=y) + + +def last_days(n=30): + now = date.today() + for i in range(n - 1, -1, -1): + yield now - timedelta(days=i) + + +def last_months(n=12): + now = date.today().replace(day=1) + for i in range(n - 1, -1, -1): + yield monthdelta(now, -i) + + +def time_filter_future(period, m, df): + def _filter(o): + if period == 'm': + return df(o).date() <= m + if period == 'y': + return df(o).date().replace(day=1) <= m + return _filter + + +def time_filter_between(period, m, df): + def _filter(o): + if period == 'm': + return df(o).year == m.year and df(o).month == m.month and df(o).day == m.day + return df(o).date() <= m and df(o).date() > (m - timedelta(days=1)) + if period == 'y': + return df(o).year == m.year and df(o).month == m.month + return (df(o).date().replace(day=1) <= m and + df(o).date().replace(day=1) > (m - timedelta(days=30))) + return _filter + + +def users_graph(period): + chart = pygal.Line(fill=True, x_label_rotation=75, show_legend=False) + chart.title = 'Users %s' % PERIOD_VERBOSE_NAME[period] + chart.x_labels = [] + values = [] + gen = last_days(30) if period == 'm' else last_months(12) + users = User.objects.all() + + for m in gen: + filter_ = time_filter_future(period, m, lambda o: o.date_joined) + users_filtered = filter(filter_, users) + values.append(len(list(users_filtered))) + chart.x_labels.append('%02d/%02d' % (m.month, m.day)) + + chart.add('Users', values) + return chart.render() + + +def payments_paid_graph(period): + chart = pygal.StackedBar(x_label_rotation=75, show_legend=True) + chart.x_labels = [] + gen = list(last_days(30) if period == 'm' else last_months(12)) + + chart.title = 'Payments %s in €' % (PERIOD_VERBOSE_NAME[period]) + + for m in gen: + chart.x_labels.append('%02d/%02d' % (m.month, m.day)) + + values = dict() + for backend_id, backend in BACKENDS.items(): + values = [] + payments = list(Payment.objects.filter(status='confirmed', backend_id=backend_id)) + + for m in gen: + filter_ = time_filter_between(period, m, lambda o: o.created) + filtered = filter(filter_, payments) + values.append(sum(u.paid_amount for u in filtered) / 100) + + chart.add(backend_id, values) + + return chart.render() + + +def payments_success_graph(period): + chart = pygal.StackedBar(x_label_rotation=75, show_legend=True) + chart.x_labels = [] + gen = list(last_days(30) if period == 'm' else last_months(12)) + + chart.title = 'Successful payments %s' % (PERIOD_VERBOSE_NAME[period]) + + for m in gen: + chart.x_labels.append('%02d/%02d' % (m.month, m.day)) + + values = dict() + for backend_id, backend in BACKENDS.items(): + values = [] + payments = list(Payment.objects.filter(status='confirmed', backend_id=backend_id)) + + for m in gen: + filter_ = time_filter_between(period, m, lambda o: o.created) + filtered = filter(filter_, payments) + values.append(sum(1 for u in filtered)) + + chart.add(backend_id, values) + + return chart.render() + diff --git a/lambdainst/management/__init__.py b/lambdainst/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambdainst/management/commands/__init__.py b/lambdainst/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambdainst/management/commands/core_info.py b/lambdainst/management/commands/core_info.py new file mode 100644 index 0000000..ed4f627 --- /dev/null +++ b/lambdainst/management/commands/core_info.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from lambdainst.core import core_api + + +class Command(BaseCommand): + help = "Get informations about core API" + + def handle(self, *args, **options): + for k, v in core_api.info.items(): + print("%s: %s" % (k, v)) diff --git a/lambdainst/management/commands/expire_notify.py b/lambdainst/management/commands/expire_notify.py new file mode 100644 index 0000000..e9d70bf --- /dev/null +++ b/lambdainst/management/commands/expire_notify.py @@ -0,0 +1,56 @@ +from django.core.management.base import BaseCommand + +from datetime import timedelta + +from django.db.models import Q, F +from django.conf import settings +from django.utils import timezone +from django.template.loader import get_template +from django.template import Context +from django.core.mail import send_mass_mail + +from lambdainst.models import VPNUser + +ROOT_URL = settings.ROOT_URL +SITE_NAME = settings.TICKETS_SITE_NAME + +NOTIFY_DAYS_BEFORE = settings.NOTIFY_DAYS_BEFORE +assert isinstance(NOTIFY_DAYS_BEFORE, (list, tuple, set)) + + +def get_next_expirations(days=3): + """ Gets users whose subscription will expire in some days """ + + limit_date = timezone.now() + timedelta(days=days) + + users = VPNUser.objects.exclude(user__email__exact='') + + users = users.filter(expiration__gt=timezone.now()) # Not expired + users = users.filter(expiration__lt=limit_date) # Expire in a few days + + # Make sure we dont send the notice twice + users = users.filter(Q(last_expiry_notice__isnull=True) + | Q(expiration__gt=F('last_expiry_notice') + + timedelta(days=days))) + return users + + +class Command(BaseCommand): + help = "Notify users near the end of their subscription" + + def handle(self, *args, **options): + from_email = settings.DEFAULT_FROM_EMAIL + + for v in NOTIFY_DAYS_BEFORE: + emails = [] + qs = get_next_expirations(v) + users = list(qs) + for u in users: + ctx = Context(dict(site_name=SITE_NAME, user=u.user, + exp=u.expiration, url=ROOT_URL)) + text = get_template('lambdainst/mail_expire_soon.txt').render(ctx) + emails.append(("CCVPN Expiration", text, from_email, [u.user.email])) + print("sending -%d days notify to %s ..." % (v, u.user.email)) + + send_mass_mail(emails) + qs.update(last_expiry_notice=timezone.now()) diff --git a/lambdainst/management/commands/send_report.py b/lambdainst/management/commands/send_report.py new file mode 100644 index 0000000..c463d47 --- /dev/null +++ b/lambdainst/management/commands/send_report.py @@ -0,0 +1,113 @@ +from datetime import timedelta +from io import StringIO + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand +from django.core.mail import EmailMessage +from django.db.models import Count +from django.utils import timezone + + +def get_prev_month(d): + if d.month == 1: + year = d.year - 1 + month = 12 + else: + year = d.year + month = d.month - 1 + return d.replace(month=month, year=year) + + +def should_bill(report, user, time_limit): + """ Determines if one user has actually paid for the current month """ + + # Here for consistency, should be filtered in the query + if not user.vpnuser.expiration or user.vpnuser.expiration < time_limit: + return False + + # Replay payments + payments = list(user.payment_set.order_by('id').filter(status='confirmed')) + paid_expiration = None + for p in payments: + d = p.confirmed_on or p.created + paid_expiration = max(paid_expiration or d, d) + p.time + + # Numbre of days paid after the start of the month + # If negative and not filtered with vpnuser.expiration, user was given time. + # If positive, user has paid for this time. + delta = paid_expiration - time_limit + + if delta < timedelta(): + report.write("- %s (#%d): %s\n" % (user.username, user.id, -delta)) + + return delta > timedelta() + + +class Command(BaseCommand): + help = "Generate and send a monthly usage report to ADMINS" + + def handle(self, *args, **options): + addresses = settings.USAGE_REPORT_DESTINATION + + def format_e(n): + return '%.2f%s' % (n / 100, settings.PAYMENTS_CURRENCY[1]) + + # Dates + end = timezone.now().replace(microsecond=0, second=0, minute=0, hour=0, day=5) + start = get_prev_month(end) + + # Filter users + filtering_report = StringIO() + all_users = User.objects.order_by('id') + active_users = all_users.filter(vpnuser__expiration__gt=start) + paying_users = active_users.filter(payment__status='confirmed').annotate(Count('payment')).filter(payment__count__gt=0) + users = [u for u in paying_users if should_bill(filtering_report, u, start)] + + # Generate report + report = "CCVPN Usage Report\n" + report += "==================\n\n" + + report += "From: %s\nTo : %s\n\n" % (start, end) + + keys = ('Users', 'Active', 'W/Payment', 'Selected') + values = (all_users.count(), active_users.count(), paying_users.count(), len(users)) + report += " | ".join("%-10s" % s for s in keys) + "\n" + report += " | ".join("%-10s" % s for s in values) + "\n" + report += "\n" + + user_cost = settings.VPN_USER_COST + total_cost = settings.VPN_USER_COST * len(users) + + report += "Billed: %d * %s = %s\n" % (len(users), format_e(user_cost), format_e(total_cost)) + report += "\n" + + if filtering_report.getvalue(): + report += "Ignored users:\n" + report += filtering_report.getvalue() + report += "\n" + + users_text = "\n".join("%s (#%d)" % (u.username, u.id) for u in users) + + subject = "[CCVPN] Usage Report: %s to %s" % ( + start.strftime('%m/%Y'), end.strftime('%m/%Y')) + + # Send + print(report) + print("-------") + print("Send to: " + ", ".join(a for a in addresses)) + + print("Confirm? [y/n] ", end='') + i = input() + if i.lower().strip() != 'y': + return + + for dest in addresses: + mail = EmailMessage(subject=subject, body=report, + from_email=settings.DEFAULT_FROM_EMAIL, to=[dest]) + mail.attach('users.txt', users_text, 'text/plain') + mail.send() + + print("Sent.") + + diff --git a/lambdainst/middleware.py b/lambdainst/middleware.py new file mode 100644 index 0000000..de06694 --- /dev/null +++ b/lambdainst/middleware.py @@ -0,0 +1,42 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from .models import User + + +class ReferrerMiddleware(): + def process_request(self, request): + if 'ref' in request.GET: + id = request.GET['ref'] + elif 'referrer' in request.COOKIES: + id = request.COOKIES['referrer'] + else: + return + + try: + id = int(id.strip()) + except (ValueError, TypeError): + return + + try: + u = User.objects.get(id=id) + except User.DoesNotExist: + return + + request.session['referrer'] = u.id + + def process_response(self, request, response): + id = request.session.get('referrer') + if not id: + return response + + max_age = 365 * 24 * 60 * 60 + expires = (datetime.utcnow() + timedelta(seconds=max_age)) + expires = expires.strftime("%a, %d-%b-%Y %H:%M:%S GMT") + response.set_cookie('referrer', id, + max_age=max_age, + expires=expires, + domain=settings.SESSION_COOKIE_DOMAIN, + secure=settings.SESSION_COOKIE_SECURE or None) + return response + diff --git a/lambdainst/migrations/0001_initial.py b/lambdainst/migrations/0001_initial.py new file mode 100644 index 0000000..ed7ecb5 --- /dev/null +++ b/lambdainst/migrations/0001_initial.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import lambdainst.models +from django.conf import settings +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GiftCode', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), + ('code', models.CharField(default=lambdainst.models.random_gift_code, max_length=32)), + ('time', models.DurationField(default=datetime.timedelta(30))), + ('created', models.DateTimeField(null=True, auto_now_add=True)), + ('single_use', models.BooleanField(default=True)), + ('free_only', models.BooleanField(default=True)), + ('available', models.BooleanField(default=True)), + ('comment', models.TextField(blank=True)), + ('created_by', models.ForeignKey(related_name='created_giftcode_set', null=True, blank=True, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Gift Codes', + 'verbose_name': 'Gift Code', + }, + ), + migrations.CreateModel( + name='GiftCodeUser', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), + ('date', models.DateTimeField(null=True, auto_now_add=True)), + ('code', models.ForeignKey(to='lambdainst.GiftCode')), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Gift Code Users', + 'verbose_name': 'Gift Code User', + }, + ), + migrations.CreateModel( + name='VPNUser', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), + ('notes', models.TextField(blank=True)), + ('expiration', models.DateTimeField(null=True, blank=True)), + ('last_expiry_notice', models.DateTimeField(null=True, blank=True)), + ('notify_expiration', models.BooleanField(default=True)), + ('trial_periods_given', models.IntegerField(default=0)), + ('last_vpn_auth', models.DateTimeField(null=True, blank=True)), + ('referrer_used', models.BooleanField(default=False)), + ('referrer', models.ForeignKey(related_name='referrals', null=True, on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'VPN Users', + 'verbose_name': 'VPN User', + }, + ), + migrations.AddField( + model_name='giftcode', + name='users', + field=models.ManyToManyField(through='lambdainst.GiftCodeUser', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/lambdainst/migrations/__init__.py b/lambdainst/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambdainst/models.py b/lambdainst/models.py new file mode 100644 index 0000000..cad186e --- /dev/null +++ b/lambdainst/models.py @@ -0,0 +1,147 @@ +import random +from datetime import timedelta +from django.db import models +from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ +from django.utils import timezone +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +assert isinstance(settings.TRIAL_PERIOD, timedelta) +assert isinstance(settings.TRIAL_PERIOD_LIMIT, int) + +prng = random.SystemRandom() + + +def random_gift_code(): + charset = "123456789ABCDEFGHIJKLMNPQRSTUVWXYZ" + return ''.join([prng.choice(charset) for n in range(10)]) + + +class VPNUser(models.Model): + class Meta: + verbose_name = _("VPN User") + verbose_name_plural = _("VPN Users") + + user = models.OneToOneField(User, on_delete=models.CASCADE) + notes = models.TextField(blank=True) + + expiration = models.DateTimeField(blank=True, null=True) + last_expiry_notice = models.DateTimeField(blank=True, null=True) + notify_expiration = models.BooleanField(default=True) + + trial_periods_given = models.IntegerField(default=0) + + last_vpn_auth = models.DateTimeField(blank=True, null=True) + + referrer = models.ForeignKey(User, blank=True, null=True, on_delete=models.SET_NULL, + related_name='referrals') + referrer_used = models.BooleanField(default=False) + + @property + def is_paid(self): + if not self.expiration: + return False + return self.expiration > timezone.now() + + @property + def time_left(self): + return timezone.now() - self.expiration + + def add_paid_time(self, time): + now = timezone.now() + if not self.expiration or self.expiration < now: + self.expiration = now + self.expiration += time + + def give_trial_period(self): + self.add_paid_time(settings.TRIAL_PERIOD) + self.trial_periods_given += 1 + + @property + def can_have_trial(self): + if self.trial_periods_given >= settings.TRIAL_PERIOD_LIMIT: + return False + if self.user.payment_set.filter(status='confirmed').count() > 0: + return False + return True + + @property + def remaining_trial_periods(self): + return settings.TRIAL_PERIOD_LIMIT - self.trial_periods_given + + def on_payment_confirmed(self, payment): + if self.referrer and not self.referrer_used: + self.referrer.vpnuser.add_paid_time(timedelta(days=14)) + self.referrer.vpnuser.save() + self.referrer_used = True + + def __str__(self): + return self.user.username + + +@receiver(post_save, sender=User) +def create_vpnuser(sender, instance, created, **kwargs): + if created: + VPNUser.objects.create(user=instance) + + +class GiftCode(models.Model): + class Meta: + verbose_name = _("Gift Code") + verbose_name_plural = _("Gift Codes") + + code = models.CharField(max_length=32, default=random_gift_code) + time = models.DurationField(default=timedelta(days=30)) + created = models.DateTimeField(auto_now_add=True, null=True, blank=True) + created_by = models.ForeignKey(User, related_name='created_giftcode_set', + on_delete=models.CASCADE, null=True, blank=True) + single_use = models.BooleanField(default=True) + free_only = models.BooleanField(default=True) + available = models.BooleanField(default=True) + comment = models.TextField(blank=True) + users = models.ManyToManyField(User, through='GiftCodeUser') + + def use_on(self, user): + if not self.available: + return False + if self.free_only and user.vpnuser.is_paid: + return False + + link = GiftCodeUser(user=user, code=self) + link.save() + + user.vpnuser.add_paid_time(self.time) + user.vpnuser.save() + + if self.single_use: + self.available = False + + self.save() + + return True + + @property + def comment_head(self): + head = self.comment.split('\n', 1)[0] + if len(head) > 80: + head = head[:80] + "..." + return head + + def __str__(self): + return self.code + + +class GiftCodeUser(models.Model): + class Meta: + verbose_name = _("Gift Code User") + verbose_name_plural = _("Gift Code Users") + + user = models.ForeignKey(User) + code = models.ForeignKey(GiftCode) + date = models.DateTimeField(auto_now_add=True, null=True, blank=True) + + def __str__(self): + return "%s (%s)" % (self.user.username, self.code.code) + diff --git a/lambdainst/openvpn.py b/lambdainst/openvpn.py new file mode 100644 index 0000000..b13997b --- /dev/null +++ b/lambdainst/openvpn.py @@ -0,0 +1,98 @@ +from django.utils.translation import ugettext as _ +from django.conf import settings + + +CA_CERT = settings.OPENVPN_CA + +CONFIG_OS = ( + ('windows', _("Windows")), + ('android', _("Android")), + ('ubuntu', _("Ubuntu")), + ('osx', _("OS X")), + ('ios', _("iOS")), + ('freebox', _("Freebox")), + ('other', _("Other / GNU/Linux")), +) + +PROTOCOLS = ( + ('udp', _("UDP (default)")), + ('tcp', _("TCP")), + ('udpl', _("UDP (low MTU)")), +) + + +def make_config(gw_name, os, protocol, http_proxy=None, ipv6=True): + use_frag = protocol == 'udpl' and os != 'ios' + ipv6 = ipv6 and (os != 'freebox') + http_proxy = http_proxy if protocol == 'tcp' else None + resolvconf = os in ('ubuntu', 'other') + + openvpn_proto = {'udp': 'udp', 'udpl': 'udp', 'tcp': 'tcp'} + openvpn_ports = {'udp': 1196, 'udpl': 1194, 'tcp': 443} + + hostname = 'gw.%s.204vpn.net' % gw_name + remote = str(hostname) + remote += ' ' + str(openvpn_ports[protocol]) + remote += ' ' + openvpn_proto[protocol] + + config = """\ +# +----------------------------+ +# | Cognitive Cryptography VPN | +# | https://vpn.ccrypto.org/ | +# +----------------------------+ + +verb 4 +client +tls-client +script-security 2 +remote-cert-tls server +dev tun +nobind +persist-key +persist-tun +comp-lzo yes + +remote {remote} + +auth-user-pass + +""".format(remote=remote) + + if os == 'ios': + # i'd like to note here how much i hate OpenVPN + config += "redirect-gateway ipv6\n" + config += 'push "route 0.0.0.0 128.0.0.0"\n' + config += 'push "route 128.0.0.0 128.0.0.0"\n' + else: + config += "redirect-gateway def1\n" + if ipv6: + config += "tun-ipv6\n" + config += "route-ipv6 2000::/3\n" + config += "\n" + + if use_frag: + config += "fragment 1300\n" + config += "mssfix 1300\n" + config += "\n" + + if http_proxy: + config += "http-proxy %s\n\n" % http_proxy + + if resolvconf: + config += "up /etc/openvpn/update-resolv-conf\n" + config += "down /etc/openvpn/update-resolv-conf\n" + config += "\n" + + if os == 'windows': + config += "register-dns\n" + config += "\n" + + config += "\n%s\n" % CA_CERT + + if os == 'windows': + config = config.replace('\n', '\r\n') + + return config + + + diff --git a/lambdainst/templatetags/active.py b/lambdainst/templatetags/active.py new file mode 100644 index 0000000..1dd1eed --- /dev/null +++ b/lambdainst/templatetags/active.py @@ -0,0 +1,19 @@ +import re + +from django import template +from django.core.urlresolvers import reverse, NoReverseMatch + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def active(context, pattern_or_urlname): + try: + pattern = '^' + reverse(pattern_or_urlname) + except NoReverseMatch: + pattern = pattern_or_urlname + path = context['request'].path + if re.search(pattern, path): + return 'active' + return '' + diff --git a/lambdainst/templatetags/bw.py b/lambdainst/templatetags/bw.py new file mode 100644 index 0000000..04c5d9f --- /dev/null +++ b/lambdainst/templatetags/bw.py @@ -0,0 +1,38 @@ +from django.utils.translation import ugettext, ungettext +from django.template import Library +from django.utils.html import avoid_wrapping +from django.utils import formats + +register = Library() + + +@register.filter(is_safe=True) +def bwformat(bps): + try: + bps = float(bps) + except (TypeError, ValueError, UnicodeDecodeError): + value = ungettext("%(bw)d bps", "%(bw)d bps", 0) % {'bw': 0} + return avoid_wrapping(value) + + filesize_number_format = lambda value: formats.number_format(round(value, 1), -1) + + K = 1 * 10 ** 3 + M = 1 * 10 ** 6 + G = 1 * 10 ** 9 + T = 1 * 10 ** 12 + P = 1 * 10 ** 15 + + if bps < K: + value = ungettext("%(size)d bps", "%(size)d bps", bps) % {'size': bps} + elif bps < M: + value = ugettext("%s Kbps") % filesize_number_format(bps / K) + elif bps < G: + value = ugettext("%s Mbps") % filesize_number_format(bps / M) + elif bps < T: + value = ugettext("%s Gbps") % filesize_number_format(bps / G) + elif bps < P: + value = ugettext("%s Tbps") % filesize_number_format(bps / T) + else: + value = ugettext("%s Pbps") % filesize_number_format(bps / P) + + return avoid_wrapping(value) diff --git a/lambdainst/tests.py b/lambdainst/tests.py new file mode 100644 index 0000000..7af92fb --- /dev/null +++ b/lambdainst/tests.py @@ -0,0 +1,274 @@ +from datetime import timedelta, datetime +from django.test import TestCase +from django.utils import timezone + +from .forms import SignupForm +from .models import VPNUser, User, random_gift_code, GiftCode, GiftCodeUser +from payments.models import Payment + + +class UserTestMixin: + def assertRemaining(self, vpnuser, time): + """ Check that the vpnuser will expire in time (+/- 5 seconds) """ + exp = vpnuser.expiration or timezone.now() + seconds = (exp - timezone.now() - time).total_seconds() + self.assertAlmostEqual(seconds, 0, delta=5) + + +class UserModelTest(TestCase, UserTestMixin): + def setUp(self): + User.objects.create_user('aaa') + + def test_add_time(self): + u = User.objects.get(username='aaa') + vu = u.vpnuser + p = timedelta(days=1) + + self.assertFalse(vu.is_paid) + + vu.expiration = timezone.now() + vu.add_paid_time(p) + final = vu.expiration + + self.assertRemaining(vu, p) + self.assertGreater(final, timezone.now()) + self.assertTrue(vu.is_paid) + + def test_add_time_past(self): + u = User.objects.get(username='aaa') + vu = u.vpnuser + p = timedelta(days=1) + + self.assertFalse(vu.is_paid) + + vu.expiration = timezone.now() - timedelta(days=135) + vu.add_paid_time(p) + final = vu.expiration + + self.assertRemaining(vu, p) + self.assertGreater(final, timezone.now()) + self.assertTrue(vu.is_paid) + + def test_add_time_initial(self): + u = User.objects.get(username='aaa') + vu = u.vpnuser + p = timedelta(days=1) + + self.assertFalse(vu.is_paid) + + vu.add_paid_time(p) + self.assertTrue(vu.is_paid) + + def test_grant_trial(self): + p = timedelta(days=1) + u = User.objects.get(username='aaa') + vu = u.vpnuser + + with self.settings(TRIAL_PERIOD=p, TRIAL_PERIOD_LIMIT=2): + self.assertEqual(vu.remaining_trial_periods, 2) + self.assertTrue(vu.can_have_trial) + vu.give_trial_period() + self.assertRemaining(vu, p) + + self.assertEqual(vu.remaining_trial_periods, 1) + self.assertTrue(vu.can_have_trial) + vu.give_trial_period() + self.assertRemaining(vu, p * 2) + + self.assertEqual(vu.remaining_trial_periods, 0) + self.assertFalse(vu.can_have_trial) + + def test_trial_refused(self): + p = timedelta(days=1) + u = User.objects.get(username='aaa') + payment = Payment.objects.create(user=u, status='confirmed', amount=300, + time=timedelta(days=30)) + payment.save() + + vu = u.vpnuser + + with self.settings(TRIAL_PERIOD=p, TRIAL_PERIOD_LIMIT=2): + self.assertEqual(vu.remaining_trial_periods, 2) + self.assertFalse(vu.can_have_trial) + + +class UserModelReferrerTest(TestCase, UserTestMixin): + def setUp(self): + self.referrer = User.objects.create_user('ref') + + self.without_ref = User.objects.create_user('aaaa') + + self.with_ref = User.objects.create_user('bbbb') + self.with_ref.vpnuser.referrer = self.referrer + + self.payment = Payment.objects.create( + user=self.with_ref, status='confirmed', amount=300, time=timedelta(days=30)) + + def test_no_ref(self): + self.without_ref.vpnuser.on_payment_confirmed(self.payment) + + def test_ref(self): + self.with_ref.vpnuser.on_payment_confirmed(self.payment) + self.assertTrue(self.with_ref.vpnuser.referrer_used) + self.assertEqual(self.with_ref.vpnuser.referrer, self.referrer) + self.assertRemaining(self.referrer.vpnuser, timedelta(days=14)) + + +class GCModelTest(TestCase): + def test_generator(self): + c = random_gift_code() + self.assertEqual(len(c), 10) + self.assertNotEqual(c, random_gift_code()) + + +class SignupViewTest(TestCase): + def test_form(self): + response = self.client.get('/account/signup') + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context['form'], SignupForm) + + def test_post(self): + response = self.client.post('/account/signup', { + 'username': 'test_un', 'password': 'test_pw', 'password2': 'test_pw'}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test_un') + self.assertTrue(user.check_password('test_pw')) + + def test_post_error(self): + response = self.client.post('/account/signup', { + 'username': 'test_un', 'password': 'test_pw', 'password2': 'qsdf'}) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context['form'], SignupForm) + self.assertFormError(response, 'form', 'password', + 'Passwords are not the same') + + def test_post_referrer(self): + ref = User.objects.create_user('referrer') + + response = self.client.post('/account/signup?ref=%d' % ref.id, { + 'username': 'test_un', 'password': 'test_pw', 'password2': 'test_pw'}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test_un') + self.assertTrue(user.check_password('test_pw')) + self.assertEqual(user.vpnuser.referrer, ref) + + +class AccountViewsTest(TestCase, UserTestMixin): + def setUp(self): + User.objects.create_user('test', None, 'testpw') + self.client.login(username='test', password='testpw') + + def test_account(self): + response = self.client.get('/account/') + self.assertEqual(response.status_code, 200) + + def test_trial_get(self): + response = self.client.get('/account/trial') + self.assertRedirects(response, '/account/') + + def test_trial(self): + p = timedelta(days=1) + with self.settings(RECAPTCHA_API='TEST', TRIAL_PERIOD=p): + good_data = {'g-recaptcha-response': 'TEST-TOKEN'} + + response = self.client.post('/account/trial', good_data) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + self.assertRemaining(user.vpnuser, p) + + def test_trial_fail(self): + p = timedelta(days=1) + with self.settings(RECAPTCHA_API='TEST', TRIAL_PERIOD=p): + bad_data = {'g-recaptcha-response': 'TOTALLY-NOT-TEST-TOKEN'} + + response = self.client.post('/account/trial', bad_data) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + self.assertRemaining(user.vpnuser, timedelta()) + + def test_settings_form(self): + response = self.client.get('/account/settings') + self.assertEqual(response.status_code, 200) + + def test_settings_post(self): + response = self.client.post('/account/settings', { + 'password': 'new_test_pw', 'password2': 'new_test_pw', + 'email': 'new_email@example.com'}) + self.assertEqual(response.status_code, 200) + + user = User.objects.get(username='test') + self.assertTrue(user.check_password('new_test_pw')) + self.assertEqual(user.email, 'new_email@example.com') + + def test_settings_post_fail(self): + response = self.client.post('/account/settings', { + 'password': 'new_test_pw', 'password2': 'new_test_pw_qsdfg', + 'email': 'new_email@example.com'}) + self.assertEqual(response.status_code, 200) + + user = User.objects.get(username='test') + self.assertFalse(user.check_password('new_test_pw')) + self.assertEqual(user.email, 'new_email@example.com') + + def test_giftcode_use_single(self): + gc = GiftCode.objects.create(time=timedelta(days=42), single_use=True) + + response = self.client.post('/account/gift_code', {'code': gc.code}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + self.assertRemaining(user.vpnuser, timedelta(days=42)) + + response = self.client.post('/account/gift_code', {'code': gc.code}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + self.assertRemaining(user.vpnuser, timedelta(days=42)) # same expiration + + def test_giftcode_use_free_only(self): + gc = GiftCode.objects.create(time=timedelta(days=42), free_only=True) + + response = self.client.post('/account/gift_code', {'code': gc.code}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + self.assertRemaining(user.vpnuser, timedelta(days=42)) + + def test_giftcode_use_free_only_fail(self): + gc = GiftCode.objects.create(time=timedelta(days=42), free_only=True) + user = User.objects.get(username='test') + user.vpnuser.add_paid_time(timedelta(days=1)) + user.vpnuser.save() + + response = self.client.post('/account/gift_code', {'code': gc.code}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + self.assertRemaining(user.vpnuser, timedelta(days=1)) + + def test_giftcode_create_gcu(self): + gc = GiftCode.objects.create(time=timedelta(days=42)) + + response = self.client.post('/account/gift_code', {'code': gc.code}) + self.assertRedirects(response, '/account/') + + user = User.objects.get(username='test') + gcu = GiftCodeUser.objects.get(user=user, code=gc) + + self.assertRemaining(user.vpnuser, timedelta(days=42)) + self.assertIn(gcu, user.giftcodeuser_set.all()) + + +class CACrtViewTest(TestCase): + def test_ca_crt(self): + with self.settings(OPENVPN_CA='test ca'): + response = self.client.get('/ca.crt') + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/x-x509-ca-cert') + self.assertEqual(response.content, b'test ca') + + diff --git a/lambdainst/urls.py b/lambdainst/urls.py new file mode 100644 index 0000000..5d1a149 --- /dev/null +++ b/lambdainst/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url +from django.contrib.auth import views as auth_views + +from . import views + +urlpatterns = [ + url(r'^login$', auth_views.login, name='login'), + url(r'^logout$', views.logout, name='logout'), + url(r'^signup$', views.signup, name='signup'), + + url(r'^settings', views.settings), + url(r'^config_dl', views.config_dl), + url(r'^config', views.config), + url(r'^logs', views.logs), + url(r'^gift_code', views.gift_code), + url(r'^trial', views.trial), + url(r'^', views.index, name='index'), +] diff --git a/lambdainst/views.py b/lambdainst/views.py new file mode 100644 index 0000000..8d31bb5 --- /dev/null +++ b/lambdainst/views.py @@ -0,0 +1,360 @@ +import requests +import io +import zipfile +from urllib.parse import urlencode +from datetime import timedelta, datetime + +from django.http import HttpResponse, HttpResponseNotFound, HttpResponseForbidden +from django.http import JsonResponse +from django.shortcuts import render, redirect +from django.contrib.auth import authenticate +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.admin.sites import site +from django.contrib import messages +from django.utils.translation import ugettext as _ +from django.utils import timezone +from django.conf import settings as project_settings +from django.views.decorators.csrf import csrf_exempt +from django.db.models import Count +from django.contrib import auth +from django.contrib.auth.models import User +from django_countries import countries + +from payments.models import ACTIVE_BACKENDS +from .forms import SignupForm +from .models import GiftCode, VPNUser +from .core import core_api, current_active_sessions, get_locations as core_get_locations +from .core import LCORE_INST_SECRET, LCORE_SOURCE_ADDR +from . import graphs +from . import openvpn + + +def get_locations(): + """ Pretty bad thing that returns get_locations() with translated stuff + that depends on the request + """ + countries_d = dict(countries) + locations = core_get_locations() + for k, v in locations: + cc = v['country_code'].upper() + v['country_name'] = countries_d.get(cc, cc) + return locations + + +def ca_crt(request): + return HttpResponse(content=project_settings.OPENVPN_CA, + content_type='application/x-x509-ca-cert') + + +def logout(request): + auth.logout(request) + return redirect('index') + + +def signup(request): + if request.user.is_authenticated(): + return redirect('account:index') + + if request.method != 'POST': + form = SignupForm() + return render(request, 'ccvpn/signup.html', dict(form=form)) + + form = SignupForm(request.POST) + + if not form.is_valid(): + return render(request, 'ccvpn/signup.html', dict(form=form)) + + user = User.objects.create_user(form.cleaned_data['username'], + form.cleaned_data['email'], + form.cleaned_data['password']) + user.save() + + try: + user.vpnuser.referrer = User.objects.get(id=request.session.get('referrer')) + except User.DoesNotExist: + pass + + user.vpnuser.save() + + user.backend = 'django.contrib.auth.backends.ModelBackend' + auth.login(request, user) + + return redirect('account:index') + + +@login_required +def index(request): + ref_url = project_settings.ROOT_URL + '?ref=' + str(request.user.id) + + twitter_url = 'https://twitter.com/intent/tweet?' + twitter_args = { + 'text': _("Awesome VPN! 3€ per month, with a free 7 days trial!"), + 'via': 'CCrypto_VPN', + 'url': ref_url, + 'related': 'CCrypto_VPN,CCrypto_org' + } + + context = dict( + title=_("Account"), + ref_url=ref_url, + twitter_link=twitter_url + urlencode(twitter_args), + backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_id), + default_backend='paypal', + recaptcha_site_key=project_settings.RECAPTCHA_SITE_KEY, + ) + return render(request, 'lambdainst/account.html', context) + + +def captcha_test(grr, request): + api_url = project_settings.RECAPTCHA_API + + if api_url == 'TEST' and grr == 'TEST-TOKEN': + # FIXME: i'm sorry. + return True + + data = dict(secret=project_settings.RECAPTCHA_SECRET_KEY, + remoteip=request.META['REMOTE_ADDR'], + response=grr) + + try: + r = requests.post(api_url, data=data) + r.raise_for_status() + d = r.json() + return d.get('success') + except (requests.ConnectionError, requests.HTTPError, ValueError): + return False + + +@login_required +def trial(request): + if request.method != 'POST' or not request.user.vpnuser.can_have_trial: + return redirect('account:index') + + grr = request.POST.get('g-recaptcha-response', '') + if captcha_test(grr, request): + request.user.vpnuser.give_trial_period() + request.user.vpnuser.save() + messages.success(request, _("OK!")) + else: + messages.error(request, _("Invalid captcha")) + + return redirect('account:index') + + +@login_required +def settings(request): + if request.method != 'POST': + return render(request, 'lambdainst/settings.html') + + pw = request.POST.get('password') + pw2 = request.POST.get('password2') + if pw and pw2: + if pw != pw2: + messages.error(request, _("Passwords do not match")) + else: + request.user.set_password(pw) + + email = request.POST.get('email') + if email: + request.user.email = email + else: + request.user.email = '' + + request.user.save() + + return render(request, 'lambdainst/settings.html', dict(title=_("Settings"))) + + +@login_required +def gift_code(request): + try: + code = GiftCode.objects.get(code=request.POST.get('code', '').strip(), available=True) + except GiftCode.DoesNotExist: + code = None + + if code is None: + messages.error(request, _("Gift code not found or already used.")) + elif not code.use_on(request.user): + messages.error(request, _("Gift code only available to free accounts.")) + else: + messages.success(request, _("OK!")) + + return redirect('account:index') + + +@login_required +def logs(request): + page_size = 20 + page = int(request.GET.get('page', 0)) + offset = page * page_size + + base = core_api.info['current_instance'] + path = '/users/' + request.user.username + '/sessions/' + l = core_api.get(base + path, offset=offset, limit=page_size) + return render(request, 'lambdainst/logs.html', { + 'sessions': l['items'], + 'page': page, + 'prev': page - 1 if page > 0 else None, + 'next': page + 1 if offset + page_size < l['total_count'] else None, + 'last_page': l['total_count'] // page_size, + 'title': _("Logs"), + }) + + +@login_required +def config(request): + return render(request, 'lambdainst/config.html', dict( + titla=_("Config"), + config_os=openvpn.CONFIG_OS, + config_countries=(c for _, c in get_locations()), + config_protocols=openvpn.PROTOCOLS, + )) + + +@login_required +def config_dl(request): + allowed_cc = [cc for (cc, _) in get_locations()] + + common_options = { + 'protocol': request.GET.get('protocol'), + 'os': request.GET.get('client_os'), + 'http_proxy': request.GET.get('http_proxy'), + 'ipv6': 'enable_ipv6' in request.GET, + } + + # Should be validated since it's used in the filename + # other common options are only put in the config file + protocol = common_options['protocol'] + if protocol not in ('udp', 'udpl', 'tcp'): + return HttpResponseNotFound() + + location = request.GET.get('gateway') + + if location == 'all': + # Multiple gateways in a zip archive + + f = io.BytesIO() + z = zipfile.ZipFile(f, mode='w') + + for gw_name in allowed_cc + ['random']: + filename = 'ccrypto-%s-%s.ovpn' % (gw_name, protocol) + config = openvpn.make_config(gw_name=gw_name, **common_options) + z.writestr(filename, config.encode('utf-8')) + + z.close() + + r = HttpResponse(content=f.getvalue(), content_type='application/zip') + r['Content-Disposition'] = 'attachment; filename="%s.zip"' % filename + return r + else: + # Single gateway + if location[3:] in allowed_cc: + gw_name = location[3:] + else: + gw_name = 'random' + filename = 'ccrypto-%s-%s.ovpn' % (gw_name, protocol) + + config = openvpn.make_config(gw_name=gw_name, **common_options) + + if 'plain' in request.GET: + return HttpResponse(content=config, content_type='text/plain') + else: + r = HttpResponse(content=config, content_type='application/x-openvpn-profile') + r['Content-Disposition'] = 'attachment; filename="%s.ovpn"' % filename + return r + + +@csrf_exempt +def api_auth(request): + if request.method != 'POST': + return HttpResponseNotFound() + + username = request.POST.get('username') + password = request.POST.get('password') + secret = request.POST.get('secret') + + if secret != LCORE_INST_SECRET: + return HttpResponseForbidden(content="Invalid secret") + + user = authenticate(username=username, password=password) + if not user or not user.is_active: + return JsonResponse(dict(status='fail', message="Invalid credentials")) + + if not user.vpnuser.is_paid: + return JsonResponse(dict(status='fail', message="Not allowed to connect")) + + user.vpnuser.last_vpn_auth = timezone.now() + user.vpnuser.save() + + return JsonResponse(dict(status='ok')) + + +def status(request): + locations = get_locations() + + ctx = { + 'title': _("Status"), + 'n_users': VPNUser.objects.filter(expiration__gte=timezone.now()).count(), + 'n_sess': current_active_sessions(), + 'n_gws': sum(l['servers'] for cc, l in locations), + 'n_countries': len(set(cc for cc, l in locations)), + 'total_bw': sum(l['bandwidth'] for cc, l in locations), + 'locations': locations, + } + return render(request, 'lambdainst/status.html', ctx) + + +@user_passes_test(lambda user: user.is_staff) +def admin_status(request): + graph_name = request.GET.get('graph_name') + graph_period = request.GET.get('period') + if graph_period not in ('y', 'm'): + graph_period = 'm' + if graph_name: + if graph_name == 'users': + content = graphs.users_graph(graph_period) + elif graph_name == 'payments_paid': + content = graphs.payments_paid_graph(graph_period) + elif graph_name == 'payments_success': + content = graphs.payments_success_graph(graph_period) + else: + return HttpResponseNotFound() + return HttpResponse(content=content, content_type='image/svg+xml') + + payment_status = ((b, b.get_info()) for b in ACTIVE_BACKENDS.values()) + payment_status = ((b, i) for (b, i) in payment_status if i) + + ctx = { + 'api_status': {k: str(v) for k, v in core_api.info.items()}, + 'payment_backends': sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_id), + 'payment_status': payment_status, + } + ctx.update(site.each_context(request)) + return render(request, 'lambdainst/admin_status.html', ctx) + + +@user_passes_test(lambda user: user.is_staff) +def admin_ref(request): + last_week = datetime.now() - timedelta(days=7) + last_month = datetime.now() - timedelta(days=30) + + top_ref = User.objects.annotate(n_ref=Count('referrals')).order_by('-n_ref')[:10] + top_ref_week = User.objects.filter(referrals__user__date_joined__gt=last_week) \ + .annotate(n_ref=Count('referrals')) \ + .order_by('-n_ref')[:10] + top_ref_month = User.objects.filter(referrals__user__date_joined__gt=last_month) \ + .annotate(n_ref=Count('referrals')) \ + .order_by('-n_ref')[:10] + + ctx = { + 'top_ref': top_ref, + 'top_ref_week': top_ref_week, + 'top_ref_month': top_ref_month, + } + ctx.update(site.each_context(request)) + return render(request, 'lambdainst/admin_ref.html', ctx) + + + + + diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..80c7d88 --- /dev/null +++ b/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,1141 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-07-07 17:43+0000\n" +"PO-Revision-Date: 2016-04-07 01:32+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Language: fr\n" +"X-Source-Language: C\n" +"X-Generator: Poedit 1.8.7.1\n" + +#: ccvpn/passwords.py:41 +msgid "algorithm" +msgstr "" + +#: ccvpn/passwords.py:42 +msgid "salt" +msgstr "" + +#: ccvpn/passwords.py:43 +msgid "hash" +msgstr "" + +#: lambdainst/admin.py:17 +msgid "Code must be [a-zA-Z0-9]" +msgstr "" + +#: lambdainst/admin.py:19 +msgid "Code must be between 1 and 32 characters" +msgstr "" + +#: lambdainst/admin.py:39 +msgid "(rewarded)" +msgstr "" + +#: lambdainst/admin.py:41 +msgid "(not rewarded)" +msgstr "" + +#: lambdainst/admin.py:43 +msgid "Referrer" +msgstr "" + +#: lambdainst/admin.py:48 lambdainst/admin.py:92 +msgid "Is paid?" +msgstr "Est payé?" + +#: lambdainst/admin.py:83 +msgid "Important dates" +msgstr "Dates importantes" + +#: lambdainst/admin.py:84 +msgid "Permissions" +msgstr "Permissions" + +#: lambdainst/admin.py:116 tickets/admin.py:52 +msgid "Comment" +msgstr "Notes" + +#: lambdainst/forms.py:25 +msgid "Username taken." +msgstr "Identifiant réservé." + +#: lambdainst/forms.py:32 lambdainst/forms.py:35 templates/ccvpn/signup.html:13 +msgid "Username" +msgstr "Identifiant" + +#: lambdainst/forms.py:39 templates/ccvpn/signup.html.py:19 +msgid "Password" +msgstr "Mot de passe" + +#: lambdainst/forms.py:40 +msgid "Anything" +msgstr "N'importe quoi" + +#: lambdainst/forms.py:43 templates/ccvpn/signup.html.py:24 +msgid "Repeat" +msgstr "Répétez" + +#: lambdainst/forms.py:44 +msgid "Same Anything" +msgstr "La même chose" + +#: lambdainst/forms.py:47 lambdainst/forms.py:48 templates/ccvpn/signup.html:30 +#: templates/lambdainst/settings.html.py:22 +#: templates/registration/password_reset_form.html:11 +msgid "E-Mail" +msgstr "E-Mail" + +#: lambdainst/forms.py:54 +msgid "Passwords are not the same" +msgstr "Les mots de passe de correspondent pas" + +#: lambdainst/models.py:24 +msgid "VPN User" +msgstr "VPN User" + +#: lambdainst/models.py:25 +msgid "VPN Users" +msgstr "VPN Users" + +#: lambdainst/models.py:92 +msgid "Gift Code" +msgstr "Code cadeau" + +#: lambdainst/models.py:93 +msgid "Gift Codes" +msgstr "Codes cadeau" + +#: lambdainst/models.py:138 +msgid "Gift Code User" +msgstr "Utilisateur de codes" + +#: lambdainst/models.py:139 +msgid "Gift Code Users" +msgstr "Utilisateurs de codes" + +#: lambdainst/openvpn.py:8 templates/ccvpn/page.html.py:17 +msgid "Windows" +msgstr "Windows" + +#: lambdainst/openvpn.py:9 templates/ccvpn/page.html.py:16 +msgid "Android" +msgstr "Android" + +#: lambdainst/openvpn.py:10 +msgid "Ubuntu" +msgstr "Ubuntu" + +#: lambdainst/openvpn.py:11 templates/ccvpn/page.html.py:19 +msgid "OS X" +msgstr "OS X" + +#: lambdainst/openvpn.py:12 +msgid "iOS" +msgstr "iOS" + +#: lambdainst/openvpn.py:13 +msgid "Freebox" +msgstr "Freebox" + +#: lambdainst/openvpn.py:14 +msgid "Other / GNU/Linux" +msgstr "Autre / GNU/Linux" + +#: lambdainst/openvpn.py:18 +msgid "UDP (default)" +msgstr "UDP (par défaut)" + +#: lambdainst/openvpn.py:19 +msgid "TCP" +msgstr "TCP" + +#: lambdainst/openvpn.py:20 +msgid "UDP (low MTU)" +msgstr "UDP (MTU réduit)" + +#: lambdainst/templatetags/bw.py:14 +#, python-format +msgid "%(bw)d bps" +msgid_plural "%(bw)d bps" +msgstr[0] "" +msgstr[1] "" + +#: lambdainst/templatetags/bw.py:26 +#, python-format +msgid "%(size)d bps" +msgid_plural "%(size)d bps" +msgstr[0] "" +msgstr[1] "" + +#: lambdainst/templatetags/bw.py:28 +#, python-format +msgid "%s Kbps" +msgstr "" + +#: lambdainst/templatetags/bw.py:30 +#, python-format +msgid "%s Mbps" +msgstr "" + +#: lambdainst/templatetags/bw.py:32 +#, python-format +msgid "%s Gbps" +msgstr "" + +#: lambdainst/templatetags/bw.py:34 +#, python-format +msgid "%s Tbps" +msgstr "" + +#: lambdainst/templatetags/bw.py:36 +#, python-format +msgid "%s Pbps" +msgstr "" + +#: lambdainst/views.py:91 +msgid "Awesome VPN! 3€ per month, with a free 7 days trial!" +msgstr "" + +#: lambdainst/views.py:98 templates/account_layout.html.py:9 +#: templates/account_layout.html:11 templates/lambdainst/account.html.py:10 +msgid "Account" +msgstr "Compte" + +#: lambdainst/views.py:137 lambdainst/views.py:180 +msgid "OK!" +msgstr "OK!" + +#: lambdainst/views.py:139 +msgid "Invalid captcha" +msgstr "Captcha invalide" + +#: lambdainst/views.py:153 +msgid "Passwords do not match" +msgstr "Les mots de passe ne correspondent pas" + +#: lambdainst/views.py:165 templates/account_layout.html.py:13 +#: templates/lambdainst/settings.html:6 +msgid "Settings" +msgstr "Options" + +#: lambdainst/views.py:176 +msgid "Gift code not found or already used." +msgstr "Code inconnu ou déjà utilisé." + +#: lambdainst/views.py:178 +msgid "Gift code only available to free accounts." +msgstr "Code uniquement disponible pour les nouveaux comptes." + +#: lambdainst/views.py:200 templates/account_layout.html.py:15 +#: templates/lambdainst/logs.html:6 +msgid "Logs" +msgstr "Logs" + +#: lambdainst/views.py:207 templates/lambdainst/config.html.py:7 +msgid "Config" +msgstr "Config" + +#: lambdainst/views.py:296 payments/backends.py:120 payments/backends.py:122 +#: templates/admin/index.html:53 templates/admin/index.html.py:56 +#: templates/lambdainst/admin_ref.html:16 +#: templates/lambdainst/admin_status.html:16 templates/payments/list.html:13 +#: templates/tickets/index.html.py:29 templates/tickets/list.html:14 +msgid "Status" +msgstr "État" + +#: payments/admin.py:16 +msgid "Payment Data" +msgstr "Données de paiement" + +#: payments/admin.py:39 +msgid "Amount" +msgstr "Montant" + +#: payments/admin.py:43 +msgid "Paid amount" +msgstr "Montant payé" + +#: payments/backends.py:47 payments/backends.py:48 +msgid "Bitcoin" +msgstr "" + +#: payments/backends.py:81 +#, python-format +msgid "Please send %(amount)s BTC to %(address)s" +msgstr "Envoyez %(amount)s BTC à %(address)s" + +#: payments/backends.py:125 +msgid "Bitcoin value" +msgstr "Valeur du Bitcoin" + +#: payments/backends.py:126 +msgid "Testnet" +msgstr "" + +#: payments/backends.py:127 +msgid "Balance" +msgstr "Balance" + +#: payments/backends.py:128 +msgid "Blocks" +msgstr "Blocks" + +#: payments/backends.py:129 +msgid "Bitcoind version" +msgstr "Version de Bitcoind" + +#: payments/backends.py:147 +msgid "Manual" +msgstr "Manuel" + +#: payments/backends.py:152 payments/backends.py:153 +msgid "PayPal" +msgstr "PayPal" + +#: payments/backends.py:189 +msgid "" +"Waiting for PayPal to confirm the transaction... It can take up to a few " +"minutes..." +msgstr "" +"En attente de la confirmation par Paypal... Cette étape peut durer quelques " +"minutes..." + +#: payments/backends.py:258 +msgid "Stripe" +msgstr "Stripe" + +#: payments/backends.py:259 +msgid "Credit Card or Alipay (Stripe)" +msgstr "Carte ou Alipay (Stripe)" + +#: payments/backends.py:312 +msgid "No payment information was received." +msgstr "Aucune information de paiement reçue." + +#: payments/backends.py:329 +msgid "The payment has been refunded or rejected." +msgstr "Le paiement a été remboursé ou rejeté." + +#: payments/backends.py:337 +msgid "The paid amount is under the required amount." +msgstr "La montant payé est inférieur au montant requis." + +#: payments/backends.py:361 +msgid "Coinbase" +msgstr "Coinbase" + +#: payments/backends.py:362 +msgid "Bitcoin with CoinBase" +msgstr "Bitcoin avec CoinBase" + +#: payments/models.py:14 +msgid "Waiting for payment" +msgstr "En attente du paiement" + +#: payments/models.py:15 +msgid "Confirmed" +msgstr "Confirmé" + +#: payments/models.py:16 +msgid "Cancelled" +msgstr "Annullé" + +#: payments/models.py:17 +msgid "Rejected by processor" +msgstr "Rejeté" + +#: payments/models.py:18 +msgid "Payment processing failed" +msgstr "Traitement échoué" + +#: payments/models.py:22 +msgid "Every 6 months" +msgstr "Tous les 6 mois" + +#: payments/models.py:23 +msgid "Yearly" +msgstr "Annuel" + +#: templates/account_layout.html:12 +msgid "Config Download" +msgstr "Configuration" + +#: templates/account_layout.html:14 +msgid "Payments" +msgstr "Paiements" + +#: templates/admin/index.html:20 +#, python-format +msgid "Models in the %(name)s application" +msgstr "" + +#: templates/admin/index.html:31 templates/lambdainst/account.html.py:61 +#: templates/lambdainst/account.html:83 +msgid "Add" +msgstr "Ajouter" + +#: templates/admin/index.html:37 +msgid "Change" +msgstr "" + +#: templates/admin/index.html:47 +msgid "You don't have permission to edit anything." +msgstr "" + +#: templates/admin/index.html:60 +msgid "Referrers Stats" +msgstr "" + +#: templates/admin/index.html:71 +msgid "Recent Actions" +msgstr "" + +#: templates/admin/index.html:72 +msgid "My Actions" +msgstr "" + +#: templates/admin/index.html:76 +msgid "None available" +msgstr "" + +#: templates/admin/index.html:90 +msgid "Unknown content" +msgstr "" + +#: templates/ccvpn/index.html:6 +msgid "CCrypto VPN is a cheap, fast, anonymous and secure VPN service" +msgstr "" +"CCrypto VPN est un service de VPN peu cher, rapide, anonyme et sécurisé" + +#: templates/ccvpn/index.html:15 +msgid "Unlimited" +msgstr "Illimité" + +#: templates/ccvpn/index.html:16 +msgid "Unlimited bandwidth" +msgstr "Bande passante illimitée" + +#: templates/ccvpn/index.html:17 +msgid "Uncensored" +msgstr "Sans censure" + +#: templates/ccvpn/index.html:18 +msgid "We have porn and pirates." +msgstr "Avec du porn et des pirates." + +#: templates/ccvpn/index.html:24 +msgid "Cheap" +msgstr "Pas cher" + +#: templates/ccvpn/index.html:25 +msgid "per month!" +msgstr "par mois!" + +#: templates/ccvpn/index.html:26 +msgid "We accept Paypal, Bitcoins and Stripe." +msgstr "Accepte PayPal, des Bitcoins et Stripe." + +#: templates/ccvpn/index.html:32 +msgid "Secure" +msgstr "Sécurisé" + +#: templates/ccvpn/index.html:33 +msgid "Encrypted tunnel" +msgstr "Tunnel chiffré" + +#: templates/ccvpn/index.html:34 +msgid "with an anonymous address." +msgstr "avec une adresse anonyme." + +#: templates/ccvpn/index.html:35 +msgid "Supports DNSSEC and PFS." +msgstr "Avec du DNSSEC et PFS." + +#: templates/ccvpn/index.html:41 +msgid "OpenVPN" +msgstr "OpenVPN" + +#: templates/ccvpn/index.html:42 +msgid "Secure, Free, easy. On:" +msgstr "Sécurisé, Libre, simple. Sur:" + +#: templates/ccvpn/index.html:54 +msgid "Fast" +msgstr "Rapide" + +#: templates/ccvpn/index.html:56 +msgid "Compressed tunnel." +msgstr "Tunnel compressé." + +#: templates/ccvpn/index.html:57 +msgid "Even on Youtube." +msgstr "Même sur YouTube." + +#: templates/ccvpn/index.html:66 +msgid "Sign up and get your free trial" +msgstr "Créez un compte et profitez de l'essai gratuit" + +#: templates/ccvpn/index.html:72 +msgid "Why?" +msgstr "Pourquoi?" + +#: templates/ccvpn/index.html:74 +msgid "Hide any personal data found from your IP address" +msgstr "Cacher les données personnelles liées à votre adresse IP" + +#: templates/ccvpn/index.html:75 +msgid "Protect yourself on open networks" +msgstr "Protéger vos données et votre vie privée sur des réseaux non sécurisés" + +#: templates/ccvpn/index.html:76 +msgid "Compress traffic on slow connections" +msgstr "Compresser les données sur les connexions lentes" + +#: templates/ccvpn/index.html:77 +msgid "Bypass overly restrictive firewalls" +msgstr "Contourner les firewalls restrictifs" + +#: templates/ccvpn/index.html:78 +msgid "Enable IPv6 on IPv4-only networks" +msgstr "Avoir de l'IPv6 sur les réseaux sans IPv6" + +#: templates/ccvpn/index.html:82 +msgid "VPN done the right way" +msgstr "Le VPN, bien fait" + +#: templates/ccvpn/index.html:84 +msgid "Anonymity" +msgstr "Anonymat" + +#: templates/ccvpn/index.html:84 +msgid "We don't even require your email address." +msgstr "Il n'y a même pas besoin de donner son adresse e-mail." + +#: templates/ccvpn/index.html:85 tickets/models.py:15 +msgid "Security" +msgstr "Sécurité" + +#: templates/ccvpn/index.html:85 +msgid "Best TLS available, RSA 4096b, BlowFish 128b." +msgstr "Meilleur TLS disponible, RSA 4096b, BlowFish 128b." + +#: templates/ccvpn/index.html:86 +msgid "Privacy" +msgstr "Intimité" + +#: templates/ccvpn/index.html:86 +msgid "Not a single byte of your traffic is logged." +msgstr "Pas un seul byte du trafic n'est enregistré." + +#: templates/ccvpn/page.html:8 +msgid "Help" +msgstr "Aide" + +#: templates/ccvpn/page.html:10 +msgid "Frequently Asked Questions" +msgstr "Questions fréquemment posées" + +#: templates/ccvpn/page.html:11 +msgid "Self-Diagnosis" +msgstr "Auto-Diagnostic" + +#: templates/ccvpn/page.html:14 +msgid "Installation" +msgstr "Installation" + +#: templates/ccvpn/page.html:18 +msgid "GNU/Linux" +msgstr "GNU/Linux" + +#: templates/ccvpn/signup.html:8 templates/ccvpn/signup.html.py:37 +#: templates/layout.html:34 +msgid "Sign up" +msgstr "Créer un compte" + +#: templates/ccvpn/signup.html:16 +msgid "2 to 32 alphanumeric characters." +msgstr "2 à 32 caractères alphanumériques." + +#: templates/ccvpn/signup.html:22 +msgid "Anything from 1 to 256 characters." +msgstr "N'importe quoi entre 1 et 256 caractères." + +#: templates/ccvpn/signup.html:27 +msgid "Same password." +msgstr "Le même mot de passe." + +#: templates/ccvpn/signup.html:33 +msgid "Optional." +msgstr "Optionnel." + +#: templates/ccvpn/signup.html:34 +msgid "Used to recover your password and confirm stuff." +msgstr "Utilisé pour retrouver votre mot de passe." + +#: templates/ccvpn/signup.html:41 +msgid "Already a member? Log in" +msgstr "Déjà membre ? Connectez vous" + +#: templates/ccvpn/signup.html:42 templates/registration/login.html.py:18 +msgid "Forgot your password?" +msgstr "Mot de passe oublié ?" + +#: templates/ccvpn/signup.html:43 templates/registration/login.html.py:19 +#: templates/registration/password_reset_confirm.html:22 +#: templates/registration/password_reset_form.html:21 +msgid "Need help?" +msgstr "Besoin d'aide ?" + +#: templates/lambdainst/account.html:15 +#, python-format +msgid "Your account is paid until %(until)s" +msgstr "Votre compte est activé jusqu'au %(until)s" + +#: templates/lambdainst/account.html:18 +#, python-format +msgid "(%(left)s left)" +msgstr "(%(left)s restant)" + +#: templates/lambdainst/account.html:24 +msgid "" +"You can activate your free trial account for two hours periods for up to one " +"week, by clicking this button:" +msgstr "" +"Vous pouvez activez votre compte d'essai pour des périodes de deux heures " +"pendant jusqu'à une semaine avec ce bouton:" + +#: templates/lambdainst/account.html:33 +msgid "Activate" +msgstr "Activer" + +#: templates/lambdainst/account.html:50 +msgid "Your account is not paid." +msgstr "Votre compte n'est pas payé." + +#: templates/lambdainst/account.html:63 +msgid "month" +msgstr "mois" + +#: templates/lambdainst/account.html:64 templates/lambdainst/account.html:65 +#: templates/lambdainst/account.html:66 +msgid "months" +msgstr "mois" + +#: templates/lambdainst/account.html:71 +msgid "with" +msgstr "avec" + +#: templates/lambdainst/account.html:93 +msgid "Gift code" +msgstr "Code cadeau" + +#: templates/lambdainst/account.html:99 +msgid "Use" +msgstr "Utiliser" + +#: templates/lambdainst/account.html:108 +msgid "" +"Get two weeks for free for every referral that takes at least one month!" +msgstr "" +"Gagnez deux semaines gratuites pour chaque client qui a utilisé ce lien !" + +#: templates/lambdainst/account.html:111 +msgid "Share this link" +msgstr "Partagez ce lien" + +#: templates/lambdainst/account.html:115 +msgid "tweet" +msgstr "" + +#: templates/lambdainst/admin_ref.html:15 +#: templates/lambdainst/admin_status.html:15 +msgid "Home" +msgstr "Accueil" + +#: templates/lambdainst/admin_ref.html:25 +msgid "Top Referrers (week)" +msgstr "" + +#: templates/lambdainst/admin_ref.html:39 +msgid "Top Referrers (month)" +msgstr "" + +#: templates/lambdainst/admin_ref.html:53 +msgid "Top Referrers (all time)" +msgstr "" + +#: templates/lambdainst/admin_status.html:25 +msgid "Core API Info" +msgstr "" + +#: templates/lambdainst/admin_status.html:39 +msgid "Active Payment Backends" +msgstr "Méthodes de payment actives" + +#: templates/lambdainst/admin_status.html:57 +msgid "Backend: " +msgstr "Méthode :" + +#: templates/lambdainst/config.html:11 +msgid "OS" +msgstr "OS" + +#: templates/lambdainst/config.html:20 +msgid "Gateway" +msgstr "Serveur" + +#: templates/lambdainst/config.html:22 +msgid "Random" +msgstr "Aléatoire" + +#: templates/lambdainst/config.html:23 +msgid "All (multiple config files)" +msgstr "Tous (plusieurs fichiers)" + +#: templates/lambdainst/config.html:26 +msgid "Country" +msgstr "Pays" + +#: templates/lambdainst/config.html:36 +msgid "Protocol" +msgstr "Protocole" + +#: templates/lambdainst/config.html:42 +msgid "" +"TCP is slower. Use it only if you have important packet loss or if UDP is " +"filtered." +msgstr "" +"TCP est plus lent. Ne l'utilisez que si l'UDP est bloqué ou pose des " +"problèmes." + +#: templates/lambdainst/config.html:46 +msgid "Use HTTP Proxy?" +msgstr "Utiliser un proxy?" + +#: templates/lambdainst/config.html:48 +msgid "Leave empty if you don't know." +msgstr "Dans le doute, laissez vide." + +#: templates/lambdainst/config.html:50 +msgid "Requires TCP." +msgstr "Nécéssite TCP." + +#: templates/lambdainst/config.html:56 +msgid "Enable IPv6?" +msgstr "Activer l'IPv6?" + +#: templates/lambdainst/config.html:60 +msgid "Download config" +msgstr "Télécharger la config" + +#: templates/lambdainst/logs.html:7 +msgid "" +"Everything we have to keep about you. Automatically deleted after 1 year." +msgstr "" +"Tout ce que nous avons l'obligation de garder. Automatiquement éffacé au " +"bout de 1 an." + +#: templates/lambdainst/logs.html:12 templates/payments/list.html.py:11 +msgid "Date" +msgstr "Date" + +#: templates/lambdainst/logs.html:13 +msgid "Duration" +msgstr "Durée" + +#: templates/lambdainst/logs.html:14 +msgid "Client IP" +msgstr "IP Source" + +#: templates/lambdainst/logs.html:15 +msgid "Shared IP" +msgstr "IP Partagée" + +#: templates/lambdainst/logs.html:16 templates/lambdainst/status.html.py:42 +msgid "Bandwidth" +msgstr "Bande passante" + +#: templates/lambdainst/logs.html:27 tickets/admin.py:58 tickets/models.py:58 +msgid "Open" +msgstr "Open" + +#: templates/lambdainst/logs.html:30 templates/lambdainst/logs.html.py:31 +msgid "[unknown]" +msgstr "[inconnu]" + +#: templates/lambdainst/mail_expire_soon.txt:4 +#, python-format +msgid "Your account will expire in %(exp)s" +msgstr "Votre compte expirera le %(exp)s" + +#: templates/lambdainst/mail_expire_soon.txt:5 +msgid "You can renew it here:" +msgstr "Vous pouvez le renouveller ici:" + +#: templates/lambdainst/settings.html:12 templates/lambdainst/settings.html:17 +msgid "Change password" +msgstr "Changer le mot de passe" + +#: templates/lambdainst/settings.html:17 +msgid "repeat" +msgstr "répétez" + +#: templates/lambdainst/settings.html:27 +msgid "Save" +msgstr "Enregistrer" + +#: templates/lambdainst/status.html:8 +msgid "Our Servers" +msgstr "Nos Serveurs" + +#: templates/lambdainst/status.html:12 +#, python-format +msgid "We have %(n_users)s active users." +msgstr "Il y a %(n_users)s comptes actifs." + +#: templates/lambdainst/status.html:15 +#, python-format +msgid "%(n_sess)s are using our VPN right now." +msgstr "%(n_sess)s sont connectés en ce moment." + +#: templates/lambdainst/status.html:20 +#, python-format +msgid "" +"They are connected to %(n_gws)s servers spread across " +"%(n_countries)s countries to provide a low-latency and secure access " +"anywhere in the world." +msgstr "" +"Ils sont connectés à %(n_gws)s serveurs répartis sur " +"%(n_countries)s pays, pour fournir un accès rapide et sécurisé dans le " +"monde entier." + +#: templates/lambdainst/status.html:28 +#, python-format +msgid "" +"Our network has a total bandwidth of %(total_bw)s and all our " +"servers are DDoS-protected." +msgstr "" +"Notre réseau a une bande passante totale de %(total_bw)s et tous " +"nos serveurs sont protégés contre les attaques DDoS." + +#: templates/lambdainst/status.html:36 +msgid "Locations" +msgstr "Localisations" + +#: templates/lambdainst/status.html:39 +msgid "Location" +msgstr "Localisation" + +#: templates/lambdainst/status.html:40 +msgid "Hostname" +msgstr "Nom" + +#: templates/lambdainst/status.html:41 templates/layout.html.py:50 +msgid "Servers" +msgstr "Serveurs" + +#: templates/layout.html:22 +msgid "Service Status" +msgstr "État des services" + +#: templates/layout.html:30 +msgid "Your account" +msgstr "Votre compte" + +#: templates/layout.html:31 +msgid "Logout" +msgstr "Se déconnecter" + +#: templates/layout.html:35 templates/registration/login.html.py:8 +#: templates/registration/login.html:13 +msgid "Log in" +msgstr "Se connecter" + +#: templates/layout.html:49 +msgid "VPN" +msgstr "VPN" + +#: templates/layout.html:51 +msgid "Guides" +msgstr "Guides" + +#: templates/layout.html:53 templates/tickets/index.html.py:6 +#: templates/tickets/layout.html:8 tickets/models.py:14 +msgid "Support" +msgstr "Support" + +#: templates/layout.html:56 +msgid "Admin" +msgstr "Admin" + +#: templates/layout.html:75 +msgid "CCrypto_VPN on Twitter" +msgstr "CCrypto_VPN sur Twitter" + +#: templates/layout.html:76 +msgid "ToS" +msgstr "CGU" + +#: templates/layout.html:77 +msgid "It's open-source!" +msgstr "C'est open-source!" + +#: templates/layout.html:85 +msgid "Any question? Chat with us" +msgstr "Une question ? Contactez nous" + +#: templates/payments/form.html:7 templates/payments/view.html.py:13 +msgid "Payment" +msgstr "Paiement" + +#: templates/payments/list.html:6 +msgid "Subscription" +msgstr "Abonnement" + +#: templates/payments/list.html:12 +msgid "Value" +msgstr "Valeur" + +#: templates/payments/view.html:23 +msgid "The payment has been confirmed." +msgstr "Le paiement a été confirmé." + +#: templates/payments/view.html:25 +msgid "The payment has been cancelled." +msgstr "Le paiement a été annulé." + +#: templates/payments/view.html:27 +msgid "This page will be updated with payment progress." +msgstr "Cette page sera mise à jour avec la progression du paiement." + +#: templates/payments/view.html:30 +msgid "Go back to your account" +msgstr "Retourner sur votre compte" + +#: templates/registration/login.html:17 +#: templates/registration/password_reset_confirm.html:20 +#: templates/registration/password_reset_form.html:19 +msgid "Don't have an account? Create one" +msgstr "Pas de compte ? Créez-en un" + +#: templates/registration/password_reset_complete.html:8 +#: templates/registration/password_reset_confirm.html:8 +#: templates/registration/password_reset_done.html:8 +#: templates/registration/password_reset_form.html:8 +msgid "Password Reset" +msgstr "Réinitialisation de mot de passe" + +#: templates/registration/password_reset_complete.html:10 +msgid "Your password has been changed." +msgstr "Votre mot de passe a bien été changé." + +#: templates/registration/password_reset_complete.html:11 +msgid "You can now log in with your new password." +msgstr "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe." + +#: templates/registration/password_reset_confirm.html:13 +msgid "Reset" +msgstr "Réinitialiser" + +#: templates/registration/password_reset_confirm.html:16 +msgid "Invalid reset link." +msgstr "Lien de réinitialisatio invalide." + +#: templates/registration/password_reset_confirm.html:21 +#: templates/registration/password_reset_form.html:20 +msgid "Remember your password? Log in" +msgstr "Vous vous en rappelez ? Connectez-vous" + +#: templates/registration/password_reset_done.html:10 +msgid "Sent! Check your emails to reset your password." +msgstr "Envoyé! Allez voir vos emails pour confirmer." + +#: templates/registration/password_reset_form.html:15 +msgid "Reset Password" +msgstr "Changer le mot de passe" + +#: templates/tickets/index.html:10 +msgid "Before creating a ticket, please check if you question is convered in" +msgstr "Avant d'ouvrir un ticket, vérifiez que votre question n'est pas dans" + +#: templates/tickets/index.html:11 +msgid "the FAQ" +msgstr "la FAQ" + +#: templates/tickets/index.html:13 +#, python-format +msgid "" +"If you would prefer to talk us in real time, we have a live chat.
You can also use your own IRC client on #ccrypto on " +"chat.freenode.net. We are however not always online and you may have to wait." +msgstr "" +"Si vous préférez nous parler directement, nous avons un chat live.
Vous pouvez aussi utiliser un client " +"IRC sur #ccrypto sur chat.freenode.net. Nous ne sommes pas présents 24h/24," +"il faudra peut-être attendre." + +#: templates/tickets/index.html:23 +msgid "Your open tickets" +msgstr "Vos tickets ouverts" + +#: templates/tickets/index.html:27 templates/tickets/list.html.py:12 +msgid "ID" +msgstr "ID" + +#: templates/tickets/index.html:28 templates/tickets/list.html.py:13 +#: tickets/forms.py:8 +msgid "Subject" +msgstr "Sujet" + +#: templates/tickets/layout.html:10 +msgid "Live Chat" +msgstr "Chat Live" + +#: templates/tickets/layout.html:11 templates/tickets/new.html.py:7 +msgid "New Ticket" +msgstr "Nouveau ticket" + +#: templates/tickets/layout.html:12 +msgid "Open Tickets" +msgstr "Tickets ouverts" + +#: templates/tickets/layout.html:14 +msgid "Closed Tickets" +msgstr "Tickets fermés" + +#: templates/tickets/layout.html:16 +msgid "All Open" +msgstr "Tous ouverts" + +#: templates/tickets/layout.html:18 +msgid "All Closed" +msgstr "Tous fermés" + +#: templates/tickets/list.html:6 +msgid "Tickets" +msgstr "Tickets" + +#: templates/tickets/list.html:16 +msgid "User" +msgstr "Auteur" + +#: templates/tickets/list.html:49 +msgid "No ticket to show." +msgstr "Aucun ticket." + +#: templates/tickets/mail_support_new.txt:5 +msgid "A new ticket has been created." +msgstr "Un nouveau ticket a été crée." + +#: templates/tickets/mail_support_new.txt:8 +msgid "Category:" +msgstr "Catégorie :" + +#: templates/tickets/mail_support_new.txt:9 +msgid "Subject:" +msgstr "Sujet :" + +#: templates/tickets/mail_support_new.txt:10 +msgid "User:" +msgstr "Auteur :" + +#: templates/tickets/mail_support_reply.txt:5 +msgid "New reply on ticket:" +msgstr "Nouvelle réponse au ticket :" + +#: templates/tickets/mail_support_reply.txt:6 +msgid "From:" +msgstr "Par :" + +#: templates/tickets/mail_user_close.txt:5 +msgid "Your ticket was closed." +msgstr "Votre ticket a été fermé." + +#: templates/tickets/mail_user_reply.txt:5 +msgid "New reply on your ticket:" +msgstr "Nouvelle réponse à votre ticket:" + +#: templates/tickets/new.html:13 +msgid "Open Ticket" +msgstr "Créer le ticket" + +#: templates/tickets/view.html:7 tickets/models.py:66 tickets/models.py:72 +#: tickets/models.py:82 +msgid "Ticket:" +msgstr "Ticket:" + +#: templates/tickets/view.html:9 +msgid "closed" +msgstr "fermé" + +#: templates/tickets/view.html:20 tickets/forms.py:23 +msgid "Private" +msgstr "Privé" + +#: templates/tickets/view.html:40 +msgid "Reply" +msgstr "Répondre" + +#: templates/tickets/view.html:42 +msgid "Close Ticket" +msgstr "Fermer le ticket" + +#: templates/tickets/view.html:44 +msgid "Re-open Ticket" +msgstr "Ré-ouvrir le ticket" + +#: tickets/admin.py:11 +msgid "Close selected tickets (without notice)" +msgstr "Fermer les tickets (sans notification)" + +#: tickets/admin.py:19 +msgid "Close selected tickets" +msgstr "Fermer les tickets séléctionnés" + +#: tickets/admin.py:56 +msgid "Re-opened" +msgstr "Ré-ouvert" + +#: tickets/admin.py:60 tickets/models.py:53 +msgid "Closed" +msgstr "Fermé" + +#: tickets/forms.py:7 +msgid "Category" +msgstr "Catégorie" + +#: tickets/forms.py:9 +msgid "Message" +msgstr "Message" + +#: tickets/models.py:16 +msgid "Account / Billing" +msgstr "Compte / Facturation" + +#: tickets/models.py:36 +msgid "Can view any ticket" +msgstr "Peut voir n'importe quel ticket" + +#: tickets/models.py:37 +msgid "Can reply to any ticket" +msgstr "Peut répondre à n'importe quel ticket" + +#: tickets/models.py:38 +msgid "Can view private messages on tickets" +msgstr "Peut voir les messages privés" + +#: tickets/models.py:39 +msgid "Can post private messages on tickets" +msgstr "Peut envoyer des messages privés" + +#: tickets/models.py:56 +msgid "Waiting for staff" +msgstr "En attente du support" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..d8626e8 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ccvpn.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/pages/faq.en.md b/pages/faq.en.md new file mode 100644 index 0000000..b4c2333 --- /dev/null +++ b/pages/faq.en.md @@ -0,0 +1,126 @@ +Title: Frequently Asked Questions + +[TOC] + +General +------- + +### What is a VPN? +A Virtual Private Network is a private network on the Internet. +Here, it is made of our customers and our servers acts like routers. +That means that everything goes through our servers, and you appear anonymous +on the Internet. +Because of the strong encryption used, attackers or your ISP cannot log, +filter, or change anything. + +### Why should I pay to use it instead of tor? +Although tor may be free (and extremely good for some usage), tor is also very +slow and exit nodes can see and intercept your traffic. +This means that tor is good if you want full anonymity, but not for +everyday web browsing, or to play an online game. For that, a VPN is perfect. + +### Do you propose an affiliate program? +Yes! Share your affiliate link and earn 2 weeks for each referral. +Invite 24 friends and you get one year of free VPN! + +### Can I have a static IP address? +Yes, as each server has its own address. You only have to choose one. + +### Can I have a dedicated IP address? +Not at the moment. + +### Do you monitor or limit bandwidth usage? +No, every user share each VPN server's connection. +We always try to have enough bandwidth (at least 20Mbps) available +for everyone. + +### Do you censor some websites or protocols? +No and we will never do it. + +### Which protocols are supported? +We only support OpenVPN for now. + +### Which payment methods are supported? +We support Paypal, Stripe (credit card) and Bitcoin. +Feel free to ask [the support](/page/help) if you need any other method. + +### Is it open-source? +Yes! Our VPN is made with OpenVPN. +Our servers' config and this website are also open-source and available on our +[GitHub project](https://github.com/CCrypto/ccvpn/). + +### Are my data kept secure? +Yes, the VPN traffic is strongly encrypted and we do not keep any data on the +VPN servers. +The website and database are on a different server, in a +different datacenter. + +### Will there be more servers/countries available? +Yes, but we first need money to pay the servers. +If you would like to have a server somewhere, know a good provider or would +like to host one, please contact us. + + +Account +------- + +### Can I have a trial account? +Yes, we provide 7 days trial accounts. +You just have to [sign up](/account/signup). + +### Can I use my account on multiple machines? +Yes, you can! Up to 10 at the same time! + +### How can I delete my account? +Contact [the support](/page/help). + + +Technical +--------- + +### Encryption used +Authentication uses a 4096 bits RSA key. (3072 bits on oldest servers) +The current recommended key size considered safe until 2030 is 2048 bits. + +VPN trafic encryption is performed with the Blowfish cipher using a random +128 bits key re-generated every 60 minutes, and unique to a VPN connection. + +Key Exchange uses a 3072 bits Diffie-Hellman parameters. +A 2048 bits key is considered safe until 2030. + +### Do you support IPv6? +Yes, most of our servers are dual stack - they perfectly support IPv4 and IPv6 +at the same time. +Some are IPv4 only (but we're working with our providers to fix it) and will +block all IPv6 traffic to make sure your IPv6 address is not leaked. + +### Do you support PPTP? +No, PPTP is not supported and will not be supported. +PPTP is considered insecure and should never be used. + + + +Legal +----- + +### What do you log? +We only keep VPN connection IP addresses because of the law. +We do not keep any data concerning your traffic. + +### Is it really anonymous? +Depends of your definition of anonymous. +It is anonymous, because we will not ask you for your name or verify your +identity to be able to use the VPN, and we use anonymous payment methods +like Bitcoin. +It is anonymous, because no one can find out your identity from the other side +of the VPN. +However, French authorities can ask us for user data (username, email, +payments, ...) + +### Will you log traffic or send user data to authorities? +We won't log your traffic under any condition. +We may give the little we know about you to authorities +only if required by the law to keep the service running. +In this case, we'll try to contact you before doing anything if possible. + + diff --git a/pages/faq.fr.md b/pages/faq.fr.md new file mode 100644 index 0000000..7308457 --- /dev/null +++ b/pages/faq.fr.md @@ -0,0 +1,131 @@ +Title: Questions fréquemment posées + +[TOC] + +Géneral +------- + +### Qu'est-ce qu'un VPN ? +Un VPN (Réseau Privé Virtuel) est un réseau virtual permettant de considérer +plusieurs ordinateurs distants comme étant sur le même réseau local. +Ici, c'est utilisé pour faire passer tout le traffic de nos clients +à travers nos serveurs. +Cela permet de paraître anonyme sur Internet et de chiffrer vos communications +pour qu'un intermédiaire (FAI, attaquant, Wifi public, ...) ne puisse pas +vous espionner ou modifier les données et vous garanti un accès neutre et +sécurisé au réseau. + +### Pourquoi payer pour un VPN au lieu d'utiliser Tor ? +Tor a les avantages d'être gratuit et parfaitement anonyme, mais les noeuds +de sortie ne sont pas toujours dignes de confiance et peuvent enregistrer ou +intercepter vos données, et c'est beaucoup plus lent qu'un VPN. +Un VPN est donc largement plus adapté à une utilisation de tous les jours. + +### Avez-vous un programme d'affiliation ? +Oui, vous pouvez partager un lien associé à votre compte, qui vous +fera gagner 2 semaines de VPN pour chaque client l'ayant suivi. +Inviter 24 personnes vous donne donc 1 an de VPN gratuit ! + +### Puis-je avoir une adresse statique ? +Oui, chaque serveur a une adresse statique. Il suffit d'en choisir un. + +### Puis-je avoir une adresse dédiée ? +Non, pas pour le moment. + +### Y a-t-il une limite de bande passante ? +Non, tous les utilisateurs partagent équitablement la connexion des serveurs. +Nous faisons en sorte qu'il y ait toujours un minimum de 20Mbps disponible +pour chaque client. + +### Censurez-vous certains sites ou protocoles ? +Non, et nous ne le ferons jamais. Le VPN vous fourni un accès complêtement neutre. + +### Avec quels protocoles fonctionne le VPN ? +Notre VPN est fait avec OpenVPN. + +### Quelles méthodes de payement sont disponibles ? +Vous pouvez payer par Paypal ou Stripe (carte), ou encore avec des Bitcoins. +Vous pouvez [nous contacter](/page/help) si vous avez besoin d'un autre moyen +de payement. + +### Est-ce Libre ? +Oui ! Notre VPN fonctionne avec OpenVPN, et ce site ansi que les outils que nous +avons développé pour gérer le VPN sont libres et disponibles sur +[GitHub](https://github.com/CCrypto/). + +### Est-ce vraiment sécurisé ? +Oui, le VPN utilise différents algorithmes de chiffrement fiables et nous ne +gardons aucune données sensible sur les serveurs du VPN. +Les comptes clients et historiques de connexions sont uniquement gardés sur des +serveurs séparés. + +### Y aura-t-il plus de serveurs ou dans d'autres pays ? +Oui, nous ajoutons des serveurs en fonction de la demande et de nos moyens. +Si vous voudriez héberger un serveur, recommander un bon hébergeur, ou +seriez simplement intéressé par une certain pays, contactez nous. + +Comptes +------- + +### Puis-je avoir un compte de test gratuit ? +Oui, pendant 7 jours. +Vous n'avez qu'à [créer un compte](/account/signup) et [nous contacter](/page/help). + +### Puis-je utiliser mon compte sur plusieurs machines ? +Oui, vous pouvez utiliser votre compte avec un maximum de 10 connexions +simultannées. +Vous devrez cependant créer un profile pour chacune des +connexions. + +### Comment supprimer mon compte ? +[Contactez nous](/page/help). + + +Technique +--------- + +### Chiffrement +L'authentification utilise une clé RSA de 4096 bits. (3072 sur les serveurs plus anciens) +Les clés de 2048 bits ou plus sont considérées sûres jusqu'à 2030. + +Le traffic est chiffré avec Blowfish, en utilisant une clé aléatoire de 128 bits +re-générée toutes les 60 minutes et unique pour chaque connexion au VPN. + +L'échange de clés (Diffie-Hellman) utilise un groupe de 3072 bits. +2048 bits ou plus est considéré suffisant jusqu'en 2030. + +### Est-ce que l'IPv6 est supporté ? +Oui, la plupart des serveurs fonctionnent en IPv4 et IPv6 (dual stack). +Quelques-uns ne fonctionnent qu'en IPv4 et bloquent entièrement l'IPv6 pour +éviter de laisser passer votre addresse IPv6. + +### Est-ce que le PPTP est supporté ? +Non, le PPTP n'est plus considéré sécurisé et ne doit plus être utilisé. + + +Légal +----- + +### Quelles informations gardez-vous ? +Nous conservons uniquement l'adresses IP et l'heure de chaque connexion, +comme exigé par la loi. Nous n'analysons et n'enregistrons rien concernant +les données passant par le VPN. + +### Est-ce réellement anonyme ? +Ça dépend de votre définition d'anonyme. +C'est anonyme, parce que nous ne vous demandons pas votre nom et ne vérifions pas +votre identité pour vous laisser profiter du VPN, et que nous autorisons des +méthodes de payement anonymes, comme le Bitcoin. +C'est anonyme, parce que l'on ne peut pas associer une connexion à travers le VPN +à votre vraie adresse. +Mais, les autorités françaises peuvent nous demander votre historique de +connexions et les données associées à votre compte. (nom, adresse e-mail, ...) + +### Donnez vous des informations aux autorités ? +Nous ne vous espionnerons jamais. +Le peu de données enregistrées peuvent être transmises aux autorités si requis +par la loi. +Dans ce cas, nous essaierons de contacter les clients concernés avant tout, +si possible. + + diff --git a/pages/help.en.md b/pages/help.en.md new file mode 100644 index 0000000..9647985 --- /dev/null +++ b/pages/help.en.md @@ -0,0 +1,17 @@ +Title: Guides + + +## Installation + + + +## Support + + - [**Frequently Asked Questions**](/page/faq) + - **[Self-Diagnosis](/page/self-diagnosis)**: Before asking for help, check here if you find the solution to your problem. + diff --git a/pages/help.fr.md b/pages/help.fr.md new file mode 100644 index 0000000..0e6f1f0 --- /dev/null +++ b/pages/help.fr.md @@ -0,0 +1,16 @@ +Title: Guides + +## Installation + + + +## Support + + - [**Questions fréquemment posées**](/page/faq) + - [**Auto-Diagnostic**](/page/self-diagnosis) : Avant de demander de l'aide, vérifiez si vous trouvez la solution à votre problème ici. + diff --git a/pages/install-android.en.md b/pages/install-android.en.md new file mode 100644 index 0000000..0c8136e --- /dev/null +++ b/pages/install-android.en.md @@ -0,0 +1,21 @@ +Title: Install on Android + +1. First, you have to install [Arne Schwabe's OpenVPN for Android][openvpn_android]. + It is the most frequently updated and secure OpenVPN client for Android. + +2. Download the .ovpn file you need in [your account](/account/config) and save it + somewhere on your Android device. + +3. Open the OpenVPN for Android application and import the .ovpn file. + It will create a new profile from the configuration file. + +4. *(Optionally)* Edit the imported profile and set your username and password if you do not + want to be asked every time. + +5. You can now connect to that profile. + On connection it will show the log while trying to connect. + If it fails to connect, you can use the menu here to send the log by email + to *support at ccrypto.org* or copy it in a support ticket. + +[openvpn_android]: https://play.google.com/store/apps/details?id=de.blinkt.openvpn + diff --git a/pages/install-android.fr.md b/pages/install-android.fr.md new file mode 100644 index 0000000..91b2180 --- /dev/null +++ b/pages/install-android.fr.md @@ -0,0 +1,21 @@ +Title: Installation sur Android + +1. Installez d'abord [OpenVPN pour Android d'Arne Schwabe][openvpn_android]. + C'est le client Android le mieux mis à jour et le plus sécurisé. + +2. Téléchargez le fichier .ovpn dont vous avez besoin dans [votre compte](/account/config) + et enregistrez-le quelque part. + +3. Ouvrez l'application OpenVPN et importez le .ovpn téléchargé. Un nouveau + profile sera crée. + +4. *(Facultatif)* Éditez le profile et entrez votre identifiant et mot de passe + pour qu'ils ne soient pas demandés à chaque connexion. + +5. Vous pouvez maintenant vous connecter à ce profile. + Pendant la connexion, le log OpenVPN sera affiché. Si il y a une erreur, + vous pouvez envoyer les informations nécessaires à *support at ccrypto.org* + grace au menu ou en copiant le log dans un ticket. + +[openvpn_android]: https://play.google.com/store/apps/details?id=de.blinkt.openvpn + diff --git a/pages/install-gnulinux.en.md b/pages/install-gnulinux.en.md new file mode 100644 index 0000000..b64a546 --- /dev/null +++ b/pages/install-gnulinux.en.md @@ -0,0 +1,107 @@ +Title: Install on GNU/Linux + + +With NetworkManager +------------------- + +*WARNING*: **This method requires a very recent NetworkManager. +Older versions will not work or may be insecure.** +It has not been tested as much as the classic one and may not work on your system. +It is known to work with an up to date Arch Linux and Linux Mint 17 or later. +**If you are not sure about it, choose the other methods** + +1. Download and install OpenVPN and the NetworkManager plugin with your package manager. + + - *Debian*: `sudo apt-get install install openvpn resolvconf network-manager-openvpn network-manager-openvpn-gnome` + - *Fedora*: `sudo yum install openvpn networkmanager-openvpn` + - *Arch Linux*: `sudo pacman -S openvpn networkmanager-openvpn` + +2. Download the .ovpn file you need in [your account](/account/config) and put + it in `/etc/openvpn/` . + ie: `/etc/openvpn/ccrypto.conf` + +3. [Download the ca.crt file](https://vpn.ccrypto.org/ca.crt) and put it in `/etc/openvpn/` aswell. + +4. Create the NetworkManager profile: + + - Create a new OpenVPN connection. This highly depends on your environment: + - *Mint*: Left click on the Network icon in the Control Panel> Network Connections> Add + - Select "Import a saved VPN configuration" + - Select your ccrypto-\*.conf config file from the /etc/openvpn/ directory + - Select "password authentication" as the authentication type + - Enter your CCrypto username and password. + - Select the ca.crt you saved into /etc/openvpn as the CA Certificate and click "Save". + +5. Your VPN is now ready to use with NetworkManager. + + + +With systemd (Arch, Fedora 16 or later, Debian 8 or later, ...) +------------ + +1. Download and install OpenVPN with your package manager. + + - Debian: `sudo apt-get install install openvpn` + - Fedora: `sudo yum install openvpn` + - Arch Linux: `sudo pacman -S openvpn` + +2. Download the .ovpn file you need in [your account](/account/config) and put + it in `/etc/openvpn/` . + ie: `/etc/openvpn/ccrypto.conf` + +3. Start the OpenVPN service: + + sudo systemctl start openvpn@ccrypto + +4. *(Optional)* To make OpenVPN start at boot, + create a text file anywhere and write your username and + password inside, on two lines. + Then, add at the end of your ccrypto.conf file: + + auth-user-pass /path/to/the/file.txt + + And enable the systemd service : + + systemctl enable openvpn@ccrypto + + For additional security, you can make sure only root is be able to access this file: + + sudo chown root:root /path/to/the/file.txt + sudo chmod 600 /path/to/the/file.txt + + + +Without systemd (Debian before 8.0, ...) +--------------- + +1. Download and install OpenVPN with your package manager. + + - Debian: `sudo apt-get install install openvpn resolvconf` + - Fedora: `sudo yum install openvpn` + +2. Download the .ovpn file you need in [your account](/account/config) and put + it in `/etc/openvpn/` . + ie: `/etc/openvpn/ccrypto.conf` + +3. Start the OpenVPN service: + + sudo service openvpn start ccrypto + +4. *(Optional)* To make OpenVPN start at boot, + create a text file anywhere and write your username and + password inside, on two lines. + Then, add at the end of your ccrypto.conf file: + + auth-user-pass /path/to/the/file.txt + + And add the configuration file name to the AUTOSTART list in `/etc/default/openvpn` (you can add it at the end): + + AUTOSTART="ccrypto" + + For additional security, you can make sure only root is be able to access this file: + + sudo chown root:root /path/to/the/file.txt + sudo chmod 600 /path/to/the/file.txt + + + diff --git a/pages/install-gnulinux.fr.md b/pages/install-gnulinux.fr.md new file mode 100644 index 0000000..5cdc201 --- /dev/null +++ b/pages/install-gnulinux.fr.md @@ -0,0 +1,252 @@ +Title: Installation sous GNU/Linux + +Avec NetworkManager +------------------- + +*ATTENTION*: **Cette méthode nécéssite une version très récente de NetworkManager. +Des versions plus anciennes peuvent ne pas fonctionner ou montrer des problèmes +de sécurité.** +Elle a été moins testée que la méthode classique et pourrait ne pas fonctionner +sur votre système. +Elle a fonctionné avec Linux Mint 17 ou plus et Arch Linux à jour. +**Dans le doute, utilisez les autres méthodes.** + +1. Téléchargez et installez OpenVPN et le plugin NM avec votre gestionnaire de paquets : + + - *Debian*: `sudo apt-get install install openvpn resolvconf network-manager-openvpn network-manager-openvpn-gnome` + - *Fedora*: `sudo yum install openvpn networkmanager-openvpn` + - *Arch Linux*: `sudo pacman -S openvpn networkmanager-openvpn` + +2. Téléchargez la configuration (.ovpn) dont vous avez besoin dans + [votre compte](/account/config) et placez la dans `/etc/openvpn/` . + ie: `/etc/openvpn/ccrypto.conf` + +2. [Téléchargez le fichier ca.crt](https://vpn.ccrypto.org/ca.crt) et placez le aussi dans `/etc/openvpn/` . + +4. Créez le profile NetworkManager : + + - Créez une nouvelle connexion. Ça dépend beaucoup de l'environnement. + - *Mint*: Clic gauche sur l'icone Network dans le Control Panel> Network Connections> Add + - Choisissez "Import a saved VPN configuration" / "Importer une configuration VPN enregistrée" + - Sélectionnez le .conf placé dans /etc/openvpn + - Choisissez "password authentication" / "mot de passe" comme type d'authentification + - Entrez vos identifiants CCrypto. + - Choisissez le ca.crt dans /etc/openvpn comme "CA Certificate" / "Certificat du CA" et enregistrez. + +5. Votre VPN est maintenant prêt à être utilisé avec NetworkManager. + + + +Avec systemd (Arch, Fedora 16 ou plus, Debian 8 ou plus, ...) +------------ + +1. Téléchargez et installez OpenVPN avec votre gestionnaire de paquets : + + - Debian: `sudo apt-get install install openvpn` + - Fedora: `sudo yum install openvpn` + - Arch Linux: `sudo pacman -S openvpn` + +2. Téléchargez la configuration (.ovpn) dont vous avez besoin dans + [votre compte](/account/config) et placez la dans `/etc/openvpn/` . + Renommez le pour avoir un .conf. + ie: `/etc/openvpn/ccrypto.conf` + +3. Démarrez le service OpenVPN : + + sudo systemctl start openvpn@ccrypto + +4. *(Facultatif)* Pour qu'OpenVPN puisse se connecter au démarrage, + créez un fichier texte quelque part avec votre identifiant et votre + mot de passe, sur deux lignes. Ajoutez ensuite cette ligne à la fin de + votre fichier .conf : + + auth-user-pass /path/to/the/file.txt + + Et activez le service systemd : + + systemctl enable openvpn@ccrypto + + Pour plus de sécurité, vous pouvez restreindre l'accès à ce fichier : + + sudo chown root:root /path/to/the/file.txt + sudo chmod 600 /path/to/the/file.txt + + +Sans systemd (Debian before 8.0, ...) +--------------- + +1. Téléchargez et installez OpenVPN avec votre gestionnaire de paquets : + + - Debian: `sudo apt-get install install openvpn resolvconf` + - Fedora: `sudo yum install openvpn` + +2. Téléchargez la configuration (.ovpn) dont vous avez besoin dans + [votre compte](/account/config) et placez la dans `/etc/openvpn/` . + Renommez le pour avoir un .conf. + ie: `/etc/openvpn/ccrypto.conf` + +3. Démarrez le service OpenVPN : + + sudo service openvpn start ccrypto + +4. *(Facultatif)* Pour qu'OpenVPN puisse se connecter au démarrage, + créez un fichier texte quelque part avec votre identifiant et votre + mot de passe, sur deux lignes. Ajoutez ensuite cette ligne à la fin de + votre fichier .conf : + + auth-user-pass /path/to/the/file.txt + + Ajoutez le nom du fichier de configuration à la liste AUTOSTART dans `/etc/default/openvpn` (vous pouvez l'ajouter à la fin): + + AUTOSTART="ccrypto" + + Pour plus de sécurité, vous pouvez restreindre l'accès à ce fichier : + + sudo chown root:root /path/to/the/file.txt + sudo chmod 600 /path/to/the/file.txt + + + + + + + + + + + + + + + + + + + + + + + + + + +Vous aurez besoin d'un fichier : Dans [votre compte](/account/), téléchargez +la config. Vous pouvez le renommer en ccrypto.conf. + +**N'utilisez pas le plugin OpenVPN pour Network-Manager.** +N-M ne supporte pas certaines options récentes d'OpenVPN, et ne peut simplement +pas se connecter à notre VPN. + +Si vous avez une question, n'hésitez pas à [nous contacter](/page/help). + + +Fedora 16 ou plus récent +------------------------ +**Vous devez être connecté en tant que root pour démarrer le VPN. +Il est aussi possible d'utiliser sudo.** + +Installez OpenVPN : + + yum install openvpn + +Placez le fichier que vous avez téléchargé dans `/etc/openvpn/`. +Par exemple : `/etc/openvpn/ccrypto.conf` + + cd /lib/systemd/system + ln openvpn@.service openvpn@ccrypto.service + +Démarrez OpenVPN : + + systemctl start openvpn@ccrypto.service + +Maintenant, vous pouvez le faire démarrer en même temps que l'OS +si vous le souhaitez : + + systemctl enable openvpn@ccrypto.service + + +Debian/Ubuntu +------------- +**Vous devez être connecté en tant que root pour démarrer le VPN. +Il est aussi possible d'utiliser sudo.** + +Installez OpenVPN : + + apt-get install openvpn resolvconf + +Placez le fichier que vous avez téléchargé dans `/etc/openvpn/`. +Par exemple : `/etc/openvpn/ccrypto.conf` + +Démarrez OpenVPN : + + service openvpn start ccrypto + +Pour le démarrer en même temps que l'OS, enregistrez vos identifiants comme +expliqué dans la partie en dessous, et éditez `/etc/default/openvpn` pour +décommenter et modifiez la ligne `AUTOSTART`: + + AUTOSTART="ccrypto" + +Linux Mint Mate Edition 17 ou plus récent +------------- + +Pré-requis: Mettez à jour votre système en utilisant la commande: + +``` +sudo aptitude update +``` + +Installez les différents programmes utilisés pour faire fonctionner votre VPN CCrypto: + +``` +sudo aptitude install openvpn resolvconf network-manager-openvpn-gnome +``` + +Redémarrez la machine pour finaliser l'installation. + +Il faut maintenant télécharger le fichier de configuration disponible [sur le site dans la partie account](https://vpn.ccrypto.org/account/). +et le placer dans /etc/openvpn. Téléchargez le fichier ca.crt [disponible ici](https://vpn.ccrypto.org/ca.crt) et placez le dans /etc/openvpn. + +Il s'agit d'un fichier .ovpn (qui fonctionne tel que sous Windows). Un simple renommage de ce fichier en ccrypto-*.conf suffit a faire fonctionner le tout. + +- On clique gauche sur l'icône réseau du Tableau de bord > Connexions Réseaux > Ajouter +- Sélectionnez "importez une configuration VPN enregistrée" +- On va dans le répertoire /etc/openvpn/ et on sélectionne le fichier ccrypto-*.conf. +La fenêtre suivante devrait s'afficher: ![screenshot](http://i.imgur.com/HcdRwgP.png) + +Choisissez Mot de passe comme type d'authentification. Rentrez votre nom d'utilisateur ainsi que votre mot de passe +Et dans certificat du CA, sélectionnez votre ca.crt téléchargé précedemment. + +Votre VPN est prêt à l'utilisation. + +Enregistrer les identifiants +---------------------------- +Vous pouvez faire qu'OpenVPN enregistre votre nom d'utilisateur et votre mot de +passe, pour ne pas avoir à l'entrer à chaque connexion. + +Créez un fichier texte "ccrypto_creds.txt" contenant votre nom sur la +première ligne, et votre mot de passe sur la deuxième, comme ceci: + + JackSparrow + s0mep4ssw0rd + +Déplacez-le ensuite dans `/etc/openvpn/`, avec le fichier +ccrypto.conf que vous avez téléchargé plus tôt. + +Ouvrez ccrypto.ovpn avec un éditeur de texte (vim, gedit, kate, ...) +et ajouter une ligne à la fin: + + auth-user-pass /etc/openvpn/ccrypto_creds.txt + +Pour que seul root puisse lire ce fichier : + + chown root:root /etc/openvpn/ccrypto_creds.txt + chmod 600 /etc/openvpn/ccrypto_creds.txt + +Autres distributions +-------------------- + +Vous devriez lire un guide adapté à la distribution : + +* ArchLinux + diff --git a/pages/install-osx.en.md b/pages/install-osx.en.md new file mode 100644 index 0000000..83329a1 --- /dev/null +++ b/pages/install-osx.en.md @@ -0,0 +1,11 @@ +Title: Install on Mac OS X + +1. Download the .ovpn file you need in [your account](/account/config). + +2. Download and install [TunnelBlick](https://tunnelblick.net/). + +3. Import the provided .ovpn OpenVPN configuration in TunnelBlick. + +4. The VPN is now ready to use within TunnelBlick. + + diff --git a/pages/install-osx.fr.md b/pages/install-osx.fr.md new file mode 100644 index 0000000..f2f8935 --- /dev/null +++ b/pages/install-osx.fr.md @@ -0,0 +1,11 @@ +Title: Installation sous Mac OS X + +1. Télécharger la configuration (.ovpn) dont vous avez besoin dans [votre compte](/account/config). + +2. Téléchargez et installez [TunnelBlick](https://tunnelblick.net/). + +3. Importez le fichier .ovpn dans TunnelBlick. + +4. Vous pouvez utiliser le VPN avec TunnelBlick. + + diff --git a/pages/install-windows.en.md b/pages/install-windows.en.md new file mode 100644 index 0000000..ccccd8a --- /dev/null +++ b/pages/install-windows.en.md @@ -0,0 +1,47 @@ +Title: Install on Windows + + +1. Download OpenVPN for Windows on + [OpenVPN.net](http://openvpn.net/index.php/open-source/downloads.html) + (you need the Windows Installer) and install OpenVPN. + +2. In [your account](/account/config), download the config file (.ovpn) you want to use, + and copy it to `C:\Program Files\OpenVPN\config\`. + If you downloaded multiple config files as an archive, extract it in that folder. + +3. Start `OpenVPN GUI` *as Administrator*. You can find it on your desktop or in the start menu. + Once it's started, you should see it in the system tray. Right click it and select Connect. + +4. It should now open a OpenVPN log window showing its progress. + If an error occured, please see the [self-diagnosis](/page/self-diagnosis) page. + If it doesn't solve your problem or you have another question, contact + our [support](/tickets/) + +5. If everything worked, the OpenVPN icon should turn green. + Your are now connected and can enjoy your secure connection. + + +Save username and password +-------------------------- +You can make OpenVPN remember your username and password, so you don't need +to type them everytime you want to use the VPN. + +This can be done by creating a text file named "ccrypto_creds.txt" containing +your username on the first line and your password on the second +(see example below). +Move it to `C:\Program Files\OpenVPN\config\`, next to the .ovpn file you +copied there before. + +It should look like this: + + JackSparrow + s0mep4ssw0rd + +Then, open the .ovpn file with a text editor (Notepad, Notepad++, ...) +and add this line at the end of the file: + + auth-user-pass ccrypto_creds.txt + +Now, if you restart OpenVPN, it should not ask you for your password anymore. + + diff --git a/pages/install-windows.fr.md b/pages/install-windows.fr.md new file mode 100644 index 0000000..4ba9185 --- /dev/null +++ b/pages/install-windows.fr.md @@ -0,0 +1,48 @@ +Title: Installation sous Windows + + +1. Téléchargez le Windows Installer d'OpenVPN sur + [OpenVPN.net](http://openvpn.net/index.php/open-source/downloads.html) + et installez OpenVPN. + +2. Dans [votre compte](/account/config), téléchargez le fichier de configuration (.ovpn) + et copiez le dans `C:\Program Files\OpenVPN\config\`. + Si vous avez téléchargé plusieurs fichiers dans une archive, extrayez l'archive dans + `C:\Program Files\OpenVPN\config\` + +3. Démarrez `OpenVPN GUI` *en tant qu'Administrateur*. Vous pouvez le trouver sur le bureau ou + dans le menu Démarrer. + Une fois démarré, vous devriez le voir dans la zone de notification. + Faites un clic droit dessus, et choisissez `Connect`. + +4. Une fenêtre de log OpenVPN devrait s'ouvrir et montrer la progression. + Si il y a une erreur, elle y sera affichée et vous pourrez lire + [la page d'auto-diagnostic](/page/self-diagnosis). + Si votre problème n'est pas résolu, [contactez le support](/tickets/). + +5. Si la connexion s'est bien passée, l'icone OpenVPN devrait devenir verte. + Vous êtes alors connecté et pouvez profiter de votre connexion sécurisée. + + +Enregistrer les identifiants +---------------------------- +Vous pouvez faire qu'OpenVPN enregistre votre nom d'utilisateur et votre mot de +passe, pour ne pas avoir à l'entrer à chaque connexion. + +Créez un fichier texte "ccrypto_creds.txt" contenant votre nom sur la +première ligne, et votre mot de passe sur la deuxième, comme ceci: + + JackSparrow + s0mep4ssw0rd + +Déplacez-le ensuite dans `C:\Program Files\OpenVPN\config\`, avec le fichier +ccrypto.ovpn que vous avez téléchargé plus tôt. + +Ouvrez ccrypto.ovpn avec un éditeur de texte (Bloc-notes, Notepad++, ...) +et ajouter une ligne à la fin: + + auth-user-pass ccrypto_creds.txt + +Pour finir, redémarrez OpenVPN GUI et connectez vous : il ne devrait plus vous +demander votre mot de passe. + diff --git a/pages/self-diagnosis.en.md b/pages/self-diagnosis.en.md new file mode 100644 index 0000000..daab17d --- /dev/null +++ b/pages/self-diagnosis.en.md @@ -0,0 +1,91 @@ +Title: Self Diagnosis + + +Windows +------- + +*Before anything, make sure you have started OpenVPN as Administrator and that your +config files exist in `C:\Program Files\OpenVPN\config\`.* + +### netsh.exe error + +If you find lines like those in your OpenVPN log: + + NETSH: C:\Windows\system32\netsh.exe interface ipv6 set address Local Area Network + ERROR: netsh command failed: returned error code 1 + +This error is really frequent on Windows and seem to happen because of +a OpenVPN problem with netsh.exe and IPv6. +To fix it, rename your network connection to avoid spaces, +for example "Local Area Network" to "lan". + + - [Rename a network connection](http://windows.microsoft.com/en-au/windows-vista/rename-a-network-connection) + + +### Multiple TAP-Windows adapters + + Error: When using --tun-ipv6, if you have more than one TAP-Windows adapter, you must also specify --dev-node + Exiting due to fatal error + +That one can happen when you have multiple TAP-Windows adapters, most of the +time because of another software using TAP. + +To fix it, open a command prompt (Shift+Right click) in your OpenVPN directory +(where openvpn.exe is), and run: + + openvpn.exe --show-adapters + +This will list your TAP adapters. +Then, open your ccrypto.ovpn configuration file with notepad and add this on a +new line: + + dev-node [name] + +Replace [name] by your TAP adapter name. + + +### Still doesn't work + +If you still cannot use the VPN, please go to the [Support page](/page/help) +and we'll do our best to help you. +Please also send us your OpenVPN logs. + + +GNU/Linux +--------- + +### I have a ".ovpn" file but I need a ".conf"! +You just have to change the extension by renamming the file. +.conf is more commonly used on GNU/Linux, but it's the same as the .ovpn file. + + +### I'm unable to use your VPN with Network-Manager. +First, check that you have properly created the profile (tutorial to come). +If it's the case, before anything else, let's make sure that OpenVPN itself is working with the following command: +`sudo openvpn --config ccrypto.conf` +(make sure to replace "ccrypto.conf" by the actual name of your configuration file) + +### I'm connected but cannot ping google.com +Try to `ping 8.8.8.8`: if it works then your computer doesn't use the right DNS server. +Add `nameserver 10.99.0.20` at the beginning of /etc/resolv.conf **once the connection is made**. +Else, continue reading. + + +### It still doesn't work! +Using the `ip route` command, make sure you have, alongside with other lines, the following: + + 0.0.0.0/1 via 10.99.2.1 dev tun0 + 10.99.0.0/24 via 10.99.2.1 dev tun0 + 10.99.2.0/24 dev tun0 proto kernel scope link src 10.99.2.18 + 128.0.0.0/1 via 10.99.2.1 dev tun0 + 199.115.114.65 via 192.168.1.1 dev wlan0 + +These values might (and for some, will) change a little depending on your configuration (for example: wlan0 → eth0, 192.168.1.1 → 192.168.42.23, etc.). +If you don't have every one of these lines, kill OpenVPN and fire it again or add the routes by hand using `ip route add`. +If you don't know how to do it, it would be best to come ask on IRC (we will need the output of both `ip addr` and `ip route`, +please paste them into https://paste.cubox.me and just give us the link to the paste). + + +### I've tried everything but nothing seems to work! T_T +Ok… I guess now you can come [ask us on IRC](/chat) (but remember to stay a while, we're not payed professionnal, we might not be around at a given time but we will answer later on). + diff --git a/pages/self-diagnosis.fr.md b/pages/self-diagnosis.fr.md new file mode 100644 index 0000000..5a1da30 --- /dev/null +++ b/pages/self-diagnosis.fr.md @@ -0,0 +1,87 @@ +Title: Auto-Diagnostic + + +Windows +------- + +*Tout d'abord, assurez vous d'avoir bien démarré OpenVPN en tant qu'administrateur +et que votre fichier de configuration est correctement placé dans +`C:\Program Files\OpenVPN\config\`.* + +### netsh.exe + +Si vous trouvez ces lignes dans votre historique OpenVPN: + + NETSH: C:\Windows\system32\netsh.exe interface ipv6 set address Connexion au réseau local + ERROR: netsh command failed: returned error code 1 + +Cette erreur est fréquente sous windows et semble arriver à cause d'un problème +d'OpenVPN avec netsh.exe et l'IPv6. +Pour le résoudre, renommez votre connection réseau pour éviter les espaces. +Par exemple « Connexion au réseau local » en « lan ». + + - [(fr) Renommer une connexion réseau](http://windows.microsoft.com/fr-xf/windows-vista/rename-a-network-connection) + + +### Multiples interfaces TAP + + Error: When using --tun-ipv6, if you have more than one TAP-Windows adapter, you must also specify --dev-node + Exiting due to fatal error + +Cette erreur pourra apparaitre si vous avec de multiples interfaces TAP, +la plupart du temps à cause d'un autre logiciel utilisant TAP. +Pour le résoudre, ouvrez un interpréteur de commandes (Shift + Clic droit) +dans votre répertoire OpenVPN (là où openvpn.exe se situe) et lancez : + + openvpn.exe --show-adapters + +Cela va lister vos interfaces TAP. +Puis, ouvrez votre fichier de configuration ccrypto.ovpn avec un éditeur de texte +et ajoutez ceci sur une nouvelle ligne : + + dev-node [nom] + +Remplacez [nom] par le nom de votre interface TAP. + + +### Ça ne fonctionne toujours pas ? + +Si vous ne pouvez toujours pas utiliser le VPN, n'hésitez pas à +[nous contacter](/page/help). +Joignez les logs d'OpenVPN à votre message, pour nous aider à trouver +le problème au plus vite. + + +GNU/Linux +--------- + +### J'ai un fichier ".ovpn" mais il me faut un ".conf" ! +Il vous suffit de changer l'extension en renommant le fichier. + +### Il m'est impossible d'utiliser votre VPN avec Network-Manager. +Tout d'abord, vérifiez que vous avez correctement créé le profil (tutoriel à venir). +Si c'est bien le cas, avant toute chose, vérifiez qu'OpenVPN lui-même est opérationnel en utilisant cette commande : +`sudo openvpn --config ccrypto.conf` +(assurez-vous de remplacer "ccrypto.conf" par le nom de votre fichier de configuration) + +### Je suis connecté mais je ne peux pas ping google.com +Essayez de `ping 8.8.8.8`, si ça marche, votre ordinateur n'utilise pas le serveur DNS. Ajoutez `nameserver 10.99.0.20` au début de /etc/resolv.con **une fois la connexion établie**. Sinon, lisez la suite. + +### Ça ne marche toujours pas ! +En utilisant la commande `ip route`, vérifiez que vous avez, entre autre choses, les lignes suivantes : + + 0.0.0.0/1 via 10.99.2.1 dev tun0 + 10.99.0.0/24 via 10.99.2.1 dev tun0 + 10.99.2.0/24 dev tun0 proto kernel scope link src 10.99.2.18 + 128.0.0.0/1 via 10.99.2.1 dev tun0 + 199.115.114.65 via 192.168.1.1 dev wlan0 + +Ces valeurs peuvent (et pour certaines, vont) changer suivant votre configuration (par exemple : wlan0 → eth0, 192.168.1.1 → 192.168.42.23, etc.) +Si vous n'avez pas toutes ces lignes, relancez OpenVPN ou ajouter les routes à la main en utilisant `ip route add`. +Si vous ne savez pas comment faire, ce serait mieux de venir nous demander sur IRC +(nous allons avoir besoin des sorties des commandes `ip addr` et `ip route`, +veuillez utiliser https://paste.cubox.me et nous envoyer uniquement le lien vers le paste). + +### J'ai tout essayé mais rien ne semble fonctionner ! T_T +Ok… Je pense que vous pouvez venir [nous demander sur IRC](/chat) (mais souvenez-vous que nous ne sommes pas des professionnels payés, nous ne sommes pas toujours présent mais nous finirons toujours par répondre si vous ne partez pas trop vite). + diff --git a/pages/tos.en.md b/pages/tos.en.md new file mode 100644 index 0000000..f1937a4 --- /dev/null +++ b/pages/tos.en.md @@ -0,0 +1,68 @@ +Title: Terms of Service + +Cognitive Cryptography provides virtual private networking (VPN) to its clients. You (Client) +acknowledge that Cognitive Cryptography and/or any of its parent companies or constituents +will not be liable for any and all liability arising from your use of its services +and website. + +Services are provided "as is" with no express or implied warranty for quality +and speed. While Cognitive Cryptography attempt to maintain the services available, it may +be subject to inavailabilities for various including maintenances, failures and +third-party services failure. + +By using our services and/or website, you agree in full to the terms and +conditions provided herein, in addition to all future amendments and modifications. +If you do not agree to these terms and conditions, then access to the service is prohibed. + +Clients who breaches or violates our terms and conditions may have their +account and access to the service removed withour any refund. + +### Acceptable Use +We support your right to free speech and privacy, but you need to comply with +all applicable laws and regulations in France and the country in which you +reside to use this service. + +By using our services, you agree to not engage in any of the following activities: + + - SPAM. You agree to not send or facilitate any unsolicited email. + - Anything that could be considered hostile by a third party, including but + not limited to Denial-of-Service attacks. + - Receiving or distributing any illegal content + - Receiving or distributing any copyrighted, trademark, or patented content + which you do not own or lack consent or license from the owner. + - Any action that results in inclusion on IP blacklists. + +### Client Responsabilities +As a client, you are responsible for maintaining the confidentility and +security of the account you are provided. + +### Cognitive Cryptography's Rights +Cognitive Cryptography reserves the right to close your account and access to the service +at any given time without any given notice. While Cognitive Cryptography attempts to provide +complete and quality service to its clients, this right is reserved for reasons +which may arise at a later date. + +Client understands that Cognitive Cryptography for reasons beyond its control may shut down +and terminate services. In this case, clients will be notified at least thirty +(30) days in advance, and will not be eligible for a partial or complete refund. + +### Refund Policy +If you are not 100% satisfied with your purchase, within 7 days from the +purchase date, we will fully refund the cost of your order. + +### Privacy Policy + +What do we collect? + + - Your account settings. + - Payment data, including transaction IDs and Paypal or Bitcoin adresses. + - The IP adresses used to log in. + +How is my Information used? +Your information is used to provide you and maintain the service. +We do not sell or redistribute your information. +Those informations will never leave our servers unless we are required to by +the law. If this happens, we will inform all people concerned. + +We use cookies. + diff --git a/payments/__init__.py b/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/admin.py b/payments/admin.py new file mode 100644 index 0000000..f56ed5d --- /dev/null +++ b/payments/admin.py @@ -0,0 +1,60 @@ +from django.shortcuts import resolve_url +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ +from .models import Payment, RecurringPaymentSource + + +class PaymentAdmin(admin.ModelAdmin): + model = Payment + list_display = ('user', 'backend', 'status', 'amount', 'paid_amount', 'created') + list_filter = ('backend_id', 'status') + + fieldsets = ( + (None, { + 'fields': ('backend', 'user_link', 'time', 'status', 'status_message'), + }), + (_("Payment Data"), { + 'fields': ('amount_fmt', 'paid_amount_fmt', 'recurring_source', + 'backend_extid_link', 'backend_data'), + }), + ) + + readonly_fields = ('backend', 'user_link', 'time', 'status', 'status_message', + 'amount_fmt', 'paid_amount_fmt', 'recurring_source', + 'backend_extid_link', 'backend_data') + search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data') + + def backend(self, object): + return object.backend.backend_verbose_name + + def backend_extid_link(self, object): + ext_url = object.backend.get_ext_url(object) + if ext_url: + return '%s' % (ext_url, object.backend_extid) + return object.backend_extid + backend_extid_link.allow_tags = True + + def amount_fmt(self, object): + return '%.2f %s' % (object.amount / 100, object.currency_name) + amount_fmt.short_description = _("Amount") + + def paid_amount_fmt(self, object): + return '%.2f %s' % (object.paid_amount / 100, object.currency_name) + paid_amount_fmt.short_description = _("Paid amount") + + def user_link(self, object): + change_url = resolve_url('admin:auth_user_change', object.user.id) + return '%s' % (change_url, object.user.username) + user_link.allow_tags = True + user_link.short_description = 'User' + + +class RecurringPaymentSourceAdmin(admin.ModelAdmin): + model = RecurringPaymentSource + list_display = ('user', 'backend', 'created') + readonly_fields = ('user', 'backend', 'created', 'last_confirmed_payment') + + +admin.site.register(Payment, PaymentAdmin) +admin.site.register(RecurringPaymentSource, RecurringPaymentSourceAdmin) + diff --git a/payments/backends.py b/payments/backends.py new file mode 100644 index 0000000..749e276 --- /dev/null +++ b/payments/backends.py @@ -0,0 +1,456 @@ +import json +from ipaddress import IPv4Address, IPv4Network +from decimal import Decimal + +from django.shortcuts import redirect +from django.utils.translation import ugettext_lazy as _ +from urllib.parse import urlencode +from urllib.request import urlopen +from django.core.urlresolvers import reverse +from django.conf import settings as project_settings + + +class BackendBase: + backend_id = None + backend_verbose_name = "" + backend_display_name = "" + backend_enabled = False + + def __init__(self, settings): + pass + + def new_payment(self, payment): + """ Initialize a payment and returns an URL to redirect the user. + Can return a HTML string that will be sent back to the user in a + default template (like a form) or a HTTP response (like a redirect). + """ + raise NotImplementedError() + + def callback(self, payment, request): + """ Handle a callback """ + raise NotImplementedError() + + def get_info(self): + """ Returns some status (key, value) list """ + return () + + def get_ext_url(self, payment): + """ Returns URL to external payment view, or None """ + return None + + +class BitcoinBackend(BackendBase): + """ Bitcoin backend. + Connects to a bitcoind. + """ + backend_id = 'bitcoin' + backend_verbose_name = _("Bitcoin") + backend_display_name = _("Bitcoin") + + COIN = 100000000 + + def __init__(self, settings): + from bitcoin import SelectParams + from bitcoin.rpc import Proxy + + self.btc_value = settings.get('BITCOIN_VALUE') + self.account = settings.get('ACCOUNT', 'ccvpn3') + + chain = settings.get('CHAIN') + if chain: + SelectParams(chain) + + self.url = settings.get('URL') + if not self.url: + return + + assert isinstance(self.btc_value, int) + + self.make_rpc = lambda: Proxy(self.url) + self.rpc = self.make_rpc() + self.backend_enabled = True + + def new_payment(self, payment): + rpc = self.make_rpc() + + # bitcoins amount = (amount in cents) / (cents per bitcoin) + btc_price = round(Decimal(payment.amount) / self.btc_value, 5) + + address = str(rpc.getnewaddress(self.account)) + + msg = _("Please send %(amount)s BTC to %(address)s") + payment.status_message = msg % dict(amount=str(btc_price), address=address) + payment.backend_extid = address + payment.backend_data = dict(btc_price=str(btc_price), btc_address=address) + payment.save() + return redirect(reverse('payments:view', args=(payment.id,))) + + def check(self, payment): + rpc = self.make_rpc() + + if payment.status != 'new': + return + + btc_price = payment.backend_data.get('btc_price') + address = payment.backend_data.get('btc_address') + if not btc_price or not address: + return + + btc_price = Decimal(btc_price) + + received = Decimal(rpc.getreceivedbyaddress(address)) / self.COIN + payment.paid_amount = int(received * self.btc_value) + payment.backend_data['btc_paid_price'] = str(received) + + if received >= btc_price: + payment.user.vpnuser.add_paid_time(payment.time) + payment.user.vpnuser.on_payment_confirmed(payment) + payment.user.vpnuser.save() + + payment.status = 'confirmed' + + payment.save() + + def get_info(self): + rpc = self.make_rpc() + + try: + info = rpc.getinfo() + if not info: + return [(_("Status"), "Error: got None")] + except Exception as e: + return [(_("Status"), "Error: " + repr(e))] + v = info.get('version', 0) + return ( + (_("Bitcoin value"), "%.2f €" % (self.btc_value / 100)), + (_("Testnet"), info['testnet']), + (_("Balance"), '{:f}'.format(info['balance'] / self.COIN)), + (_("Blocks"), info['blocks']), + (_("Bitcoind version"), '.'.join(str(v // 10 ** (2 * i) % 10 ** (2 * i)) + for i in range(3, -1, -1))), + ) + + def get_ext_url(self, payment): + if not payment.backend_extid: + return None + return 'http://blockr.io/address/info/%s' % payment.backend_extid + + +class ManualBackend(BackendBase): + """ Manual backend used to store and display informations about a + payment processed manually. + More a placeholder than an actual payment beckend, everything raises + NotImplementedError(). + """ + + backend_id = 'manual' + backend_verbose_name = _("Manual") + + +class PaypalBackend(BackendBase): + backend_id = 'paypal' + backend_verbose_name = _("PayPal") + backend_display_name = _("PayPal") + + def __init__(self, settings): + self.test = settings.get('TEST', False) + self.header_image = settings.get('HEADER_IMAGE', None) + self.title = settings.get('TITLE', 'VPN Payment') + self.currency = settings.get('CURRENCY', 'EUR') + self.account_address = settings.get('ADDRESS') + self.receiver_address = settings.get('RECEIVER', self.account_address) + + if self.test: + default_api = 'https://www.sandbox.paypal.com/' + else: + default_api = 'https://www.paypal.com/' + self.api_base = settings.get('API_BASE', default_api) + + if self.account_address: + self.backend_enabled = True + + def new_payment(self, payment): + ROOT_URL = project_settings.ROOT_URL + params = { + 'cmd': '_xclick', + 'notify_url': ROOT_URL + reverse('payments:cb_paypal', args=(payment.id,)), + 'item_name': self.title, + 'amount': '%.2f' % (payment.amount / 100), + 'currency_code': self.currency, + 'business': self.account_address, + 'no_shipping': '1', + 'return': ROOT_URL + reverse('payments:view', args=(payment.id,)), + 'cancel_return': ROOT_URL + reverse('payments:cancel', args=(payment.id,)), + } + + if self.header_image: + params['cpp_header_image'] = self.header_image + + payment.status_message = _("Waiting for PayPal to confirm the transaction... It can take up to a few minutes...") + payment.save() + + return redirect(self.api_base + '/cgi-bin/webscr?' + urlencode(params)) + + def handle_verified_callback(self, payment, params): + if self.test and params['test_ipn'] != '1': + raise ValueError('Test IPN') + + txn_type = params.get('txn_type') + if txn_type not in (None, 'web_accept', 'express_checkout'): + # Not handled here and can be ignored + return + + if params['payment_status'] == 'Refunded': + payment.status = 'refunded' + payment.status_message = None + + elif params['payment_status'] == 'Completed': + if self.receiver_address != params['receiver_email']: + raise ValueError('Wrong receiver: ' + params['receiver_email']) + if self.currency.lower() != params['mc_currency'].lower(): + raise ValueError('Wrong currency: ' + params['mc_currency']) + + payment.paid_amount = int(float(params['mc_gross']) * 100) + if payment.paid_amount < payment.amount: + raise ValueError('Not fully paid.') + + payment.user.vpnuser.add_paid_time(payment.time) + payment.user.vpnuser.on_payment_confirmed(payment) + payment.user.vpnuser.save() + + payment.backend_extid = params['txn_id'] + payment.status = 'confirmed' + payment.status_message = None + payment.save() + + def verify_ipn(self, payment, request): + v_url = self.api_base + '/cgi-bin/webscr?cmd=_notify-validate' + v_req = urlopen(v_url, data=request.body, timeout=5) + v_res = v_req.read() + return v_res == b'VERIFIED' + + def callback(self, payment, request): + if not self.verify_ipn(payment, request): + return False + + params = request.POST + + try: + self.handle_verified_callback(payment, params) + return True + except (KeyError, ValueError) as e: + payment.status = 'error' + payment.status_message = None + payment.backend_data['ipn_exception'] = repr(e) + payment.backend_data['ipn_last_data'] = repr(request.POST) + payment.save() + raise + + def get_ext_url(self, payment): + if not payment.backend_extid: + return None + url = 'https://history.paypal.com/webscr?cmd=_history-details-from-hub&id=%s' + return url % payment.backend_extid + + +class StripeBackend(BackendBase): + backend_id = 'stripe' + backend_verbose_name = _("Stripe") + backend_display_name = _("Credit Card or Alipay (Stripe)") + + def __init__(self, settings): + if 'API_KEY' not in settings or 'PUBLIC_KEY' not in settings: + return + + import stripe + self.stripe = stripe + + stripe.api_key = settings['API_KEY'] + self.pubkey = settings['PUBLIC_KEY'] + self.header_image = settings.get('HEADER_IMAGE', '') + self.currency = settings.get('CURRENCY', 'EUR') + self.name = settings.get('NAME', 'VPN Payment') + + self.backend_enabled = True + + def new_payment(self, payment): + desc = str(payment.time) + ' for ' + payment.user.username + form = ''' +
+ +
+ ''' + return form.format( + post=reverse('payments:cb_stripe', args=(payment.id,)), + pubkey=self.pubkey, + img=self.header_image, + email=payment.user.email or '', + name=self.name, + desc=desc, + amount=payment.amount, + curr=self.currency, + ) + + def callback(self, payment, request): + post_data = request.POST + + token = post_data.get('stripeToken') + if not token: + payment.status = 'cancelled' + payment.status_message = _("No payment information was received.") + return + + months = int(payment.time.days / 30) + username = payment.user.username + + try: + charge = self.stripe.Charge.create( + amount=payment.amount, + currency=self.currency, + card=token, + description="%d months for %s" % (months, username), + ) + payment.backend_extid = charge['id'] + + if charge['refunded'] or not charge['paid']: + payment.status = 'rejected' + payment.status_message = _("The payment has been refunded or rejected.") + payment.save() + return + + payment.paid_amount = int(charge['amount']) + + if payment.paid_amount < payment.amount: + payment.status = 'error' + payment.status_message = _("The paid amount is under the required amount.") + payment.save() + return + + payment.status = 'confirmed' + payment.status_message = None + payment.save() + payment.user.vpnuser.add_paid_time(payment.time) + payment.user.vpnuser.on_payment_confirmed(payment) + payment.user.vpnuser.save() + + except self.stripe.error.CardError as e: + payment.status = 'rejected' + payment.status_message = e.json_body['error']['message'] + payment.save() + + def get_ext_url(self, payment): + if not payment.backend_extid: + return None + return 'https://dashboard.stripe.com/payments/%s' % payment.backend_extid + + +class CoinbaseBackend(BackendBase): + backend_id = 'coinbase' + backend_verbose_name = _("Coinbase") + backend_display_name = _("Bitcoin with CoinBase") + + def __init__(self, settings): + self.sandbox = settings.get('SANDBOX', False) + if self.sandbox: + default_site = 'https://sandbox.coinbase.com/' + default_base = 'https://api.sandbox.coinbase.com/' + else: + default_site = 'https://www.coinbase.com/' + default_base = 'https://api.coinbase.com/' + + self.currency = settings.get('CURRENCY', 'EUR') + self.key = settings.get('KEY') + self.secret = settings.get('SECRET') + self.base = settings.get('BASE_URL', default_base) + self.site = settings.get('SITE_URL', default_site) + + self.callback_secret = settings.get('CALLBACK_SECRET') + self.callback_source_ip = settings.get('CALLBACK_SOURCE', '54.175.255.192/27') + + if not self.key or not self.secret or not self.callback_secret: + return + + from coinbase.wallet.client import Client + self.client = Client(self.key, self.secret, self.base) + self.backend_enabled = True + + def new_payment(self, payment): + ROOT_URL = project_settings.ROOT_URL + + months = int(payment.time.days / 30) + username = payment.user.username + + amount_str = '%.2f' % (payment.amount / 100) + name = "%d months for %s" % (months, username) + checkout = self.client.create_checkout( + amount=amount_str, + currency=self.currency, + name=name, + success_url=ROOT_URL + reverse('payments:view', args=(payment.id,)), + cancel_url=ROOT_URL + reverse('payments:cancel', args=(payment.id,)), + metadata={'payment_id': payment.id}, + ) + embed_id = checkout['embed_code'] + payment.backend_data['checkout_id'] = checkout['id'] + payment.backend_data['embed_code'] = checkout['embed_code'] + return redirect(self.site + 'checkouts/' + embed_id + + '?custom=' + str(payment.id)) + + def callback(self, Payment, request): + if self.callback_source_ip: + if ('.' in request.META['REMOTE_ADDR']) != ('.' in self.callback_source_ip): + print("source IP version") + print(repr(request.META.get('REMOTE_ADDR'))) + print(repr(self.callback_source_ip)) + return False # IPv6 TODO + net = IPv4Network(self.callback_source_ip) + if IPv4Address(request.META['REMOTE_ADDR']) not in net: + print("source IP") + return False + + secret = request.GET.get('secret') + if secret != self.callback_secret: + print("secret") + return False + + data = json.loads(request.body.decode('utf-8')) + order = data.get('order') + + if not order: + # OK but we don't care + print("order") + return True + + id = order.get('custom') + try: + payment = Payment.objects.get(id=id) + except Payment.DoesNotExist: + # Wrong ID - Valid request, ignore + print("wrong payment") + return True + + button = order.get('button') + if not button: + # Wrong structure. + print("button") + return False + + payment.status = 'confirmed' + payment.save() + payment.user.vpnuser.add_paid_time(payment.time) + payment.user.vpnuser.on_payment_confirmed(payment) + payment.user.vpnuser.save() + return True + diff --git a/payments/forms.py b/payments/forms.py new file mode 100644 index 0000000..65d9c92 --- /dev/null +++ b/payments/forms.py @@ -0,0 +1,15 @@ +from django import forms +from .models import BACKEND_CHOICES + + +class NewPaymentForm(forms.Form): + TIME_CHOICES = ( + ('1', '1'), + ('3', '3'), + ('6', '6'), + ('12', '12'), + ) + + time = forms.ChoiceField(choices=TIME_CHOICES) + method = forms.ChoiceField(choices=BACKEND_CHOICES) + diff --git a/payments/management/__init__.py b/payments/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/management/commands/__init__.py b/payments/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/management/commands/bitcoin_info.py b/payments/management/commands/bitcoin_info.py new file mode 100644 index 0000000..66265fd --- /dev/null +++ b/payments/management/commands/bitcoin_info.py @@ -0,0 +1,15 @@ +from django.core.management.base import BaseCommand, CommandError + +from payments.models import ACTIVE_BACKENDS + + +class Command(BaseCommand): + help = "Get bitcoind info" + + def handle(self, *args, **options): + if 'bitcoin' not in ACTIVE_BACKENDS: + raise CommandError("bitcoin backend not active.") + + backend = ACTIVE_BACKENDS['bitcoin'] + for key, value in backend.get_info(): + self.stdout.write("%s: %s" % (key, value)) diff --git a/payments/management/commands/check_btc_payments.py b/payments/management/commands/check_btc_payments.py new file mode 100644 index 0000000..0a0e68b --- /dev/null +++ b/payments/management/commands/check_btc_payments.py @@ -0,0 +1,28 @@ +from django.core.management.base import BaseCommand, CommandError + +from payments.models import Payment, ACTIVE_BACKENDS + + +class Command(BaseCommand): + help = "Check bitcoin payments status" + + def handle(self, *args, **options): + if 'bitcoin' not in ACTIVE_BACKENDS: + raise CommandError("bitcoin backend not active.") + + backend = ACTIVE_BACKENDS['bitcoin'] + + payments = Payment.objects.filter(backend_id='bitcoin', status='new') + + self.stdout.write("Found %d active unconfirmed payments." % len(payments)) + + for p in payments: + self.stdout.write("Checking payment #%d... " % p.id, ending="") + backend.check(p) + + if p.status == 'confirmed': + self.stdout.write("OK.") + else: + self.stdout.write("Waiting") + + diff --git a/payments/management/commands/confirm_payment.py b/payments/management/commands/confirm_payment.py new file mode 100644 index 0000000..9cbbe4a --- /dev/null +++ b/payments/management/commands/confirm_payment.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.utils.dateparse import parse_duration + +from payments.models import Payment + + +class Command(BaseCommand): + help = "Manually confirm a Payment" + + def add_arguments(self, parser): + parser.add_argument('id', action='store', type=int, help="Payment ID") + parser.add_argument('--paid-amount', dest='amount', action='store', type=int, help="Paid amount") + parser.add_argument('--extid', dest='extid', action='store', type=str) + parser.add_argument('-n', dest='sim', action='store_true', help="Simulate") + + def handle(self, *args, **options): + try: + p = Payment.objects.get(id=options['id']) + except Payment.DoesNotExist: + self.stderr.write("Cannot find payment #%d" % options['id']) + return + + print("Payment #%d by %s (amount=%d; paid_amount=%d)" % (p.id, p.user.username, p.amount, p.paid_amount)) + + if options['amount']: + pa = options['amount'] + else: + pa = p.amount + + extid = options['extid'] + + print("Status -> confirmed") + print("Paid amount -> %d" % pa) + if extid: + print("Ext ID -> %s" % extid) + + print("Confirm? [y/n] ") + i = input() + if i.lower().strip() == 'y': + p.user.vpnuser.add_paid_time(p.time) + p.user.vpnuser.on_payment_confirmed(p) + p.user.vpnuser.save() + + p.paid_amount = pa + p.status = 'confirmed' + if extid: + p.backend_extid = extid + p.save() + else: + print("aborted.") + diff --git a/payments/management/commands/expire_payments.py b/payments/management/commands/expire_payments.py new file mode 100644 index 0000000..deaf1e0 --- /dev/null +++ b/payments/management/commands/expire_payments.py @@ -0,0 +1,31 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.utils.dateparse import parse_duration + +from payments.models import Payment + + +class Command(BaseCommand): + help = "Cancels expired Payments" + + def add_arguments(self, parser): + parser.add_argument('-n', dest='sim', action='store_true', help="Simulate") + parser.add_argument('-e', '--exp-time', action='store', + help="Expiration time.", default='3 00:00:00') + + def handle(self, *args, **options): + now = timezone.now() + expdate = now - parse_duration(options['exp_time']) + + self.stdout.write("Now: " + now.isoformat()) + self.stdout.write("Exp: " + expdate.isoformat()) + + expired = Payment.objects.filter(created__lte=expdate, status='new', + paid_amount=0) + + for p in expired: + self.stdout.write("Payment #%d (%s): %s" % (p.id, p.user.username, p.created)) + if not options['sim']: + p.status = 'cancelled' + p.save() + diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py new file mode 100644 index 0000000..4706156 --- /dev/null +++ b/payments/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)), + ('status', models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], max_length=16)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('confirmed_on', models.DateTimeField(null=True, blank=True)), + ('amount', models.IntegerField()), + ('paid_amount', models.IntegerField(default=0)), + ('time', models.DurationField()), + ('status_message', models.TextField(null=True, blank=True)), + ('backend_extid', models.CharField(null=True, max_length=64, blank=True)), + ('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ('-created',), + }, + ), + migrations.CreateModel( + name='RecurringPaymentSource', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('backend', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('period', models.CharField(choices=[('monthly', 'Monthly'), ('biannually', 'Bianually'), ('yearly', 'Yearly')], max_length=16)), + ('last_confirmed_payment', models.DateTimeField(null=True, blank=True)), + ('backend_id', models.CharField(null=True, max_length=64, blank=True)), + ('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='payment', + name='recurring_source', + field=models.ForeignKey(null=True, to='payments.RecurringPaymentSource', blank=True), + ), + migrations.AddField( + model_name='payment', + name='user', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/payments/migrations/0002_auto_20151204_0341.py b/payments/migrations/0002_auto_20151204_0341.py new file mode 100644 index 0000000..8755270 --- /dev/null +++ b/payments/migrations/0002_auto_20151204_0341.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='recurringpaymentsource', + name='period', + field=models.CharField(max_length=16, choices=[('6m', 'Every 6 months'), ('1year', 'Yearly')]), + ), + ] diff --git a/payments/migrations/0003_auto_20151209_0440.py b/payments/migrations/0003_auto_20151209_0440.py new file mode 100644 index 0000000..312fb3f --- /dev/null +++ b/payments/migrations/0003_auto_20151209_0440.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-09 04:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0002_auto_20151204_0341'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='status', + field=models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], default='new', max_length=16), + ), + ] diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/models.py b/payments/models.py new file mode 100644 index 0000000..2138251 --- /dev/null +++ b/payments/models.py @@ -0,0 +1,114 @@ +from django.db import models +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from jsonfield import JSONField + +from .backends import BackendBase + +backend_settings = settings.PAYMENTS_BACKENDS +assert isinstance(backend_settings, dict) + +CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY + +STATUS_CHOICES = ( + ('new', _("Waiting for payment")), + ('confirmed', _("Confirmed")), + ('cancelled', _("Cancelled")), + ('rejected', _("Rejected by processor")), + ('error', _("Payment processing failed")), +) + +PERIOD_CHOICES = ( + ('6m', _("Every 6 months")), + ('1year', _("Yearly")), +) + +# All known backends (classes) +BACKENDS = {} +BACKEND_CHOICES = [] + +# All enabled backends (configured instances) +ACTIVE_BACKENDS = {} +ACTIVE_BACKEND_CHOICES = [] + +for cls in BackendBase.__subclasses__(): + name = cls.backend_id + assert isinstance(name, str) + + obj = cls(backend_settings.get(name, {})) + if not obj.backend_enabled: + if name in backend_settings: + raise Exception("Invalid settings for payment backend %r" % name) + + BACKENDS[name] = obj + BACKEND_CHOICES.append((name, cls.backend_verbose_name)) + + if obj.backend_enabled: + ACTIVE_BACKENDS[name] = obj + ACTIVE_BACKEND_CHOICES.append((name, cls.backend_verbose_name)) + +BACKEND_CHOICES = sorted(BACKEND_CHOICES, key=lambda x: x[0]) +ACTIVE_BACKEND_CHOICES = sorted(ACTIVE_BACKEND_CHOICES, key=lambda x: x[0]) + + +class Payment(models.Model): + """ Just a payment. + If recurring_source is not null, it has been automatically issued. + backend_id is the external transaction ID, backend_data is other + things that should only be used by the associated backend. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='new') + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + confirmed_on = models.DateTimeField(null=True, blank=True) + amount = models.IntegerField() + paid_amount = models.IntegerField(default=0) + time = models.DurationField() + recurring_source = models.ForeignKey('RecurringPaymentSource', null=True, blank=True) + status_message = models.TextField(blank=True, null=True) + + backend_extid = models.CharField(max_length=64, null=True, blank=True) + backend_data = JSONField(blank=True) + + @property + def currency_code(self): + return CURRENCY_CODE + + @property + def currency_name(self): + return CURRENCY_NAME + + @property + def backend(self): + """ Returns a global instance of the backend + :rtype: BackendBase + """ + return BACKENDS[self.backend_id] + + def get_amount_display(self): + return '%.2f %s' % (self.amount / 100, CURRENCY_NAME) + + @property + def is_confirmed(self): + return self.status == 'confirmed' + + class Meta: + ordering = ('-created', ) + + +class RecurringPaymentSource(models.Model): + """ Used as a source to periodically make Payments. + They use the same backends. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + backend = models.CharField(max_length=16, choices=BACKEND_CHOICES) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + period = models.CharField(max_length=16, choices=PERIOD_CHOICES) + last_confirmed_payment = models.DateTimeField(blank=True, null=True) + + backend_id = models.CharField(max_length=64, null=True, blank=True) + backend_data = JSONField(blank=True) + diff --git a/payments/tests.py b/payments/tests.py new file mode 100644 index 0000000..eae9e4c --- /dev/null +++ b/payments/tests.py @@ -0,0 +1,323 @@ +from datetime import timedelta +from urllib.parse import parse_qs + +from django.test import TestCase, RequestFactory +from django.http import HttpResponseRedirect +from django.contrib.auth.models import User + +from .models import Payment +from .backends import BitcoinBackend, PaypalBackend, StripeBackend + +from decimal import Decimal + + +class FakeBTCRPCNew: + def getnewaddress(self, account): + return 'TEST_ADDRESS' + + +class FakeBTCRPCUnpaid: + def getreceivedbyaddress(self, address): + assert address == 'TEST_ADDRESS' + return Decimal('0') + + +class FakeBTCRPCPartial: + def getreceivedbyaddress(self, address): + assert address == 'TEST_ADDRESS' + return Decimal('0.5') * 100000000 + + +class FakeBTCRPCPaid: + def getreceivedbyaddress(self, address): + assert address == 'TEST_ADDRESS' + return Decimal('1') * 100000000 + + +PAYPAL_IPN_TEST = '''\ +mc_gross=3.00&\ +protection_eligibility=Eligible&\ +address_status=confirmed&\ +payer_id=LPLWNMTBWMFAY&\ +tax=0.00&\ +address_street=1+Main+St&\ +payment_date=20%3A12%3A59+Jan+13%2C+2009+PST&\ +payment_status=Completed&\ +charset=windows-1252&\ +address_zip=95131&\ +first_name=Test&\ +mc_fee=0.88&\ +address_country_code=US&\ +address_name=Test+User&\ +notify_version=2.6&\ +custom=&\ +payer_status=verified&\ +address_country=United+States&\ +address_city=San+Jose&\ +quantity=1&\ +verify_sign=AtkOfCXbDm2hu0ZELryHFjY-Vb7PAUvS6nMXgysbElEn9v-1XcmSoGtf&\ +payer_email=test_user@example.com&\ +txn_id=61E67681CH3238416&\ +payment_type=instant&\ +last_name=User&\ +address_state=CA&\ +receiver_email=test_business@example.com&\ +payment_fee=0.88&\ +receiver_id=S8XGHLYDW9T3S&\ +txn_type=express_checkout&\ +item_name=&\ +mc_currency=EUR&\ +item_number=&\ +residence_country=US&\ +test_ipn=1&\ +handling_amount=0.00&\ +transaction_subject=&\ +payment_gross=3.00&\ +shipping=0.00''' + + +class BitcoinBackendTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('test', 'test_user@example.com', None) + + self.p = Payment.objects.create( + user=self.user, time=timedelta(days=30), backend='bitcoin', + amount=300) + + def test_new(self): + backend = BitcoinBackend(dict(BITCOIN_VALUE=300, URL='')) + backend.make_rpc = FakeBTCRPCNew + + backend.new_payment(self.p) + redirect = backend.new_payment(self.p) + self.assertEqual(self.p.backend_extid, 'TEST_ADDRESS') + self.assertEqual(self.p.status, 'new') + self.assertIn('btc_price', self.p.backend_data) + self.assertIn('btc_address', self.p.backend_data) + self.assertEqual(self.p.backend_data['btc_address'], 'TEST_ADDRESS') + self.assertIsInstance(redirect, HttpResponseRedirect) + self.assertEqual(redirect.url, '/payments/view/%d' % self.p.id) + self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS") + + def test_rounding(self): + """ Rounding test + 300 / 300 = 1 => 1.00000 BTC + 300 / 260 = Decimal('1.153846153846153846153846154') => 1.15385 BTC + """ + backend = BitcoinBackend(dict(BITCOIN_VALUE=300, URL='')) + backend.make_rpc = FakeBTCRPCNew + backend.new_payment(self.p) + self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS") + + backend = BitcoinBackend(dict(BITCOIN_VALUE=260, URL='')) + backend.make_rpc = FakeBTCRPCNew + backend.new_payment(self.p) + self.assertEqual(self.p.status_message, "Please send 1.15385 BTC to TEST_ADDRESS") + + +class BitcoinBackendConfirmTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('test', 'test_user@example.com', None) + + self.p = Payment.objects.create( + user=self.user, time=timedelta(days=30), backend='bitcoin', + amount=300) + + # call new_payment + backend = BitcoinBackend(dict(BITCOIN_VALUE=300, URL='')) + backend.make_rpc = FakeBTCRPCNew + backend.new_payment(self.p) + + def test_check_unpaid(self): + backend = BitcoinBackend(dict(BITCOIN_VALUE=300, URL='')) + backend.make_rpc = FakeBTCRPCUnpaid + + backend.check(self.p) + self.assertEqual(self.p.status, 'new') + self.assertEqual(self.p.paid_amount, 0) + + def test_check_partially_paid(self): + backend = BitcoinBackend(dict(BITCOIN_VALUE=300, URL='')) + backend.make_rpc = FakeBTCRPCPartial + backend.check(self.p) + self.assertEqual(self.p.status, 'new') + self.assertEqual(self.p.paid_amount, 150) + + def test_check_paid(self): + backend = BitcoinBackend(dict(BITCOIN_VALUE=300, URL='')) + backend.make_rpc = FakeBTCRPCPaid + backend.check(self.p) + self.assertEqual(self.p.paid_amount, 300) + self.assertEqual(self.p.status, 'confirmed') + + +class BackendTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('test', 'test_user@example.com', None) + + def test_paypal(self): + # TODO: This checks the most simple and perfect payment that could + # happen, but not errors or other/invalid IPN + + payment = Payment.objects.create( + user=self.user, + time=timedelta(days=30), + backend='paypal', + amount=300 + ) + + settings = dict( + TEST=True, + TITLE='Test Title', + CURRENCY='EUR', + ADDRESS='test_business@example.com', + ) + + with self.settings(ROOT_URL='root'): + backend = PaypalBackend(settings) + redirect = backend.new_payment(payment) + + self.assertIsInstance(redirect, HttpResponseRedirect) + + host, params = redirect.url.split('?', 1) + params = parse_qs(params) + + expected_notify_url = 'root/payments/callback/paypal/%d' % payment.id + expected_return_url = 'root/payments/view/%d' % payment.id + expected_cancel_url = 'root/payments/cancel/%d' % payment.id + + self.assertEqual(params['cmd'][0], '_xclick') + self.assertEqual(params['notify_url'][0], expected_notify_url) + self.assertEqual(params['return'][0], expected_return_url) + self.assertEqual(params['cancel_return'][0], expected_cancel_url) + self.assertEqual(params['business'][0], 'test_business@example.com') + self.assertEqual(params['currency_code'][0], 'EUR') + self.assertEqual(params['amount'][0], '3.00') + self.assertEqual(params['item_name'][0], 'Test Title') + + # Replace PaypalBackend.verify_ipn to not call the PayPal API + # we will assume the IPN is authentic + backend.verify_ipn = lambda payment, request: True + + ipn_url = '/payments/callback/paypal/%d' % payment.id + ipn_request = RequestFactory().post( + ipn_url, + content_type='application/x-www-form-urlencoded', + data=PAYPAL_IPN_TEST) + r = backend.callback(payment, ipn_request) + + self.assertTrue(r) + self.assertEqual(payment.status, 'confirmed') + self.assertEqual(payment.paid_amount, 300) + self.assertEqual(payment.backend_extid, '61E67681CH3238416') + + def test_paypal_ipn_error(self): + payment = Payment.objects.create( + user=self.user, + time=timedelta(days=30), + backend='paypal', + amount=300 + ) + + settings = dict( + TEST=True, + TITLE='Test Title', + CURRENCY='EUR', + ADDRESS='test_business@example.com', + ) + + with self.settings(ROOT_URL='root'): + backend = PaypalBackend(settings) + redirect = backend.new_payment(payment) + + self.assertIsInstance(redirect, HttpResponseRedirect) + + host, params = redirect.url.split('?', 1) + params = parse_qs(params) + + expected_notify_url = 'root/payments/callback/paypal/%d' % payment.id + + # Replace PaypalBackend.verify_ipn to not call the PayPal API + # we will assume the IPN is authentic + backend.verify_ipn = lambda payment, request: True + + ipn_url = '/payments/callback/paypal/%d' % payment.id + ipn_request = RequestFactory().post( + ipn_url, + content_type='application/x-www-form-urlencoded', + data=PAYPAL_IPN_TEST) + r = backend.callback(payment, ipn_request) + + self.assertTrue(r) + self.assertEqual(payment.status, 'confirmed') + self.assertEqual(payment.paid_amount, 300) + self.assertEqual(payment.backend_extid, '61E67681CH3238416') + + def test_stripe(self): + payment = Payment.objects.create( + user=self.user, + time=timedelta(days=30), + backend='stripe', + amount=300 + ) + + settings = dict( + API_KEY='test_secret_key', + PUBLIC_KEY='test_public_key', + CURRENCY='EUR', + NAME='Test Name', + ) + + with self.settings(ROOT_URL='root'): + backend = StripeBackend(settings) + form_html = backend.new_payment(payment) + + expected_form = ''' +
+ +
+ '''.format(id=payment.id) + self.maxDiff = None + self.assertEqual(expected_form, form_html) + + def create_charge(**kwargs): + self.assertEqual(kwargs, { + 'amount': 300, + 'currency': 'EUR', + 'card': 'TEST_TOKEN', + 'description': "1 months for test", + }) + return { + 'id': 'TEST_CHARGE_ID', + 'refunded': False, + 'paid': True, + 'amount': 300, + } + + # Replace the Stripe api instance + backend.stripe = type('Stripe', (object, ), { + 'Charge': type('Charge', (object, ), { + 'create': create_charge, + }), + 'error': type('error', (object, ), { + 'CardError': type('CardError', (Exception, ), {}), + }), + }) + + request = RequestFactory().post('', {'stripeToken': 'TEST_TOKEN'}) + backend.callback(payment, request) + + self.assertEqual(payment.backend_extid, 'TEST_CHARGE_ID') + diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 0000000..0be8fe6 --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url(r'^new$', views.new), + url(r'^view/(?P[0-9]+)$', views.view, name='view'), + url(r'^cancel/(?P[0-9]+)$', views.cancel, name='cancel'), + + url(r'^callback/paypal/(?P[0-9]+)$', views.callback_paypal, name='cb_paypal'), + url(r'^callback/stripe/(?P[0-9]+)$', views.callback_stripe, name='cb_stripe'), + url(r'^callback/coinbase/$', views.callback_coinbase, name='cb_coinbase'), + + url(r'^$', views.list_payments), +] diff --git a/payments/views.py b/payments/views.py new file mode 100644 index 0000000..2b6d1dc --- /dev/null +++ b/payments/views.py @@ -0,0 +1,116 @@ +from datetime import timedelta +from django.shortcuts import render, redirect +from django.core.urlresolvers import reverse +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound +from django.views.decorators.csrf import csrf_exempt +from django.utils import timezone + +from .forms import NewPaymentForm +from .models import Payment, BACKENDS + + +monthly_price = settings.PAYMENTS_MONTHLY_PRICE + + +@login_required +def new(request): + if request.method != 'POST': + return redirect('account:index') + + form = NewPaymentForm(request.POST) + + if not form.is_valid(): + return redirect('account:index') + + months = int(form.cleaned_data['time']) + payment = Payment( + user=request.user, + backend_id=form.cleaned_data['method'], + status='new', + time=timedelta(days=30 * months), + amount=monthly_price * months + ) + + if not payment.backend.backend_enabled: + return HttpResponseNotFound() + + payment.save() + + r = payment.backend.new_payment(payment) + + if not r: + payment.status = 'error' + payment.save() + raise Exception("Failed to initialize payment #%d" % payment.id) + + if isinstance(r, str): + return render(request, 'payments/form.html', dict(html=r)) + elif r is None: + return redirect('payments:view', payment.id) + + return r + + +@csrf_exempt +def callback_paypal(request, id): + """ PayPal IPN """ + if not BACKENDS['paypal'].backend_enabled: + return HttpResponseNotFound() + + p = Payment.objects.get(id=id) + if BACKENDS['paypal'].callback(p, request): + return HttpResponse() + else: + return HttpResponseBadRequest() + + +@csrf_exempt +@login_required +def callback_stripe(request, id): + """ Stripe button POST """ + if not BACKENDS['stripe'].backend_enabled: + return HttpResponseNotFound() + + p = Payment.objects.get(id=id) + BACKENDS['stripe'].callback(p, request) + return redirect(reverse('payments:view', args=(id,))) + + +@csrf_exempt +def callback_coinbase(request): + if not BACKENDS['coinbase'].backend_enabled: + return HttpResponseNotFound() + + if BACKENDS['coinbase'].callback(Payment, request): + return HttpResponse() + else: + return HttpResponseBadRequest() + + +@login_required +@csrf_exempt +def view(request, id): + p = Payment.objects.get(id=id, user=request.user) + return render(request, 'payments/view.html', dict(payment=p)) + + +@login_required +def cancel(request, id): + p = Payment.objects.get(id=id, user=request.user) + if p.status == 'new': + p.status = 'cancelled' + p.save() + return render(request, 'payments/view.html', dict(payment=p)) + + +@login_required +def list_payments(request): + # Only show recent cancelled payments + cancelled_limit = timezone.now() - timedelta(days=3) + + objects = request.user.payment_set.exclude(status='cancelled', + created__lte=cancelled_limit) + return render(request, 'payments/list.html', dict(payments=objects)) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bcfae59 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +django +django-jsonfield +django_countries +markdown +requests +pygal +pytz +python-bitcoinlib +stripe + diff --git a/static/affimg/banner.png b/static/affimg/banner.png new file mode 100644 index 0000000..718e034 Binary files /dev/null and b/static/affimg/banner.png differ diff --git a/static/affimg/leaderboard.png b/static/affimg/leaderboard.png new file mode 100644 index 0000000..e354b62 Binary files /dev/null and b/static/affimg/leaderboard.png differ diff --git a/static/css/admin_status.css b/static/css/admin_status.css new file mode 100644 index 0000000..6d34033 --- /dev/null +++ b/static/css/admin_status.css @@ -0,0 +1,23 @@ +.dashboard #content { + width: auto; +} + +.admin-graph { + width: 48%; + margin: auto; + display: inline-block; + margin: 0; + padding: 0; +} + + +.half-module { + display: inline-block; + width: 50%; +} +.half-module table { + width: 95%; +} +.half-module + .half-module { + float: left; +} diff --git a/static/css/font-awesome.css b/static/css/font-awesome.css new file mode 100644 index 0000000..b2a5fe2 --- /dev/null +++ b/static/css/font-awesome.css @@ -0,0 +1,2086 @@ +/*! + * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */ +/* FONT PATH + * -------------------------- */ +@font-face { + font-family: 'FontAwesome'; + src: url('../fonts/fontawesome-webfont.eot?v=4.5.0'); + src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg'); + font-weight: normal; + font-style: normal; +} +.fa { + display: inline-block; + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +/* makes the font 33% larger relative to the icon container */ +.fa-lg { + font-size: 1.33333333em; + line-height: 0.75em; + vertical-align: -15%; +} +.fa-2x { + font-size: 2em; +} +.fa-3x { + font-size: 3em; +} +.fa-4x { + font-size: 4em; +} +.fa-5x { + font-size: 5em; +} +.fa-fw { + width: 1.28571429em; + text-align: center; +} +.fa-ul { + padding-left: 0; + margin-left: 2.14285714em; + list-style-type: none; +} +.fa-ul > li { + position: relative; +} +.fa-li { + position: absolute; + left: -2.14285714em; + width: 2.14285714em; + top: 0.14285714em; + text-align: center; +} +.fa-li.fa-lg { + left: -1.85714286em; +} +.fa-border { + padding: .2em .25em .15em; + border: solid 0.08em #eeeeee; + border-radius: .1em; +} +.fa-pull-left { + float: left; +} +.fa-pull-right { + float: right; +} +.fa.fa-pull-left { + margin-right: .3em; +} +.fa.fa-pull-right { + margin-left: .3em; +} +/* Deprecated as of 4.4.0 */ +.pull-right { + float: right; +} +.pull-left { + float: left; +} +.fa.pull-left { + margin-right: .3em; +} +.fa.pull-right { + margin-left: .3em; +} +.fa-spin { + -webkit-animation: fa-spin 2s infinite linear; + animation: fa-spin 2s infinite linear; +} +.fa-pulse { + -webkit-animation: fa-spin 1s infinite steps(8); + animation: fa-spin 1s infinite steps(8); +} +@-webkit-keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +@keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +.fa-rotate-90 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); + -webkit-transform: rotate(90deg); + -ms-transform: rotate(90deg); + transform: rotate(90deg); +} +.fa-rotate-180 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg); +} +.fa-rotate-270 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); + -webkit-transform: rotate(270deg); + -ms-transform: rotate(270deg); + transform: rotate(270deg); +} +.fa-flip-horizontal { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1); + -webkit-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); +} +.fa-flip-vertical { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1); + -webkit-transform: scale(1, -1); + -ms-transform: scale(1, -1); + transform: scale(1, -1); +} +:root .fa-rotate-90, +:root .fa-rotate-180, +:root .fa-rotate-270, +:root .fa-flip-horizontal, +:root .fa-flip-vertical { + filter: none; +} +.fa-stack { + position: relative; + display: inline-block; + width: 2em; + height: 2em; + line-height: 2em; + vertical-align: middle; +} +.fa-stack-1x, +.fa-stack-2x { + position: absolute; + left: 0; + width: 100%; + text-align: center; +} +.fa-stack-1x { + line-height: inherit; +} +.fa-stack-2x { + font-size: 2em; +} +.fa-inverse { + color: #ffffff; +} +/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen + readers do not read off random characters that represent icons */ +.fa-glass:before { + content: "\f000"; +} +.fa-music:before { + content: "\f001"; +} +.fa-search:before { + content: "\f002"; +} +.fa-envelope-o:before { + content: "\f003"; +} +.fa-heart:before { + content: "\f004"; +} +.fa-star:before { + content: "\f005"; +} +.fa-star-o:before { + content: "\f006"; +} +.fa-user:before { + content: "\f007"; +} +.fa-film:before { + content: "\f008"; +} +.fa-th-large:before { + content: "\f009"; +} +.fa-th:before { + content: "\f00a"; +} +.fa-th-list:before { + content: "\f00b"; +} +.fa-check:before { + content: "\f00c"; +} +.fa-remove:before, +.fa-close:before, +.fa-times:before { + content: "\f00d"; +} +.fa-search-plus:before { + content: "\f00e"; +} +.fa-search-minus:before { + content: "\f010"; +} +.fa-power-off:before { + content: "\f011"; +} +.fa-signal:before { + content: "\f012"; +} +.fa-gear:before, +.fa-cog:before { + content: "\f013"; +} +.fa-trash-o:before { + content: "\f014"; +} +.fa-home:before { + content: "\f015"; +} +.fa-file-o:before { + content: "\f016"; +} +.fa-clock-o:before { + content: "\f017"; +} +.fa-road:before { + content: "\f018"; +} +.fa-download:before { + content: "\f019"; +} +.fa-arrow-circle-o-down:before { + content: "\f01a"; +} +.fa-arrow-circle-o-up:before { + content: "\f01b"; +} +.fa-inbox:before { + content: "\f01c"; +} +.fa-play-circle-o:before { + content: "\f01d"; +} +.fa-rotate-right:before, +.fa-repeat:before { + content: "\f01e"; +} +.fa-refresh:before { + content: "\f021"; +} +.fa-list-alt:before { + content: "\f022"; +} +.fa-lock:before { + content: "\f023"; +} +.fa-flag:before { + content: "\f024"; +} +.fa-headphones:before { + content: "\f025"; +} +.fa-volume-off:before { + content: "\f026"; +} +.fa-volume-down:before { + content: "\f027"; +} +.fa-volume-up:before { + content: "\f028"; +} +.fa-qrcode:before { + content: "\f029"; +} +.fa-barcode:before { + content: "\f02a"; +} +.fa-tag:before { + content: "\f02b"; +} +.fa-tags:before { + content: "\f02c"; +} +.fa-book:before { + content: "\f02d"; +} +.fa-bookmark:before { + content: "\f02e"; +} +.fa-print:before { + content: "\f02f"; +} +.fa-camera:before { + content: "\f030"; +} +.fa-font:before { + content: "\f031"; +} +.fa-bold:before { + content: "\f032"; +} +.fa-italic:before { + content: "\f033"; +} +.fa-text-height:before { + content: "\f034"; +} +.fa-text-width:before { + content: "\f035"; +} +.fa-align-left:before { + content: "\f036"; +} +.fa-align-center:before { + content: "\f037"; +} +.fa-align-right:before { + content: "\f038"; +} +.fa-align-justify:before { + content: "\f039"; +} +.fa-list:before { + content: "\f03a"; +} +.fa-dedent:before, +.fa-outdent:before { + content: "\f03b"; +} +.fa-indent:before { + content: "\f03c"; +} +.fa-video-camera:before { + content: "\f03d"; +} +.fa-photo:before, +.fa-image:before, +.fa-picture-o:before { + content: "\f03e"; +} +.fa-pencil:before { + content: "\f040"; +} +.fa-map-marker:before { + content: "\f041"; +} +.fa-adjust:before { + content: "\f042"; +} +.fa-tint:before { + content: "\f043"; +} +.fa-edit:before, +.fa-pencil-square-o:before { + content: "\f044"; +} +.fa-share-square-o:before { + content: "\f045"; +} +.fa-check-square-o:before { + content: "\f046"; +} +.fa-arrows:before { + content: "\f047"; +} +.fa-step-backward:before { + content: "\f048"; +} +.fa-fast-backward:before { + content: "\f049"; +} +.fa-backward:before { + content: "\f04a"; +} +.fa-play:before { + content: "\f04b"; +} +.fa-pause:before { + content: "\f04c"; +} +.fa-stop:before { + content: "\f04d"; +} +.fa-forward:before { + content: "\f04e"; +} +.fa-fast-forward:before { + content: "\f050"; +} +.fa-step-forward:before { + content: "\f051"; +} +.fa-eject:before { + content: "\f052"; +} +.fa-chevron-left:before { + content: "\f053"; +} +.fa-chevron-right:before { + content: "\f054"; +} +.fa-plus-circle:before { + content: "\f055"; +} +.fa-minus-circle:before { + content: "\f056"; +} +.fa-times-circle:before { + content: "\f057"; +} +.fa-check-circle:before { + content: "\f058"; +} +.fa-question-circle:before { + content: "\f059"; +} +.fa-info-circle:before { + content: "\f05a"; +} +.fa-crosshairs:before { + content: "\f05b"; +} +.fa-times-circle-o:before { + content: "\f05c"; +} +.fa-check-circle-o:before { + content: "\f05d"; +} +.fa-ban:before { + content: "\f05e"; +} +.fa-arrow-left:before { + content: "\f060"; +} +.fa-arrow-right:before { + content: "\f061"; +} +.fa-arrow-up:before { + content: "\f062"; +} +.fa-arrow-down:before { + content: "\f063"; +} +.fa-mail-forward:before, +.fa-share:before { + content: "\f064"; +} +.fa-expand:before { + content: "\f065"; +} +.fa-compress:before { + content: "\f066"; +} +.fa-plus:before { + content: "\f067"; +} +.fa-minus:before { + content: "\f068"; +} +.fa-asterisk:before { + content: "\f069"; +} +.fa-exclamation-circle:before { + content: "\f06a"; +} +.fa-gift:before { + content: "\f06b"; +} +.fa-leaf:before { + content: "\f06c"; +} +.fa-fire:before { + content: "\f06d"; +} +.fa-eye:before { + content: "\f06e"; +} +.fa-eye-slash:before { + content: "\f070"; +} +.fa-warning:before, +.fa-exclamation-triangle:before { + content: "\f071"; +} +.fa-plane:before { + content: "\f072"; +} +.fa-calendar:before { + content: "\f073"; +} +.fa-random:before { + content: "\f074"; +} +.fa-comment:before { + content: "\f075"; +} +.fa-magnet:before { + content: "\f076"; +} +.fa-chevron-up:before { + content: "\f077"; +} +.fa-chevron-down:before { + content: "\f078"; +} +.fa-retweet:before { + content: "\f079"; +} +.fa-shopping-cart:before { + content: "\f07a"; +} +.fa-folder:before { + content: "\f07b"; +} +.fa-folder-open:before { + content: "\f07c"; +} +.fa-arrows-v:before { + content: "\f07d"; +} +.fa-arrows-h:before { + content: "\f07e"; +} +.fa-bar-chart-o:before, +.fa-bar-chart:before { + content: "\f080"; +} +.fa-twitter-square:before { + content: "\f081"; +} +.fa-facebook-square:before { + content: "\f082"; +} +.fa-camera-retro:before { + content: "\f083"; +} +.fa-key:before { + content: "\f084"; +} +.fa-gears:before, +.fa-cogs:before { + content: "\f085"; +} +.fa-comments:before { + content: "\f086"; +} +.fa-thumbs-o-up:before { + content: "\f087"; +} +.fa-thumbs-o-down:before { + content: "\f088"; +} +.fa-star-half:before { + content: "\f089"; +} +.fa-heart-o:before { + content: "\f08a"; +} +.fa-sign-out:before { + content: "\f08b"; +} +.fa-linkedin-square:before { + content: "\f08c"; +} +.fa-thumb-tack:before { + content: "\f08d"; +} +.fa-external-link:before { + content: "\f08e"; +} +.fa-sign-in:before { + content: "\f090"; +} +.fa-trophy:before { + content: "\f091"; +} +.fa-github-square:before { + content: "\f092"; +} +.fa-upload:before { + content: "\f093"; +} +.fa-lemon-o:before { + content: "\f094"; +} +.fa-phone:before { + content: "\f095"; +} +.fa-square-o:before { + content: "\f096"; +} +.fa-bookmark-o:before { + content: "\f097"; +} +.fa-phone-square:before { + content: "\f098"; +} +.fa-twitter:before { + content: "\f099"; +} +.fa-facebook-f:before, +.fa-facebook:before { + content: "\f09a"; +} +.fa-github:before { + content: "\f09b"; +} +.fa-unlock:before { + content: "\f09c"; +} +.fa-credit-card:before { + content: "\f09d"; +} +.fa-feed:before, +.fa-rss:before { + content: "\f09e"; +} +.fa-hdd-o:before { + content: "\f0a0"; +} +.fa-bullhorn:before { + content: "\f0a1"; +} +.fa-bell:before { + content: "\f0f3"; +} +.fa-certificate:before { + content: "\f0a3"; +} +.fa-hand-o-right:before { + content: "\f0a4"; +} +.fa-hand-o-left:before { + content: "\f0a5"; +} +.fa-hand-o-up:before { + content: "\f0a6"; +} +.fa-hand-o-down:before { + content: "\f0a7"; +} +.fa-arrow-circle-left:before { + content: "\f0a8"; +} +.fa-arrow-circle-right:before { + content: "\f0a9"; +} +.fa-arrow-circle-up:before { + content: "\f0aa"; +} +.fa-arrow-circle-down:before { + content: "\f0ab"; +} +.fa-globe:before { + content: "\f0ac"; +} +.fa-wrench:before { + content: "\f0ad"; +} +.fa-tasks:before { + content: "\f0ae"; +} +.fa-filter:before { + content: "\f0b0"; +} +.fa-briefcase:before { + content: "\f0b1"; +} +.fa-arrows-alt:before { + content: "\f0b2"; +} +.fa-group:before, +.fa-users:before { + content: "\f0c0"; +} +.fa-chain:before, +.fa-link:before { + content: "\f0c1"; +} +.fa-cloud:before { + content: "\f0c2"; +} +.fa-flask:before { + content: "\f0c3"; +} +.fa-cut:before, +.fa-scissors:before { + content: "\f0c4"; +} +.fa-copy:before, +.fa-files-o:before { + content: "\f0c5"; +} +.fa-paperclip:before { + content: "\f0c6"; +} +.fa-save:before, +.fa-floppy-o:before { + content: "\f0c7"; +} +.fa-square:before { + content: "\f0c8"; +} +.fa-navicon:before, +.fa-reorder:before, +.fa-bars:before { + content: "\f0c9"; +} +.fa-list-ul:before { + content: "\f0ca"; +} +.fa-list-ol:before { + content: "\f0cb"; +} +.fa-strikethrough:before { + content: "\f0cc"; +} +.fa-underline:before { + content: "\f0cd"; +} +.fa-table:before { + content: "\f0ce"; +} +.fa-magic:before { + content: "\f0d0"; +} +.fa-truck:before { + content: "\f0d1"; +} +.fa-pinterest:before { + content: "\f0d2"; +} +.fa-pinterest-square:before { + content: "\f0d3"; +} +.fa-google-plus-square:before { + content: "\f0d4"; +} +.fa-google-plus:before { + content: "\f0d5"; +} +.fa-money:before { + content: "\f0d6"; +} +.fa-caret-down:before { + content: "\f0d7"; +} +.fa-caret-up:before { + content: "\f0d8"; +} +.fa-caret-left:before { + content: "\f0d9"; +} +.fa-caret-right:before { + content: "\f0da"; +} +.fa-columns:before { + content: "\f0db"; +} +.fa-unsorted:before, +.fa-sort:before { + content: "\f0dc"; +} +.fa-sort-down:before, +.fa-sort-desc:before { + content: "\f0dd"; +} +.fa-sort-up:before, +.fa-sort-asc:before { + content: "\f0de"; +} +.fa-envelope:before { + content: "\f0e0"; +} +.fa-linkedin:before { + content: "\f0e1"; +} +.fa-rotate-left:before, +.fa-undo:before { + content: "\f0e2"; +} +.fa-legal:before, +.fa-gavel:before { + content: "\f0e3"; +} +.fa-dashboard:before, +.fa-tachometer:before { + content: "\f0e4"; +} +.fa-comment-o:before { + content: "\f0e5"; +} +.fa-comments-o:before { + content: "\f0e6"; +} +.fa-flash:before, +.fa-bolt:before { + content: "\f0e7"; +} +.fa-sitemap:before { + content: "\f0e8"; +} +.fa-umbrella:before { + content: "\f0e9"; +} +.fa-paste:before, +.fa-clipboard:before { + content: "\f0ea"; +} +.fa-lightbulb-o:before { + content: "\f0eb"; +} +.fa-exchange:before { + content: "\f0ec"; +} +.fa-cloud-download:before { + content: "\f0ed"; +} +.fa-cloud-upload:before { + content: "\f0ee"; +} +.fa-user-md:before { + content: "\f0f0"; +} +.fa-stethoscope:before { + content: "\f0f1"; +} +.fa-suitcase:before { + content: "\f0f2"; +} +.fa-bell-o:before { + content: "\f0a2"; +} +.fa-coffee:before { + content: "\f0f4"; +} +.fa-cutlery:before { + content: "\f0f5"; +} +.fa-file-text-o:before { + content: "\f0f6"; +} +.fa-building-o:before { + content: "\f0f7"; +} +.fa-hospital-o:before { + content: "\f0f8"; +} +.fa-ambulance:before { + content: "\f0f9"; +} +.fa-medkit:before { + content: "\f0fa"; +} +.fa-fighter-jet:before { + content: "\f0fb"; +} +.fa-beer:before { + content: "\f0fc"; +} +.fa-h-square:before { + content: "\f0fd"; +} +.fa-plus-square:before { + content: "\f0fe"; +} +.fa-angle-double-left:before { + content: "\f100"; +} +.fa-angle-double-right:before { + content: "\f101"; +} +.fa-angle-double-up:before { + content: "\f102"; +} +.fa-angle-double-down:before { + content: "\f103"; +} +.fa-angle-left:before { + content: "\f104"; +} +.fa-angle-right:before { + content: "\f105"; +} +.fa-angle-up:before { + content: "\f106"; +} +.fa-angle-down:before { + content: "\f107"; +} +.fa-desktop:before { + content: "\f108"; +} +.fa-laptop:before { + content: "\f109"; +} +.fa-tablet:before { + content: "\f10a"; +} +.fa-mobile-phone:before, +.fa-mobile:before { + content: "\f10b"; +} +.fa-circle-o:before { + content: "\f10c"; +} +.fa-quote-left:before { + content: "\f10d"; +} +.fa-quote-right:before { + content: "\f10e"; +} +.fa-spinner:before { + content: "\f110"; +} +.fa-circle:before { + content: "\f111"; +} +.fa-mail-reply:before, +.fa-reply:before { + content: "\f112"; +} +.fa-github-alt:before { + content: "\f113"; +} +.fa-folder-o:before { + content: "\f114"; +} +.fa-folder-open-o:before { + content: "\f115"; +} +.fa-smile-o:before { + content: "\f118"; +} +.fa-frown-o:before { + content: "\f119"; +} +.fa-meh-o:before { + content: "\f11a"; +} +.fa-gamepad:before { + content: "\f11b"; +} +.fa-keyboard-o:before { + content: "\f11c"; +} +.fa-flag-o:before { + content: "\f11d"; +} +.fa-flag-checkered:before { + content: "\f11e"; +} +.fa-terminal:before { + content: "\f120"; +} +.fa-code:before { + content: "\f121"; +} +.fa-mail-reply-all:before, +.fa-reply-all:before { + content: "\f122"; +} +.fa-star-half-empty:before, +.fa-star-half-full:before, +.fa-star-half-o:before { + content: "\f123"; +} +.fa-location-arrow:before { + content: "\f124"; +} +.fa-crop:before { + content: "\f125"; +} +.fa-code-fork:before { + content: "\f126"; +} +.fa-unlink:before, +.fa-chain-broken:before { + content: "\f127"; +} +.fa-question:before { + content: "\f128"; +} +.fa-info:before { + content: "\f129"; +} +.fa-exclamation:before { + content: "\f12a"; +} +.fa-superscript:before { + content: "\f12b"; +} +.fa-subscript:before { + content: "\f12c"; +} +.fa-eraser:before { + content: "\f12d"; +} +.fa-puzzle-piece:before { + content: "\f12e"; +} +.fa-microphone:before { + content: "\f130"; +} +.fa-microphone-slash:before { + content: "\f131"; +} +.fa-shield:before { + content: "\f132"; +} +.fa-calendar-o:before { + content: "\f133"; +} +.fa-fire-extinguisher:before { + content: "\f134"; +} +.fa-rocket:before { + content: "\f135"; +} +.fa-maxcdn:before { + content: "\f136"; +} +.fa-chevron-circle-left:before { + content: "\f137"; +} +.fa-chevron-circle-right:before { + content: "\f138"; +} +.fa-chevron-circle-up:before { + content: "\f139"; +} +.fa-chevron-circle-down:before { + content: "\f13a"; +} +.fa-html5:before { + content: "\f13b"; +} +.fa-css3:before { + content: "\f13c"; +} +.fa-anchor:before { + content: "\f13d"; +} +.fa-unlock-alt:before { + content: "\f13e"; +} +.fa-bullseye:before { + content: "\f140"; +} +.fa-ellipsis-h:before { + content: "\f141"; +} +.fa-ellipsis-v:before { + content: "\f142"; +} +.fa-rss-square:before { + content: "\f143"; +} +.fa-play-circle:before { + content: "\f144"; +} +.fa-ticket:before { + content: "\f145"; +} +.fa-minus-square:before { + content: "\f146"; +} +.fa-minus-square-o:before { + content: "\f147"; +} +.fa-level-up:before { + content: "\f148"; +} +.fa-level-down:before { + content: "\f149"; +} +.fa-check-square:before { + content: "\f14a"; +} +.fa-pencil-square:before { + content: "\f14b"; +} +.fa-external-link-square:before { + content: "\f14c"; +} +.fa-share-square:before { + content: "\f14d"; +} +.fa-compass:before { + content: "\f14e"; +} +.fa-toggle-down:before, +.fa-caret-square-o-down:before { + content: "\f150"; +} +.fa-toggle-up:before, +.fa-caret-square-o-up:before { + content: "\f151"; +} +.fa-toggle-right:before, +.fa-caret-square-o-right:before { + content: "\f152"; +} +.fa-euro:before, +.fa-eur:before { + content: "\f153"; +} +.fa-gbp:before { + content: "\f154"; +} +.fa-dollar:before, +.fa-usd:before { + content: "\f155"; +} +.fa-rupee:before, +.fa-inr:before { + content: "\f156"; +} +.fa-cny:before, +.fa-rmb:before, +.fa-yen:before, +.fa-jpy:before { + content: "\f157"; +} +.fa-ruble:before, +.fa-rouble:before, +.fa-rub:before { + content: "\f158"; +} +.fa-won:before, +.fa-krw:before { + content: "\f159"; +} +.fa-bitcoin:before, +.fa-btc:before { + content: "\f15a"; +} +.fa-file:before { + content: "\f15b"; +} +.fa-file-text:before { + content: "\f15c"; +} +.fa-sort-alpha-asc:before { + content: "\f15d"; +} +.fa-sort-alpha-desc:before { + content: "\f15e"; +} +.fa-sort-amount-asc:before { + content: "\f160"; +} +.fa-sort-amount-desc:before { + content: "\f161"; +} +.fa-sort-numeric-asc:before { + content: "\f162"; +} +.fa-sort-numeric-desc:before { + content: "\f163"; +} +.fa-thumbs-up:before { + content: "\f164"; +} +.fa-thumbs-down:before { + content: "\f165"; +} +.fa-youtube-square:before { + content: "\f166"; +} +.fa-youtube:before { + content: "\f167"; +} +.fa-xing:before { + content: "\f168"; +} +.fa-xing-square:before { + content: "\f169"; +} +.fa-youtube-play:before { + content: "\f16a"; +} +.fa-dropbox:before { + content: "\f16b"; +} +.fa-stack-overflow:before { + content: "\f16c"; +} +.fa-instagram:before { + content: "\f16d"; +} +.fa-flickr:before { + content: "\f16e"; +} +.fa-adn:before { + content: "\f170"; +} +.fa-bitbucket:before { + content: "\f171"; +} +.fa-bitbucket-square:before { + content: "\f172"; +} +.fa-tumblr:before { + content: "\f173"; +} +.fa-tumblr-square:before { + content: "\f174"; +} +.fa-long-arrow-down:before { + content: "\f175"; +} +.fa-long-arrow-up:before { + content: "\f176"; +} +.fa-long-arrow-left:before { + content: "\f177"; +} +.fa-long-arrow-right:before { + content: "\f178"; +} +.fa-apple:before { + content: "\f179"; +} +.fa-windows:before { + content: "\f17a"; +} +.fa-android:before { + content: "\f17b"; +} +.fa-linux:before { + content: "\f17c"; +} +.fa-dribbble:before { + content: "\f17d"; +} +.fa-skype:before { + content: "\f17e"; +} +.fa-foursquare:before { + content: "\f180"; +} +.fa-trello:before { + content: "\f181"; +} +.fa-female:before { + content: "\f182"; +} +.fa-male:before { + content: "\f183"; +} +.fa-gittip:before, +.fa-gratipay:before { + content: "\f184"; +} +.fa-sun-o:before { + content: "\f185"; +} +.fa-moon-o:before { + content: "\f186"; +} +.fa-archive:before { + content: "\f187"; +} +.fa-bug:before { + content: "\f188"; +} +.fa-vk:before { + content: "\f189"; +} +.fa-weibo:before { + content: "\f18a"; +} +.fa-renren:before { + content: "\f18b"; +} +.fa-pagelines:before { + content: "\f18c"; +} +.fa-stack-exchange:before { + content: "\f18d"; +} +.fa-arrow-circle-o-right:before { + content: "\f18e"; +} +.fa-arrow-circle-o-left:before { + content: "\f190"; +} +.fa-toggle-left:before, +.fa-caret-square-o-left:before { + content: "\f191"; +} +.fa-dot-circle-o:before { + content: "\f192"; +} +.fa-wheelchair:before { + content: "\f193"; +} +.fa-vimeo-square:before { + content: "\f194"; +} +.fa-turkish-lira:before, +.fa-try:before { + content: "\f195"; +} +.fa-plus-square-o:before { + content: "\f196"; +} +.fa-space-shuttle:before { + content: "\f197"; +} +.fa-slack:before { + content: "\f198"; +} +.fa-envelope-square:before { + content: "\f199"; +} +.fa-wordpress:before { + content: "\f19a"; +} +.fa-openid:before { + content: "\f19b"; +} +.fa-institution:before, +.fa-bank:before, +.fa-university:before { + content: "\f19c"; +} +.fa-mortar-board:before, +.fa-graduation-cap:before { + content: "\f19d"; +} +.fa-yahoo:before { + content: "\f19e"; +} +.fa-google:before { + content: "\f1a0"; +} +.fa-reddit:before { + content: "\f1a1"; +} +.fa-reddit-square:before { + content: "\f1a2"; +} +.fa-stumbleupon-circle:before { + content: "\f1a3"; +} +.fa-stumbleupon:before { + content: "\f1a4"; +} +.fa-delicious:before { + content: "\f1a5"; +} +.fa-digg:before { + content: "\f1a6"; +} +.fa-pied-piper:before { + content: "\f1a7"; +} +.fa-pied-piper-alt:before { + content: "\f1a8"; +} +.fa-drupal:before { + content: "\f1a9"; +} +.fa-joomla:before { + content: "\f1aa"; +} +.fa-language:before { + content: "\f1ab"; +} +.fa-fax:before { + content: "\f1ac"; +} +.fa-building:before { + content: "\f1ad"; +} +.fa-child:before { + content: "\f1ae"; +} +.fa-paw:before { + content: "\f1b0"; +} +.fa-spoon:before { + content: "\f1b1"; +} +.fa-cube:before { + content: "\f1b2"; +} +.fa-cubes:before { + content: "\f1b3"; +} +.fa-behance:before { + content: "\f1b4"; +} +.fa-behance-square:before { + content: "\f1b5"; +} +.fa-steam:before { + content: "\f1b6"; +} +.fa-steam-square:before { + content: "\f1b7"; +} +.fa-recycle:before { + content: "\f1b8"; +} +.fa-automobile:before, +.fa-car:before { + content: "\f1b9"; +} +.fa-cab:before, +.fa-taxi:before { + content: "\f1ba"; +} +.fa-tree:before { + content: "\f1bb"; +} +.fa-spotify:before { + content: "\f1bc"; +} +.fa-deviantart:before { + content: "\f1bd"; +} +.fa-soundcloud:before { + content: "\f1be"; +} +.fa-database:before { + content: "\f1c0"; +} +.fa-file-pdf-o:before { + content: "\f1c1"; +} +.fa-file-word-o:before { + content: "\f1c2"; +} +.fa-file-excel-o:before { + content: "\f1c3"; +} +.fa-file-powerpoint-o:before { + content: "\f1c4"; +} +.fa-file-photo-o:before, +.fa-file-picture-o:before, +.fa-file-image-o:before { + content: "\f1c5"; +} +.fa-file-zip-o:before, +.fa-file-archive-o:before { + content: "\f1c6"; +} +.fa-file-sound-o:before, +.fa-file-audio-o:before { + content: "\f1c7"; +} +.fa-file-movie-o:before, +.fa-file-video-o:before { + content: "\f1c8"; +} +.fa-file-code-o:before { + content: "\f1c9"; +} +.fa-vine:before { + content: "\f1ca"; +} +.fa-codepen:before { + content: "\f1cb"; +} +.fa-jsfiddle:before { + content: "\f1cc"; +} +.fa-life-bouy:before, +.fa-life-buoy:before, +.fa-life-saver:before, +.fa-support:before, +.fa-life-ring:before { + content: "\f1cd"; +} +.fa-circle-o-notch:before { + content: "\f1ce"; +} +.fa-ra:before, +.fa-rebel:before { + content: "\f1d0"; +} +.fa-ge:before, +.fa-empire:before { + content: "\f1d1"; +} +.fa-git-square:before { + content: "\f1d2"; +} +.fa-git:before { + content: "\f1d3"; +} +.fa-y-combinator-square:before, +.fa-yc-square:before, +.fa-hacker-news:before { + content: "\f1d4"; +} +.fa-tencent-weibo:before { + content: "\f1d5"; +} +.fa-qq:before { + content: "\f1d6"; +} +.fa-wechat:before, +.fa-weixin:before { + content: "\f1d7"; +} +.fa-send:before, +.fa-paper-plane:before { + content: "\f1d8"; +} +.fa-send-o:before, +.fa-paper-plane-o:before { + content: "\f1d9"; +} +.fa-history:before { + content: "\f1da"; +} +.fa-circle-thin:before { + content: "\f1db"; +} +.fa-header:before { + content: "\f1dc"; +} +.fa-paragraph:before { + content: "\f1dd"; +} +.fa-sliders:before { + content: "\f1de"; +} +.fa-share-alt:before { + content: "\f1e0"; +} +.fa-share-alt-square:before { + content: "\f1e1"; +} +.fa-bomb:before { + content: "\f1e2"; +} +.fa-soccer-ball-o:before, +.fa-futbol-o:before { + content: "\f1e3"; +} +.fa-tty:before { + content: "\f1e4"; +} +.fa-binoculars:before { + content: "\f1e5"; +} +.fa-plug:before { + content: "\f1e6"; +} +.fa-slideshare:before { + content: "\f1e7"; +} +.fa-twitch:before { + content: "\f1e8"; +} +.fa-yelp:before { + content: "\f1e9"; +} +.fa-newspaper-o:before { + content: "\f1ea"; +} +.fa-wifi:before { + content: "\f1eb"; +} +.fa-calculator:before { + content: "\f1ec"; +} +.fa-paypal:before { + content: "\f1ed"; +} +.fa-google-wallet:before { + content: "\f1ee"; +} +.fa-cc-visa:before { + content: "\f1f0"; +} +.fa-cc-mastercard:before { + content: "\f1f1"; +} +.fa-cc-discover:before { + content: "\f1f2"; +} +.fa-cc-amex:before { + content: "\f1f3"; +} +.fa-cc-paypal:before { + content: "\f1f4"; +} +.fa-cc-stripe:before { + content: "\f1f5"; +} +.fa-bell-slash:before { + content: "\f1f6"; +} +.fa-bell-slash-o:before { + content: "\f1f7"; +} +.fa-trash:before { + content: "\f1f8"; +} +.fa-copyright:before { + content: "\f1f9"; +} +.fa-at:before { + content: "\f1fa"; +} +.fa-eyedropper:before { + content: "\f1fb"; +} +.fa-paint-brush:before { + content: "\f1fc"; +} +.fa-birthday-cake:before { + content: "\f1fd"; +} +.fa-area-chart:before { + content: "\f1fe"; +} +.fa-pie-chart:before { + content: "\f200"; +} +.fa-line-chart:before { + content: "\f201"; +} +.fa-lastfm:before { + content: "\f202"; +} +.fa-lastfm-square:before { + content: "\f203"; +} +.fa-toggle-off:before { + content: "\f204"; +} +.fa-toggle-on:before { + content: "\f205"; +} +.fa-bicycle:before { + content: "\f206"; +} +.fa-bus:before { + content: "\f207"; +} +.fa-ioxhost:before { + content: "\f208"; +} +.fa-angellist:before { + content: "\f209"; +} +.fa-cc:before { + content: "\f20a"; +} +.fa-shekel:before, +.fa-sheqel:before, +.fa-ils:before { + content: "\f20b"; +} +.fa-meanpath:before { + content: "\f20c"; +} +.fa-buysellads:before { + content: "\f20d"; +} +.fa-connectdevelop:before { + content: "\f20e"; +} +.fa-dashcube:before { + content: "\f210"; +} +.fa-forumbee:before { + content: "\f211"; +} +.fa-leanpub:before { + content: "\f212"; +} +.fa-sellsy:before { + content: "\f213"; +} +.fa-shirtsinbulk:before { + content: "\f214"; +} +.fa-simplybuilt:before { + content: "\f215"; +} +.fa-skyatlas:before { + content: "\f216"; +} +.fa-cart-plus:before { + content: "\f217"; +} +.fa-cart-arrow-down:before { + content: "\f218"; +} +.fa-diamond:before { + content: "\f219"; +} +.fa-ship:before { + content: "\f21a"; +} +.fa-user-secret:before { + content: "\f21b"; +} +.fa-motorcycle:before { + content: "\f21c"; +} +.fa-street-view:before { + content: "\f21d"; +} +.fa-heartbeat:before { + content: "\f21e"; +} +.fa-venus:before { + content: "\f221"; +} +.fa-mars:before { + content: "\f222"; +} +.fa-mercury:before { + content: "\f223"; +} +.fa-intersex:before, +.fa-transgender:before { + content: "\f224"; +} +.fa-transgender-alt:before { + content: "\f225"; +} +.fa-venus-double:before { + content: "\f226"; +} +.fa-mars-double:before { + content: "\f227"; +} +.fa-venus-mars:before { + content: "\f228"; +} +.fa-mars-stroke:before { + content: "\f229"; +} +.fa-mars-stroke-v:before { + content: "\f22a"; +} +.fa-mars-stroke-h:before { + content: "\f22b"; +} +.fa-neuter:before { + content: "\f22c"; +} +.fa-genderless:before { + content: "\f22d"; +} +.fa-facebook-official:before { + content: "\f230"; +} +.fa-pinterest-p:before { + content: "\f231"; +} +.fa-whatsapp:before { + content: "\f232"; +} +.fa-server:before { + content: "\f233"; +} +.fa-user-plus:before { + content: "\f234"; +} +.fa-user-times:before { + content: "\f235"; +} +.fa-hotel:before, +.fa-bed:before { + content: "\f236"; +} +.fa-viacoin:before { + content: "\f237"; +} +.fa-train:before { + content: "\f238"; +} +.fa-subway:before { + content: "\f239"; +} +.fa-medium:before { + content: "\f23a"; +} +.fa-yc:before, +.fa-y-combinator:before { + content: "\f23b"; +} +.fa-optin-monster:before { + content: "\f23c"; +} +.fa-opencart:before { + content: "\f23d"; +} +.fa-expeditedssl:before { + content: "\f23e"; +} +.fa-battery-4:before, +.fa-battery-full:before { + content: "\f240"; +} +.fa-battery-3:before, +.fa-battery-three-quarters:before { + content: "\f241"; +} +.fa-battery-2:before, +.fa-battery-half:before { + content: "\f242"; +} +.fa-battery-1:before, +.fa-battery-quarter:before { + content: "\f243"; +} +.fa-battery-0:before, +.fa-battery-empty:before { + content: "\f244"; +} +.fa-mouse-pointer:before { + content: "\f245"; +} +.fa-i-cursor:before { + content: "\f246"; +} +.fa-object-group:before { + content: "\f247"; +} +.fa-object-ungroup:before { + content: "\f248"; +} +.fa-sticky-note:before { + content: "\f249"; +} +.fa-sticky-note-o:before { + content: "\f24a"; +} +.fa-cc-jcb:before { + content: "\f24b"; +} +.fa-cc-diners-club:before { + content: "\f24c"; +} +.fa-clone:before { + content: "\f24d"; +} +.fa-balance-scale:before { + content: "\f24e"; +} +.fa-hourglass-o:before { + content: "\f250"; +} +.fa-hourglass-1:before, +.fa-hourglass-start:before { + content: "\f251"; +} +.fa-hourglass-2:before, +.fa-hourglass-half:before { + content: "\f252"; +} +.fa-hourglass-3:before, +.fa-hourglass-end:before { + content: "\f253"; +} +.fa-hourglass:before { + content: "\f254"; +} +.fa-hand-grab-o:before, +.fa-hand-rock-o:before { + content: "\f255"; +} +.fa-hand-stop-o:before, +.fa-hand-paper-o:before { + content: "\f256"; +} +.fa-hand-scissors-o:before { + content: "\f257"; +} +.fa-hand-lizard-o:before { + content: "\f258"; +} +.fa-hand-spock-o:before { + content: "\f259"; +} +.fa-hand-pointer-o:before { + content: "\f25a"; +} +.fa-hand-peace-o:before { + content: "\f25b"; +} +.fa-trademark:before { + content: "\f25c"; +} +.fa-registered:before { + content: "\f25d"; +} +.fa-creative-commons:before { + content: "\f25e"; +} +.fa-gg:before { + content: "\f260"; +} +.fa-gg-circle:before { + content: "\f261"; +} +.fa-tripadvisor:before { + content: "\f262"; +} +.fa-odnoklassniki:before { + content: "\f263"; +} +.fa-odnoklassniki-square:before { + content: "\f264"; +} +.fa-get-pocket:before { + content: "\f265"; +} +.fa-wikipedia-w:before { + content: "\f266"; +} +.fa-safari:before { + content: "\f267"; +} +.fa-chrome:before { + content: "\f268"; +} +.fa-firefox:before { + content: "\f269"; +} +.fa-opera:before { + content: "\f26a"; +} +.fa-internet-explorer:before { + content: "\f26b"; +} +.fa-tv:before, +.fa-television:before { + content: "\f26c"; +} +.fa-contao:before { + content: "\f26d"; +} +.fa-500px:before { + content: "\f26e"; +} +.fa-amazon:before { + content: "\f270"; +} +.fa-calendar-plus-o:before { + content: "\f271"; +} +.fa-calendar-minus-o:before { + content: "\f272"; +} +.fa-calendar-times-o:before { + content: "\f273"; +} +.fa-calendar-check-o:before { + content: "\f274"; +} +.fa-industry:before { + content: "\f275"; +} +.fa-map-pin:before { + content: "\f276"; +} +.fa-map-signs:before { + content: "\f277"; +} +.fa-map-o:before { + content: "\f278"; +} +.fa-map:before { + content: "\f279"; +} +.fa-commenting:before { + content: "\f27a"; +} +.fa-commenting-o:before { + content: "\f27b"; +} +.fa-houzz:before { + content: "\f27c"; +} +.fa-vimeo:before { + content: "\f27d"; +} +.fa-black-tie:before { + content: "\f27e"; +} +.fa-fonticons:before { + content: "\f280"; +} +.fa-reddit-alien:before { + content: "\f281"; +} +.fa-edge:before { + content: "\f282"; +} +.fa-credit-card-alt:before { + content: "\f283"; +} +.fa-codiepie:before { + content: "\f284"; +} +.fa-modx:before { + content: "\f285"; +} +.fa-fort-awesome:before { + content: "\f286"; +} +.fa-usb:before { + content: "\f287"; +} +.fa-product-hunt:before { + content: "\f288"; +} +.fa-mixcloud:before { + content: "\f289"; +} +.fa-scribd:before { + content: "\f28a"; +} +.fa-pause-circle:before { + content: "\f28b"; +} +.fa-pause-circle-o:before { + content: "\f28c"; +} +.fa-stop-circle:before { + content: "\f28d"; +} +.fa-stop-circle-o:before { + content: "\f28e"; +} +.fa-shopping-bag:before { + content: "\f290"; +} +.fa-shopping-basket:before { + content: "\f291"; +} +.fa-hashtag:before { + content: "\f292"; +} +.fa-bluetooth:before { + content: "\f293"; +} +.fa-bluetooth-b:before { + content: "\f294"; +} +.fa-percent:before { + content: "\f295"; +} diff --git a/static/css/font-awesome.min.css b/static/css/font-awesome.min.css new file mode 100644 index 0000000..d0603cb --- /dev/null +++ b/static/css/font-awesome.min.css @@ -0,0 +1,4 @@ +/*! + * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.5.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"} diff --git a/static/css/grids-responsive-min.css b/static/css/grids-responsive-min.css new file mode 100644 index 0000000..1df05db --- /dev/null +++ b/static/css/grids-responsive-min.css @@ -0,0 +1,7 @@ +/*! +Pure v0.6.0 +Copyright 2014 Yahoo! Inc. All rights reserved. +Licensed under the BSD License. +https://github.com/yahoo/pure/blob/master/LICENSE.md +*/ +@media screen and (min-width:35.5em){.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-1-2,.pure-u-sm-1-3,.pure-u-sm-2-3,.pure-u-sm-1-4,.pure-u-sm-3-4,.pure-u-sm-1-5,.pure-u-sm-2-5,.pure-u-sm-3-5,.pure-u-sm-4-5,.pure-u-sm-5-5,.pure-u-sm-1-6,.pure-u-sm-5-6,.pure-u-sm-1-8,.pure-u-sm-3-8,.pure-u-sm-5-8,.pure-u-sm-7-8,.pure-u-sm-1-12,.pure-u-sm-5-12,.pure-u-sm-7-12,.pure-u-sm-11-12,.pure-u-sm-1-24,.pure-u-sm-2-24,.pure-u-sm-3-24,.pure-u-sm-4-24,.pure-u-sm-5-24,.pure-u-sm-6-24,.pure-u-sm-7-24,.pure-u-sm-8-24,.pure-u-sm-9-24,.pure-u-sm-10-24,.pure-u-sm-11-24,.pure-u-sm-12-24,.pure-u-sm-13-24,.pure-u-sm-14-24,.pure-u-sm-15-24,.pure-u-sm-16-24,.pure-u-sm-17-24,.pure-u-sm-18-24,.pure-u-sm-19-24,.pure-u-sm-20-24,.pure-u-sm-21-24,.pure-u-sm-22-24,.pure-u-sm-23-24,.pure-u-sm-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-sm-1-24{width:4.1667%;*width:4.1357%}.pure-u-sm-1-12,.pure-u-sm-2-24{width:8.3333%;*width:8.3023%}.pure-u-sm-1-8,.pure-u-sm-3-24{width:12.5%;*width:12.469%}.pure-u-sm-1-6,.pure-u-sm-4-24{width:16.6667%;*width:16.6357%}.pure-u-sm-1-5{width:20%;*width:19.969%}.pure-u-sm-5-24{width:20.8333%;*width:20.8023%}.pure-u-sm-1-4,.pure-u-sm-6-24{width:25%;*width:24.969%}.pure-u-sm-7-24{width:29.1667%;*width:29.1357%}.pure-u-sm-1-3,.pure-u-sm-8-24{width:33.3333%;*width:33.3023%}.pure-u-sm-3-8,.pure-u-sm-9-24{width:37.5%;*width:37.469%}.pure-u-sm-2-5{width:40%;*width:39.969%}.pure-u-sm-5-12,.pure-u-sm-10-24{width:41.6667%;*width:41.6357%}.pure-u-sm-11-24{width:45.8333%;*width:45.8023%}.pure-u-sm-1-2,.pure-u-sm-12-24{width:50%;*width:49.969%}.pure-u-sm-13-24{width:54.1667%;*width:54.1357%}.pure-u-sm-7-12,.pure-u-sm-14-24{width:58.3333%;*width:58.3023%}.pure-u-sm-3-5{width:60%;*width:59.969%}.pure-u-sm-5-8,.pure-u-sm-15-24{width:62.5%;*width:62.469%}.pure-u-sm-2-3,.pure-u-sm-16-24{width:66.6667%;*width:66.6357%}.pure-u-sm-17-24{width:70.8333%;*width:70.8023%}.pure-u-sm-3-4,.pure-u-sm-18-24{width:75%;*width:74.969%}.pure-u-sm-19-24{width:79.1667%;*width:79.1357%}.pure-u-sm-4-5{width:80%;*width:79.969%}.pure-u-sm-5-6,.pure-u-sm-20-24{width:83.3333%;*width:83.3023%}.pure-u-sm-7-8,.pure-u-sm-21-24{width:87.5%;*width:87.469%}.pure-u-sm-11-12,.pure-u-sm-22-24{width:91.6667%;*width:91.6357%}.pure-u-sm-23-24{width:95.8333%;*width:95.8023%}.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-5-5,.pure-u-sm-24-24{width:100%}}@media screen and (min-width:48em){.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-1-2,.pure-u-md-1-3,.pure-u-md-2-3,.pure-u-md-1-4,.pure-u-md-3-4,.pure-u-md-1-5,.pure-u-md-2-5,.pure-u-md-3-5,.pure-u-md-4-5,.pure-u-md-5-5,.pure-u-md-1-6,.pure-u-md-5-6,.pure-u-md-1-8,.pure-u-md-3-8,.pure-u-md-5-8,.pure-u-md-7-8,.pure-u-md-1-12,.pure-u-md-5-12,.pure-u-md-7-12,.pure-u-md-11-12,.pure-u-md-1-24,.pure-u-md-2-24,.pure-u-md-3-24,.pure-u-md-4-24,.pure-u-md-5-24,.pure-u-md-6-24,.pure-u-md-7-24,.pure-u-md-8-24,.pure-u-md-9-24,.pure-u-md-10-24,.pure-u-md-11-24,.pure-u-md-12-24,.pure-u-md-13-24,.pure-u-md-14-24,.pure-u-md-15-24,.pure-u-md-16-24,.pure-u-md-17-24,.pure-u-md-18-24,.pure-u-md-19-24,.pure-u-md-20-24,.pure-u-md-21-24,.pure-u-md-22-24,.pure-u-md-23-24,.pure-u-md-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-md-1-24{width:4.1667%;*width:4.1357%}.pure-u-md-1-12,.pure-u-md-2-24{width:8.3333%;*width:8.3023%}.pure-u-md-1-8,.pure-u-md-3-24{width:12.5%;*width:12.469%}.pure-u-md-1-6,.pure-u-md-4-24{width:16.6667%;*width:16.6357%}.pure-u-md-1-5{width:20%;*width:19.969%}.pure-u-md-5-24{width:20.8333%;*width:20.8023%}.pure-u-md-1-4,.pure-u-md-6-24{width:25%;*width:24.969%}.pure-u-md-7-24{width:29.1667%;*width:29.1357%}.pure-u-md-1-3,.pure-u-md-8-24{width:33.3333%;*width:33.3023%}.pure-u-md-3-8,.pure-u-md-9-24{width:37.5%;*width:37.469%}.pure-u-md-2-5{width:40%;*width:39.969%}.pure-u-md-5-12,.pure-u-md-10-24{width:41.6667%;*width:41.6357%}.pure-u-md-11-24{width:45.8333%;*width:45.8023%}.pure-u-md-1-2,.pure-u-md-12-24{width:50%;*width:49.969%}.pure-u-md-13-24{width:54.1667%;*width:54.1357%}.pure-u-md-7-12,.pure-u-md-14-24{width:58.3333%;*width:58.3023%}.pure-u-md-3-5{width:60%;*width:59.969%}.pure-u-md-5-8,.pure-u-md-15-24{width:62.5%;*width:62.469%}.pure-u-md-2-3,.pure-u-md-16-24{width:66.6667%;*width:66.6357%}.pure-u-md-17-24{width:70.8333%;*width:70.8023%}.pure-u-md-3-4,.pure-u-md-18-24{width:75%;*width:74.969%}.pure-u-md-19-24{width:79.1667%;*width:79.1357%}.pure-u-md-4-5{width:80%;*width:79.969%}.pure-u-md-5-6,.pure-u-md-20-24{width:83.3333%;*width:83.3023%}.pure-u-md-7-8,.pure-u-md-21-24{width:87.5%;*width:87.469%}.pure-u-md-11-12,.pure-u-md-22-24{width:91.6667%;*width:91.6357%}.pure-u-md-23-24{width:95.8333%;*width:95.8023%}.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-5-5,.pure-u-md-24-24{width:100%}}@media screen and (min-width:64em){.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-1-2,.pure-u-lg-1-3,.pure-u-lg-2-3,.pure-u-lg-1-4,.pure-u-lg-3-4,.pure-u-lg-1-5,.pure-u-lg-2-5,.pure-u-lg-3-5,.pure-u-lg-4-5,.pure-u-lg-5-5,.pure-u-lg-1-6,.pure-u-lg-5-6,.pure-u-lg-1-8,.pure-u-lg-3-8,.pure-u-lg-5-8,.pure-u-lg-7-8,.pure-u-lg-1-12,.pure-u-lg-5-12,.pure-u-lg-7-12,.pure-u-lg-11-12,.pure-u-lg-1-24,.pure-u-lg-2-24,.pure-u-lg-3-24,.pure-u-lg-4-24,.pure-u-lg-5-24,.pure-u-lg-6-24,.pure-u-lg-7-24,.pure-u-lg-8-24,.pure-u-lg-9-24,.pure-u-lg-10-24,.pure-u-lg-11-24,.pure-u-lg-12-24,.pure-u-lg-13-24,.pure-u-lg-14-24,.pure-u-lg-15-24,.pure-u-lg-16-24,.pure-u-lg-17-24,.pure-u-lg-18-24,.pure-u-lg-19-24,.pure-u-lg-20-24,.pure-u-lg-21-24,.pure-u-lg-22-24,.pure-u-lg-23-24,.pure-u-lg-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-lg-1-24{width:4.1667%;*width:4.1357%}.pure-u-lg-1-12,.pure-u-lg-2-24{width:8.3333%;*width:8.3023%}.pure-u-lg-1-8,.pure-u-lg-3-24{width:12.5%;*width:12.469%}.pure-u-lg-1-6,.pure-u-lg-4-24{width:16.6667%;*width:16.6357%}.pure-u-lg-1-5{width:20%;*width:19.969%}.pure-u-lg-5-24{width:20.8333%;*width:20.8023%}.pure-u-lg-1-4,.pure-u-lg-6-24{width:25%;*width:24.969%}.pure-u-lg-7-24{width:29.1667%;*width:29.1357%}.pure-u-lg-1-3,.pure-u-lg-8-24{width:33.3333%;*width:33.3023%}.pure-u-lg-3-8,.pure-u-lg-9-24{width:37.5%;*width:37.469%}.pure-u-lg-2-5{width:40%;*width:39.969%}.pure-u-lg-5-12,.pure-u-lg-10-24{width:41.6667%;*width:41.6357%}.pure-u-lg-11-24{width:45.8333%;*width:45.8023%}.pure-u-lg-1-2,.pure-u-lg-12-24{width:50%;*width:49.969%}.pure-u-lg-13-24{width:54.1667%;*width:54.1357%}.pure-u-lg-7-12,.pure-u-lg-14-24{width:58.3333%;*width:58.3023%}.pure-u-lg-3-5{width:60%;*width:59.969%}.pure-u-lg-5-8,.pure-u-lg-15-24{width:62.5%;*width:62.469%}.pure-u-lg-2-3,.pure-u-lg-16-24{width:66.6667%;*width:66.6357%}.pure-u-lg-17-24{width:70.8333%;*width:70.8023%}.pure-u-lg-3-4,.pure-u-lg-18-24{width:75%;*width:74.969%}.pure-u-lg-19-24{width:79.1667%;*width:79.1357%}.pure-u-lg-4-5{width:80%;*width:79.969%}.pure-u-lg-5-6,.pure-u-lg-20-24{width:83.3333%;*width:83.3023%}.pure-u-lg-7-8,.pure-u-lg-21-24{width:87.5%;*width:87.469%}.pure-u-lg-11-12,.pure-u-lg-22-24{width:91.6667%;*width:91.6357%}.pure-u-lg-23-24{width:95.8333%;*width:95.8023%}.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-5-5,.pure-u-lg-24-24{width:100%}}@media screen and (min-width:80em){.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-1-2,.pure-u-xl-1-3,.pure-u-xl-2-3,.pure-u-xl-1-4,.pure-u-xl-3-4,.pure-u-xl-1-5,.pure-u-xl-2-5,.pure-u-xl-3-5,.pure-u-xl-4-5,.pure-u-xl-5-5,.pure-u-xl-1-6,.pure-u-xl-5-6,.pure-u-xl-1-8,.pure-u-xl-3-8,.pure-u-xl-5-8,.pure-u-xl-7-8,.pure-u-xl-1-12,.pure-u-xl-5-12,.pure-u-xl-7-12,.pure-u-xl-11-12,.pure-u-xl-1-24,.pure-u-xl-2-24,.pure-u-xl-3-24,.pure-u-xl-4-24,.pure-u-xl-5-24,.pure-u-xl-6-24,.pure-u-xl-7-24,.pure-u-xl-8-24,.pure-u-xl-9-24,.pure-u-xl-10-24,.pure-u-xl-11-24,.pure-u-xl-12-24,.pure-u-xl-13-24,.pure-u-xl-14-24,.pure-u-xl-15-24,.pure-u-xl-16-24,.pure-u-xl-17-24,.pure-u-xl-18-24,.pure-u-xl-19-24,.pure-u-xl-20-24,.pure-u-xl-21-24,.pure-u-xl-22-24,.pure-u-xl-23-24,.pure-u-xl-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xl-1-24{width:4.1667%;*width:4.1357%}.pure-u-xl-1-12,.pure-u-xl-2-24{width:8.3333%;*width:8.3023%}.pure-u-xl-1-8,.pure-u-xl-3-24{width:12.5%;*width:12.469%}.pure-u-xl-1-6,.pure-u-xl-4-24{width:16.6667%;*width:16.6357%}.pure-u-xl-1-5{width:20%;*width:19.969%}.pure-u-xl-5-24{width:20.8333%;*width:20.8023%}.pure-u-xl-1-4,.pure-u-xl-6-24{width:25%;*width:24.969%}.pure-u-xl-7-24{width:29.1667%;*width:29.1357%}.pure-u-xl-1-3,.pure-u-xl-8-24{width:33.3333%;*width:33.3023%}.pure-u-xl-3-8,.pure-u-xl-9-24{width:37.5%;*width:37.469%}.pure-u-xl-2-5{width:40%;*width:39.969%}.pure-u-xl-5-12,.pure-u-xl-10-24{width:41.6667%;*width:41.6357%}.pure-u-xl-11-24{width:45.8333%;*width:45.8023%}.pure-u-xl-1-2,.pure-u-xl-12-24{width:50%;*width:49.969%}.pure-u-xl-13-24{width:54.1667%;*width:54.1357%}.pure-u-xl-7-12,.pure-u-xl-14-24{width:58.3333%;*width:58.3023%}.pure-u-xl-3-5{width:60%;*width:59.969%}.pure-u-xl-5-8,.pure-u-xl-15-24{width:62.5%;*width:62.469%}.pure-u-xl-2-3,.pure-u-xl-16-24{width:66.6667%;*width:66.6357%}.pure-u-xl-17-24{width:70.8333%;*width:70.8023%}.pure-u-xl-3-4,.pure-u-xl-18-24{width:75%;*width:74.969%}.pure-u-xl-19-24{width:79.1667%;*width:79.1357%}.pure-u-xl-4-5{width:80%;*width:79.969%}.pure-u-xl-5-6,.pure-u-xl-20-24{width:83.3333%;*width:83.3023%}.pure-u-xl-7-8,.pure-u-xl-21-24{width:87.5%;*width:87.469%}.pure-u-xl-11-12,.pure-u-xl-22-24{width:91.6667%;*width:91.6357%}.pure-u-xl-23-24{width:95.8333%;*width:95.8023%}.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-5-5,.pure-u-xl-24-24{width:100%}} \ No newline at end of file diff --git a/static/css/pure-min.css b/static/css/pure-min.css new file mode 100644 index 0000000..f0aa374 --- /dev/null +++ b/static/css/pure-min.css @@ -0,0 +1,11 @@ +/*! +Pure v0.6.0 +Copyright 2014 Yahoo! Inc. All rights reserved. +Licensed under the BSD License. +https://github.com/yahoo/pure/blob/master/LICENSE.md +*/ +/*! +normalize.css v^3.0 | MIT License | git.io/normalize +Copyright (c) Nicolas Gallagher and Jonathan Neal +*/ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em;text-rendering:optimizespeed;font-family:FreeSans,Arimo,"Droid Sans",Helvetica,Arial,sans-serif;display:-webkit-flex;-webkit-flex-flow:row wrap;display:-ms-flexbox;-ms-flex-flow:row wrap;-ms-align-content:flex-start;-webkit-align-content:flex-start;align-content:flex-start}.opera-only :-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-g [class *="pure-u"]{font-family:sans-serif}.pure-u-1,.pure-u-1-1,.pure-u-1-2,.pure-u-1-3,.pure-u-2-3,.pure-u-1-4,.pure-u-3-4,.pure-u-1-5,.pure-u-2-5,.pure-u-3-5,.pure-u-4-5,.pure-u-5-5,.pure-u-1-6,.pure-u-5-6,.pure-u-1-8,.pure-u-3-8,.pure-u-5-8,.pure-u-7-8,.pure-u-1-12,.pure-u-5-12,.pure-u-7-12,.pure-u-11-12,.pure-u-1-24,.pure-u-2-24,.pure-u-3-24,.pure-u-4-24,.pure-u-5-24,.pure-u-6-24,.pure-u-7-24,.pure-u-8-24,.pure-u-9-24,.pure-u-10-24,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%;*width:4.1357%}.pure-u-1-12,.pure-u-2-24{width:8.3333%;*width:8.3023%}.pure-u-1-8,.pure-u-3-24{width:12.5%;*width:12.469%}.pure-u-1-6,.pure-u-4-24{width:16.6667%;*width:16.6357%}.pure-u-1-5{width:20%;*width:19.969%}.pure-u-5-24{width:20.8333%;*width:20.8023%}.pure-u-1-4,.pure-u-6-24{width:25%;*width:24.969%}.pure-u-7-24{width:29.1667%;*width:29.1357%}.pure-u-1-3,.pure-u-8-24{width:33.3333%;*width:33.3023%}.pure-u-3-8,.pure-u-9-24{width:37.5%;*width:37.469%}.pure-u-2-5{width:40%;*width:39.969%}.pure-u-5-12,.pure-u-10-24{width:41.6667%;*width:41.6357%}.pure-u-11-24{width:45.8333%;*width:45.8023%}.pure-u-1-2,.pure-u-12-24{width:50%;*width:49.969%}.pure-u-13-24{width:54.1667%;*width:54.1357%}.pure-u-7-12,.pure-u-14-24{width:58.3333%;*width:58.3023%}.pure-u-3-5{width:60%;*width:59.969%}.pure-u-5-8,.pure-u-15-24{width:62.5%;*width:62.469%}.pure-u-2-3,.pure-u-16-24{width:66.6667%;*width:66.6357%}.pure-u-17-24{width:70.8333%;*width:70.8023%}.pure-u-3-4,.pure-u-18-24{width:75%;*width:74.969%}.pure-u-19-24{width:79.1667%;*width:79.1357%}.pure-u-4-5{width:80%;*width:79.969%}.pure-u-5-6,.pure-u-20-24{width:83.3333%;*width:83.3023%}.pure-u-7-8,.pure-u-21-24{width:87.5%;*width:87.469%}.pure-u-11-12,.pure-u-22-24{width:91.6667%;*width:91.6357%}.pure-u-23-24{width:95.8333%;*width:95.8023%}.pure-u-1,.pure-u-1-1,.pure-u-5-5,.pure-u-24-24{width:100%}.pure-button{display:inline-block;zoom:1;line-height:normal;white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button{font-family:inherit;font-size:100%;padding:.5em 1em;color:#444;color:rgba(0,0,0,.8);border:1px solid #999;border:0 rgba(0,0,0,0);background-color:#E6E6E6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:hover,.pure-button:focus{filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#1a000000', GradientType=0);background-image:-webkit-gradient(linear,0 0,0 100%,from(transparent),color-stop(40%,rgba(0,0,0,.05)),to(rgba(0,0,0,.1)));background-image:-webkit-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:-moz-linear-gradient(top,rgba(0,0,0,.05) 0,rgba(0,0,0,.1));background-image:-o-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;border-color:#000\9}.pure-button[disabled],.pure-button-disabled,.pure-button-disabled:hover,.pure-button-disabled:focus,.pure-button-disabled:active{border:0;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);filter:alpha(opacity=40);-khtml-opacity:.4;-moz-opacity:.4;opacity:.4;cursor:not-allowed;box-shadow:none}.pure-button-hidden{display:none}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-form input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=text]:focus,.pure-form input[type=password]:focus,.pure-form input[type=email]:focus,.pure-form input[type=url]:focus,.pure-form input[type=date]:focus,.pure-form input[type=month]:focus,.pure-form input[type=time]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=week]:focus,.pure-form input[type=number]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=color]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;border-color:#129FEA}.pure-form input:not([type]):focus{outline:0;border-color:#129FEA}.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus,.pure-form input[type=checkbox]:focus{outline:thin solid #129FEA;outline:1px auto #129FEA}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=text][disabled],.pure-form input[type=password][disabled],.pure-form input[type=email][disabled],.pure-form input[type=url][disabled],.pure-form input[type=date][disabled],.pure-form input[type=month][disabled],.pure-form input[type=time][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=week][disabled],.pure-form input[type=number][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=color][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{background-color:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form textarea:focus:invalid,.pure-form select:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus,.pure-form input[type=checkbox]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=text],.pure-form-stacked input[type=password],.pure-form-stacked input[type=email],.pure-form-stacked input[type=url],.pure-form-stacked input[type=date],.pure-form-stacked input[type=month],.pure-form-stacked input[type=time],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=week],.pure-form-stacked input[type=number],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=color],.pure-form-stacked input[type=file],.pure-form-stacked select,.pure-form-stacked label,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned textarea,.pure-form-aligned select,.pure-form-aligned .pure-help-inline,.pure-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form input.pure-input-rounded,.pure-form .pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{display:block;padding:10px;margin:0 0 -1px;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{top:1px;border-radius:4px 4px 0 0;margin:0}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{top:1px;border-radius:4px;margin:0}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{top:-2px;border-radius:0 0 4px 4px;margin:0}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form .pure-help-inline,.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:.875em}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=text],.pure-group input[type=password],.pure-group input[type=email],.pure-group input[type=url],.pure-group input[type=date],.pure-group input[type=month],.pure-group input[type=time],.pure-group input[type=datetime],.pure-group input[type=datetime-local],.pure-group input[type=week],.pure-group input[type=number],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=color]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0}.pure-form .pure-help-inline,.pure-form-message-inline,.pure-form-message{display:block;font-size:.75em;padding:.2em 0 .8em}}.pure-menu{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-menu-fixed{position:fixed;left:0;top:0;z-index:3}.pure-menu-list,.pure-menu-item{position:relative}.pure-menu-list{list-style:none;margin:0;padding:0}.pure-menu-item{padding:0;margin:0;height:100%}.pure-menu-link,.pure-menu-heading{display:block;text-decoration:none;white-space:nowrap}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-separator{display:inline-block;*display:inline;zoom:1;vertical-align:middle}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{display:none;position:absolute;left:100%;top:0;margin:0;padding:0;z-index:3}.pure-menu-horizontal .pure-menu-children{left:0;top:auto;width:inherit}.pure-menu-allow-hover:hover>.pure-menu-children,.pure-menu-active>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:"\25B8";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:"\25BE"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;padding:.5em 0}.pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar{display:none}.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-link,.pure-menu-disabled,.pure-menu-heading{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent}.pure-menu-active>.pure-menu-link,.pure-menu-link:hover,.pure-menu-link:focus{background-color:#eee}.pure-menu-selected .pure-menu-link,.pure-menu-selected .pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table td:first-child,.pure-table th:first-child{border-left-width:0}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0} \ No newline at end of file diff --git a/static/css/reset.css b/static/css/reset.css new file mode 100644 index 0000000..0c2b821 --- /dev/null +++ b/static/css/reset.css @@ -0,0 +1,49 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + /*font: inherit;*/ + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..ce83887 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,688 @@ +* { + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +html, body { + height: 100%; +} + +body { + margin: 0 auto; + background: #eee; +} + +html, button, input, select, textarea, +.pure-g [class *= "pure-u"] { + font-family: 'Open Sans', Arial, sans-serif; +} + +a{ + color: #1c619a; +} + +em { + color: #9A691C; + font-style: normal; + font-weight: bold; +} + +pre { + font-family: monospace; + margin-top: 1em; + margin-bottom: 1em; +} + +div#captcha > div > div { + margin: 1em auto; +} + +/* Firefox fix */ +.formpage.pure-g { + text-align: center; +} +.formpage.pure-g > div{ + text-align: left; +} + + +/***************************************************/ +/********************* Top bar */ + +#topbar{ + padding: 0 3%; + color: #737373; + background: #000; +} +#topbar p { + margin: 0; +} +#topbar a:hover{ + cursor: pointer; + background: none; +} +#topbar a{ + display: inline-block; + color: #2c99f3; + text-decoration: none; + padding: 4px 5px; +} +#topbar a:hover{ + background: #222; +} +#topbar .topbar-left{ + float: left; +} +#topbar .topbar-right{ + float: right; + text-align: right; +} +#topbar .topbar-right a{ + font-size: 1.2em; + padding: 2px 5px 2px; +} + + +/***************************************************/ +/********************* Header */ + +header{ + height: 80px; + padding: 0 3%; + color: #fff; + background-color: #1c619a; + border-bottom: 1px solid #000; + +} +header #logo{ + float: left; + height: 80px; + padding-left: 75px; + background: url(../img/bg-logo.svg) no-repeat; + background-size: 70px; + background-position: left center; +} +header #logo * { + margin: 0; + padding: 0; + border: 0; +} +header #logo h1, header #logo h2 { + font-size: 100%; +} +header #logo h1 a{ + margin: 0px; + position: relative; + top: -24px; + font-size: 68px; +} +header #logo h2 a{ + display: block; + margin: 0px; + margin-top: 3px; + font-size: 16px; + font-weight: normal; + letter-spacing: 0.425em; +} +header #logo h1 a, header #logo h2 a { + color: white; + text-decoration: none; +} +header nav{ + float: right; + font-size: 1.2em; + text-transform: uppercase; +} +header nav ul{ + margin: 0px; + padding: 0px; + padding-top: 26px; +} +header nav li{ + display: inline; + margin: 0px 7px; +} +header nav li.selected{ + border-bottom: 2px solid #ddd; +} +header nav li:hover{ + border-bottom: 2px solid #1c80d4; +} +header nav a{ + color: #fff; + text-decoration: none; +} + + +/***************************************************/ +/********************* Common layout */ + +.left-menu { + float: left; + display: block; + width: 200px; + line-height: 20px; + position: absolute; +} +.left-menu p.menu-title { + text-align: center; + padding-top: 1.5em; + margin: 0; +} +.left-menu .title a { + color: white; + font-weight: 600; +} +.left-menu ul { + list-style-type: disc; + padding: 0.5em 1em 0.5em 30px; + margin: 0; +} +.left-menu li { + padding: 0.3em 0; +} +.left-menu a { + text-decoration: none; +} + +.left-menu + .content { + margin-left: 200px; +} + + +.content { + padding: 0em 2em 3em 2em; + background: #fff; + overflow: auto; + + -webkit-box-shadow: -1px 1px 2px -1px rgba(0,0,0,0.53); + -moz-box-shadow: -1px 1px 2px -1px rgba(0,0,0,0.53); + box-shadow: -1px 1px 2px -1px rgba(0,0,0,0.53); +} + +.content h1 { + font-size: 2em; + color: #1c619a; + text-align: center; +} + +.content h2 { + font-size: 1.5em; + font-weight: normal; +} + +.content h3 { + font-size: 1em; + padding-left: 2em; +} + + +footer{ + padding-top: 4em; + clear: both; + color: #1c619a; + text-align: center; +} +footer a { color: #1c619a; } +footer p { margin-top: 0.6em; margin-bottom: 0.5em; } + + +.message p { + text-align: center; + width: 100%; + padding: 1.2em; + margin: 0; + color: white; +} +.message p.info, .message p.success { + background-color: #062D4D; +} +.message p.error, .message p.critical, .message .warning { + background-color: #A7332F; +} + + +table.admin-list { + width: 100%; +} +table.admin-list thead td { + text-align: center; + font-size: 1.1em; + font-weight: 600; + border: 1px solid #bbb; + border-top: 0; +} +table.admin-list thead tr td:first-child { border-left: 0; } +table.admin-list thead tr td:last-child { border-right: 0; } +table.admin-list tbody tr td:first-child { font-size: 1.1em; border-left: 0; } +table.admin-list tbody tr td { padding: 6px; border: 1px solid #bbb; } +table.admin-list tbody tr td:first-child { border-left: 0; } +table.admin-list tbody tr td:last-child { border-right: 0; } +table.admin-list tbody tr:last-child td { border-bottom: 0; } +table.admin-list tbody tr:nth-child(even) { background: #eee; } + + +@media screen and (max-width: 64em) { + .content { + padding: 0em 1em 3em 1em; + } + + header{ + height: 120px; + } + header nav{ + float: left; + width: 100%; + } + header nav ul{ + text-align: center; + padding-top: 0; + } +} + + + +/***************************************************/ +/********************* Forms / Buttons */ + +.pure-form select { + height: 2.5em; +} + +.pure-button-primary, .pure-button-selected, +a.pure-button-primary, a.pure-button-selected { + color: #fff; + background-color: #1c619a; + padding: 0.5em 2em; +} + +form p.inputinfo { + font-size: 0.8em; + display: block; + margin: 0.5em 1em 1em 180px; +} + + +.formpage div.pure-g > div { + margin: auto; +} + +.formpage form.pure-form label { + margin-top: 1.25em 0 0.25em 0; +} +.formpage form.pure-form input, +.formpage form.pure-form select, +.formpage form.pure-form textarea { + margin-left: auto; + margin-right: auto; + margin: 0.10em auto; + width: 90%; +} +.formpage form.pure-form { + text-align: center; +} +.formpage form.pure-form input[type=submit] { + margin-top: 1.25em; + +} +.formpage form.pure-form { + +} +.formpage form.pure-form .inputhelp { + width: 80%; + display: inline-block; + margin: 0; + color: #606060; + font-size: 0.8em; +} + +.formpage > div { + margin: auto; +} + +ul.errorlist { + list-style-type: none; + color: #b00000; +} + +.formpage div.links { + margin: 20px auto 0 auto; +} +.formpage div.links ul{ + padding: 0px 20px 0 20px; +} +.formpage div.links ul li{ + list-style: none; +} +.formpage div.links ul li:before{ + content: "→ "; +} +.formpage div.links a{ + text-decoration: none; +} +.formpage div.links a:hover{ + text-decoration: underline; +} + +.formpage form.pure-form input.button-1-2 { + width: 45%; +} + + +/***************************************************/ +/********************* Help Pages */ + +.install-guides { + text-align: center; + list-style-type: none; +} +.install-guides li { + display: inline-block; + width: 10em; + padding-top: 1em; + text-align: center; + border: 1px solid #ccc; + border-radius: 5px; + margin: 0 1em; +} +.install-guides li a { + line-height: 3em; + text-decoration: none; +} +.install-guides li i { + display: block; + color: #0b2d4f; +} + +.page > .content > p { + margin: 1.5em 0; +} + + +/***************************************************/ +/********************* Home Page */ + +.home-content { + width: 75%; + margin: 3em auto; +} +.home-item{ + float: left; + text-align: center; +} +.home-item img{ + width: 60%; + margin: auto; +} +.home-item h2{ + color: #1c619a; + text-transform: uppercase; + font-weight: normal; + font-size: 1.5em; + margin: 0.80em 0 0.60em 0; +} +.home-item p{ + margin: 0px; + padding: 0.2em; +} +.home-item b{ color: #1c619a; } +.home-item ul{ + margin: 0; + padding: 0 1.5em; +} +.home-item ul li{ + margin: 0px; + padding: 0px; + list-style: none; +} + +.home-signup { + text-align: center; +} +a.home-signup-button { + padding: 0.75em 2em; +} + +@media screen and (max-width: 80em) { + .home-content { + width: 100%; + } +} + +@media screen and (max-width: 48em) { + .home-content { + width: 100%; + margin-top: 0; + } + .home-item-content { + margin-left: 20%; + } + .home-item h2 { + margin: 1em 0 0.20em 0; + } + .home-item img { + width: 20%; + float: left; + margin-top: 3em; + } + .home-signup-button { + min-width: 50%; + } +} + + + +/***************************************************/ +/********************* Account */ + +.account-status { + text-align: center; + margin-bottom: 3em; +} +.account-status-paid, .account-status-disabled { + font-weight: bold; +} + +.account-aff-box { + background: #E6F5FF; + border-radius: 4px; + border: 1px solid #72B6ED; + box-shadow: 1px 1px 3px #aaa; + padding: 0.6em 2em; + margin: 2em 0 0 0; +} + +.account-payment-box label, .account-giftcode-box label { + width: 8em; +} + +@media screen and (min-width: 64em) { + .account-payment-box { + border-right: 1px solid #1c619a; + } +} + + +/***************************************************/ +/********************* Gateways */ + +#gateways table { + width: 100%; +} +#gateways table thead td { + text-align: center; + font-size: 1.1em; + font-weight: 600; + border: 1px solid #bbb; + border-top: 0; +} +#gateways table thead tr td:first-child { + border-left: 0; +} +#gateways table thead tr td:last-child { + border-right: 0; +} +#gateways table tbody tr td:first-child { + font-size: 1.2em; + border-left: 0; +} +#gateways table tbody tr td { + padding: 10px; + border: 1px solid #bbb; +} +#gateways table tbody tr td:first-child { + border-left: 0; +} +#gateways table tbody tr td:last-child { + border-right: 0; +} +#gateways table tbody tr:last-child td { + border-bottom: 0; +} +#gateways table tbody tr:nth-child(even) { + background: #eee; +} +#gateways table tr td:not(:first-child) { + width: 20%; +} +#gateways table tr td .td-info { + display: block; + font-size: 0.8em; + font-weight: normal; +} +#gateways .host_name { + font-family: monospace; +} + + + +/***************************************************/ +/********************* Tickets */ + +div.ticket-messages { + margin: 2em 2em; + border: 1px solid #c7c7c7; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} + +div.ticket-message { + background: #f4f4f4; + border-bottom: 1px solid #c7c7c7; + padding: 0.25em 1em 1em 1em; +} +div.ticket-message:last-child { + border-bottom: 0; +} + +div.ticket-message-user { + background: #e8e8e8; +} +div.ticket-message-staff { +} +div.ticket-message-private { + background: #f8e7e7; +} + +.ticket-message-author, +.ticket-message-date { + font-weight: bold; + font-size: 0.9em; + margin: 0; + margin-bottom: 0.5em; +} +.ticket-message-author { + text-align: left; + float: left; +} +.ticket-message-date { + text-align: right; + +} +.ticket-message-content { + margin: 0; +} +.ticket-message-reply textarea { + width: 80%; + margin: auto; +} +.ticket-message-reply input[type=submit] { + margin: auto; +} + + +.pages { + text-align: center; +} +.pages a { + margin: 1em 0.5em; + padding: 0.20em 0.6em; + border: 1px solid #c7c7c7; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + text-decoration: none; +} + + + +/***************************************************/ +/********************* Live chat */ + +.livechat-thing { + position: fixed; + bottom: 0; + right: 0; +} +.livechat-thing .icon{ + width: 1.4em; + height: 1.4em; + display: inline-block; + background: url(../img/chat.svg); + background-size: contain; + background-repeat: no-repeat; + margin-right: 0.4em; + margin-bottom: -0.4em; +} +.livechat-thing a { + display: block; + padding: 0.6em 0.9em; + background-color: #1c619a; + color: white; + text-decoration: none; + font-size: 0.9em; + border-left: 1px solid #1c619a; + + -webkit-border-top-left-radius: 10px; + -moz-border-radius-topleft: 10px; + border-top-left-radius: 10px; +} +#livechat-iframe { + border: 0; + width: 100%; + height: 80%; +} + + + +/***************************************************/ +/********************* Fonts */ + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: local('Open Sans'), local('OpenSans'), url(../fonts/OpenSans.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: local('Open Sans Bold'), local('OpenSans-Bold'), url(../fonts/OpenSansB.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} + + + diff --git a/static/fonts/OpenSans.woff2 b/static/fonts/OpenSans.woff2 new file mode 100644 index 0000000..5287058 Binary files /dev/null and b/static/fonts/OpenSans.woff2 differ diff --git a/static/fonts/OpenSansB.woff2 b/static/fonts/OpenSansB.woff2 new file mode 100644 index 0000000..be4c25b Binary files /dev/null and b/static/fonts/OpenSansB.woff2 differ diff --git a/static/fonts/fontawesome-webfont.eot b/static/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000..9b6afae Binary files /dev/null and b/static/fonts/fontawesome-webfont.eot differ diff --git a/static/fonts/fontawesome-webfont.svg b/static/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000..d05688e --- /dev/null +++ b/static/fonts/fontawesome-webfont.svg @@ -0,0 +1,655 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/fonts/fontawesome-webfont.ttf b/static/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000..26dea79 Binary files /dev/null and b/static/fonts/fontawesome-webfont.ttf differ diff --git a/static/fonts/fontawesome-webfont.woff b/static/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000..dc35ce3 Binary files /dev/null and b/static/fonts/fontawesome-webfont.woff differ diff --git a/static/fonts/fontawesome-webfont.woff2 b/static/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000..500e517 Binary files /dev/null and b/static/fonts/fontawesome-webfont.woff2 differ diff --git a/static/img/7proxies.png b/static/img/7proxies.png new file mode 100644 index 0000000..b12dc26 Binary files /dev/null and b/static/img/7proxies.png differ diff --git a/static/img/anon.svg b/static/img/anon.svg new file mode 100644 index 0000000..b27d462 --- /dev/null +++ b/static/img/anon.svg @@ -0,0 +1,65 @@ + + + +image/svg+xml + + + + + + + \ No newline at end of file diff --git a/static/img/bg-logo.svg b/static/img/bg-logo.svg new file mode 100644 index 0000000..d92e663 --- /dev/null +++ b/static/img/bg-logo.svg @@ -0,0 +1,65 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/img/bg-title.png b/static/img/bg-title.png new file mode 100644 index 0000000..931a476 Binary files /dev/null and b/static/img/bg-title.png differ diff --git a/static/img/bg.png b/static/img/bg.png new file mode 100644 index 0000000..b6b954d Binary files /dev/null and b/static/img/bg.png differ diff --git a/static/img/bolt.svg b/static/img/bolt.svg new file mode 100644 index 0000000..27f6ac0 --- /dev/null +++ b/static/img/bolt.svg @@ -0,0 +1,54 @@ + + + +image/svg+xml + + + \ No newline at end of file diff --git a/static/img/chat.png b/static/img/chat.png new file mode 100644 index 0000000..5df57bc Binary files /dev/null and b/static/img/chat.png differ diff --git a/static/img/chat.svg b/static/img/chat.svg new file mode 100644 index 0000000..b56c127 --- /dev/null +++ b/static/img/chat.svg @@ -0,0 +1,57 @@ + +image/svg+xml \ No newline at end of file diff --git a/static/img/cheap.svg b/static/img/cheap.svg new file mode 100644 index 0000000..a39b876 --- /dev/null +++ b/static/img/cheap.svg @@ -0,0 +1,65 @@ + + + +image/svg+xml + + + + + + + \ No newline at end of file diff --git a/static/img/fast.svg b/static/img/fast.svg new file mode 100644 index 0000000..27f6ac0 --- /dev/null +++ b/static/img/fast.svg @@ -0,0 +1,54 @@ + + + +image/svg+xml + + + \ No newline at end of file diff --git a/static/img/openvpn.svg b/static/img/openvpn.svg new file mode 100644 index 0000000..03e303c --- /dev/null +++ b/static/img/openvpn.svg @@ -0,0 +1,71 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/img/unlimited.svg b/static/img/unlimited.svg new file mode 100644 index 0000000..940b254 --- /dev/null +++ b/static/img/unlimited.svg @@ -0,0 +1,57 @@ + + + +image/svg+xml + + + + + \ No newline at end of file diff --git a/static/ping.js b/static/ping.js new file mode 100644 index 0000000..15e7529 --- /dev/null +++ b/static/ping.js @@ -0,0 +1,139 @@ +var NPINGS = 6; +var TIMEOUT = 3000; + +function img_ping(host, callback, avg, count) { + var start; + + var avg = avg || 0; + if (typeof(count) === 'undefined') { + // -1 to ignore the first ping, so the DNS lookup does not change + // the average time. + count = -1; + callback('...'); + } + count = count + 1; + + + if (count > 0) { + // no timeout for the first ping, DNS can be slow + var timer = setTimeout(function() { + callback('timeout'); + }, TIMEOUT); + } + + function ok() { + var time = new Date() - start; + + clearTimeout(timer); + + if (count > 0) { + avg = ((avg * (count-1)) + time) / (count); + } else { + avg = 0; + } + + if (count >= NPINGS) { + callback(Math.round(avg) + 'ms'); + } else { + ping(host, callback, avg, count); + } + } + + + var img = new Image(); + img.onload = ok; + img.onerror = ok; + + start = new Date(); + img.src = 'http://' + host + '/ping'; + + callback('...'); +} + +function perf_ping(host, callback, start) { + if (start == undefined) { + var perfEntries = performance.getEntries(); + // use the last perf entry to ignore any request preceding + // this ping() call + start = 0; + for (var i = 0; i < perfEntries.length; i++) { + if (start < perfEntries[i].startTime) { + start = perfEntries[i].startTime; + } + } + console.log("start: " + start); + } + + var timer = setTimeout(function() { + callback('timeout'); + }, TIMEOUT); + + var url = 'http://' + host + '/ping'; + var random_thing = "?" + String(Math.random()).slice(2) + + function ok() { + clearTimeout(timer); + + var average = 0; + var count = 0; + + var perfEntries = performance.getEntries(); + for (var i = 0; i < perfEntries.length; i++) { + var e = perfEntries[i]; + + // ignore old entries + if (e.startTime < start) { + continue; + } + + // ignore other stuff + if (e.name.slice(0, url.length) != url || e.entryType != 'resource') { + continue; + } + + var time = e.duration - Math.max(e.requestStart - e.startTime, 0); + average = (average*count + time) / (count+1); + count += 1; + } + + if (count < NPINGS) { + ping(host, callback, count, start); + return; + } + + average -= 2; // average difference to a simple icmp ping + callback(Math.round(average) + 'ms'); + } + + var img = new Image(); + img.onload = ok; + img.onerror = ok; + img.src = url + random_thing; + callback('...'); +} + +var ping = (performance) ? perf_ping : img_ping; + +window.addEventListener('load', function() { + var lines = document.getElementsByClassName('host_line'); + for (var i=0; i + +
+ {% block account_content %}{% endblock %} +
+ +{% endblock %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..0c3a7c9 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,98 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_static %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colMS{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} +
+ +{% if app_list %} + {% for app in app_list %} +
+ + + {% for model in app.models %} + + {% if model.admin_url %} + + {% else %} + + {% endif %} + + {% if model.add_url %} + + {% else %} + + {% endif %} + + {% if model.admin_url %} + + {% else %} + + {% endif %} + + {% endfor %} +
+ {{ app.name }} +
{{ model.name }}{{ model.name }}{% trans 'Add' %} {% trans 'Change' %} 
+
+ {% endfor %} +{% else %} +

{% trans "You don't have permission to edit anything." %}

+{% endif %} + + +
+{% endblock %} + +{% block sidebar %} + +{% endblock %} diff --git a/templates/admin/tickets/ticket/change_list.html b/templates/admin/tickets/ticket/change_list.html new file mode 100644 index 0000000..e05ac54 --- /dev/null +++ b/templates/admin/tickets/ticket/change_list.html @@ -0,0 +1,17 @@ +{% extends "admin/change_list.html" %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} diff --git a/templates/ccvpn/chat.html b/templates/ccvpn/chat.html new file mode 100644 index 0000000..a822d27 --- /dev/null +++ b/templates/ccvpn/chat.html @@ -0,0 +1,27 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block wrap %} + + + +{% endblock %} + +{# Remove the blue live chat link in the corner #} +{% block livechat_thing %} +{% endblock %} diff --git a/templates/ccvpn/index.html b/templates/ccvpn/index.html new file mode 100644 index 0000000..c0c1a05 --- /dev/null +++ b/templates/ccvpn/index.html @@ -0,0 +1,95 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block headers %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+

{% trans 'Unlimited' %}

+

{% trans 'Unlimited bandwidth' %}.
+ {% trans 'Uncensored' %}.
+ {% trans 'We have porn and pirates.' %}

+
+
+
+ +
+

{% trans 'Cheap' %}

+

{{eur_price}}€ {% trans 'per month!' %}
+ {% trans 'We accept Paypal, Bitcoins and Stripe.' %}

+
+
+
+ +
+

{% trans 'Secure' %}

+

{% trans 'Encrypted tunnel' %} + {% trans 'with an anonymous address.' %}
+ {% trans 'Supports DNSSEC and PFS.' %}

+
+
+
+ +
+

{% trans 'OpenVPN' %}

+

{% trans 'Secure, Free, easy. On:' %}
+

    +
  • Windows, OSX
  • +
  • GNU/Linux, BSD
  • +
  • Android, iOS
  • +
+

+
+
+
+ +
+

{% trans 'Fast' %}

+

1Gbps
+ {% trans 'Compressed tunnel.' %}
+ {% trans 'Even on Youtube.' %} +

+
+
+
+
+ + + +
+
+

{% trans 'Why?' %}

+
    +
  • {% trans 'Hide any personal data found from your IP address' %}
  • +
  • {% trans 'Protect yourself on open networks' %}
  • +
  • {% trans 'Compress traffic on slow connections' %}
  • +
  • {% trans 'Bypass overly restrictive firewalls' %}
  • +
  • {% trans 'Enable IPv6 on IPv4-only networks' %}
  • +
+
+
+

{% trans 'VPN done the right way' %}

+
    +
  • {% trans 'Anonymity' %} - {% trans "We don't even require your email address." %}
  • +
  • {% trans 'Security' %} - {% trans "Best TLS available, RSA 4096b, BlowFish 128b." %}
  • +
  • {% trans 'Privacy' %} - {% trans "Not a single byte of your traffic is logged." %}
  • +
+
+
+ +
+ +{% endblock %} + + diff --git a/templates/ccvpn/page.html b/templates/ccvpn/page.html new file mode 100644 index 0000000..32c162f --- /dev/null +++ b/templates/ccvpn/page.html @@ -0,0 +1,30 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+ +
+ {% if title %} +

{{ title }}

+ {% endif %} + {{ content|safe }} +
+ +{% endblock %} + diff --git a/templates/ccvpn/signup.html b/templates/ccvpn/signup.html new file mode 100644 index 0000000..4437862 --- /dev/null +++ b/templates/ccvpn/signup.html @@ -0,0 +1,50 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+
+

{% trans 'Sign up' %}

+
+ {% csrf_token %} + + {{ form.username.errors }} + + +

{% trans '2 to 32 alphanumeric characters.' %}

+ + {{ form.password.errors }} + + +

{% trans 'Anything from 1 to 256 characters.' %}

+ + + +

{% trans 'Same password.' %}

+ + {{ form.email.errors }} + + +

+ {% trans 'Optional.' %} + {% trans 'Used to recover your password and confirm stuff.' %} +

+ + +
+ +
+
+ +{% endblock %} + diff --git a/templates/lambdainst/account.html b/templates/lambdainst/account.html new file mode 100644 index 0000000..3d35df2 --- /dev/null +++ b/templates/lambdainst/account.html @@ -0,0 +1,126 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block headers %} +{% endblock %} + +{% block account_content %} +
+

{% trans 'Account' %} : {{user.username}}

+ + + +
+ + +
+ + +
+ +{% endblock %} + + diff --git a/templates/lambdainst/admin_ref.html b/templates/lambdainst/admin_ref.html new file mode 100644 index 0000000..5de1d95 --- /dev/null +++ b/templates/lambdainst/admin_ref.html @@ -0,0 +1,69 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_static %} + +{% block extrastyle %}{{ block.super }} + + +{% endblock %} + +{% block coltype %}{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+ + + {% for r in top_ref_week %} + + + + + {% endfor %} +
+ {% trans 'Top Referrers (week)' %} +
{{r.username}}{{r.n_ref}}
+
+ +
+ + + {% for r in top_ref_month %} + + + + + {% endfor %} +
+ {% trans 'Top Referrers (month)' %} +
{{r.username}}{{r.n_ref}}
+
+ +
+ + + {% for r in top_ref %} + + + + + {% endfor %} +
+ {% trans 'Top Referrers (all time)' %} +
{{r.username}}{{r.n_ref}}
+
+
+ +{% endblock %} + +{% block sidebar %} +{% endblock %} + diff --git a/templates/lambdainst/admin_status.html b/templates/lambdainst/admin_status.html new file mode 100644 index 0000000..ff36ef5 --- /dev/null +++ b/templates/lambdainst/admin_status.html @@ -0,0 +1,82 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_static %} + +{% block extrastyle %}{{ block.super }} + + +{% endblock %} + +{% block coltype %}{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+ + + {% for k, v in api_status.items %} + + + + + {% endfor %} +
+ {% trans 'Core API Info' %} +
{{k}}{{v}}
+
+ + + {% for backend in payment_backends %} + + + + + + {% endfor %} +
+ {% trans 'Active Payment Backends' %} +
{{backend.backend_id}}{{backend.backend_verbose_name}}{{backend.backend_display_name}}
+
+ +
+ + {% for backend, info in payment_status %} +
+ + + {% for name, value in info %} + + + + + {% endfor %} +
+ {% trans 'Backend: ' %} {{ backend.backend_verbose_name }} +
{{name}}{{value}}
+
+ {% endfor %} +
+ +
+
+ +
+
+ +
+
+{% endblock %} + +{% block sidebar %} +{% endblock %} + diff --git a/templates/lambdainst/config.html b/templates/lambdainst/config.html new file mode 100644 index 0000000..a2abb82 --- /dev/null +++ b/templates/lambdainst/config.html @@ -0,0 +1,68 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block account_content %} +
+

{% trans 'Config' %}

+
+
+
+ + +
+ +
+ + +
+ +
+ + +

{% trans 'TCP is slower. Use it only if you have important packet loss or if UDP is filtered.' %}

+
+ +
+ + +

{% trans 'Requires TCP.' %}

+
+ +
+ + + +
+
+
+
+ +{% endblock %} + + diff --git a/templates/lambdainst/logs.html b/templates/lambdainst/logs.html new file mode 100644 index 0000000..e72e274 --- /dev/null +++ b/templates/lambdainst/logs.html @@ -0,0 +1,54 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block account_content %} +

{% trans 'Logs' %}

+

{% trans 'Everything we have to keep about you. Automatically deleted after 1 year.' %}

+ + + + + + + + + + + + + {% for line in sessions %} + + + + + + + + {% endfor %} + +
{% trans 'Date' %}{% trans 'Duration' %}{% trans 'Client IP' %}{% trans 'Shared IP' %}{% trans 'Bandwidth' %}
{{ line.connect_date }} + {% if line.disconnect_date != None %} + {{ line.connect_date|timesince:line.disconnect_date }} + {% else %} + {% trans "Open" %} + {% endif %} + {{ line.remote.addr|default:_('[unknown]') }}{{ line.gateway.main_addr.ipv4|default:_('[unknown]') }}{{ line.stats.up | filesizeformat }} / + {{ line.stats.down | filesizeformat }} +
+

+ {% if prev != None and prev > 0 %} + << + {% endif %} + {% if prev != None %} + < + {% endif %} + {{ page }} + {% if next != None %} + > + {% endif %} + {% if next != None and last_page > next %} + >> + {% endif %} +

+{% endblock %} diff --git a/templates/lambdainst/mail_expire_soon.txt b/templates/lambdainst/mail_expire_soon.txt new file mode 100644 index 0000000..3a2653b --- /dev/null +++ b/templates/lambdainst/mail_expire_soon.txt @@ -0,0 +1,7 @@ +{% load i18n %}{{ site_name }} +======================================== + +{% blocktrans with exp=exp|timeuntil %}Your account will expire in {{exp}}{% endblocktrans %} +{% trans 'You can renew it here:' %} +{{ url }} + diff --git a/templates/lambdainst/settings.html b/templates/lambdainst/settings.html new file mode 100644 index 0000000..bb539cc --- /dev/null +++ b/templates/lambdainst/settings.html @@ -0,0 +1,31 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block account_content %} +

{% trans 'Settings' %}

+ +
+ {% csrf_token %} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/templates/lambdainst/status.html b/templates/lambdainst/status.html new file mode 100644 index 0000000..ca7fba0 --- /dev/null +++ b/templates/lambdainst/status.html @@ -0,0 +1,61 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} +{% load bw %} + +{% block content %} +
+

{% trans 'Our Servers' %}

+ +
    +
  • + {% blocktrans trimmed with n_users=n_users %} + We have {{ n_users }} active users. + {% endblocktrans %} + {% blocktrans trimmed with n_sess=n_sess %} + {{ n_sess }} are using our VPN right now. + {% endblocktrans %} +
  • +
  • + {% blocktrans trimmed with n_gw=n_gw n_countries=n_countries %} + They are connected to {{ n_gws }} servers spread across + {{ n_countries }} countries to provide a low-latency + and secure access anywhere in the world. + {% endblocktrans %} +
  • +
  • + {% with total_bw|bwformat as total_bw_f %} + {% blocktrans trimmed with total_bw=total_bw_f %} + Our network has a total bandwidth of {{ total_bw }} and + all our servers are DDoS-protected. + {% endblocktrans %} + {% endwith %} +
  • +
+ +

{% trans 'Locations' %}

+ + + + + + + + + + {% for country, d in locations %} + + + + + + {% endfor %} + +
{% trans 'Location' %}{% trans 'Hostname' %}{% trans 'Servers' %}{% trans 'Bandwidth' %}
{{ d.country_name }} + {{ d.hostname }} + {{ d.servers }}{{ d.bandwidth|bwformat }}
+
+ + + +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..397eef6 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,93 @@ +{% load i18n %} +{% load staticfiles %} +{% get_current_language as LANGUAGE_CODE %} + + + + {{ title | default:'CCrypto VPN' }} + + + + + + + {{ADDITIONAL_HEADER_HTML | safe}} + {% block headers %}{% endblock %} + + +
+
+

+ CCrypto + // {% trans 'Service Status' %} + {% for l, _ in LANGUAGES %} + | {{l.upper}} + {% endfor %} +

+
+
+ {% if user.is_authenticated %} +

{% trans 'Your account' %} + {% trans 'Logout' %} +

+ {% else %} +

{% trans 'Sign up' %} + {% trans 'Log in' %} +

+ {% endif %} +
+
+
+ + + + {% block wrap %} + {% for message in messages %} +
+

{{ message }}

+
+ {% endfor %} + + {% block content %}{% endblock %} + {% endblock %} + + + + {% block livechat_thing %} + + {% endblock %} + + {{ADDITIONAL_HTML | safe}} + + + diff --git a/templates/payments/form.html b/templates/payments/form.html new file mode 100644 index 0000000..e571b3b --- /dev/null +++ b/templates/payments/form.html @@ -0,0 +1,13 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block account_content %} +
+

{% trans 'Payment' %}

+ + {{ html | safe }} +
+ +{% endblock %} + diff --git a/templates/payments/list.html b/templates/payments/list.html new file mode 100644 index 0000000..c1a0cc9 --- /dev/null +++ b/templates/payments/list.html @@ -0,0 +1,28 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block account_content %} +

{% trans 'Subscription' %}

+ + + + + + + + + + + {% for payment in payments %} + + + + + + {% endfor %} + +
{% trans 'Date' %}{% trans 'Value' %}{% trans 'Status' %}
{{ payment.created }}{{ payment.time }} + ({{ payment.get_amount_display }}) + {{ payment.get_status_display }}
+{% endblock %} diff --git a/templates/payments/view.html b/templates/payments/view.html new file mode 100644 index 0000000..b19d121 --- /dev/null +++ b/templates/payments/view.html @@ -0,0 +1,34 @@ +{% extends 'account_layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block headers %} + {% if payment.status == 'new' %} + + {% endif %} +{% endblock %} + +{% block account_content %} +
+

{% trans 'Payment' %} #{{payment.id}}: + {{ payment.get_status_display }} +

+ + {% if payment.status_message %} +

{{payment.status_message}}

+ {% endif %} + +

+ {% if payment.status == 'confirmed' %} + {% trans 'The payment has been confirmed.' %} + {% elif payment.status == 'cancelled' %} + {% trans 'The payment has been cancelled.' %} + {% elif payment.status == 'new' %} + {% trans 'This page will be updated with payment progress.' %} + {% endif %} +
+ {% trans 'Go back to your account' %} +

+
+{% endblock %} + diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..d128c0e --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,25 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+
+

{% trans 'Log in' %}

+
+ {% csrf_token %} + {{ form.as_p }} + + +
+ +
+
+{% endblock %} + diff --git a/templates/registration/password_reset_complete.html b/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..179457a --- /dev/null +++ b/templates/registration/password_reset_complete.html @@ -0,0 +1,16 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+
+

{% trans 'Password Reset' %}

+

+ {% trans 'Your password has been changed.' %} + {% trans 'You can now log in with your new password.' %} +

+
+
+{% endblock %} + diff --git a/templates/registration/password_reset_confirm.html b/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..7f87ecd --- /dev/null +++ b/templates/registration/password_reset_confirm.html @@ -0,0 +1,30 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+
+

{% trans 'Password Reset' %}

+ {% if form %} +
+ {% csrf_token %} + {{ form }} + +
+ {% else %} +

{% trans 'Invalid reset link.' %}

+ {% endif %} + +
+
+{% endblock %} + + + diff --git a/templates/registration/password_reset_done.html b/templates/registration/password_reset_done.html new file mode 100644 index 0000000..3e8b82e --- /dev/null +++ b/templates/registration/password_reset_done.html @@ -0,0 +1,16 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+
+

{% trans 'Password Reset' %}

+

+ {% trans 'Sent! Check your emails to reset your password.' %} +

+
+
+{% endblock %} + + diff --git a/templates/registration/password_reset_form.html b/templates/registration/password_reset_form.html new file mode 100644 index 0000000..404cf3f --- /dev/null +++ b/templates/registration/password_reset_form.html @@ -0,0 +1,28 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} +
+
+

{% trans 'Password Reset' %}

+
+ {% csrf_token %} + + + + +
+ +
+
+{% endblock %} + + diff --git a/templates/tickets/index.html b/templates/tickets/index.html new file mode 100644 index 0000000..37ebc10 --- /dev/null +++ b/templates/tickets/index.html @@ -0,0 +1,56 @@ +{% extends 'tickets/layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block tickets_content %} +

{% trans 'Support' %}

+ +{% url 'chat' as chat_url %} + +

{% trans 'Before creating a ticket, please check if you question is convered in' %} + {% trans 'the FAQ' %}. +

+

{% blocktrans trimmed with chat_url as chat_url %} + If you would prefer to talk us in real time, we have a + live chat.
+ You can also use your own IRC client on #ccrypto on chat.freenode.net. + We are however not always online and you may have to wait. + {% endblocktrans %} +

+ + +{% if tickets %} +

{% trans 'Your open tickets' %}

+ + + + + + + + + + {% for ticket in tickets %} + + + + + + {% endfor %} + +
#{% trans 'ID' %}{% trans 'Subject' %}{% trans 'Status' %}
#{{ticket.id}}{{ ticket.subject }}{{ ticket.status_text }}
+ + {% if tickets.has_previous or tickets.has_next %} +

+ {% if tickets.has_previous %} + < + {% endif %} + {{ tickets.number }} + {% if tickets.has_next %} + > + {% endif %} +

+ {% endif %} +{% endif %} +{% endblock %} + diff --git a/templates/tickets/layout.html b/templates/tickets/layout.html new file mode 100644 index 0000000..7d26ca6 --- /dev/null +++ b/templates/tickets/layout.html @@ -0,0 +1,27 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block content %} + +{% endblock %} + diff --git a/templates/tickets/list.html b/templates/tickets/list.html new file mode 100644 index 0000000..f25e3b0 --- /dev/null +++ b/templates/tickets/list.html @@ -0,0 +1,52 @@ +{% extends 'tickets/layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block tickets_content %} +

{% trans 'Tickets' %}

+ +{% if tickets %} + + + + + + + {% if not single_user %} + + {% endif %} + + + + {% for ticket in tickets %} + + + + + {% if not single_user %} + {% if ticket.user %} + + {% else %} + + {% endif %} + {% endif %} + + {% endfor %} + +
#{% trans 'ID' %}{% trans 'Subject' %}{% trans 'Status' %}{% trans 'User' %}
#{{ticket.id}}{{ ticket.subject }}{{ ticket.status_text }} + {{ticket.user.username}} + -
+

+ {% if tickets.has_previous %} + < + {% endif %} + {{ tickets.number }} + {% if tickets.has_next %} + > + {% endif %} +

+{% else %} +

{% trans 'No ticket to show.' %}

+{% endif %} +{% endblock %} + diff --git a/templates/tickets/mail_support_new.txt b/templates/tickets/mail_support_new.txt new file mode 100644 index 0000000..a5b46d3 --- /dev/null +++ b/templates/tickets/mail_support_new.txt @@ -0,0 +1,14 @@ +{% load i18n %} +{{ site_name }} +======================================== + +{% trans "A new ticket has been created." %} +{{ url }} + +{% trans "Category:" %} {{ ticket.get_category_display }} +{% trans "Subject:" %} {{ ticket.subject }} +{% trans "User:" %} {{ ticket.user.username }} + +{{ message.message | safe }} + + diff --git a/templates/tickets/mail_support_reply.txt b/templates/tickets/mail_support_reply.txt new file mode 100644 index 0000000..d8a05e7 --- /dev/null +++ b/templates/tickets/mail_support_reply.txt @@ -0,0 +1,10 @@ +{% load i18n %} +{{ site_name }} +======================================== + +{% trans "New reply on ticket:" %} {{ ticket.subject }} +{% trans "From:" %} {{ message.user.username }} +{{ url }} + +{{ message.message | safe }} + diff --git a/templates/tickets/mail_user_close.txt b/templates/tickets/mail_user_close.txt new file mode 100644 index 0000000..b0717d2 --- /dev/null +++ b/templates/tickets/mail_user_close.txt @@ -0,0 +1,7 @@ +{% load i18n %} +{{ site_name }} +======================================== + +{% trans "Your ticket was closed." %} +{{ url }} + diff --git a/templates/tickets/mail_user_reply.txt b/templates/tickets/mail_user_reply.txt new file mode 100644 index 0000000..882665b --- /dev/null +++ b/templates/tickets/mail_user_reply.txt @@ -0,0 +1,9 @@ +{% load i18n %} +{{ site_name }} +======================================== + +{% trans "New reply on your ticket:" %} {{ ticket.subject }} +{{ url }} + +{{ message.message | safe}} + diff --git a/templates/tickets/new.html b/templates/tickets/new.html new file mode 100644 index 0000000..bfc1568 --- /dev/null +++ b/templates/tickets/new.html @@ -0,0 +1,17 @@ +{% extends 'tickets/layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block tickets_content %} +
+

{% trans 'New Ticket' %}

+ +
+ {% csrf_token %} + {{ form }} + + +
+
+{% endblock %} + diff --git a/templates/tickets/view.html b/templates/tickets/view.html new file mode 100644 index 0000000..58052de --- /dev/null +++ b/templates/tickets/view.html @@ -0,0 +1,50 @@ +{% extends 'tickets/layout.html' %} +{% load i18n %} +{% load staticfiles %} + +{% block tickets_content %} +
+

{% trans 'Ticket:' %} {{ ticket.subject }} + {% if not ticket.is_open %} + [{% trans 'closed' %}] + {% endif %} +

+ +
+ {% for message in ticket_messages %} +
+

+ {% if message.staff_only %}[{% trans "Private" %}]{% endif %} + {% if ticket.user != message.user %} CCrypto Support - {% endif %} + {{ message.user.username|default:'-' }}: +

+

+ {% with created=message.created|date:'d/m/Y H:i' %} + {{ created }} + {% endwith %} +

+

+ {{ message.message | linebreaksbr | urlize }} +

+
+ {% endfor %} +
+ +
+ {% csrf_token %} + {{ form }} + + + {% if ticket.is_open %} + + {% else %} + + {% endif %} +
+ +
+{% endblock %} + diff --git a/tickets/__init__.py b/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tickets/admin.py b/tickets/admin.py new file mode 100644 index 0000000..d440994 --- /dev/null +++ b/tickets/admin.py @@ -0,0 +1,84 @@ +from django.contrib import admin +from django.shortcuts import resolve_url +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ +from django.utils import formats +from .models import Ticket, TicketMessage, TicketNotifyAddress + + +def close_without_notice(modeladmin, request, queryset): + queryset.update(is_open=False, closed=timezone.now()) +close_without_notice.short_description = _("Close selected tickets (without notice)") + + +def close_tickets(modeladmin, request, queryset): + for t in queryset: + if t.is_open: + t.notify_close() + queryset.update(is_open=False, closed=timezone.now()) +close_tickets.short_description = _("Close selected tickets") + + +class TicketMessageAdmin(admin.StackedInline): + model = TicketMessage + fields = ('user_link', 'remote_addr', 'created', 'staff_only', 'message') + readonly_fields = ('user_link', 'created') + extra = 1 + + def user_link(self, object): + change_url = resolve_url('admin:auth_user_change', object.user.id) + return '%s' % (change_url, object.user.username) + user_link.allow_tags = True + user_link.short_description = 'User' + + +class TicketAdmin(admin.ModelAdmin): + fields = ('category', 'subject', 'user_link', 'created', 'status', 'closed') + readonly_fields = ('user_link', 'created', 'status', 'closed') + list_display = ('subject', 'user', 'created', 'category', 'is_open') + list_filter = ('category', 'is_open') + search_fields = ('subject', 'user__username', 'message_set__message') + actions = (close_tickets, close_without_notice) + inlines = (TicketMessageAdmin,) + + def user_link(self, object): + change_url = resolve_url('admin:auth_user_change', object.user.id) + return '%s' % (change_url, object.user.username) + user_link.allow_tags = True + user_link.short_description = 'User' + + def comment_head(self, object): + return object.comment_head + comment_head.short_description = _("Comment") + + def status(self, object): + if object.is_open and object.closed: + return _("Re-opened") + elif object.is_open: + return _("Open") + else: + return _("Closed") + + def save_model(self, request, obj, form, change): + if not change: + obj.user = request.user + obj.save() + + def save_formset(self, request, form, formset, change): + formset.save() + if not change: + for f in formset.forms: + obj = f.instance + obj.user = request.user + obj.save() + + +class TicketNotifyAddressAdmin(admin.ModelAdmin): + list_display = ('category', 'address') + list_filter = ('category', ) + search_fields = ('address', ) + + +admin.site.register(Ticket, TicketAdmin) +admin.site.register(TicketNotifyAddress, TicketNotifyAddressAdmin) + diff --git a/tickets/forms.py b/tickets/forms.py new file mode 100644 index 0000000..ae63fa0 --- /dev/null +++ b/tickets/forms.py @@ -0,0 +1,24 @@ +from django import forms +from .models import TicketMessage, CATEGORY_CHOICES +from django.utils.translation import ugettext_lazy as _ + + +class NewTicketForm(forms.Form): + category = forms.ChoiceField(label=_("Category"), choices=CATEGORY_CHOICES) + subject = forms.CharField(label=_("Subject"), min_length=1, max_length=100) + message = forms.CharField(label=_("Message"), widget=forms.Textarea) + + +class ReplyForm(forms.ModelForm): + class Meta: + model = TicketMessage + fields = ('message',) + + +class StaffReplyForm(forms.ModelForm): + class Meta: + model = TicketMessage + fields = ('message', 'staff_only') + + staff_only = forms.BooleanField(label=_("Private"), required=False) + diff --git a/tickets/migrations/0001_initial.py b/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..421b836 --- /dev/null +++ b/tickets/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, primary_key=True, serialize=False)), + ('category', models.CharField(max_length=16, choices=[('support', 'Support'), ('security', 'Security'), ('billing', 'Account / Billing')])), + ('subject', models.CharField(max_length=100)), + ('created', models.DateTimeField(auto_now_add=True)), + ('is_open', models.BooleanField(default=True)), + ('closed', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=django.db.models.deletion.SET_NULL)), + ], + options={ + 'ordering': ('-created',), + 'permissions': (('view_any_ticket', 'Can view any ticket'), ('reply_any_ticket', 'Can reply to any ticket'), ('view_private_message', 'Can view private messages on tickets'), ('post_private_message', 'Can post private messages on tickets')), + }, + ), + migrations.CreateModel( + name='TicketMessage', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, primary_key=True, serialize=False)), + ('remote_addr', models.GenericIPAddressField(blank=True, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('message', models.TextField()), + ('staff_only', models.BooleanField(default=False)), + ('ticket', models.ForeignKey(related_name='message_set', to='tickets.Ticket')), + ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=django.db.models.deletion.SET_NULL)), + ], + ), + migrations.CreateModel( + name='TicketNotifyAddress', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, primary_key=True, serialize=False)), + ('category', models.CharField(max_length=16, choices=[('support', 'Support'), ('security', 'Security'), ('billing', 'Account / Billing')])), + ('address', models.EmailField(max_length=254)), + ], + ), + ] diff --git a/tickets/migrations/0002_auto_20160106_1750.py b/tickets/migrations/0002_auto_20160106_1750.py new file mode 100644 index 0000000..49c7ee1 --- /dev/null +++ b/tickets/migrations/0002_auto_20160106_1750.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='ticketmessage', + options={'ordering': ('created',)}, + ), + ] diff --git a/tickets/migrations/__init__.py b/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tickets/models.py b/tickets/models.py new file mode 100644 index 0000000..cc64b7a --- /dev/null +++ b/tickets/models.py @@ -0,0 +1,109 @@ +from django.db import models +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from django.core.urlresolvers import reverse +from django.template.loader import get_template +from django.template import Context +from django.core.mail import send_mail + + +ROOT_URL = settings.ROOT_URL +SITE_NAME = settings.TICKETS_SITE_NAME + +CATEGORY_CHOICES = ( + ('support', _("Support")), + ('security', _("Security")), + ('billing', _("Account / Billing")), +) + +if hasattr(settings, 'TICKETS_CATEGORIES'): + CATEGORY_CHOICES = settings.TICKETS_CATEGORIES + + +def notify(subject, template, recipient_list, params): + ctx = Context(dict(site_name=SITE_NAME, **params)) + text = get_template(template).render(ctx) + + for a in recipient_list: + send_mail(subject, text, settings.DEFAULT_FROM_EMAIL, [a], fail_silently=True) + + +class Ticket(models.Model): + class Meta: + ordering = ('-created',) + + permissions = ( + ('view_any_ticket', _("Can view any ticket")), + ('reply_any_ticket', _("Can reply to any ticket")), + ('view_private_message', _("Can view private messages on tickets")), + ('post_private_message', _("Can post private messages on tickets")), + ) + + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, + on_delete=models.SET_NULL) + category = models.CharField(max_length=16, choices=CATEGORY_CHOICES) + subject = models.CharField(max_length=100) + created = models.DateTimeField(auto_now_add=True) + is_open = models.BooleanField(default=True) + closed = models.DateTimeField(blank=True, null=True) + + @property + def status_text(self): + if self.closed: + return _("Closed") + last_msg = self.message_set.last() + if last_msg and last_msg.user == self.user: + return _("Waiting for staff") + else: + return _("Open") + + def get_contacts(self): + contacts = TicketNotifyAddress.objects.filter(category=self.category) + return [c.address for c in contacts] + + def notify_new(self, first_message): + url = ROOT_URL + reverse('tickets:view', args=(self.id,)) + subject = _("Ticket:") + " " + self.subject + ctx = dict(ticket=self, message=first_message, url=url) + notify(subject, 'tickets/mail_support_new.txt', self.get_contacts(), ctx) + + def notify_reply(self, message): + url = ROOT_URL + reverse('tickets:view', args=(self.id,)) + subject = _("Ticket:") + " " + self.subject + ctx = dict(ticket=self, message=message, url=url) + notify(subject, 'tickets/mail_support_reply.txt', self.get_contacts(), ctx) + if self.user and self.user.email: + if message.staff_only and not self.user.has_perm('tickets.view_private_message'): + return + notify(subject, 'tickets/mail_user_reply.txt', [self.user.email], ctx) + + def notify_close(self): + url = ROOT_URL + reverse('tickets:view', args=(self.id,)) + subject = _("Ticket:") + " " + self.subject + ctx = dict(ticket=self, url=url) + notify(subject, 'tickets/mail_user_close.txt', [self.user.email], ctx) + + def __str__(self): + return self.subject + + def get_absolute_url(self): + return reverse('tickets:view', args=(self.id,)) + + +class TicketNotifyAddress(models.Model): + category = models.CharField(max_length=16, choices=CATEGORY_CHOICES) + address = models.EmailField() + + +class TicketMessage(models.Model): + ticket = models.ForeignKey(Ticket, related_name='message_set', + on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, + on_delete=models.SET_NULL) + remote_addr = models.GenericIPAddressField(blank=True, null=True) + created = models.DateTimeField(auto_now_add=True) + message = models.TextField() + staff_only = models.BooleanField(default=False) + + class Meta: + ordering = ('created',) diff --git a/tickets/tests.py b/tickets/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tickets/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tickets/urls.py b/tickets/urls.py new file mode 100644 index 0000000..8baf96f --- /dev/null +++ b/tickets/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^new$', views.new, name='new'), + url(r'^view/(?P[0-9]+)$', views.view, name='view'), + url(r'^$', views.index, name='index'), + url(r'^open$', views.index, dict(f='open'), name='index_open'), + url(r'^closed$', views.index, dict(f='closed'), name='index_closed'), + url(r'^all_open$', views.index, dict(f='open', all=True), name='index_open_all'), + url(r'^all_closed$', views.index, dict(f='closed', all=True), name='index_closed_all'), +] + diff --git a/tickets/views.py b/tickets/views.py new file mode 100644 index 0000000..e9a600e --- /dev/null +++ b/tickets/views.py @@ -0,0 +1,154 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.http.response import HttpResponseNotFound +from django.utils import timezone +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from .models import Ticket, TicketMessage +from .forms import NewTicketForm, ReplyForm, StaffReplyForm + + +def common_context(request): + context = { + 'open_n': Ticket.objects.filter(is_open=True, user=request.user).count(), + 'closed_n': Ticket.objects.filter(is_open=False, user=request.user).count(), + } + if request.user.has_perm('tickets.view_any_ticket'): + context.update({ + 'all_open_n': Ticket.objects.filter(is_open=True).count(), + 'all_closed_n': Ticket.objects.filter(is_open=False).count(), + }) + return context + + +@login_required +def index(request, f=None, all=False): + tickets = Ticket.objects + + if f == 'closed': + tickets = tickets.filter(is_open=False) + else: + tickets = tickets.filter(is_open=True) + + if all is False or not request.user.has_perm('tickets.view_any_ticket'): + tickets = tickets.filter(user=request.user) + single_user = True + else: + single_user = False + + paginator = Paginator(tickets, 20) + page = request.GET.get('page') + try: + tickets = paginator.page(page) + except PageNotAnInteger: + tickets = paginator.page(1) + except EmptyPage: + tickets = paginator.page(paginator.num_pages) + + context = dict(tickets=tickets, filter=f, single_user=single_user) + context.update(common_context(request)) + if not f: + return render(request, 'tickets/index.html', context) + else: + return render(request, 'tickets/list.html', context) + + +@login_required +def new(request): + context = common_context(request) + if request.method != 'POST': + context['form'] = NewTicketForm() + return render(request, 'tickets/new.html', context) + + form = NewTicketForm(request.POST) + + if not form.is_valid(): + context['form'] = form + return render(request, 'tickets/new.html', context) + + ticket = Ticket(category=form.cleaned_data['category'], + subject=form.cleaned_data['subject'], + user=request.user) + ticket.save() + + firstmsg = TicketMessage(ticket=ticket, user=request.user, + message=form.cleaned_data['message']) + + if not request.user.is_staff: + firstmsg.remote_addr = request.META['REMOTE_ADDR'] + + firstmsg.save() + + ticket.notify_new(firstmsg) + + return redirect('tickets:view', id=ticket.id) + + +@login_required +def view(request, id): + ticket = get_object_or_404(Ticket, id=id) + + view_any_ticket = request.user.has_perm('tickets.view_any_ticket') + reply_any_ticket = request.user.has_perm('tickets.reply_any_ticket') + + if not view_any_ticket and ticket.user != request.user: + return HttpResponseNotFound() + + if request.user.has_perm('tickets.view_private_message'): + messages = ticket.message_set.all() + else: + messages = ticket.message_set.filter(staff_only=False) + + if request.method != 'POST': + if request.user.is_staff: + form = StaffReplyForm() + else: + form = ReplyForm() + + ctx = dict(ticket=ticket, ticket_messages=messages, form=form) + ctx.update(common_context(request)) + return render(request, 'tickets/view.html', ctx) + + if not reply_any_ticket and ticket.user != request.user: + return HttpResponseNotFound() + + if request.POST.get('close') or request.POST.get('button_close'): + ticket.is_open = False + ticket.closed = timezone.now() + ticket.save() + ticket.notify_close() + return redirect('tickets:view', id=ticket.id) + + if request.POST.get('reopen') or request.POST.get('button_reopen'): + ticket.is_open = True + ticket.save() + return redirect('tickets:view', id=ticket.id) + + if request.user.has_perm('tickets.post_private_message'): + form = StaffReplyForm(request.POST) + else: + form = ReplyForm(request.POST) + + if not form.is_valid(): + ctx = dict(ticket=ticket, ticket_messages=messages, form=form) + ctx.update(common_context(request)) + return render(request, 'tickets/view.html', ctx) + + msg = TicketMessage(ticket=ticket, user=request.user, + **form.cleaned_data) + + if not request.user.is_staff: + msg.remote_addr = request.META['REMOTE_ADDR'] + + msg.save() + + if not ticket.is_open: + ticket.is_open = True + ticket.save() + + ticket.notify_reply(msg) + + return redirect('tickets:view', id=ticket.id) + + +