mirror of
https://github.com/simple-login/app.git
synced 2024-09-20 06:55:59 +08:00
create BaseForm to enable CSRF
register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
This commit is contained in:
parent
0b3dd21a06
commit
c18d9f5280
1
.dockerignore
Normal file
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
|||
db.sqlite
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -1,3 +1,6 @@
|
|||
.idea/
|
||||
*.pyc
|
||||
db.sqlite
|
||||
db.sqlite
|
||||
.env
|
||||
.pytest_cache
|
||||
.vscode
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
@ -0,0 +1,15 @@
|
|||
FROM python:3.7
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
COPY ./requirements.txt ./
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD gunicorn wsgi:app -b 0.0.0.0:5000 -w 2 --timeout 15 --log-level DEBUG
|
||||
|
||||
#CMD ["/usr/local/bin/gunicorn", "wsgi:app", "-k", "gthread", "-b", "0.0.0.0:5000", "-w", "2", "--timeout", "15", "--log-level", "DEBUG"]
|
110
README.md
Normal file
110
README.md
Normal file
|
@ -0,0 +1,110 @@
|
|||
|
||||
## OAuth flow
|
||||
|
||||
Authorization code flow:
|
||||
|
||||
http://sl-server:5000/oauth/authorize?client_id=client-id&state=123456&response_type=code&redirect_uri=http%3A%2F%2Fsl-client%3A7000%2Fcallback&state=dvoQ6Jtv0PV68tBUgUMM035oFiZw57
|
||||
|
||||
Implicit flow:
|
||||
http://sl-server:5000/oauth/authorize?client_id=client-id&state=123456&response_type=token&redirect_uri=http%3A%2F%2Fsl-client%3A7000%2Fcallback&state=dvoQ6Jtv0PV68tBUgUMM035oFiZw57
|
||||
|
||||
Exchange the code to get the token with `{code}` replaced by the code obtained in previous step.
|
||||
|
||||
http -f -a client-id:client-secret http://localhost:5000/oauth/token grant_type=authorization_code code={code}
|
||||
|
||||
Get user info:
|
||||
|
||||
http http://localhost:5000/oauth/user_info 'Authorization:Bearer {token}'
|
||||
|
||||
|
||||
## Template structure
|
||||
|
||||
base
|
||||
single: for login, register page
|
||||
default: for all pages when user log ins
|
||||
|
||||
## How to create new migration
|
||||
|
||||
Whenever the model changes, a new migration needs to be created
|
||||
|
||||
Set the database connection to use staging environment:
|
||||
|
||||
> set -x CONFIG ~/config/simplelogin/staging.env
|
||||
|
||||
Generate the migration script and make sure to review it:
|
||||
|
||||
> flask db migrate
|
||||
|
||||
## Code structure
|
||||
|
||||
local_data/: contain files used only locally. In deployment, these files should be replaced.
|
||||
- jwtRS256.key: generated using
|
||||
|
||||
```bash
|
||||
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
|
||||
# Don't add passphrase
|
||||
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
|
||||
```
|
||||
|
||||
## OpenID, OAuth2 response_type & scope
|
||||
|
||||
According to https://medium.com/@darutk/diagrams-of-all-the-openid-connect-flows-6968e3990660
|
||||
|
||||
- `response_type` can be either `code, token, id_token` or any combination.
|
||||
- `scope` can contain `openid` or not
|
||||
|
||||
Below is the different combinations that are taken into account until now:
|
||||
|
||||
response_type=code
|
||||
scope:
|
||||
with `openid` in scope, return `id_token` at /token: OK
|
||||
without: OK
|
||||
|
||||
response_type=token
|
||||
scope:
|
||||
with and without `openid`, nothing to do: OK
|
||||
|
||||
response_type=id_token
|
||||
return `id_token` in /authorization endpoint
|
||||
|
||||
response_type=id_token token
|
||||
return `id_token` in addition to `access_token` in /authorization endpoint
|
||||
|
||||
response_type=id_token code
|
||||
return `id_token` in addition to `authorization_code` in /authorization endpoint
|
||||
|
||||
|
||||
# Plan Upgrade, downgrade flow
|
||||
|
||||
Here's an example:
|
||||
|
||||
July 2019: user takes yearly plan, valid until July 2020
|
||||
user.plan=yearly, user.plan_expiration=None
|
||||
set user.stripe card-token, customer-id, subscription-id
|
||||
|
||||
December 2019: user cancels his plan.
|
||||
set plan_expiration to "period end of subscription", ie July 2020
|
||||
call stripe:
|
||||
stripe.Subscription.modify(
|
||||
user.stripe_subscription_id,
|
||||
cancel_at_period_end=True
|
||||
)
|
||||
|
||||
There are 2 possible scenarios at this point:
|
||||
1) user decides to renew on March 2020:
|
||||
set plan_expiration = None
|
||||
stripe.Subscription.modify(
|
||||
user.stripe_subscription_id,
|
||||
cancel_at_period_end=False
|
||||
)
|
||||
|
||||
2) the plan ends on July 2020.
|
||||
The cronjob set
|
||||
- user stripe_subscription_id , stripe_card_token, stripe_customer_id to None
|
||||
- user.plan=free, user.plan_expiration=None
|
||||
- delete customer on stripe
|
||||
|
||||
user decides to take the premium plan again: go through all normal flow
|
||||
|
||||
|
||||
|
22
app/admin_model.py
Normal file
22
app/admin_model.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from flask import redirect, url_for, request
|
||||
from flask_admin import expose, AdminIndexView
|
||||
from flask_admin.contrib import sqla
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
class SLModelView(sqla.ModelView):
|
||||
def is_accessible(self):
|
||||
return current_user.is_authenticated and current_user.is_admin
|
||||
|
||||
def inaccessible_callback(self, name, **kwargs):
|
||||
# redirect to login page if user doesn't have access
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
|
||||
|
||||
class SLAdminIndexView(AdminIndexView):
|
||||
@expose("/")
|
||||
def index(self):
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
|
||||
return super(SLAdminIndexView, self).index()
|
|
@ -1 +1,9 @@
|
|||
from .views import login, logout
|
||||
from .views import (
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
activate,
|
||||
resend_activation,
|
||||
reset_password,
|
||||
forgot_password,
|
||||
)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from flask import Blueprint
|
||||
|
||||
auth_bp = Blueprint(name="auth", import_name=__name__, url_prefix="/auth")
|
||||
auth_bp = Blueprint(
|
||||
name="auth", import_name=__name__, url_prefix="/auth", template_folder="templates"
|
||||
)
|
||||
|
|
16
app/auth/templates/auth/activate.html
Normal file
16
app/auth/templates/auth/activate.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "error.html" %}
|
||||
|
||||
{% block error_name %}
|
||||
{{ error }}
|
||||
{% endblock %}
|
||||
|
||||
{% block error_description %}
|
||||
|
||||
{% if show_resend_activation %}
|
||||
<div class="text-center text-muted small mt-4">
|
||||
Ask for another activation email?
|
||||
<a href="{{ url_for('auth.resend_activation') }}" style="color: #4d21ff">Resend</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
37
app/auth/templates/auth/forgot_password.html
Normal file
37
app/auth/templates/auth/forgot_password.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Forgot Password
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
{% if error %}
|
||||
<div class="text-danger text-center mb-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Forgot password</div>
|
||||
<p class="text-muted">Enter your email address and your will receive an email to reset your password.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email", placeholder="Enter email") }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Reset Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-muted">
|
||||
Forget it, <a href="{{ url_for('auth.login') }}">send me back</a> to the sign in screen.
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
63
app/auth/templates/auth/login.html
Normal file
63
app/auth/templates/auth/login.html
Normal file
|
@ -0,0 +1,63 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Login
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
{% if error %}
|
||||
<div class="text-danger text-center mb-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_resend_activation %}
|
||||
<div class="text-center text-muted small mb-4">
|
||||
You haven't received the activation email?
|
||||
<a href="{{ url_for('auth.resend_activation') }}">Resend</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Login to your account</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email") }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Password
|
||||
<a href="{{ url_for('auth.forgot_password') }}" class="float-right small">
|
||||
I forgot password
|
||||
</a>
|
||||
</label>
|
||||
{{ form.password(class="form-control", type="password") }}
|
||||
{{ render_field_errors(form.password) }}
|
||||
</div>
|
||||
|
||||
<!-- TODO: add remember me
|
||||
<div class="form-group">
|
||||
<label class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input"/>
|
||||
<span class="custom-control-label">Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-muted">
|
||||
Don't have account yet? <a href="{{ url_for('auth.register') }}">Sign up</a>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
14
app/auth/templates/auth/logout.html
Normal file
14
app/auth/templates/auth/logout.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Logout
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<div class="text-center text-muted">
|
||||
You are logged out.
|
||||
|
||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
52
app/auth/templates/auth/register.html
Normal file
52
app/auth/templates/auth/register.html
Normal file
|
@ -0,0 +1,52 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Register
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Create new account</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">How should we call you?</label>
|
||||
{{ form.name(class="form-control") }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email") }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
{{ form.password(class="form-control", type="password") }}
|
||||
{{ render_field_errors(form.password) }}
|
||||
</div>
|
||||
|
||||
<!-- TODO: add terms
|
||||
<div class="form-group">
|
||||
<label class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input"/>
|
||||
<span class="custom-control-label">Agree the <a href="terms.html">terms and policy</a></span>
|
||||
</label>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Create new account</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-muted">
|
||||
Already have account? <a href="{{ url_for('auth.login') }}">Sign in</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
22
app/auth/templates/auth/register_waiting_activation.html
Normal file
22
app/auth/templates/auth/register_waiting_activation.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Activation Email Sent
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<div class="text-center">
|
||||
<h1>
|
||||
An email to validate your email is on its way.
|
||||
</h1>
|
||||
|
||||
<h3>
|
||||
Please check your inbox/spam folder.
|
||||
</h3>
|
||||
<small>
|
||||
Yeah we know. An email to confirm an email ...
|
||||
</small>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
31
app/auth/templates/auth/resend_activation.html
Normal file
31
app/auth/templates/auth/resend_activation.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Resend activation email
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Resend activation email</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email") }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Resend</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-muted">
|
||||
Don't have account yet? <a href="{{ url_for('auth.register') }}">Sign up</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
31
app/auth/templates/auth/reset_password.html
Normal file
31
app/auth/templates/auth/reset_password.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Reset password
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
{% if error %}
|
||||
<div class="text-danger text-center mb-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Reset your password</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
{{ form.password(class="form-control", type="password") }}
|
||||
{{ render_field_errors(form.password) }}
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
56
app/auth/views/activate.py
Normal file
56
app/auth/views/activate.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
import arrow
|
||||
from flask import request, redirect, url_for, flash, render_template
|
||||
from flask_login import login_user, current_user
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import ActivationCode
|
||||
|
||||
|
||||
@auth_bp.route("/activate", methods=["GET", "POST"])
|
||||
def activate():
|
||||
if current_user.is_authenticated:
|
||||
return (
|
||||
render_template("auth/activate.html", error="You are already logged in"),
|
||||
400,
|
||||
)
|
||||
|
||||
code = request.args.get("code")
|
||||
|
||||
activation_code: ActivationCode = ActivationCode.get_by(code=code)
|
||||
|
||||
if not activation_code:
|
||||
return (
|
||||
render_template("auth/activate.html", error="Activation code not found"),
|
||||
400,
|
||||
)
|
||||
|
||||
if activation_code.expired and activation_code.expired < arrow.now():
|
||||
return (
|
||||
render_template(
|
||||
"auth/activate.html",
|
||||
error="Activation code is expired",
|
||||
show_resend_activation=True,
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
user = activation_code.user
|
||||
user.activated = True
|
||||
login_user(user)
|
||||
|
||||
# activation code is to be used only once
|
||||
activation_code.delete()
|
||||
db.session.commit()
|
||||
|
||||
flash("Your account has been activated", "success")
|
||||
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
if "next" in request.args:
|
||||
next_url = request.args.get("next")
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
30
app/auth/views/forgot_password.py
Normal file
30
app/auth/views/forgot_password.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from flask import request, render_template, redirect, url_for
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.dashboard.views.setting import send_reset_password_email
|
||||
from app.models import User
|
||||
|
||||
|
||||
class ForgotPasswordForm(FlaskForm):
|
||||
email = StringField("Email", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
|
||||
def forgot_password():
|
||||
form = ForgotPasswordForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
email = form.email.data
|
||||
|
||||
user = User.get_by(email=email)
|
||||
|
||||
if not user:
|
||||
error = "No such user, are you sure the email is correct?"
|
||||
return render_template("auth/forgot_password.html", form=form, error=error)
|
||||
|
||||
send_reset_password_email(user)
|
||||
return redirect(url_for("auth.forgot_password"))
|
||||
|
||||
return render_template("auth/forgot_password.html", form=form)
|
|
@ -1,13 +1,14 @@
|
|||
from flask import request, flash, render_template, redirect, url_for
|
||||
from flask import request, render_template, redirect, url_for
|
||||
from flask_login import login_user
|
||||
from wtforms import Form, StringField, validators
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
|
||||
|
||||
class LoginForm(Form):
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField("Email", validators=[validators.DataRequired()])
|
||||
password = StringField("Password", validators=[validators.DataRequired()])
|
||||
|
||||
|
@ -16,21 +17,35 @@ class LoginForm(Form):
|
|||
def login():
|
||||
form = LoginForm(request.form)
|
||||
|
||||
if request.method == "POST":
|
||||
if form.validate():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if form.validate_on_submit():
|
||||
user = User.filter_by(email=form.email.data).first()
|
||||
|
||||
if not user:
|
||||
flash("No such email", "warning")
|
||||
return render_template("auth/login.html", form=form)
|
||||
if not user:
|
||||
return render_template(
|
||||
"auth/login.html", form=form, error="Email not exist in our system"
|
||||
)
|
||||
|
||||
if not user.check_password(form.password.data):
|
||||
flash("Wrong password", "warning")
|
||||
return render_template("auth/login.html", form=form)
|
||||
if not user.check_password(form.password.data):
|
||||
return render_template("auth/login.html", form=form, error="Wrong password")
|
||||
|
||||
LOG.debug("log user %s in", user)
|
||||
login_user(user)
|
||||
if not user.activated:
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
form=form,
|
||||
show_resend_activation=True,
|
||||
error="Please check your inbox for the activation email. You can also have this email re-sent",
|
||||
)
|
||||
|
||||
LOG.debug("log user %s in", user)
|
||||
login_user(user)
|
||||
|
||||
# User comes to login page from another page
|
||||
if "next" in request.args:
|
||||
next_url = request.args.get("next")
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
|
89
app/auth/views/register.py
Normal file
89
app/auth/views/register.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
import arrow
|
||||
from flask import request, flash, render_template
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import email_utils
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import URL
|
||||
from app.email_utils import notify_admin
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import User, ActivationCode, PlanEnum, GenEmail
|
||||
from app.utils import random_string, encode_url
|
||||
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
email = StringField("Email", validators=[validators.DataRequired()])
|
||||
password = StringField(
|
||||
"Password", validators=[validators.DataRequired(), validators.Length(min=8)]
|
||||
)
|
||||
name = StringField("Name", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
form = RegisterForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User.filter_by(email=form.email.data).first()
|
||||
|
||||
if user:
|
||||
flash(f"Email {form.email.data} already exists", "warning")
|
||||
return render_template("auth/register.html", form=form)
|
||||
|
||||
LOG.debug("create user %s", form.email.data)
|
||||
user = User.create(email=form.email.data, name=form.name.data)
|
||||
user.set_password(form.password.data)
|
||||
|
||||
# by default new user will be trial period
|
||||
user.plan = PlanEnum.trial
|
||||
user.plan_expiration = arrow.now().shift(days=+15)
|
||||
db.session.flush()
|
||||
|
||||
# create a first alias mail to show user how to use when they login
|
||||
GenEmail.create_new_gen_email(user_id=user.id)
|
||||
db.session.commit()
|
||||
|
||||
send_activation_email(user)
|
||||
notify_admin(
|
||||
f"new user signs up {user.email}", f"{user.name} signs up at {arrow.now()}"
|
||||
)
|
||||
|
||||
return render_template("auth/register_waiting_activation.html")
|
||||
|
||||
return render_template("auth/register.html", form=form)
|
||||
|
||||
|
||||
def send_activation_email(user):
|
||||
activation = ActivationCode.create(user_id=user.id, code=random_string(30))
|
||||
db.session.commit()
|
||||
|
||||
# Send user activation email
|
||||
activation_link = f"{URL}/auth/activate?code={activation.code}"
|
||||
if "next" in request.args:
|
||||
LOG.d("redirect user to %s after activation", request.args["next"])
|
||||
activation_link = activation_link + "&next=" + encode_url(request.args["next"])
|
||||
|
||||
email_utils.send(
|
||||
user.email,
|
||||
f"Welcome to SimpleLogin {user.name} - just one more step!",
|
||||
html_content=f"""
|
||||
Welcome to SimpleLogin! <br><br>
|
||||
|
||||
Our mission is to make the login process as smooth and as secure as possible. This should be easy. <br><br>
|
||||
|
||||
To get started, we need to confirm your email address, so please click this <a href="{activation_link}">link</a>
|
||||
to finish creating your account. Or you can paste this link into your browser: <br><br>
|
||||
|
||||
{activation_link} <br><br>
|
||||
|
||||
Your feedbacks are very important to us. Please feel free to reply to this email to let us know any
|
||||
of your suggestion! <br><br>
|
||||
|
||||
Thanks! <br><br>
|
||||
|
||||
SimpleLogin team.
|
||||
|
||||
""",
|
||||
)
|
39
app/auth/views/resend_activation.py
Normal file
39
app/auth/views/resend_activation.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from flask import request, flash, render_template, redirect, url_for
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.auth.views.register import send_activation_email
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
|
||||
|
||||
class ResendActivationForm(FlaskForm):
|
||||
email = StringField("Email", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
@auth_bp.route("/resend_activation", methods=["GET", "POST"])
|
||||
def resend_activation():
|
||||
form = ResendActivationForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User.filter_by(email=form.email.data).first()
|
||||
|
||||
if not user:
|
||||
flash("There's no such email", "warning")
|
||||
return render_template("auth/resend_activation.html", form=form)
|
||||
|
||||
if user.activated:
|
||||
flash("your account is already activated, please login", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# user is not activated
|
||||
LOG.d("user %s is not activated", user)
|
||||
flash(
|
||||
"An activation email is on its way, please check your inbox/spam folder",
|
||||
"warning",
|
||||
)
|
||||
send_activation_email(user)
|
||||
return render_template("auth/register_waiting_activation.html")
|
||||
|
||||
return render_template("auth/resend_activation.html", form=form)
|
59
app/auth/views/reset_password.py
Normal file
59
app/auth/views/reset_password.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
import arrow
|
||||
from flask import request, flash, render_template, redirect, url_for
|
||||
from flask_login import login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.extensions import db
|
||||
from app.models import ResetPasswordCode
|
||||
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = StringField(
|
||||
"Password", validators=[validators.DataRequired(), validators.Length(min=8)]
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route("/reset_password", methods=["GET", "POST"])
|
||||
def reset_password():
|
||||
form = ResetPasswordForm(request.form)
|
||||
|
||||
reset_password_code_str = request.args.get("code")
|
||||
|
||||
reset_password_code: ResetPasswordCode = ResetPasswordCode.get_by(
|
||||
code=reset_password_code_str
|
||||
)
|
||||
|
||||
if not reset_password_code:
|
||||
error = (
|
||||
"The reset password link can be used only once. "
|
||||
"Please make a new request to reset password"
|
||||
)
|
||||
return render_template("auth/reset_password.html", form=form, error=error)
|
||||
|
||||
if reset_password_code.expired < arrow.now():
|
||||
error = (
|
||||
"The link is already expired. Please make a new request to reset password"
|
||||
)
|
||||
return render_template("auth/reset_password.html", form=form, error=error)
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = reset_password_code.user
|
||||
|
||||
user.set_password(form.password.data)
|
||||
|
||||
flash("Your new password has been set", "success")
|
||||
|
||||
# this can be served to activate user too
|
||||
user.activated = True
|
||||
|
||||
# remove the reset password code
|
||||
reset_password_code.delete()
|
||||
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
return render_template("auth/reset_password.html", form=form)
|
59
app/config.py
Normal file
59
app/config.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
SHA1 = subprocess.getoutput("git rev-parse HEAD")
|
||||
|
||||
config_file = os.environ.get("CONFIG")
|
||||
if config_file:
|
||||
print("load config file", config_file)
|
||||
load_dotenv(config_file)
|
||||
else:
|
||||
load_dotenv()
|
||||
|
||||
|
||||
URL = os.environ.get("URL") or "http://sl-server:5000"
|
||||
EMAIL_DOMAIN = os.environ.get("EMAIL_DOMAIN") or "sl"
|
||||
SUPPORT_EMAIL = os.environ.get("SUPPORT_EMAIL") or "support@sl"
|
||||
SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY")
|
||||
DB_URI = os.environ.get("DB_URI") or "sqlite:///db.sqlite"
|
||||
|
||||
FLASK_SECRET = os.environ.get("FLASK_SECRET") or "secret"
|
||||
|
||||
# invalidate the session at each new version by changing the secret
|
||||
FLASK_SECRET = FLASK_SECRET + SHA1
|
||||
|
||||
ENABLE_SENTRY = "ENABLE_SENTRY" in os.environ
|
||||
ENV = os.environ.get("ENV")
|
||||
|
||||
print("email domain is", EMAIL_DOMAIN)
|
||||
|
||||
|
||||
AWS_REGION = "eu-west-3"
|
||||
BUCKET = os.environ.get("BUCKET") or "local.sl"
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
|
||||
CLOUDWATCH_LOG_GROUP = os.environ.get("CLOUDWATCH_LOG_GROUP")
|
||||
CLOUDWATCH_LOG_STREAM = os.environ.get("CLOUDWATCH_LOG_STREAM")
|
||||
|
||||
STRIPE_API = os.environ.get("STRIPE_API") # Stripe public key
|
||||
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
|
||||
STRIPE_YEARLY_PLAN = os.environ.get("STRIPE_YEARLY_PLAN")
|
||||
STRIPE_MONTHLY_PLAN = os.environ.get("STRIPE_MONTHLY_PLAN")
|
||||
|
||||
# Max number emails user can generate for free plan
|
||||
MAX_NB_EMAIL_FREE_PLAN = int(os.environ.get("MAX_NB_EMAIL_FREE_PLAN"))
|
||||
|
||||
LYRA_ANALYTICS_ID = os.environ.get("LYRA_ANALYTICS_ID")
|
||||
|
||||
# Used to sign id_token
|
||||
OPENID_PRIVATE_KEY_PATH = os.environ.get("OPENID_PRIVATE_KEY_PATH")
|
||||
OPENID_PUBLIC_KEY_PATH = os.environ.get("OPENID_PUBLIC_KEY_PATH")
|
||||
|
||||
PARTNER_CODES = ["SL2019"]
|
||||
|
||||
# Allow user to have 1 year of premium: set the expiration_date to 1 year more
|
||||
PROMO_CODE = "SIMPLEISBETTER"
|
|
@ -1 +1 @@
|
|||
from .views import index
|
||||
from .views import index, pricing, setting
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
from flask import Blueprint
|
||||
|
||||
dashboard_bp = Blueprint(
|
||||
name="dashboard", import_name=__name__, url_prefix="/dashboard"
|
||||
name="dashboard",
|
||||
import_name=__name__,
|
||||
url_prefix="/dashboard",
|
||||
template_folder="templates",
|
||||
)
|
||||
|
|
244
app/dashboard/templates/dashboard/index.html
Normal file
244
app/dashboard/templates/dashboard/index.html
Normal file
|
@ -0,0 +1,244 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Dashboard
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="page-header row">
|
||||
<h3 class="page-title col"
|
||||
data-intro="Here, you find the list of all <b>email alias</b> created. <br><br>
|
||||
Emails sent to an <b>alias</b> will be forwarded to your personal email. <br><br>
|
||||
Please note that email alias is <b>NOT</b> temporary, meaning an alias works forever! <br><br>
|
||||
Email alias is a great way to hide your personal email so feel free to
|
||||
use it whenever possible, for ex on untrusted websites.">
|
||||
Email Alias
|
||||
</h3>
|
||||
<form method="post" class="col text-right">
|
||||
<input type="hidden" name="form-name" value="create-new-email">
|
||||
<button class="btn btn-success">Create email alias</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards row-deck mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>
|
||||
Used On
|
||||
<i class="fe fe-help-circle" data-toggle="tooltip"
|
||||
title="List of app/website that has received this email"></i>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
<th>
|
||||
Enable/Disable Email Forwarding
|
||||
</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for gen_email in gen_emails %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<a href="mailto: {{ gen_email.email }}">{{ gen_email.email }}</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% for client_user in gen_email.client_users %}
|
||||
{{ client_user.client.name }} <br>
|
||||
{% endfor %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<button class="clipboard btn btn-secondary btn-sm"
|
||||
data-clipboard-text="{{ gen_email.email }}">
|
||||
Copy
|
||||
</button>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="trigger-email">
|
||||
<input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
|
||||
|
||||
{% if gen_email.enabled %}
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
{% if loop.index ==1 %}
|
||||
data-intro="By triggering the test email,
|
||||
SimpleLogin server will send an email to this alias
|
||||
and this email should arrive to your personal email"
|
||||
{% endif %}
|
||||
>Trigger Test Email
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="switch-email-forwarding">
|
||||
<input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
|
||||
|
||||
<label class="custom-switch"
|
||||
{% if loop.index ==1 %}
|
||||
data-intro="By turning off an alias, emails sent to this alias will <b>NOT</b>
|
||||
be forwarded to your personal email. <br><br>
|
||||
This should only be used with care as others might
|
||||
not be able to reach you after ...
|
||||
"
|
||||
{% endif %}
|
||||
>
|
||||
<input type="checkbox" class="custom-switch-input"
|
||||
{{ "checked" if gen_email.enabled else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ gen_email.created_at | dt }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-header row">
|
||||
<h3 class="page-title col" data-intro="Here you can find the list of website/app on which
|
||||
you have used the <b>Connect with SimpleLogin</b> button <br><br>
|
||||
You also see what information that SimpleLogin has communicated to these website/app when you sign in.
|
||||
">
|
||||
Apps
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards row-deck mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
App
|
||||
</th>
|
||||
<th>
|
||||
Information
|
||||
<i class="fe fe-help-circle" data-toggle="tooltip"
|
||||
title="Information sent to this app/website"></i>
|
||||
</th>
|
||||
<th class="text-center">
|
||||
First used
|
||||
<i class="fe fe-help-circle" data-toggle="tooltip"
|
||||
title="The first time you have used the SimpleLogin on this app/website"></i>
|
||||
</th>
|
||||
<!--<th class="text-center">Last used</th>-->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client_user in client_users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ client_user.client.name }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% for scope, val in client_user.get_user_info().items() %}
|
||||
<div>
|
||||
{% if scope == "email" %}
|
||||
Email: <a href="mailto:{{ val }}">{{ val }}</a>
|
||||
{% elif scope == "name" %}
|
||||
Name: {{ val }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
|
||||
|
||||
<td class="text-center">
|
||||
{{ client_user.created_at | dt }}
|
||||
</td>
|
||||
|
||||
{# TODO: add last_used#}
|
||||
<!--
|
||||
<td class="text-center">
|
||||
<div>4 minutes ago</div>
|
||||
</td>
|
||||
-->
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
require(['clipboard', 'notie', 'jquery', 'intro'], function (Clipboard, notie, $, intro) {
|
||||
var clipboard = new Clipboard('.clipboard');
|
||||
|
||||
var introShown = localStorage.getItem("introShown");
|
||||
console.log(introShown);
|
||||
if ("yes" !== introShown) {
|
||||
intro().start();
|
||||
localStorage.setItem("introShown", "yes")
|
||||
}
|
||||
|
||||
clipboard.on('success', function (e) {
|
||||
notie.alert({
|
||||
type: "success",
|
||||
text: "Copied to clipboard",
|
||||
time: 1,
|
||||
});
|
||||
|
||||
e.clearSelection();
|
||||
});
|
||||
|
||||
// the modal does not get close when user clicks outside of modal
|
||||
// necessary for obligatory modal such as the one displayed when user enable/display email forwarding
|
||||
notie.setOptions({
|
||||
overlayClickDismiss: false,
|
||||
});
|
||||
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
var message = "";
|
||||
|
||||
if (e.target.checked) {
|
||||
message = `After this, you will start receiving email sent to this email address, please confirm`;
|
||||
} else {
|
||||
message = `After this, you will stop receiving email sent to this email address, please confirm`;
|
||||
}
|
||||
|
||||
notie.confirm({
|
||||
text: message,
|
||||
cancelCallback: () => {
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
},
|
||||
submitCallback: () => {
|
||||
$(this).closest("form").submit();
|
||||
}
|
||||
});
|
||||
})
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
182
app/dashboard/templates/dashboard/pricing.html
Normal file
182
app/dashboard/templates/dashboard/pricing.html
Normal file
|
@ -0,0 +1,182 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Pricing
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style type="text/css">
|
||||
/**
|
||||
* The CSS shown here will not be introduced in the Quickstart guide, but shows
|
||||
* how you can use CSS to style your Element's container.
|
||||
*/
|
||||
.StripeElement {
|
||||
box-sizing: border-box;
|
||||
|
||||
height: 40px;
|
||||
|
||||
padding: 10px 12px;
|
||||
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
|
||||
box-shadow: 0 1px 3px 0 #e6ebf1;
|
||||
-webkit-transition: box-shadow 150ms ease;
|
||||
transition: box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.StripeElement--focus {
|
||||
box-shadow: 0 1px 3px 0 #cfd7df;
|
||||
}
|
||||
|
||||
.StripeElement--invalid {
|
||||
border-color: #fa755a;
|
||||
}
|
||||
|
||||
.StripeElement--webkit-autofill {
|
||||
background-color: #fefde5 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-category">Premium</div>
|
||||
<div class="display-4 my-6">$10/year</div>
|
||||
<div class="display-5 my-6">or</div>
|
||||
<div class="display-4 my-6">$1/month</div>
|
||||
<ul class="list-unstyled leading-loose">
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Privacy protected</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Infinite Login</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Infinite Emails</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Support us and our application partners
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="display-6">
|
||||
The payment is processed by <a href="https://stripe.com" target="_blank">Stripe</a>. <br>
|
||||
Your card number is never stored on our server.
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" id="payment-form">
|
||||
<div class="form-group">
|
||||
<label for="card-element" class="form-label">
|
||||
Credit or debit card
|
||||
</label>
|
||||
<div id="card-element">
|
||||
<!-- A Stripe Element will be inserted here. -->
|
||||
</div>
|
||||
|
||||
<!-- Used to display form errors. -->
|
||||
<div id="card-errors" role="alert" class="text-danger"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">Plan</div>
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" class="custom-control-input" name="plan" value="yearly" checked>
|
||||
<span class="custom-control-label">Yearly</span>
|
||||
</label>
|
||||
<label class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" class="custom-control-input" name="plan" value="monthly">
|
||||
<span class="custom-control-label">Monthly</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">Upgrade</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Create a Stripe client.
|
||||
var stripe = Stripe('{{ stripe_api }}');
|
||||
|
||||
// Create an instance of Elements.
|
||||
var elements = stripe.elements();
|
||||
|
||||
// Custom styling can be passed to options when creating an Element.
|
||||
// (Note that this demo uses a wider set of styles than the guide below.)
|
||||
var style = {
|
||||
base: {
|
||||
color: '#32325d',
|
||||
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
|
||||
fontSmoothing: 'antialiased',
|
||||
fontSize: '16px',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4'
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
color: '#fa755a',
|
||||
iconColor: '#fa755a'
|
||||
}
|
||||
};
|
||||
|
||||
// Create an instance of the card Element.
|
||||
// the postal code is asked on Safari but not on other browsers ...
|
||||
// Disable it explicitly
|
||||
var card = elements.create('card', {hidePostalCode: true, style: style});
|
||||
|
||||
// Add an instance of the card Element into the `card-element` <div>.
|
||||
card.mount('#card-element');
|
||||
|
||||
// Handle real-time validation errors from the card Element.
|
||||
card.addEventListener('change', function (event) {
|
||||
var displayError = document.getElementById('card-errors');
|
||||
if (event.error) {
|
||||
displayError.textContent = event.error.message;
|
||||
} else {
|
||||
displayError.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission.
|
||||
var form = document.getElementById('payment-form');
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
stripe.createToken(card).then(function (result) {
|
||||
if (result.error) {
|
||||
// Inform the user if there was an error.
|
||||
var errorElement = document.getElementById('card-errors');
|
||||
errorElement.textContent = result.error.message;
|
||||
} else {
|
||||
// Send the token to your server.
|
||||
stripeTokenHandler(result.token);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Submit the form with the token ID.
|
||||
function stripeTokenHandler(token) {
|
||||
// Insert the token ID into the form so it gets submitted to the server
|
||||
var form = document.getElementById('payment-form');
|
||||
var hiddenInput = document.createElement('input');
|
||||
hiddenInput.setAttribute('type', 'hidden');
|
||||
hiddenInput.setAttribute('name', 'stripeToken');
|
||||
hiddenInput.setAttribute('value', token.id);
|
||||
form.appendChild(hiddenInput);
|
||||
|
||||
// Submit the form
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
101
app/dashboard/templates/dashboard/setting.html
Normal file
101
app/dashboard/templates/dashboard/setting.html
Normal file
|
@ -0,0 +1,101 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Setting
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="update-profile">
|
||||
|
||||
<h3>Profile</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
{{ form.name(class="form-control", value=current_user.name) }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">Profile picture</div>
|
||||
{{ form.profile_picture(class="form-control-file") }}
|
||||
{{ render_field_errors(form.profile_picture) }}
|
||||
|
||||
<img src="{{ current_user.profile_picture_url() }}" class="profile-picture">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary">Update</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
<h3>Current subscription</h3>
|
||||
Your current plan is
|
||||
{% if current_user.is_premium() %}
|
||||
<b>{{ current_user.plan.name }}</b>
|
||||
<br>
|
||||
{% if current_user.plan_expiration %}
|
||||
Ends {{ current_user.plan_expiration.humanize() }}
|
||||
{% else %}
|
||||
Renewed {{ current_user.plan_current_period_end().humanize() }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<b>{{ current_user.plan.name }}</b><br>
|
||||
{% if current_user.plan == PlanEnum.trial %}
|
||||
Ends {{ current_user.plan_expiration.humanize() }}<br>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">
|
||||
Upgrade To Premium
|
||||
</a>
|
||||
<br><br>
|
||||
<form method="post">
|
||||
{{ promo_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="promo-code">
|
||||
<h5>If you have a promo code, you can enter it here</h5>
|
||||
<p class="text-muted">You can use a given promo code only once :)</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Promo code</label>
|
||||
{{ promo_form.code(class="form-control") }}
|
||||
{{ render_field_errors(promo_form.code) }}
|
||||
</div>
|
||||
<button class="btn btn-primary">Apply</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_premium() %}
|
||||
<!-- This corresponds to the more rare case where user has upgraded the plan,
|
||||
downgraded it and decides to upgrade again before the end of the previous plan -->
|
||||
{% if current_user.plan_expiration %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="reactivate-subscription">
|
||||
<br><br>
|
||||
<button class="btn btn-warning">Reactivate subscription</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<!-- current_user.plan_expiration=None, this corresponds to the usual case
|
||||
where user has upgraded the plan, and now decide to downgrade it. -->
|
||||
<form method="post"
|
||||
onsubmit="return confirm('Your plan will be downgraded to free plan {{ current_user.plan_current_period_end().humanize() }}, please confirm.')">
|
||||
<input type="hidden" name="form-name" value="cancel-subscription">
|
||||
<br><br>
|
||||
<button class="btn btn-warning">Cancel subscription</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<h3>Change password</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="change-password">
|
||||
<button class="btn btn-outline-primary">Change password</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,10 +1,89 @@
|
|||
from flask import render_template
|
||||
from flask_login import login_required
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app import email_utils
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import GenEmail, ClientUser
|
||||
|
||||
|
||||
@dashboard_bp.route("/")
|
||||
@dashboard_bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
return render_template("dashboard/index.html")
|
||||
# User generates a new email
|
||||
if request.method == "POST":
|
||||
if request.form.get("form-name") == "trigger-email":
|
||||
gen_email_id = request.form.get("gen-email-id")
|
||||
gen_email = GenEmail.get(gen_email_id)
|
||||
|
||||
LOG.d("trigger an email to %s", gen_email)
|
||||
email_utils.send(
|
||||
gen_email.email,
|
||||
"A Test Email",
|
||||
f"""
|
||||
Hi {current_user.name} ! <br><br>
|
||||
This is a test email to make sure you receive email sent at {gen_email.email} <br><br>
|
||||
If you have any question, feel free to reply to this email :) <br><br>
|
||||
Have a nice day <br><br>
|
||||
SimpleLogin team.
|
||||
""",
|
||||
)
|
||||
flash(
|
||||
f"An email sent to {gen_email.email} is on its way, please check your inbox/spam folder",
|
||||
"success",
|
||||
)
|
||||
|
||||
elif request.form.get("form-name") == "create-new-email":
|
||||
can_create_new_email = current_user.can_create_new_email()
|
||||
|
||||
if can_create_new_email:
|
||||
gen_email = GenEmail.create_new_gen_email(user_id=current_user.id)
|
||||
db.session.commit()
|
||||
|
||||
LOG.d("generate new email %s for user %s", gen_email, current_user)
|
||||
flash(f"Email {gen_email.email} has been created", "success")
|
||||
else:
|
||||
flash(f"You need to upgrade your plan to create new email.", "warning")
|
||||
|
||||
elif request.form.get("form-name") == "switch-email-forwarding":
|
||||
gen_email_id = request.form.get("gen-email-id")
|
||||
gen_email: GenEmail = GenEmail.get(gen_email_id)
|
||||
|
||||
LOG.d("switch email forwarding for %s", gen_email)
|
||||
|
||||
gen_email.enabled = not gen_email.enabled
|
||||
if gen_email.enabled:
|
||||
flash(
|
||||
f"The email forwarding for {gen_email.email} has been enabled",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"The email forwarding for {gen_email.email} has been disabled",
|
||||
"warning",
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
client_users = (
|
||||
ClientUser.filter_by(user_id=current_user.id)
|
||||
.options(joinedload(ClientUser.client))
|
||||
.options(joinedload(ClientUser.gen_email))
|
||||
.all()
|
||||
)
|
||||
|
||||
sorted(client_users, key=lambda cu: cu.client.name)
|
||||
|
||||
gen_emails = (
|
||||
GenEmail.filter_by(user_id=current_user.id)
|
||||
.order_by(GenEmail.email)
|
||||
.options(joinedload(GenEmail.client_users))
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"dashboard/index.html", client_users=client_users, gen_emails=gen_emails
|
||||
)
|
||||
|
|
90
app/dashboard/views/pricing.py
Normal file
90
app/dashboard/views/pricing.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
import stripe
|
||||
from flask import render_template, request, flash, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from stripe.error import CardError
|
||||
|
||||
from app.config import STRIPE_API, STRIPE_MONTHLY_PLAN, STRIPE_YEARLY_PLAN
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.email_utils import notify_admin
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import PlanEnum
|
||||
|
||||
|
||||
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def pricing():
|
||||
# sanity check: make sure this page is only for free user that has never subscribed before
|
||||
# case user unsubscribe and re-subscribe will be handled later
|
||||
if current_user.is_premium():
|
||||
flash("You are already a premium user", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
if (
|
||||
current_user.stripe_customer_id
|
||||
or current_user.stripe_card_token
|
||||
or current_user.stripe_subscription_id
|
||||
):
|
||||
raise Exception("only user not exist on stripe can view this page")
|
||||
|
||||
if stripe.Customer.list(email=current_user.email):
|
||||
raise Exception("user email is already used on stripe!")
|
||||
|
||||
if request.method == "POST":
|
||||
plan_str = request.form.get("plan") # either monthly or yearly
|
||||
if plan_str == "monthly":
|
||||
plan = PlanEnum.monthly
|
||||
elif plan_str == "yearly":
|
||||
plan = PlanEnum.yearly
|
||||
else:
|
||||
raise Exception("Plan must be either yearly or monthly")
|
||||
|
||||
stripe_token = request.form.get("stripeToken")
|
||||
LOG.d("stripe card token %s for plan %s", stripe_token, plan)
|
||||
current_user.stripe_card_token = stripe_token
|
||||
|
||||
try:
|
||||
customer = stripe.Customer.create(
|
||||
source=stripe_token,
|
||||
email=current_user.email,
|
||||
metadata={"id": current_user.id},
|
||||
name=current_user.name,
|
||||
)
|
||||
except CardError as e:
|
||||
LOG.exception("payment problem, code:%s", e.code)
|
||||
flash(
|
||||
"Payment refused with error {e.message}. Could you re-try with another card please?",
|
||||
"danger",
|
||||
)
|
||||
else:
|
||||
LOG.d("stripe customer %s", customer)
|
||||
current_user.stripe_customer_id = customer.id
|
||||
|
||||
stripe_plan = (
|
||||
STRIPE_MONTHLY_PLAN if plan == PlanEnum.monthly else STRIPE_YEARLY_PLAN
|
||||
)
|
||||
subscription = stripe.Subscription.create(
|
||||
customer=current_user.stripe_customer_id,
|
||||
items=[{"plan": stripe_plan}],
|
||||
expand=["latest_invoice.payment_intent"],
|
||||
)
|
||||
|
||||
LOG.d("stripe subscription %s", subscription)
|
||||
|
||||
current_user.stripe_subscription_id = subscription.id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if subscription.latest_invoice.payment_intent.status == "succeeded":
|
||||
LOG.d("payment successful for user %s", current_user)
|
||||
current_user.plan = plan
|
||||
current_user.plan_expiration = None
|
||||
db.session.commit()
|
||||
flash("Thanks for your subscription!", "success")
|
||||
notify_admin(
|
||||
f"user {current_user.email} has finished subscription",
|
||||
f"plan: {plan}",
|
||||
)
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
return render_template("dashboard/pricing.html", stripe_api=STRIPE_API)
|
173
app/dashboard/views/setting.py
Normal file
173
app/dashboard/views/setting.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
from io import BytesIO
|
||||
|
||||
import arrow
|
||||
import stripe
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import s3, email_utils
|
||||
from app.config import URL, PROMO_CODE
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.email_utils import notify_admin
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import PlanEnum, File, ResetPasswordCode
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
class SettingForm(FlaskForm):
|
||||
name = StringField("Name", validators=[validators.DataRequired()])
|
||||
profile_picture = FileField("Profile Picture")
|
||||
|
||||
|
||||
class PromoCodeForm(FlaskForm):
|
||||
code = StringField("Name", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
@dashboard_bp.route("/setting", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def setting():
|
||||
form = SettingForm()
|
||||
promo_form = PromoCodeForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form.get("form-name") == "update-profile":
|
||||
if form.validate():
|
||||
# update user info
|
||||
current_user.name = form.name.data
|
||||
|
||||
if form.profile_picture.data:
|
||||
file_path = random_string(30)
|
||||
file = File.create(path=file_path)
|
||||
|
||||
s3.upload_from_bytesio(
|
||||
file_path, BytesIO(form.profile_picture.data.read())
|
||||
)
|
||||
|
||||
db.session.flush()
|
||||
LOG.d("upload file %s to s3", file)
|
||||
|
||||
current_user.profile_picture_id = file.id
|
||||
db.session.flush()
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Your profile has been updated", "success")
|
||||
elif request.form.get("form-name") == "cancel-subscription":
|
||||
# sanity check
|
||||
if not (current_user.is_premium() and current_user.plan_expiration is None):
|
||||
raise Exception("user cannot cancel subscription")
|
||||
|
||||
notify_admin(f"user {current_user} cancels subscription")
|
||||
|
||||
# the plan will finish at the end of the current period
|
||||
current_user.plan_expiration = current_user.plan_current_period_end()
|
||||
stripe.Subscription.modify(
|
||||
current_user.stripe_subscription_id, cancel_at_period_end=True
|
||||
)
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"Your plan will be downgraded {current_user.plan_expiration.humanize()}",
|
||||
"success",
|
||||
)
|
||||
elif request.form.get("form-name") == "reactivate-subscription":
|
||||
if not (current_user.is_premium() and current_user.plan_expiration):
|
||||
raise Exception("user cannot reactivate subscription")
|
||||
|
||||
notify_admin(f"user {current_user} reactivates subscription")
|
||||
|
||||
# the plan will finish at the end of the current period
|
||||
current_user.plan_expiration = None
|
||||
stripe.Subscription.modify(
|
||||
current_user.stripe_subscription_id, cancel_at_period_end=False
|
||||
)
|
||||
db.session.commit()
|
||||
flash(f"Your plan is reactivated now, thank you!", "success")
|
||||
elif request.form.get("form-name") == "change-password":
|
||||
send_reset_password_email(current_user)
|
||||
elif request.form.get("form-name") == "promo-code":
|
||||
if promo_form.validate():
|
||||
promo_code = promo_form.code.data.upper()
|
||||
if promo_code != PROMO_CODE:
|
||||
flash(
|
||||
"Unknown promo code. Are you sure this is the right code?",
|
||||
"warning",
|
||||
)
|
||||
return render_template(
|
||||
"dashboard/setting.html",
|
||||
form=form,
|
||||
PlanEnum=PlanEnum,
|
||||
promo_form=promo_form,
|
||||
)
|
||||
elif promo_code in current_user.get_promo_codes():
|
||||
flash(
|
||||
"You have already used this promo code. A code can be used only once :(",
|
||||
"warning",
|
||||
)
|
||||
return render_template(
|
||||
"dashboard/setting.html",
|
||||
form=form,
|
||||
PlanEnum=PlanEnum,
|
||||
promo_form=promo_form,
|
||||
)
|
||||
else:
|
||||
LOG.d("apply promo code %s for user %s", promo_code, current_user)
|
||||
current_user.plan = PlanEnum.trial
|
||||
|
||||
if current_user.plan_expiration:
|
||||
LOG.d("extend the current plan 1 year")
|
||||
current_user.plan_expiration = current_user.plan_expiration.shift(
|
||||
years=1
|
||||
)
|
||||
else:
|
||||
LOG.d("set plan_expiration to 1 year from now")
|
||||
current_user.plan_expiration = arrow.now().shift(years=1)
|
||||
|
||||
current_user.save_new_promo_code(promo_code)
|
||||
db.session.commit()
|
||||
|
||||
flash(
|
||||
"The promo code has been applied successfully to your account!",
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
return render_template(
|
||||
"dashboard/setting.html", form=form, PlanEnum=PlanEnum, promo_form=promo_form
|
||||
)
|
||||
|
||||
|
||||
def send_reset_password_email(user):
|
||||
"""
|
||||
generate a new ResetPasswordCode and send it over email to user
|
||||
"""
|
||||
reset_password_code = ResetPasswordCode.create(
|
||||
user_id=user.id, code=random_string(60)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
|
||||
|
||||
email_utils.send(
|
||||
user.email,
|
||||
f"Reset your password on SimpleLogin",
|
||||
html_content=f"""
|
||||
Hi {user.name}! <br><br>
|
||||
|
||||
To reset or change your password, please follow this link <a href="{reset_password_link}">reset password</a>.
|
||||
Or you can paste this link into your browser: <br><br>
|
||||
|
||||
{reset_password_link} <br><br>
|
||||
|
||||
Cheers,
|
||||
SimpleLogin team.
|
||||
""",
|
||||
)
|
||||
|
||||
flash(
|
||||
"You are going to receive an email containing instruction to change your password",
|
||||
"success",
|
||||
)
|
1
app/developer/__init__.py
Normal file
1
app/developer/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .views import index, new_client, client_detail
|
18
app/developer/base.py
Normal file
18
app/developer/base.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from flask import Blueprint, render_template
|
||||
from flask_login import current_user
|
||||
|
||||
from app.log import LOG
|
||||
|
||||
developer_bp = Blueprint(
|
||||
name="developer",
|
||||
import_name=__name__,
|
||||
url_prefix="/developer",
|
||||
template_folder="templates",
|
||||
)
|
||||
|
||||
|
||||
@developer_bp.before_request
|
||||
def before_request():
|
||||
if current_user.is_authenticated and not current_user.is_developer:
|
||||
LOG.error("User %s tries to go developer tab")
|
||||
return render_template("error/403.html"), 403
|
150
app/developer/templates/developer/client_detail.html
Normal file
150
app/developer/templates/developer/client_detail.html
Normal file
|
@ -0,0 +1,150 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "developer" %}
|
||||
|
||||
{% block title %}
|
||||
Developer - Edit client
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<h3>App Information</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">App Name</label>
|
||||
{{ form.name(class="form-control", value=client.name) }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Website Url</label>
|
||||
{{ form.home_url(class="form-control", type="url", value=client.home_url or "") }}
|
||||
{{ render_field_errors(form.home_url) }}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">App Icon</div>
|
||||
{{ form.icon(class="form-control-file") }}
|
||||
{{ render_field_errors(form.icon) }}
|
||||
|
||||
{% if client.icon_id %}
|
||||
<img src="{{ client.icon.get_url() }}" class="client-icon">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3>OpenID/OAuth2 parameters</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">OAuth2 Client ID</label>
|
||||
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" value="{{ client.oauth_client_id }}" class="form-control">
|
||||
<span class="input-group-append">
|
||||
<button
|
||||
data-clipboard-text="{{ client.oauth_client_id }}"
|
||||
class="clipboard btn btn-primary" type="button">
|
||||
<i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">OAuth2 Client Secret</label>
|
||||
|
||||
<div class="input-group mt-2">
|
||||
<input type="password" value="{{ client.oauth_client_secret }}" class="form-control">
|
||||
<span class="input-group-append">
|
||||
<button
|
||||
data-clipboard-text="{{ client.oauth_client_secret }}"
|
||||
class="clipboard btn btn-primary" type="button">
|
||||
<i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Authorized URIs</label>
|
||||
|
||||
{% for redirect_uri in client.redirect_uris %}
|
||||
<div class="input-group mt-2">
|
||||
<input type="url" name="uri" class="form-control" value="{{ redirect_uri.uri }}" required>
|
||||
|
||||
<span class="input-group-append">
|
||||
<button class="remove-uri btn btn-primary" type="button">
|
||||
<i class="fe fe-x"></i>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div id="new-uris">
|
||||
<!-- New uri will be put here -->
|
||||
</div>
|
||||
|
||||
|
||||
<button type="button" id="create-new-uri" class="mt-2 btn btn-outline-secondary">Add new uri</button>
|
||||
</div>
|
||||
<hr>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Update</button>
|
||||
</form>
|
||||
|
||||
<!-- template for new uri -->
|
||||
<div class="input-group mt-2" id="hidden-uri" style="display: none">
|
||||
<input type="url" name="uri" class="form-control" required>
|
||||
|
||||
<span class="input-group-append">
|
||||
<button class="remove-uri btn btn-primary" type="button">
|
||||
<i class="fe fe-x"></i>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script type="text/x-template" id="course-detail">
|
||||
<h1> ALO </h1>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
require(["jquery", "notie", "clipboard"], function ($, notie, Clipboard) {
|
||||
|
||||
$("#create-new-uri").on("click", function (e) {
|
||||
var clone = $("#hidden-uri").clone(true, true); // (true, true) to clone withDataAndEvents, deepWithDataAndEvents
|
||||
clone.removeAttr("id");
|
||||
|
||||
$("#new-uris").append(clone);
|
||||
clone.show();
|
||||
});
|
||||
|
||||
$(".remove-uri").click(function (e) {
|
||||
var currentElement = $(this);
|
||||
currentElement.parent().parent().remove();
|
||||
});
|
||||
|
||||
var clipboard = new Clipboard('.clipboard');
|
||||
|
||||
clipboard.on('success', function (e) {
|
||||
notie.alert({
|
||||
type: "success",
|
||||
text: "Copied to clipboard",
|
||||
time: 2,
|
||||
});
|
||||
|
||||
e.clearSelection();
|
||||
});
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
164
app/developer/templates/developer/index.html
Normal file
164
app/developer/templates/developer/index.html
Normal file
|
@ -0,0 +1,164 @@
|
|||
{% from "_formhelpers.html" import render_field %}
|
||||
|
||||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "developer" %}
|
||||
|
||||
{% block title %}
|
||||
Developer
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<a href="{{ url_for('developer.new_client') }}" class="btn btn-success">Create new app</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards row-deck mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center w-1"><i class="icon-people"></i></th>
|
||||
<th>Name</th>
|
||||
<th>OAuth2 Client ID</th>
|
||||
<th>Scopes</th>
|
||||
<th>Number Users</th>
|
||||
<th>Edit</th>
|
||||
<!--<th>Publish</th>-->
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
{% if client.icon_id %}
|
||||
<div class="avatar d-block" style="background-image: url({{ client.icon.get_url() }})">
|
||||
<span class="avatar-status bg-green"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<a href="{{ url_for('developer.client_detail', client_id=client.id) }}">
|
||||
{{ client.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
Created at: {{ client.created_at |dt }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ client.oauth_client_id }}
|
||||
</td>
|
||||
|
||||
<td class="align-middle">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for scope in client.scopes %}
|
||||
<li>
|
||||
<i class="fe fe-check"></i>
|
||||
{{ scope.name }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ client.nb_user() }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="{{ url_for('developer.client_detail', client_id=client.id) }}" class="btn btn-info">
|
||||
<i class="fe fe-edit"></i>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<!-- TODO: uncomment when bringing back "Discover" feature
|
||||
<td>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="switch-client-publish">
|
||||
<input type="hidden" name="client-id" value="{{ client.id }}">
|
||||
|
||||
<label class="custom-switch">
|
||||
<input type="checkbox" class="custom-switch-input"
|
||||
{{ "checked" if client.published else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
-->
|
||||
|
||||
<td>
|
||||
<form method="post"
|
||||
onsubmit="return confirm('Please make sure no user is using this client. This operation is not reversible');">
|
||||
<input type="hidden" name="form-name" value="delete-client">
|
||||
<input type="hidden" name="client-id" value="{{ client.id }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fe fe-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
require(['clipboard', 'notie', 'jquery'], function (Clipboard, notie, $) {
|
||||
var clipboard = new Clipboard('.btn');
|
||||
|
||||
clipboard.on('success', function (e) {
|
||||
notie.alert({
|
||||
type: "success",
|
||||
text: "Copied to clipboard",
|
||||
time: 1,
|
||||
});
|
||||
|
||||
e.clearSelection();
|
||||
});
|
||||
|
||||
// the modal does not get close when user clicks outside of modal
|
||||
// necessary for obligatory modal such as the one displayed when user enable/display email forwarding
|
||||
notie.setOptions({
|
||||
overlayClickDismiss: false,
|
||||
});
|
||||
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
// Only ask for confirmation when publishing, not when un-publishing
|
||||
if (e.target.checked) {
|
||||
var message = `After this, your app/website will made available in "Discover", please confirm`;
|
||||
|
||||
notie.confirm({
|
||||
text: message,
|
||||
cancelCallback: () => {
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
},
|
||||
submitCallback: () => {
|
||||
$(this).closest("form").submit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$(this).closest("form").submit();
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
37
app/developer/templates/developer/new_client.html
Normal file
37
app/developer/templates/developer/new_client.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "developer" %}
|
||||
|
||||
{% block title %}
|
||||
Developer - Create new client
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">App Name</label>
|
||||
{{ form.name(class="form-control") }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Website Url</label>
|
||||
{{ form.home_url(class="form-control", type="url") }}
|
||||
{{ render_field_errors(form.home_url) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">App Icon</div>
|
||||
{{ form.icon(class="form-control-file") }}
|
||||
{{ render_field_errors(form.icon) }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</form>
|
||||
|
||||
|
||||
{% endblock %}
|
0
app/developer/views/__init__.py
Normal file
0
app/developer/views/__init__.py
Normal file
71
app/developer/views/client_detail.py
Normal file
71
app/developer/views/client_detail.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
from io import BytesIO
|
||||
|
||||
from flask import request, render_template, redirect, url_for, flash
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import s3
|
||||
from app.developer.base import developer_bp
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import Client, RedirectUri, File
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
class EditClientForm(FlaskForm):
|
||||
name = StringField("Name", validators=[validators.DataRequired()])
|
||||
icon = FileField("Icon")
|
||||
home_url = StringField("Home Url")
|
||||
|
||||
|
||||
@developer_bp.route("/clients/<client_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def client_detail(client_id):
|
||||
form = EditClientForm()
|
||||
|
||||
client = Client.get(client_id)
|
||||
if not client:
|
||||
flash("no such client", "warning")
|
||||
return redirect(url_for("developer.index"))
|
||||
|
||||
if client.user_id != current_user.id:
|
||||
flash("you cannot see this client", "warning")
|
||||
return redirect(url_for("developer.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
if form.validate():
|
||||
client.name = form.name.data
|
||||
client.home_url = form.home_url.data
|
||||
|
||||
if form.icon.data:
|
||||
# todo: remove current icon if any
|
||||
# todo: handle remove icon
|
||||
file_path = random_string(30)
|
||||
file = File.create(path=file_path)
|
||||
|
||||
s3.upload_from_bytesio(file_path, BytesIO(form.icon.data.read()))
|
||||
|
||||
db.session.commit()
|
||||
LOG.d("upload file %s to s3", file)
|
||||
|
||||
client.icon_id = file.id
|
||||
db.session.commit()
|
||||
|
||||
uris = request.form.getlist("uri")
|
||||
|
||||
# replace all uris. TODO: optimize this?
|
||||
for redirect_uri in client.redirect_uris:
|
||||
redirect_uri.delete()
|
||||
|
||||
for uri in uris:
|
||||
RedirectUri.create(client_id=client_id, uri=uri)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f"client {client.name} has been updated", "success")
|
||||
|
||||
return redirect(url_for("developer.client_detail", client_id=client.id))
|
||||
|
||||
return render_template("developer/client_detail.html", form=form, client=client)
|
52
app/developer/views/index.py
Normal file
52
app/developer/views/index.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
"""List of clients"""
|
||||
from flask import render_template, request, flash, redirect, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.developer.base import developer_bp
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import Client
|
||||
|
||||
|
||||
@developer_bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
# delete client
|
||||
if request.method == "POST":
|
||||
if request.form.get("form-name") == "delete-client":
|
||||
client_id = int(request.form.get("client-id"))
|
||||
client = Client.get(client_id)
|
||||
|
||||
if client.user_id != current_user.id:
|
||||
flash("You cannot remove this client", "warning")
|
||||
else:
|
||||
client_name = client.name
|
||||
client.delete()
|
||||
db.session.commit()
|
||||
LOG.d("Remove client %s", client)
|
||||
flash(f"Client {client_name} has been deleted successfully", "success")
|
||||
|
||||
elif request.form.get("form-name") == "switch-client-publish":
|
||||
client_id = int(request.form.get("client-id"))
|
||||
client = Client.get(client_id)
|
||||
|
||||
if client.user_id != current_user.id:
|
||||
flash("You cannot modify this client", "warning")
|
||||
else:
|
||||
client.published = not client.published
|
||||
db.session.commit()
|
||||
LOG.d("Switch client.published %s", client)
|
||||
|
||||
if client.published:
|
||||
flash(
|
||||
f"Client {client.name} has been published on Discover",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash(f"Client {client.name} has been un-published", "success")
|
||||
|
||||
return redirect(url_for("developer.index"))
|
||||
|
||||
clients = Client.filter_by(user_id=current_user.id).all()
|
||||
|
||||
return render_template("developer/index.html", clients=clients)
|
50
app/developer/views/new_client.py
Normal file
50
app/developer/views/new_client.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
from io import BytesIO
|
||||
|
||||
from flask import request, render_template, redirect, url_for, flash
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import s3
|
||||
from app.developer.base import developer_bp
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import Client, File
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
class NewClientForm(FlaskForm):
|
||||
name = StringField("Name", validators=[validators.DataRequired()])
|
||||
icon = FileField("Icon")
|
||||
home_url = StringField("Home Url")
|
||||
|
||||
|
||||
@developer_bp.route("/new_client", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def new_client():
|
||||
form = NewClientForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if form.validate():
|
||||
client = Client.create_new(form.name.data, current_user.id)
|
||||
client.home_url = form.home_url.data
|
||||
db.session.commit()
|
||||
|
||||
if form.icon.data:
|
||||
file_path = random_string(30)
|
||||
file = File.create(path=file_path)
|
||||
|
||||
s3.upload_from_bytesio(file_path, BytesIO(form.icon.data.read()))
|
||||
|
||||
db.session.commit()
|
||||
LOG.d("upload file %s to s3", file)
|
||||
|
||||
client.icon_id = file.id
|
||||
db.session.commit()
|
||||
|
||||
flash("New client has been created", "success")
|
||||
|
||||
return redirect(url_for("developer.client_detail", client_id=client.id))
|
||||
|
||||
return render_template("developer/new_client.html", form=form)
|
1
app/discover/__init__.py
Normal file
1
app/discover/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .views import index
|
8
app/discover/base.py
Normal file
8
app/discover/base.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from flask import Blueprint
|
||||
|
||||
discover_bp = Blueprint(
|
||||
name="discover",
|
||||
import_name=__name__,
|
||||
url_prefix="/discover",
|
||||
template_folder="templates",
|
||||
)
|
41
app/discover/templates/discover/index.html
Normal file
41
app/discover/templates/discover/index.html
Normal file
|
@ -0,0 +1,41 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "discover" %}
|
||||
|
||||
{% block title %}
|
||||
Discover
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
|
||||
<h3>Apps</h3>
|
||||
<p class="text-muted">
|
||||
App/Website that have implemented <b>Connect with SimpeLogin</b>
|
||||
</p>
|
||||
|
||||
<div class="row row-cards row-deck">
|
||||
{% for client in clients %}
|
||||
<div class="col-sm-4 col-xl-2">
|
||||
<div class="card">
|
||||
<a href="{{ client.home_url }}" target="_blank">
|
||||
<img class="card-img-top" src="{{ client.get_icon_url() }}">
|
||||
</a>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h4><a href="{{ client.home_url }}">
|
||||
{{ client.name }}
|
||||
</a></h4>
|
||||
|
||||
<div class="text-muted">
|
||||
{{ client.home_url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
0
app/discover/views/__init__.py
Normal file
0
app/discover/views/__init__.py
Normal file
12
app/discover/views/index.py
Normal file
12
app/discover/views/index.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from flask import render_template
|
||||
from flask_login import login_required
|
||||
|
||||
from app.discover.base import discover_bp
|
||||
from app.models import Client
|
||||
|
||||
|
||||
@discover_bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
clients = Client.filter_by(published=True).all()
|
||||
return render_template("discover/index.html", clients=clients)
|
38
app/email_utils.py
Normal file
38
app/email_utils.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
# using SendGrid's Python Library
|
||||
# https://github.com/sendgrid/sendgrid-python
|
||||
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
|
||||
from app.config import SUPPORT_EMAIL, SENDGRID_API_KEY, ENV
|
||||
from app.log import LOG
|
||||
|
||||
|
||||
def send(to_email, subject, html_content):
|
||||
# On local only print out email content
|
||||
if ENV == "local":
|
||||
LOG.d(
|
||||
"send mail to %s, subject:%s, content:%s", to_email, subject, html_content
|
||||
)
|
||||
return
|
||||
|
||||
message = Mail(
|
||||
from_email=SUPPORT_EMAIL,
|
||||
to_emails=to_email,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
)
|
||||
sg = SendGridAPIClient(SENDGRID_API_KEY)
|
||||
response = sg.send(message)
|
||||
LOG.d("sendgrid res:%s, email:%s", response.status_code, to_email)
|
||||
|
||||
|
||||
def notify_admin(subject, html_content):
|
||||
send(
|
||||
SUPPORT_EMAIL,
|
||||
subject,
|
||||
f"""
|
||||
<html><body>
|
||||
{html_content}
|
||||
</body></html>""",
|
||||
)
|
|
@ -1,34 +1,8 @@
|
|||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy, Model
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
|
||||
class CRUDMixin(Model):
|
||||
"""Mixin that adds convenience methods for CRUD (create, read, update, delete) operations."""
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kwargs):
|
||||
"""Create a new record and save it the database."""
|
||||
instance = cls(**kwargs)
|
||||
return instance.save()
|
||||
|
||||
def update(self, commit=True, **kwargs):
|
||||
"""Update specific fields of a record."""
|
||||
for attr, value in kwargs.items():
|
||||
setattr(self, attr, value)
|
||||
return commit and self.save() or self
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save the record."""
|
||||
db.session.add(self)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return self
|
||||
|
||||
def delete(self, commit=True):
|
||||
"""Remove the record from the database."""
|
||||
db.session.delete(self)
|
||||
return commit and db.session.commit()
|
||||
|
||||
|
||||
db = SQLAlchemy(model_class=CRUDMixin)
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate(db=db)
|
||||
|
|
47
app/jose_utils.py
Normal file
47
app/jose_utils.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
import arrow
|
||||
from jwcrypto import jwk, jwt
|
||||
|
||||
from app.config import OPENID_PRIVATE_KEY_PATH, URL
|
||||
from app.log import LOG
|
||||
from app.models import ClientUser
|
||||
|
||||
with open(OPENID_PRIVATE_KEY_PATH, "rb") as f:
|
||||
key = jwk.JWK.from_pem(f.read())
|
||||
|
||||
|
||||
def get_jwk_key() -> dict:
|
||||
return key._public_params()
|
||||
|
||||
|
||||
def make_id_token(client_user: ClientUser):
|
||||
"""Make id_token for OpenID Connect
|
||||
According to RFC 7519, these claims are mandatory:
|
||||
- iss
|
||||
- sub
|
||||
- aud
|
||||
- exp
|
||||
- iat
|
||||
"""
|
||||
claims = {
|
||||
"iss": URL,
|
||||
"sub": str(client_user.id),
|
||||
"aud": client_user.client.oauth_client_id,
|
||||
"exp": arrow.now().shift(hours=1).timestamp,
|
||||
"iat": arrow.now().timestamp,
|
||||
}
|
||||
|
||||
claims = {**claims, **client_user.get_user_info()}
|
||||
|
||||
jwt_token = jwt.JWT(header={"alg": "RS256", "kid": "simple-login"}, claims=claims)
|
||||
jwt_token.make_signed_token(key)
|
||||
return jwt_token.serialize()
|
||||
|
||||
|
||||
def verify_id_token(id_token) -> bool:
|
||||
try:
|
||||
jwt.JWT(key=key, jwt=id_token)
|
||||
except Exception:
|
||||
LOG.exception("id token not verified")
|
||||
return False
|
||||
else:
|
||||
return True
|
64
app/log.py
64
app/log.py
|
@ -2,22 +2,50 @@ import logging
|
|||
import sys
|
||||
import time
|
||||
|
||||
import boto3
|
||||
import watchtower
|
||||
|
||||
from app.config import (
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION,
|
||||
CLOUDWATCH_LOG_GROUP,
|
||||
ENABLE_CLOUDWATCH,
|
||||
CLOUDWATCH_LOG_STREAM,
|
||||
)
|
||||
|
||||
_log_format = "%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(module)s:%(lineno)d - %(funcName)s - %(message)s"
|
||||
_log_formatter = logging.Formatter(_log_format)
|
||||
|
||||
|
||||
def _get_console_handler(level=None):
|
||||
def _get_console_handler():
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(_log_formatter)
|
||||
console_handler.formatter.converter = time.gmtime
|
||||
|
||||
if level:
|
||||
console_handler.setLevel(level)
|
||||
|
||||
return console_handler
|
||||
|
||||
|
||||
def get_logger(name):
|
||||
def _get_watchtower_handler():
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||
region_name=AWS_REGION,
|
||||
)
|
||||
|
||||
handler = watchtower.CloudWatchLogHandler(
|
||||
log_group=CLOUDWATCH_LOG_GROUP,
|
||||
stream_name=CLOUDWATCH_LOG_STREAM,
|
||||
send_interval=5, # every 5 sec
|
||||
boto3_session=session,
|
||||
)
|
||||
|
||||
handler.setFormatter(_log_formatter)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _get_logger(name):
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
@ -25,7 +53,16 @@ def get_logger(name):
|
|||
# leave the handlers level at NOTSET so the level checking is only handled by the logger
|
||||
logger.addHandler(_get_console_handler())
|
||||
|
||||
# no propagation to avoid unexpected behaviour
|
||||
if ENABLE_CLOUDWATCH:
|
||||
print(
|
||||
"enable cloudwatch, log group",
|
||||
CLOUDWATCH_LOG_GROUP,
|
||||
"; log stream:",
|
||||
CLOUDWATCH_LOG_STREAM,
|
||||
)
|
||||
logger.addHandler(_get_watchtower_handler())
|
||||
|
||||
# no propagation to avoid propagating to root logger
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
@ -33,13 +70,12 @@ def get_logger(name):
|
|||
|
||||
print(f">>> init logging <<<")
|
||||
|
||||
# ### config root logger ###
|
||||
# do not use the default (buggy) logger
|
||||
logging.root.handlers.clear()
|
||||
# Disable flask logs such as 127.0.0.1 - - [15/Feb/2013 10:52:22] "GET /index.html HTTP/1.1" 200
|
||||
log = logging.getLogger("werkzeug")
|
||||
log.disabled = True
|
||||
|
||||
# add handlers with the default level = "warn"
|
||||
# need to add level at handler level as there's no level check in root logger
|
||||
# all the libs logs having level >= WARN will be handled by these 2 handlers
|
||||
logging.root.addHandler(_get_console_handler(logging.WARN))
|
||||
# Set some shortcuts
|
||||
logging.Logger.d = logging.Logger.debug
|
||||
logging.Logger.i = logging.Logger.info
|
||||
|
||||
LOG = get_logger("yourkey")
|
||||
LOG = _get_logger("sl")
|
||||
|
|
386
app/models.py
386
app/models.py
|
@ -1,30 +1,141 @@
|
|||
# <<< Models >>>
|
||||
from datetime import datetime
|
||||
import enum
|
||||
import hashlib
|
||||
|
||||
import arrow
|
||||
import bcrypt
|
||||
import stripe
|
||||
from arrow import Arrow
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy_utils import ArrowType
|
||||
|
||||
from app import s3
|
||||
from app.config import URL, MAX_NB_EMAIL_FREE_PLAN, EMAIL_DOMAIN
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.oauth_models import ScopeE
|
||||
from app.utils import convert_to_id, random_string
|
||||
|
||||
|
||||
class ModelMixin(object):
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=None, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(ArrowType, default=arrow.utcnow, nullable=False)
|
||||
updated_at = db.Column(ArrowType, default=None, onupdate=arrow.utcnow)
|
||||
|
||||
_repr_hide = ["created_at", "updated_at"]
|
||||
|
||||
@classmethod
|
||||
def query(cls):
|
||||
return db.session.query(cls)
|
||||
|
||||
@classmethod
|
||||
def get(cls, id):
|
||||
return cls.query.get(id)
|
||||
|
||||
@classmethod
|
||||
def get_by(cls, **kw):
|
||||
return cls.query.filter_by(**kw).first()
|
||||
|
||||
@classmethod
|
||||
def filter_by(cls, **kw):
|
||||
return cls.query.filter_by(**kw)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, **kw):
|
||||
r = cls.get_by(**kw)
|
||||
if not r:
|
||||
r = cls(**kw)
|
||||
db.session.add(r)
|
||||
|
||||
return r
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kw):
|
||||
r = cls(**kw)
|
||||
db.session.add(r)
|
||||
return r
|
||||
|
||||
def save(self):
|
||||
db.session.add(self)
|
||||
|
||||
def delete(self):
|
||||
db.session.delete(self)
|
||||
|
||||
def __repr__(self):
|
||||
values = ", ".join(
|
||||
"%s=%r" % (n, getattr(self, n))
|
||||
for n in self.__table__.c.keys()
|
||||
if n not in self._repr_hide
|
||||
)
|
||||
return "%s(%s)" % (self.__class__.__name__, values)
|
||||
|
||||
|
||||
class Client(db.Model, ModelMixin):
|
||||
client_id = db.Column(db.String(128), unique=True)
|
||||
client_secret = db.Column(db.String(128))
|
||||
redirect_uri = db.Column(db.String(1024))
|
||||
name = db.Column(db.String(128))
|
||||
class File(db.Model, ModelMixin):
|
||||
path = db.Column(db.String(128), unique=True, nullable=False)
|
||||
|
||||
def get_url(self):
|
||||
return s3.get_url(self.path)
|
||||
|
||||
|
||||
class PlanEnum(enum.Enum):
|
||||
free = 0
|
||||
trial = 1
|
||||
monthly = 2
|
||||
yearly = 3
|
||||
|
||||
|
||||
class User(db.Model, ModelMixin, UserMixin):
|
||||
email = db.Column(db.String(128), unique=True)
|
||||
__tablename__ = "users"
|
||||
email = db.Column(db.String(128), unique=True, nullable=False)
|
||||
salt = db.Column(db.String(128), nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
name = db.Column(db.String(128))
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
activated = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
plan = db.Column(
|
||||
db.Enum(PlanEnum),
|
||||
nullable=False,
|
||||
default=PlanEnum.free,
|
||||
server_default=PlanEnum.free.name,
|
||||
)
|
||||
|
||||
# only relevant for trial period
|
||||
plan_expiration = db.Column(ArrowType)
|
||||
|
||||
stripe_customer_id = db.Column(db.String(128), unique=True)
|
||||
stripe_card_token = db.Column(db.String(128), unique=True)
|
||||
stripe_subscription_id = db.Column(db.String(128), unique=True)
|
||||
|
||||
profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
|
||||
is_developer = db.Column(db.Boolean, nullable=False, server_default="0")
|
||||
|
||||
# contain the list of promo codes user has used. Promo codes are separated by ","
|
||||
promo_codes = db.Column(db.Text, nullable=True)
|
||||
|
||||
profile_picture = db.relationship(File)
|
||||
|
||||
def should_upgrade(self):
|
||||
"""User is invited to upgrade if they are in free plan or their trial ends soon"""
|
||||
if self.plan == PlanEnum.free:
|
||||
return True
|
||||
elif self.plan == PlanEnum.trial and self.plan_expiration < arrow.now().shift(
|
||||
weeks=1
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_premium(self):
|
||||
return self.plan in (PlanEnum.monthly, PlanEnum.yearly)
|
||||
|
||||
def can_create_new_email(self):
|
||||
if self.is_premium():
|
||||
return True
|
||||
# plan not expired yet
|
||||
elif self.plan == PlanEnum.trial and self.plan_expiration > arrow.now():
|
||||
return True
|
||||
else: # free or trial expired
|
||||
return GenEmail.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
|
||||
|
||||
def set_password(self, password):
|
||||
salt = bcrypt.gensalt()
|
||||
|
@ -36,16 +147,259 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
password_hash = bcrypt.hashpw(password.encode(), self.salt.encode())
|
||||
return self.password.encode() == password_hash
|
||||
|
||||
def profile_picture_url(self):
|
||||
if self.profile_picture_id:
|
||||
return self.profile_picture.get_url()
|
||||
else: # use gravatar
|
||||
hash_email = hashlib.md5(self.email.encode("utf-8")).hexdigest()
|
||||
return f"https://www.gravatar.com/avatar/{hash_email}"
|
||||
|
||||
def plan_current_period_end(self) -> Arrow:
|
||||
if not self.stripe_subscription_id:
|
||||
LOG.error(
|
||||
"plan_current_period_end should not be called with empty stripe_subscription_id"
|
||||
)
|
||||
return None
|
||||
|
||||
current_period_end_ts = stripe.Subscription.retrieve(
|
||||
self.stripe_subscription_id
|
||||
)["current_period_end"]
|
||||
|
||||
return arrow.get(current_period_end_ts)
|
||||
|
||||
def get_promo_codes(self) -> [str]:
|
||||
if not self.promo_codes:
|
||||
return []
|
||||
return self.promo_codes.split(",")
|
||||
|
||||
def save_new_promo_code(self, promo_code):
|
||||
current_promo_codes = self.get_promo_codes()
|
||||
current_promo_codes.append(promo_code)
|
||||
|
||||
self.promo_codes = ",".join(current_promo_codes)
|
||||
|
||||
|
||||
class ActivationCode(db.Model, ModelMixin):
|
||||
"""For activate user account"""
|
||||
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
code = db.Column(db.String(128), unique=True, nullable=False)
|
||||
|
||||
user = db.relationship(User)
|
||||
|
||||
# the activation code is valid for 1h
|
||||
expired = db.Column(ArrowType, default=arrow.now().shift(hours=1))
|
||||
|
||||
|
||||
class ResetPasswordCode(db.Model, ModelMixin):
|
||||
"""For resetting password"""
|
||||
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
code = db.Column(db.String(128), unique=True, nullable=False)
|
||||
|
||||
user = db.relationship(User)
|
||||
|
||||
# the activation code is valid for 1h
|
||||
expired = db.Column(ArrowType, default=arrow.now().shift(hours=1), nullable=False)
|
||||
|
||||
|
||||
class Partner(db.Model, ModelMixin):
|
||||
email = db.Column(db.String(128))
|
||||
name = db.Column(db.String(128))
|
||||
website = db.Column(db.String(1024))
|
||||
additional_information = db.Column(db.Text)
|
||||
|
||||
# If apply from a authenticated user, set user_id to the user who has applied for partnership
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=True)
|
||||
|
||||
|
||||
# <<< OAUTH models >>>
|
||||
|
||||
client_scope = db.Table(
|
||||
"client_scope",
|
||||
db.Column(
|
||||
"client_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("client.id", ondelete="cascade"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
db.Column(
|
||||
"scope_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("scope.id", ondelete="cascade"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def generate_oauth_client_id(client_name) -> str:
|
||||
oauth_client_id = convert_to_id(client_name) + "-" + random_string()
|
||||
|
||||
# check that the client does not exist yet
|
||||
if not Client.get_by(oauth_client_id=oauth_client_id):
|
||||
LOG.debug("generate oauth_client_id %s", oauth_client_id)
|
||||
return oauth_client_id
|
||||
|
||||
# Rerun the function
|
||||
LOG.warning(
|
||||
"client_id %s already exists, generate a new client_id", oauth_client_id
|
||||
)
|
||||
return generate_oauth_client_id(client_name)
|
||||
|
||||
|
||||
class Client(db.Model, ModelMixin):
|
||||
oauth_client_id = db.Column(db.String(128), unique=True, nullable=False)
|
||||
oauth_client_secret = db.Column(db.String(128), nullable=False)
|
||||
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
home_url = db.Column(db.String(1024))
|
||||
published = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# user who created this client
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
icon_id = db.Column(db.ForeignKey(File.id), nullable=True)
|
||||
|
||||
scopes = db.relationship("Scope", secondary=client_scope, lazy="subquery")
|
||||
icon = db.relationship(File)
|
||||
|
||||
def nb_user(self):
|
||||
return ClientUser.filter_by(client_id=self.id).count()
|
||||
|
||||
@classmethod
|
||||
def create_new(cls, name, user_id) -> "Client":
|
||||
# generate a client-id
|
||||
oauth_client_id = generate_oauth_client_id(name)
|
||||
oauth_client_secret = random_string(40)
|
||||
client = Client.create(
|
||||
name=name,
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# By default, add email and name scope
|
||||
client.scopes.append(Scope.get_by(name=ScopeE.NAME.value))
|
||||
client.scopes.append(Scope.get_by(name=ScopeE.EMAIL.value))
|
||||
|
||||
return client
|
||||
|
||||
def get_icon_url(self):
|
||||
if self.icon_id:
|
||||
return self.icon.get_url()
|
||||
else:
|
||||
return URL + "/static/default-icon.svg"
|
||||
|
||||
|
||||
class RedirectUri(db.Model, ModelMixin):
|
||||
"""Valid redirect uris for a client"""
|
||||
|
||||
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
|
||||
uri = db.Column(db.String(1024), nullable=False)
|
||||
|
||||
client = db.relationship(Client, backref="redirect_uris")
|
||||
|
||||
|
||||
class AuthorizationCode(db.Model, ModelMixin):
|
||||
code = db.Column(db.String(128), unique=True)
|
||||
client_id = db.Column(db.ForeignKey(Client.id))
|
||||
user_id = db.Column(db.ForeignKey(User.id))
|
||||
code = db.Column(db.String(128), unique=True, nullable=False)
|
||||
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
scope = db.Column(db.String(128))
|
||||
redirect_uri = db.Column(db.String(1024))
|
||||
|
||||
user = db.relationship(User, lazy=False)
|
||||
client = db.relationship(Client, lazy=False)
|
||||
|
||||
|
||||
class OauthToken(db.Model, ModelMixin):
|
||||
access_token = db.Column(db.String(128), unique=True)
|
||||
client_id = db.Column(db.ForeignKey(Client.id))
|
||||
user_id = db.Column(db.ForeignKey(User.id))
|
||||
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
scope = db.Column(db.String(128))
|
||||
redirect_uri = db.Column(db.String(1024))
|
||||
|
||||
user = db.relationship(User)
|
||||
client = db.relationship(Client)
|
||||
|
||||
|
||||
class Scope(db.Model, ModelMixin):
|
||||
name = db.Column(db.String(128), unique=True, nullable=False)
|
||||
|
||||
|
||||
def generate_email() -> str:
|
||||
"""generate an email address that does not exist before"""
|
||||
random_email = random_string(40) + "@" + EMAIL_DOMAIN
|
||||
|
||||
# check that the client does not exist yet
|
||||
if not GenEmail.get_by(email=random_email):
|
||||
LOG.debug("generate email %s", random_email)
|
||||
return random_email
|
||||
|
||||
# Rerun the function
|
||||
LOG.warning("email %s already exists, generate a new email", random_email)
|
||||
return generate_email()
|
||||
|
||||
|
||||
class GenEmail(db.Model, ModelMixin):
|
||||
"""Generated email"""
|
||||
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
email = db.Column(db.String(128), unique=True, nullable=False)
|
||||
|
||||
enabled = db.Column(db.Boolean(), default=True, nullable=False)
|
||||
|
||||
@classmethod
|
||||
def create_new_gen_email(cls, user_id):
|
||||
random_email = generate_email()
|
||||
return GenEmail.create(user_id=user_id, email=random_email)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GenEmail {self.id} {self.email}>"
|
||||
|
||||
|
||||
class ClientUser(db.Model, ModelMixin):
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("user_id", "client_id", name="uq_client_user"),
|
||||
)
|
||||
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
# Null means client has access to user original email
|
||||
gen_email_id = db.Column(
|
||||
db.ForeignKey(GenEmail.id, ondelete="cascade"), nullable=True
|
||||
)
|
||||
|
||||
gen_email = db.relationship(GenEmail, backref="client_users")
|
||||
|
||||
user = db.relationship(User)
|
||||
client = db.relationship(Client)
|
||||
|
||||
def get_email(self):
|
||||
return self.gen_email.email if self.gen_email_id else self.user.email
|
||||
|
||||
def get_user_info(self) -> dict:
|
||||
"""return user info according to client scope
|
||||
Return dict with key being scope name
|
||||
|
||||
"""
|
||||
res = {"id": self.id, "client": self.client.name, "email_verified": True}
|
||||
|
||||
for scope in self.client.scopes:
|
||||
if scope.name == ScopeE.NAME.value:
|
||||
res[ScopeE.NAME.value] = self.user.name
|
||||
elif scope.name == ScopeE.EMAIL.value:
|
||||
# Use generated email
|
||||
if self.gen_email_id:
|
||||
LOG.debug(
|
||||
"Use gen email for user %s, client %s", self.user, self.client
|
||||
)
|
||||
res[ScopeE.EMAIL.value] = self.gen_email.email
|
||||
# Use user original email
|
||||
else:
|
||||
res[ScopeE.EMAIL.value] = self.user.email
|
||||
|
||||
return res
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import subprocess
|
||||
|
||||
from app.config import SHA1
|
||||
from app.monitor.base import monitor_bp
|
||||
|
||||
SHA1 = subprocess.getoutput("git rev-parse HEAD")
|
||||
|
||||
|
||||
@monitor_bp.route("/git")
|
||||
def git_sha1():
|
||||
|
|
1
app/oauth/__init__.py
Normal file
1
app/oauth/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .views import authorize, token, user_info
|
5
app/oauth/base.py
Normal file
5
app/oauth/base.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from flask import Blueprint
|
||||
|
||||
oauth_bp = Blueprint(
|
||||
name="oauth", import_name=__name__, url_prefix="/oauth", template_folder="templates"
|
||||
)
|
81
app/oauth/templates/oauth/authorize.html
Normal file
81
app/oauth/templates/oauth/authorize.html
Normal file
|
@ -0,0 +1,81 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% block title %}
|
||||
Authorization
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<form class="card" method="post">
|
||||
<div class="card-body p-6">
|
||||
<!-- User has already authorized this client -->
|
||||
{% if client_user %}
|
||||
<div class="card-title">
|
||||
You have already authorized <b>{{ client.name }}</b>.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<b>{{ client.name }}</b> has access to the following information:
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{% for scope in client.scopes %}
|
||||
<li>{{ scope.name }}: {{ user_info[scope.name] }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="card-title">
|
||||
<b>{{ client.name }}</b> will receive your following information:
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{% for scope in client.scopes %}
|
||||
<li>{{ scope.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if client_user %}
|
||||
<div class="form-footer">
|
||||
<div class="btn-group" role="group" aria-label="Basic example">
|
||||
<button type="submit" name="button" value="allow"
|
||||
class="btn btn-success">Allow
|
||||
</button>
|
||||
|
||||
<a class="btn btn-light" href="javascript:history.back()">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-checkbox">
|
||||
<input type="checkbox" name="gen-email"
|
||||
class="custom-control-input" checked>
|
||||
<span class="custom-control-label">Generate a new email</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<small class="form-text text-muted">
|
||||
If checked, a new random email address will be generated for this app.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<div class="btn-group btn-block" role="group" aria-label="Basic example">
|
||||
<button type="submit" name="button" value="allow"
|
||||
class="btn btn-success">Allow
|
||||
</button>
|
||||
|
||||
<button type="submit" name="button" value="deny"
|
||||
class="btn btn-light">Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
39
app/oauth/templates/oauth/authorize_nonlogin_user.html
Normal file
39
app/oauth/templates/oauth/authorize_nonlogin_user.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block single_content %}
|
||||
<div class="row">
|
||||
<b>{{ client.name }}</b> would like to have access to your following data:
|
||||
|
||||
<ul class="mt-3">
|
||||
{% for scope in client.scopes %}
|
||||
<li>{{ scope.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<label>
|
||||
In order to accept the request, you need to login or sign up.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="btn-group w-100">
|
||||
|
||||
<a href="{{ url_for('auth.login', next=next) }}" class="btn btn-success">
|
||||
Login
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('auth.register', next=next) }}" class="btn btn-info">
|
||||
Sign Up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<a class="btn btn-block btn-secondary" href="javascript:history.back()">
|
||||
<i class="fe fe-arrow-left mr-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
0
app/oauth/views/__init__.py
Normal file
0
app/oauth/views/__init__.py
Normal file
197
app/oauth/views/authorize.py
Normal file
197
app/oauth/views/authorize.py
Normal file
|
@ -0,0 +1,197 @@
|
|||
import random
|
||||
from typing import Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import request, render_template, redirect
|
||||
from flask_login import current_user
|
||||
|
||||
from app.extensions import db
|
||||
from app.jose_utils import make_id_token
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Client,
|
||||
AuthorizationCode,
|
||||
ClientUser,
|
||||
GenEmail,
|
||||
RedirectUri,
|
||||
OauthToken,
|
||||
)
|
||||
from app.oauth.base import oauth_bp
|
||||
from app.oauth_models import get_response_types, ResponseType
|
||||
from app.utils import random_string, encode_url
|
||||
|
||||
|
||||
@oauth_bp.route("/authorize", methods=["GET", "POST"])
|
||||
def authorize():
|
||||
"""
|
||||
Redirected from client when user clicks on "Login with Server".
|
||||
This is a GET request with the following field in url
|
||||
- client_id
|
||||
- (optional) state
|
||||
- response_type: must be code
|
||||
"""
|
||||
oauth_client_id = request.args.get("client_id")
|
||||
state = request.args.get("state")
|
||||
scope = request.args.get("scope")
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
|
||||
try:
|
||||
response_types: [ResponseType] = get_response_types(request)
|
||||
except ValueError:
|
||||
return (
|
||||
"response_type must be code, token, id_token or certain combination of these."
|
||||
" Please see /.well-known/openid-configuration to see what response_type are supported ",
|
||||
400,
|
||||
)
|
||||
|
||||
if not redirect_uri:
|
||||
LOG.d("no redirect uri")
|
||||
return "redirect_uri must be set", 400
|
||||
|
||||
client = Client.get_by(oauth_client_id=oauth_client_id)
|
||||
if not client:
|
||||
return f"no such client with oauth-client-id {oauth_client_id}", 400
|
||||
|
||||
# check if redirect_uri is valid
|
||||
# allow localhost by default
|
||||
# todo: only allow https
|
||||
hostname, scheme = get_host_name_and_scheme(redirect_uri)
|
||||
if hostname != "localhost":
|
||||
if not RedirectUri.get_by(client_id=client.id, uri=redirect_uri):
|
||||
return f"{redirect_uri} is not authorized", 400
|
||||
|
||||
# redirect from client website
|
||||
if request.method == "GET":
|
||||
if current_user.is_authenticated:
|
||||
# user has already allowed this client
|
||||
client_user: ClientUser = ClientUser.get_by(
|
||||
client_id=client.id, user_id=current_user.id
|
||||
)
|
||||
user_info = {}
|
||||
if client_user:
|
||||
LOG.debug("user %s has already allowed client %s", current_user, client)
|
||||
user_info = client_user.get_user_info()
|
||||
|
||||
return render_template(
|
||||
"oauth/authorize.html", client=client, user_info=user_info
|
||||
)
|
||||
else:
|
||||
# after user logs in, redirect user back to this page
|
||||
return render_template(
|
||||
"oauth/authorize_nonlogin_user.html", client=client, next=request.url
|
||||
)
|
||||
else: # user allows or denies
|
||||
gen_new_email = request.form.get("gen-email") == "on"
|
||||
|
||||
if request.form.get("button") == "deny":
|
||||
LOG.debug("User %s denies Client %s", current_user, client)
|
||||
final_redirect_uri = f"{redirect_uri}?error=deny&state={state}"
|
||||
return redirect(final_redirect_uri)
|
||||
|
||||
LOG.debug("User %s allows Client %s", current_user, client)
|
||||
client_user = ClientUser.get_by(client_id=client.id, user_id=current_user.id)
|
||||
|
||||
# user has already allowed this client
|
||||
if client_user:
|
||||
LOG.d("user %s has already allowed client %s", current_user, client)
|
||||
# User cannot choose to gen new email
|
||||
gen_new_email = False
|
||||
else:
|
||||
client_user = ClientUser.create(
|
||||
client_id=client.id, user_id=current_user.id
|
||||
)
|
||||
db.session.flush()
|
||||
LOG.d("create client-user for client %s, user %s", client, current_user)
|
||||
|
||||
redirect_args = {}
|
||||
|
||||
if state:
|
||||
redirect_args["state"] = state
|
||||
else:
|
||||
LOG.warning(
|
||||
"more security reason, state should be added. client %s", client
|
||||
)
|
||||
|
||||
if scope:
|
||||
redirect_args["scope"] = scope
|
||||
|
||||
for response_type in response_types:
|
||||
if response_type == ResponseType.CODE:
|
||||
# Create authorization code
|
||||
auth_code = AuthorizationCode.create(
|
||||
client_id=client.id,
|
||||
user_id=current_user.id,
|
||||
code=random_string(),
|
||||
scope=scope,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
db.session.add(auth_code)
|
||||
redirect_args["code"] = auth_code.code
|
||||
elif response_type == ResponseType.TOKEN:
|
||||
# create access-token
|
||||
oauth_token = OauthToken.create(
|
||||
client_id=client.id,
|
||||
user_id=current_user.id,
|
||||
scope=scope,
|
||||
redirect_uri=redirect_uri,
|
||||
access_token=generate_access_token(),
|
||||
)
|
||||
db.session.add(oauth_token)
|
||||
redirect_args["access_token"] = oauth_token.access_token
|
||||
elif response_type == ResponseType.ID_TOKEN:
|
||||
redirect_args["id_token"] = make_id_token(client_user)
|
||||
|
||||
if gen_new_email:
|
||||
client_user.gen_email_id = create_or_choose_gen_email(current_user).id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# construct redirect_uri with redirect_args
|
||||
return redirect(construct_url(redirect_uri, redirect_args))
|
||||
|
||||
|
||||
def create_or_choose_gen_email(user) -> GenEmail:
|
||||
can_create_new_email = user.can_create_new_email()
|
||||
|
||||
if can_create_new_email:
|
||||
gen_email = GenEmail.create_new_gen_email(user_id=user.id)
|
||||
db.session.flush()
|
||||
LOG.debug("generate email %s for user %s", gen_email.email, user)
|
||||
else: # need to reuse one of the gen emails created
|
||||
LOG.d("pick a random email for gen emails for user %s", current_user)
|
||||
gen_emails = GenEmail.filter_by(user_id=current_user.id).all()
|
||||
gen_email = random.choice(gen_emails)
|
||||
|
||||
return gen_email
|
||||
|
||||
|
||||
def construct_url(url, args: Dict[str, str]):
|
||||
for i, (k, v) in enumerate(args.items()):
|
||||
# make sure to escape v
|
||||
v = encode_url(v)
|
||||
|
||||
if i == 0:
|
||||
url += f"?{k}={v}"
|
||||
else:
|
||||
url += f"&{k}={v}"
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def generate_access_token() -> str:
|
||||
"""generate an access-token that does not exist before"""
|
||||
access_token = random_string(40)
|
||||
|
||||
if not OauthToken.get_by(access_token=access_token):
|
||||
return access_token
|
||||
|
||||
# Rerun the function
|
||||
LOG.warning("access token already exists, generate a new one")
|
||||
return generate_access_token()
|
||||
|
||||
|
||||
def get_host_name_and_scheme(url: str) -> (str, str):
|
||||
"""http://localhost:5000?a=b -> (localhost, http) """
|
||||
url_comp = urlparse(url)
|
||||
|
||||
return url_comp.hostname, url_comp.scheme
|
88
app/oauth/views/token.py
Normal file
88
app/oauth/views/token.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
from flask import request, jsonify
|
||||
|
||||
from app.extensions import db
|
||||
from app.jose_utils import make_id_token
|
||||
from app.log import LOG
|
||||
from app.models import Client, AuthorizationCode, OauthToken, ClientUser
|
||||
from app.oauth.base import oauth_bp
|
||||
from app.oauth.views.authorize import generate_access_token
|
||||
from app.oauth_models import ScopeE
|
||||
|
||||
|
||||
@oauth_bp.route("/token", methods=["POST"])
|
||||
def get_access_token():
|
||||
"""
|
||||
Calls by client to exchange the access token given the authorization code.
|
||||
The client authentications using Basic Authentication.
|
||||
The form contains the following data:
|
||||
- grant_type: must be "authorization_code"
|
||||
- code: the code obtained in previous step
|
||||
"""
|
||||
# Basic authentication
|
||||
oauth_client_id = (
|
||||
request.authorization and request.authorization.username
|
||||
) or request.form.get("client_id")
|
||||
|
||||
oauth_client_secret = (
|
||||
request.authorization and request.authorization.password
|
||||
) or request.form.get("client_secret")
|
||||
|
||||
client = Client.filter_by(
|
||||
oauth_client_id=oauth_client_id, oauth_client_secret=oauth_client_secret
|
||||
).first()
|
||||
|
||||
if not client:
|
||||
return jsonify(error="wrong client-id or client-secret"), 400
|
||||
|
||||
# Get code from form data
|
||||
grant_type = request.form.get("grant_type")
|
||||
code = request.form.get("code")
|
||||
|
||||
# sanity check
|
||||
if grant_type != "authorization_code":
|
||||
return jsonify(error="grant_type must be authorization_code"), 400
|
||||
|
||||
auth_code: AuthorizationCode = AuthorizationCode.filter_by(code=code).first()
|
||||
if not auth_code:
|
||||
return jsonify(error=f"no such authorization code {code}"), 400
|
||||
|
||||
if auth_code.client_id != client.id:
|
||||
return jsonify(error=f"are you sure this code belongs to you?"), 400
|
||||
|
||||
LOG.debug(
|
||||
"Create Oauth token for user %s, client %s", auth_code.user, auth_code.client
|
||||
)
|
||||
|
||||
# Create token
|
||||
oauth_token = OauthToken.create(
|
||||
client_id=auth_code.client_id,
|
||||
user_id=auth_code.user_id,
|
||||
scope=auth_code.scope,
|
||||
redirect_uri=auth_code.redirect_uri,
|
||||
access_token=generate_access_token(),
|
||||
)
|
||||
db.session.add(oauth_token)
|
||||
|
||||
# Auth code can be used only once
|
||||
db.session.delete(auth_code)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
client_user: ClientUser = ClientUser.get_by(
|
||||
client_id=auth_code.client_id, user_id=auth_code.user_id
|
||||
)
|
||||
|
||||
user_data = client_user.get_user_info()
|
||||
|
||||
res = {
|
||||
"access_token": oauth_token.access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "",
|
||||
"user": user_data,
|
||||
}
|
||||
|
||||
if oauth_token.scope and ScopeE.OPENID.value in oauth_token.scope:
|
||||
res["id_token"] = make_id_token(client_user)
|
||||
|
||||
return jsonify(res)
|
30
app/oauth/views/user_info.py
Normal file
30
app/oauth/views/user_info.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from flask import request, jsonify
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.models import OauthToken, ClientUser
|
||||
from app.oauth.base import oauth_bp
|
||||
|
||||
|
||||
@oauth_bp.route("/user_info")
|
||||
@oauth_bp.route("/me")
|
||||
@oauth_bp.route("/userinfo")
|
||||
@cross_origin()
|
||||
def user_info():
|
||||
"""
|
||||
Call by client to get user information
|
||||
Usually bearer token is used.
|
||||
"""
|
||||
if "AUTHORIZATION" in request.headers:
|
||||
access_token = request.headers["AUTHORIZATION"].replace("Bearer ", "")
|
||||
else:
|
||||
access_token = request.args.get("access_token")
|
||||
|
||||
oauth_token: OauthToken = OauthToken.get_by(access_token=access_token)
|
||||
if not oauth_token:
|
||||
return jsonify(error="Invalid access token"), 400
|
||||
|
||||
client_user = ClientUser.get_or_create(
|
||||
client_id=oauth_token.client_id, user_id=oauth_token.user_id
|
||||
)
|
||||
|
||||
return jsonify(client_user.get_user_info())
|
57
app/oauth_models.py
Normal file
57
app/oauth_models.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
import enum
|
||||
from typing import Set, Union
|
||||
|
||||
import flask
|
||||
|
||||
|
||||
class ScopeE(enum.Enum):
|
||||
"""ScopeE to distinguish with Scope model"""
|
||||
|
||||
EMAIL = "email"
|
||||
NAME = "name"
|
||||
OPENID = "openid"
|
||||
|
||||
|
||||
class ResponseType(enum.Enum):
|
||||
CODE = "code"
|
||||
TOKEN = "token"
|
||||
ID_TOKEN = "id_token"
|
||||
|
||||
|
||||
def get_scopes(request: flask.Request) -> Set[ScopeE]:
|
||||
scope_strs = _split_arg(request.args.getlist("scope"))
|
||||
|
||||
return set([ScopeE(scope_str) for scope_str in scope_strs])
|
||||
|
||||
|
||||
def get_response_types(request: flask.Request) -> Set[ResponseType]:
|
||||
response_type_strs = _split_arg(request.args.getlist("response_type"))
|
||||
|
||||
return set([ResponseType(r) for r in response_type_strs])
|
||||
|
||||
|
||||
def _split_arg(arg_input: Union[str, list]) -> Set[str]:
|
||||
"""convert input response_type/scope into a set of string.
|
||||
arg_input = request.args.getlist(response_type|scope)
|
||||
Take into account different variations and their combinations
|
||||
- the split character is " " or ","
|
||||
- the response_type/scope passed as a list ?scope=scope_1&scope=scope_2
|
||||
"""
|
||||
res = set()
|
||||
if type(arg_input) is str:
|
||||
if " " in arg_input:
|
||||
for x in arg_input.split(" "):
|
||||
if x:
|
||||
res.add(x.lower())
|
||||
elif "," in arg_input:
|
||||
for x in arg_input.split(","):
|
||||
if x:
|
||||
res.add(x.lower())
|
||||
else:
|
||||
res.add(arg_input)
|
||||
|
||||
else:
|
||||
for arg in arg_input:
|
||||
res = res.union(_split_arg(arg))
|
||||
|
||||
return res
|
1
app/partner/__init__.py
Normal file
1
app/partner/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .views import become
|
8
app/partner/base.py
Normal file
8
app/partner/base.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from flask import Blueprint
|
||||
|
||||
partner_bp = Blueprint(
|
||||
name="partner",
|
||||
import_name=__name__,
|
||||
url_prefix="/partner",
|
||||
template_folder="templates",
|
||||
)
|
58
app/partner/templates/partner/become.html
Normal file
58
app/partner/templates/partner/become.html
Normal file
|
@ -0,0 +1,58 @@
|
|||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Become Partner
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
{% if error %}
|
||||
<div class="text-danger text-center mb-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Together, let's create the best login experience for users!</div>
|
||||
<p class="text-muted">Becoming a partner will give you access to technical resources on SimpleLogin.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Your Email</label>
|
||||
{{ form.email(class="form-control", type="email", placeholder="partner@my-app.com", value=current_user.email) }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Your Business Name</label>
|
||||
{{ form.name(class="form-control", placeholder="My App Inc", value=current_user.name) }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Your Website/App URL</label>
|
||||
{{ form.website(class="form-control", type="url", placeholder="https://my-app.com") }}
|
||||
{{ render_field_errors(form.website) }}
|
||||
</div>
|
||||
|
||||
<!-- Possibility to bypass using promo code. Only applied for user already authenticated -->
|
||||
{% if current_user.is_authenticated %}
|
||||
<hr>
|
||||
<h4 class="text-center">
|
||||
Or if you have a <em>partner code</em>, you can become a partner right away!
|
||||
</h4>
|
||||
<br>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Partner Code</label>
|
||||
{{ form.partner_code(class="form-control", type="text", placeholder="Partner Code") }}
|
||||
{{ render_field_errors(form.partner_code) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Become a Partner</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
0
app/partner/views/__init__.py
Normal file
0
app/partner/views/__init__.py
Normal file
77
app/partner/views/become.py
Normal file
77
app/partner/views/become.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
from flask import request, render_template, redirect, url_for, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField
|
||||
|
||||
from app.config import PARTNER_CODES
|
||||
from app.email_utils import notify_admin
|
||||
from app.extensions import db
|
||||
from app.models import Partner
|
||||
from app.partner.base import partner_bp
|
||||
|
||||
|
||||
class BecomePartnerForm(FlaskForm):
|
||||
email = StringField("Email")
|
||||
name = StringField("Name")
|
||||
website = StringField("Website")
|
||||
additional_information = StringField("Additional Information")
|
||||
partner_code = StringField("Partner Code")
|
||||
|
||||
|
||||
@partner_bp.route("/become", methods=["GET", "POST"])
|
||||
def become():
|
||||
form = BecomePartnerForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# bypass the application
|
||||
if form.partner_code.data:
|
||||
if not current_user.is_authenticated:
|
||||
raise Exception("only authenticated user can enter partner code")
|
||||
|
||||
if form.partner_code.data in PARTNER_CODES:
|
||||
notify_admin(
|
||||
f"User {current_user.name} has become partner!",
|
||||
{current_user.email},
|
||||
)
|
||||
|
||||
current_user.is_developer = True
|
||||
db.session.commit()
|
||||
|
||||
flash(
|
||||
"Congratulations, you are now a SimpleLogin partner! "
|
||||
"You will have access to tech resources on SimpleLogin.",
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for("developer.index"))
|
||||
else:
|
||||
error = (
|
||||
"The partner code is unknown. Are you sure this is the right code?"
|
||||
)
|
||||
return render_template("partner/become.html", form=form, error=error)
|
||||
else:
|
||||
partner = Partner.create(
|
||||
email=form.email.data,
|
||||
name=form.name.data,
|
||||
website=form.website.data,
|
||||
additional_information=form.additional_information.data,
|
||||
)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
partner.user_id = current_user.id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
notify_admin(
|
||||
f"New partner {partner.name} {partner.email} has signed up!",
|
||||
partner.website,
|
||||
)
|
||||
|
||||
flash(
|
||||
"Your request has been submitted, we'll come back to you asap!",
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for("partner.become"))
|
||||
|
||||
return render_template("partner/become.html", form=form)
|
38
app/s3.py
Normal file
38
app/s3.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
from io import BytesIO
|
||||
|
||||
import boto3
|
||||
|
||||
from app.config import AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||
region_name=AWS_REGION,
|
||||
)
|
||||
|
||||
|
||||
def upload_from_bytesio(key: str, bs: BytesIO, content_type="string") -> None:
|
||||
bs.seek(0)
|
||||
session.resource("s3").Bucket(BUCKET).put_object(
|
||||
Key=key, Body=bs, ContentType=content_type
|
||||
)
|
||||
|
||||
|
||||
def delete_file(key: str) -> None:
|
||||
o = session.resource("s3").Bucket(BUCKET).Object(key)
|
||||
o.delete()
|
||||
|
||||
|
||||
def get_url(key: str) -> str:
|
||||
"""by default the link will expire in 1h (3600 seconds)"""
|
||||
s3_client = session.client("s3")
|
||||
return s3_client.generate_presigned_url(
|
||||
ClientMethod="get_object", Params={"Bucket": BUCKET, "Key": key}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open("/tmp/1.png", "rb") as f:
|
||||
upload_from_bytesio("1.png", BytesIO(f.read()))
|
||||
|
||||
print(get_url(BUCKET, "1.png"))
|
15
app/utils.py
15
app/utils.py
|
@ -1,8 +1,23 @@
|
|||
import random
|
||||
import string
|
||||
import urllib.parse
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
def random_string(length=10):
|
||||
"""Generate a random string of fixed length """
|
||||
letters = string.ascii_lowercase
|
||||
return "".join(random.choice(letters) for _ in range(length))
|
||||
|
||||
|
||||
def convert_to_id(s: str):
|
||||
"""convert a string to id-like: remove space, remove special accent"""
|
||||
s = s.replace(" ", "")
|
||||
s = s.lower()
|
||||
s = unidecode(s)
|
||||
return s
|
||||
|
||||
|
||||
def encode_url(url):
|
||||
return urllib.parse.quote(url, safe="")
|
||||
|
|
37
cron.py
Normal file
37
cron.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import arrow
|
||||
import stripe
|
||||
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import User, PlanEnum
|
||||
from server import create_app
|
||||
|
||||
|
||||
def downgrade_expired_plan():
|
||||
"""set user plan to free when plan is expired, ie plan_expiration < now
|
||||
"""
|
||||
for user in User.query.filter(
|
||||
User.plan != PlanEnum.free, User.plan_expiration < arrow.now()
|
||||
).all():
|
||||
LOG.d("set user %s to free plan", user)
|
||||
|
||||
user.plan_expiration = None
|
||||
user.plan = PlanEnum.free
|
||||
|
||||
if user.stripe_customer_id:
|
||||
LOG.d("delete user %s on stripe", user.stripe_customer_id)
|
||||
stripe.Customer.delete(user.stripe_customer_id)
|
||||
|
||||
user.stripe_card_token = None
|
||||
user.stripe_customer_id = None
|
||||
user.stripe_subscription_id = None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
LOG.d("Start running cronjob")
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
downgrade_expired_plan()
|
6
crontab.yml
Normal file
6
crontab.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
jobs:
|
||||
- name: downgrade_expired_plan
|
||||
command: python /code/cron.py
|
||||
shell: /bin/bash
|
||||
schedule: "0 0 * * *"
|
||||
captureStderr: true
|
51
local_data/jwtRS256.key
Normal file
51
local_data/jwtRS256.key
Normal file
|
@ -0,0 +1,51 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIJKQIBAAKCAgEAveotF/UeMVHdm1FSgxflIbJr0yJZ1vyDGlQRK9DFx8HU8TVp
|
||||
9iqbY4CQEcOaa7cIVI5U0fWHW7kqByJ0BwLQciHienNZKnQishmMAkqNwfK3iJNc
|
||||
GFlNMhhrRGhEpWLox5qfpizK4xd7LK1tu2X5mEMWZtJs+wLr0SyVOPhdYCvOnSeT
|
||||
/SMSgDxvFCM1tlAv/wOV0SIF6xEIKb4lyHN3YKcs4z1IkqyPtHSeaq2BHeaFTPGq
|
||||
fAL2k4W7ziHxv7dsjCN9j11UnVQRKo+/kNJtOftH08l1J2FuG3YdTxX0R0KAl7wN
|
||||
QgvbGKjns1pj2az5uQKsne5SZBFjSe86Hbk8OKUJoJqy3LV29r7eZjj2wQoIBqbh
|
||||
BkMgPJY6rC9umWkaQKi79a24KeOEZPpTvbKy+LvoWh4UAs+7hfrKHRimQj2k74Jk
|
||||
SYxwrFejpxMYt+GJqPhympcz4gv4qIuiH2CV623/K9H+WrddIHpGCjpqkGtTZSQZ
|
||||
xyPMcEp+I26MuS1dFoaK39WFx2M62OhTenhDmOgPyWp1a71eYxzwfYtBECPJ4Agq
|
||||
SJrNIHu8/h9uZ+OTGXGN5k97BiWvqLEuz12PwH1QXX/sVzfjYi3khN0yxLYPooht
|
||||
fSQa8hg2VPJHVhVZNNCytC84E0xU5yNfIuUdIZjxJVcfV4C6dtt/QVQl9lsCAwEA
|
||||
AQKCAgAvuig2+xzpXB+LJvbLhzfILiS23M0jIDZ6aWIfVso9l1LCg5/rg22lpeuO
|
||||
609lfowTY+mhEklAHdqYDGqIUIa+CBH4oABqkOEfTRhIgx/4+9xv8EiWveqOimB6
|
||||
wpFt1tuVPiCdDGi4hXApHDSVgd0mDMYWdQ96TZOh78hYluIwhxHXoNiqJyRBIe7w
|
||||
aqDW/nPxbJ87/YbrOk6I3wZzx8Dag2jeespAQimjOiONv6jRMNuTKLCllcEN9e/q
|
||||
r9EnUxtuZITrgJMBLt1ZiuKjrJ5SkfnNGbXdfbjEIfzfoS7Qsb/LYjEaxgv7uIby
|
||||
JecuDzB69FcZIYmHKG+BZyN90M13J/bgpaMyYtdCZg+lJRO2gJvY34cw9oE9/M6O
|
||||
Lpfhx2viMQ2Mij1XNn9Qz0NIe89m8A0s1YbuDWieXU9iP4iA9YDPvsI0CwZoDT+W
|
||||
rLsSL3z7ltj8Ku6ySb655TFDPZysbMM2Oc8MmC9n7xuDfhuAr8eOqNgTtLLB0cnz
|
||||
aasASouAVtl5dN1hs5LakUq414wWhLzDqXd8kwRKFkT1WIBHy8+mk9MQj/m2rM+2
|
||||
avrIVKvdewRAB3TCwy0BdnWeiJER6r+Ae/Kglbo9NuDHJIkqwLZNbtX5xleJ+5Tp
|
||||
SoG/Lmz6AH+clL0IQYg6zLViI1tgPlYPt1ZZKp7bn+qDCn1/oQKCAQEA4eJwQ/gf
|
||||
3BtFvxmwpWKJnhKSACiJEfHimHIp/kAYtmtlhaSbEhYp3V699iqc6ziVHvAGssVi
|
||||
QCLGAuwdyaxAuWUYg+LSUic+hJagv5U+iJLYyYqI2PT1RjMnc/VoG+yqLpv7XyHd
|
||||
5/b64A2XNAsaX2DaUpdbTskxCZQ/l1ifRLR0mpxQQtZvXt4+2I1T3fvGcY6562G7
|
||||
dCSunm6fP5yvVKjg+j1ezCapF3aHJAV2OG6Mvu+shZxyACDQRmHpl9ujT64Ibcc6
|
||||
p1SmeHHr8/gOJY54gg/Iujne8GVGix7lTS1dqWXEF4xLlTomYD1FNZnt6bUqjqga
|
||||
9YZIvzID9FJ5cwKCAQEA1zwR1ajM27H4GvAGi+MfE2MTa99PEGGbJghAlMBzF8Bl
|
||||
He9SCADawOCejTiVBuWghU+qg3cb2JP/Qxnokd0eXXTiuHfJB3PwZPpfsiVUMKhN
|
||||
X1ypA06qvL2VLQNpCkgLuZB3pxkxn36EYM/NPqfZmQv25qsLC/eM5mRyWTu31kIw
|
||||
C4zRsHvy0IgHJJz9YJmcS/0PRnMvy96yXx/biYK80x3Zui7foCvRmPYeCCr/qoSb
|
||||
A9olFtv2yUPKt1m0lwxknl0tEhi7EiVNnOuWP416MhvJq0pz12CuYr5MHo3Zwmrw
|
||||
pyK0hlCmMePRQTe080oSDZP8UM/DkeMaFB+uw3N1eQKCAQBmoMsBFqrjBkEaIkHv
|
||||
4mVEPIu5JrGgRZX+TWBm9BhGSWVG4xLRlOBQg8srHRFOjdayx7tDXgrVuPbePQkL
|
||||
qAeAND5/LX8BdHMjKoy+fsB6rL1yVE74w9LsojE6rjUu+sgXhScggfKggcZaJdKd
|
||||
Aq5ox0hqXfpOQXrWL1T1Hn6+aH7SAFM3CtZu8+r52LxSDyKKVZ6DI1RX4JK1yOzx
|
||||
qe6/ODt/doKrnqUU0/VymEiuOwwXdC2eRwZEqKP4VmQbat84RInv1qT/gaZg8uGR
|
||||
ZxKGXcTC0wkQE1sHPfxfGRp1hjcXz/TX/hYZJuJot23KfLVribRcPGSDSQ+kTsUd
|
||||
LJuhAoIBAQCrjvfwREI2A59taVDug7SrcVdzrmWI+yP9pqpDZzrV/ccbmzzZoES9
|
||||
ZM08Z5NyEepnGF8jtvb9JMpco/QbABNKDvcAbopQZHuDIYbRqqt2tVAm6ObW+gdh
|
||||
tgOIA6XgShj+akbVbGF/bgr6V+iTPptVQJImvsNpYIJwyjPTKKSaJdvB+RbTA5lB
|
||||
2otHBdN5Ajfw4d8hGoNIj1PCOtR0wT7dUHfRzbb2JrdEozjA7fUn59bftSvHEsGd
|
||||
H2ofx2MI2xoAmOhp+khyaEV7BNWYBp8V/cw7unangCrADksCN7MRIsh7kFAwl2xB
|
||||
bAPJZivXmHzXUdPWXiTWzhxlWfOlWwyRAoIBAQCzAvoyoh/T6l9wrA7fbmiyHIJa
|
||||
82wKBkKXsbXqsxRuFYz4J9d5AmxE/QjIQpP8jfQwNDR6vB2Gzd8aQbb3edLV5gzM
|
||||
19X1brn5qluQOuzK+J76RKvrJKvC4YvYKwSFxujXQgELTQtPsqMuYiXEdAlS9V6/
|
||||
p8l5KlA9fEySPJGmQjfQkEvS082rMGQil2jjazuiRKxGabJ/kOpWeXURhw11MbbT
|
||||
AIfult3Mt6XxdGEWUm0ERHiuF3sr5QpYrwCPxOn0z4T4j4hJPMgbU+om8d1Oqp1k
|
||||
4+L6jF/eCYArqJOTS5oQ2SchKLrF5OYRNUDWLQtt3NiGxeJVfB++sp4losCx
|
||||
-----END RSA PRIVATE KEY-----
|
14
local_data/jwtRS256.key.pub
Normal file
14
local_data/jwtRS256.key.pub
Normal file
|
@ -0,0 +1,14 @@
|
|||
-----BEGIN PUBLIC KEY-----
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAveotF/UeMVHdm1FSgxfl
|
||||
IbJr0yJZ1vyDGlQRK9DFx8HU8TVp9iqbY4CQEcOaa7cIVI5U0fWHW7kqByJ0BwLQ
|
||||
ciHienNZKnQishmMAkqNwfK3iJNcGFlNMhhrRGhEpWLox5qfpizK4xd7LK1tu2X5
|
||||
mEMWZtJs+wLr0SyVOPhdYCvOnSeT/SMSgDxvFCM1tlAv/wOV0SIF6xEIKb4lyHN3
|
||||
YKcs4z1IkqyPtHSeaq2BHeaFTPGqfAL2k4W7ziHxv7dsjCN9j11UnVQRKo+/kNJt
|
||||
OftH08l1J2FuG3YdTxX0R0KAl7wNQgvbGKjns1pj2az5uQKsne5SZBFjSe86Hbk8
|
||||
OKUJoJqy3LV29r7eZjj2wQoIBqbhBkMgPJY6rC9umWkaQKi79a24KeOEZPpTvbKy
|
||||
+LvoWh4UAs+7hfrKHRimQj2k74JkSYxwrFejpxMYt+GJqPhympcz4gv4qIuiH2CV
|
||||
623/K9H+WrddIHpGCjpqkGtTZSQZxyPMcEp+I26MuS1dFoaK39WFx2M62OhTenhD
|
||||
mOgPyWp1a71eYxzwfYtBECPJ4AgqSJrNIHu8/h9uZ+OTGXGN5k97BiWvqLEuz12P
|
||||
wH1QXX/sVzfjYi3khN0yxLYPoohtfSQa8hg2VPJHVhVZNNCytC84E0xU5yNfIuUd
|
||||
IZjxJVcfV4C6dtt/QVQl9lsCAwEAAQ==
|
||||
-----END PUBLIC KEY-----
|
45
migrations/alembic.ini
Normal file
45
migrations/alembic.ini
Normal file
|
@ -0,0 +1,45 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
96
migrations/env.py
Normal file
96
migrations/env.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
from __future__ import with_statement
|
||||
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
from flask import current_app
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url', current_app.config.get(
|
||||
'SQLALCHEMY_DATABASE_URI').replace('%', '%%'))
|
||||
target_metadata = current_app.extensions['migrate'].db.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
process_revision_directives=process_revision_directives,
|
||||
**current_app.extensions['migrate'].configure_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
25
migrations/script.py.mako
Normal file
25
migrations/script.py.mako
Normal file
|
@ -0,0 +1,25 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
29
migrations/versions/0256244cd7c8_.py
Normal file
29
migrations/versions/0256244cd7c8_.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 0256244cd7c8
|
||||
Revises: 3cd10cfce8c3
|
||||
Create Date: 2019-06-28 11:19:50.401222
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0256244cd7c8'
|
||||
down_revision = '3cd10cfce8c3'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('activation_code', sa.Column('expired', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('activation_code', 'expired')
|
||||
# ### end Alembic commands ###
|
24
migrations/versions/213fcca48483_.py
Normal file
24
migrations/versions/213fcca48483_.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 213fcca48483
|
||||
Revises: 0256244cd7c8
|
||||
Create Date: 2019-06-30 11:11:51.823062
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '213fcca48483'
|
||||
down_revision = '0256244cd7c8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.alter_column('users', 'trial_expiration', new_column_name='plan_expiration')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.alter_column('users', 'plan_expiration', new_column_name='trial_expiration')
|
28
migrations/versions/2fe19381f386_.py
Normal file
28
migrations/versions/2fe19381f386_.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 2fe19381f386
|
||||
Revises: d03e433dc248
|
||||
Create Date: 2019-07-01 11:47:24.934574
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2fe19381f386'
|
||||
down_revision = 'd03e433dc248'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('is_developer', sa.Boolean(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'is_developer')
|
||||
# ### end Alembic commands ###
|
34
migrations/versions/3cd10cfce8c3_.py
Normal file
34
migrations/versions/3cd10cfce8c3_.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 3cd10cfce8c3
|
||||
Revises: 5e549314e1e2
|
||||
Create Date: 2019-06-27 10:40:12.606337
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3cd10cfce8c3'
|
||||
down_revision = '5e549314e1e2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('authorization_code', sa.Column('redirect_uri', sa.String(length=1024), nullable=True))
|
||||
op.add_column('authorization_code', sa.Column('scope', sa.String(length=128), nullable=True))
|
||||
op.add_column('oauth_token', sa.Column('redirect_uri', sa.String(length=1024), nullable=True))
|
||||
op.add_column('oauth_token', sa.Column('scope', sa.String(length=128), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('oauth_token', 'scope')
|
||||
op.drop_column('oauth_token', 'redirect_uri')
|
||||
op.drop_column('authorization_code', 'scope')
|
||||
op.drop_column('authorization_code', 'redirect_uri')
|
||||
# ### end Alembic commands ###
|
29
migrations/versions/590d89f981c0_.py
Normal file
29
migrations/versions/590d89f981c0_.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 590d89f981c0
|
||||
Revises: b20ee72fd9a4
|
||||
Create Date: 2019-07-01 21:46:58.613910
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '590d89f981c0'
|
||||
down_revision = 'b20ee72fd9a4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('promo_codes', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'promo_codes')
|
||||
# ### end Alembic commands ###
|
172
migrations/versions/5e549314e1e2_.py
Normal file
172
migrations/versions/5e549314e1e2_.py
Normal file
|
@ -0,0 +1,172 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 5e549314e1e2
|
||||
Revises:
|
||||
Create Date: 2019-06-23 16:02:14.692075
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
from sqlalchemy.dialects.postgresql import ENUM
|
||||
|
||||
revision = '5e549314e1e2'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# alembic cannot handle enum for now
|
||||
enum = ENUM("free", "trial", "monthly", "yearly", name="plan_enum", create_type=False)
|
||||
enum.create(op.get_bind(), checkfirst=False)
|
||||
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('file',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('path', sa.String(length=128), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('path')
|
||||
)
|
||||
op.create_table('scope',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('email', sa.String(length=128), nullable=False),
|
||||
sa.Column('salt', sa.String(length=128), nullable=False),
|
||||
sa.Column('password', sa.String(length=128), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('is_admin', sa.Boolean(), nullable=False),
|
||||
sa.Column('activated', sa.Boolean(), nullable=False),
|
||||
sa.Column('plan', enum, server_default='free', nullable=False),
|
||||
sa.Column('trial_expiration', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('stripe_customer_id', sa.String(length=128), nullable=True),
|
||||
sa.Column('stripe_card_token', sa.String(length=128), nullable=True),
|
||||
sa.Column('stripe_subscription_id', sa.String(length=128), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('stripe_card_token'),
|
||||
sa.UniqueConstraint('stripe_customer_id'),
|
||||
sa.UniqueConstraint('stripe_subscription_id')
|
||||
)
|
||||
op.create_table('activation_code',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(length=128), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
op.create_table('client',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('oauth_client_id', sa.String(length=128), nullable=False),
|
||||
sa.Column('oauth_client_secret', sa.String(length=128), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('home_url', sa.String(length=1024), nullable=True),
|
||||
sa.Column('published', sa.Boolean(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('icon_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['icon_id'], ['file.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('oauth_client_id')
|
||||
)
|
||||
op.create_table('gen_email',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(length=128), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
op.create_table('authorization_code',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('code', sa.String(length=128), nullable=False),
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
op.create_table('client_scope',
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('scope_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['scope_id'], ['scope.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('client_id', 'scope_id')
|
||||
)
|
||||
op.create_table('client_user',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('gen_email_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['gen_email_id'], ['gen_email.id'], ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'client_id', name='uq_client_user')
|
||||
)
|
||||
op.create_table('oauth_token',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('access_token', sa.String(length=128), nullable=True),
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('access_token')
|
||||
)
|
||||
op.create_table('redirect_uri',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('uri', sa.String(length=1024), nullable=False),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('redirect_uri')
|
||||
op.drop_table('oauth_token')
|
||||
op.drop_table('client_user')
|
||||
op.drop_table('client_scope')
|
||||
op.drop_table('authorization_code')
|
||||
op.drop_table('gen_email')
|
||||
op.drop_table('client')
|
||||
op.drop_table('activation_code')
|
||||
op.drop_table('users')
|
||||
op.drop_table('scope')
|
||||
op.drop_table('file')
|
||||
# ### end Alembic commands ###
|
40
migrations/versions/b20ee72fd9a4_.py
Normal file
40
migrations/versions/b20ee72fd9a4_.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: b20ee72fd9a4
|
||||
Revises: 2fe19381f386
|
||||
Create Date: 2019-07-01 13:15:05.391100
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b20ee72fd9a4'
|
||||
down_revision = '2fe19381f386'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('partner',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('email', sa.String(length=128), nullable=True),
|
||||
sa.Column('name', sa.String(length=128), nullable=True),
|
||||
sa.Column('website', sa.String(length=1024), nullable=True),
|
||||
sa.Column('additional_information', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('partner')
|
||||
# ### end Alembic commands ###
|
39
migrations/versions/d03e433dc248_.py
Normal file
39
migrations/versions/d03e433dc248_.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: d03e433dc248
|
||||
Revises: f234688f5ebd
|
||||
Create Date: 2019-06-30 23:24:28.486465
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd03e433dc248'
|
||||
down_revision = 'f234688f5ebd'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('reset_password_code',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(length=128), nullable=False),
|
||||
sa.Column('expired', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('reset_password_code')
|
||||
# ### end Alembic commands ###
|
30
migrations/versions/f234688f5ebd_.py
Normal file
30
migrations/versions/f234688f5ebd_.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: f234688f5ebd
|
||||
Revises: 213fcca48483
|
||||
Create Date: 2019-06-30 18:30:55.295040
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f234688f5ebd'
|
||||
down_revision = '213fcca48483'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('profile_picture_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'users', 'file', ['profile_picture_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'users', type_='foreignkey')
|
||||
op.drop_column('users', 'profile_picture_id')
|
||||
# ### end Alembic commands ###
|
0
poc/__init__.py
Normal file
0
poc/__init__.py
Normal file
17
poc/jwt-jws-jwk.py
Normal file
17
poc/jwt-jws-jwk.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
|
||||
# Don't add passphrase
|
||||
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
|
||||
|
||||
"""
|
||||
from jwcrypto import jwk, jws, jwt
|
||||
|
||||
with open("jwtRS256.key", "rb") as f:
|
||||
key = jwk.JWK.from_pem(f.read())
|
||||
|
||||
Token = jwt.JWT(header={"alg": "RS256"}, claims={"info": "I'm a signed token"})
|
||||
Token.make_signed_token(key)
|
||||
print(Token.serialize())
|
||||
|
||||
# verify
|
||||
jwt.JWT(key=key, jwt=Token.serialize())
|
33
poc/poc_send_email.py
Normal file
33
poc/poc_send_email.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
"""POC on how to send email through postfix directly
|
||||
TODO: need to improve email score before using this
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
fromaddr = "hello@u.sl.meo.ovh"
|
||||
toaddr = "test-7hxfo@mail-tester.com"
|
||||
|
||||
# alternative is necessary so email client will display html version first, then use plain one as fall-back
|
||||
msg = MIMEMultipart("alternative")
|
||||
|
||||
msg["From"] = fromaddr
|
||||
msg["To"] = toaddr
|
||||
msg["Subject"] = "test subject 2"
|
||||
|
||||
msg.attach(MIMEText("test plain body", "plain"))
|
||||
msg.attach(
|
||||
MIMEText(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<b>Test body</b>
|
||||
</body>
|
||||
</html>""",
|
||||
"html",
|
||||
)
|
||||
)
|
||||
|
||||
with smtplib.SMTP(host="localhost") as server:
|
||||
server.sendmail(fromaddr, toaddr, msg.as_string())
|
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
|
@ -0,0 +1,18 @@
|
|||
[tool.black]
|
||||
exclude = '''
|
||||
(
|
||||
/(
|
||||
\.eggs # exclude a few common directories in the
|
||||
| \.git # root of the project
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| _build
|
||||
| buck-out
|
||||
| build
|
||||
| dist
|
||||
| migrations # migrations/ is generated by alembic
|
||||
)/
|
||||
)
|
||||
'''
|
27
requirements.in
Normal file
27
requirements.in
Normal file
|
@ -0,0 +1,27 @@
|
|||
flask_sqlalchemy
|
||||
flask
|
||||
flask_login
|
||||
wtforms
|
||||
unidecode
|
||||
gunicorn
|
||||
pip-tools
|
||||
bcrypt
|
||||
python-dotenv
|
||||
ipython
|
||||
sqlalchemy_utils
|
||||
psycopg2-binary
|
||||
sentry_sdk
|
||||
blinker
|
||||
arrow
|
||||
sendgrid
|
||||
Flask-WTF
|
||||
boto3
|
||||
Flask-Migrate
|
||||
flask_admin
|
||||
pytest
|
||||
flask-cors
|
||||
watchtower
|
||||
sqlalchemy-utils
|
||||
stripe
|
||||
jwcrypto
|
||||
yacron
|
|
@ -1,4 +1,89 @@
|
|||
flask_sqlalchemy
|
||||
flask
|
||||
flask_login
|
||||
wtforms
|
||||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile --output-file requirements.txt requirements.in
|
||||
#
|
||||
aiohttp==3.5.4 # via raven-aiohttp, yacron
|
||||
aiosmtplib==1.0.6 # via yacron
|
||||
alembic==1.0.10 # via flask-migrate
|
||||
appnope==0.1.0 # via ipython
|
||||
arrow==0.14.2
|
||||
asn1crypto==0.24.0 # via cryptography
|
||||
async-timeout==3.0.1 # via aiohttp
|
||||
atomicwrites==1.3.0 # via pytest
|
||||
attrs==19.1.0 # via aiohttp, pytest
|
||||
backcall==0.1.0 # via ipython
|
||||
bcrypt==3.1.6
|
||||
blinker==1.4
|
||||
boto3==1.9.167
|
||||
botocore==1.12.167 # via boto3, s3transfer
|
||||
certifi==2019.3.9 # via requests, sentry-sdk
|
||||
cffi==1.12.3 # via bcrypt, cryptography
|
||||
chardet==3.0.4 # via aiohttp, requests
|
||||
click==7.0 # via flask, pip-tools
|
||||
crontab==0.22.5 # via yacron
|
||||
cryptography==2.7 # via jwcrypto
|
||||
decorator==4.4.0 # via ipython, traitlets
|
||||
docutils==0.14 # via botocore
|
||||
flask-admin==1.5.3
|
||||
flask-cors==3.0.8
|
||||
flask-login==0.4.1
|
||||
flask-migrate==2.5.2
|
||||
flask-sqlalchemy==2.4.0
|
||||
flask-wtf==0.14.2
|
||||
flask==1.0.3
|
||||
gunicorn==19.9.0
|
||||
idna==2.8 # via requests, yarl
|
||||
importlib-metadata==0.18 # via pluggy, pytest
|
||||
ipython-genutils==0.2.0 # via traitlets
|
||||
ipython==7.5.0
|
||||
itsdangerous==1.1.0 # via flask
|
||||
jedi==0.13.3 # via ipython
|
||||
jinja2==2.10.1 # via flask, yacron
|
||||
jmespath==0.9.4 # via boto3, botocore
|
||||
jwcrypto==0.6.0
|
||||
mako==1.0.12 # via alembic
|
||||
markupsafe==1.1.1 # via jinja2, mako
|
||||
more-itertools==7.0.0 # via pytest
|
||||
multidict==4.5.2 # via aiohttp, yarl
|
||||
packaging==19.0 # via pytest
|
||||
parso==0.4.0 # via jedi
|
||||
pexpect==4.7.0 # via ipython
|
||||
pickleshare==0.7.5 # via ipython
|
||||
pip-tools==3.8.0
|
||||
pluggy==0.12.0 # via pytest
|
||||
prompt-toolkit==2.0.9 # via ipython
|
||||
psycopg2-binary==2.8.2
|
||||
ptyprocess==0.6.0 # via pexpect
|
||||
py==1.8.0 # via pytest
|
||||
pycparser==2.19 # via cffi
|
||||
pygments==2.4.2 # via ipython
|
||||
pyparsing==2.4.0 # via packaging
|
||||
pytest==4.6.3
|
||||
python-dateutil==2.8.0 # via alembic, arrow, botocore, strictyaml
|
||||
python-dotenv==0.10.3
|
||||
python-editor==1.0.4 # via alembic
|
||||
python-http-client==3.1.0 # via sendgrid
|
||||
raven-aiohttp==0.7.0 # via yacron
|
||||
raven==6.10.0 # via raven-aiohttp, yacron
|
||||
requests==2.22.0 # via stripe
|
||||
ruamel.yaml==0.15.97 # via strictyaml
|
||||
s3transfer==0.2.1 # via boto3
|
||||
sendgrid==6.0.5
|
||||
sentry-sdk==0.9.0
|
||||
six==1.12.0 # via bcrypt, cryptography, flask-cors, packaging, pip-tools, prompt-toolkit, pytest, python-dateutil, sqlalchemy-utils, traitlets
|
||||
sqlalchemy-utils==0.33.11
|
||||
sqlalchemy==1.3.4 # via alembic, flask-sqlalchemy, sqlalchemy-utils
|
||||
strictyaml==1.0.2 # via yacron
|
||||
stripe==2.30.1
|
||||
traitlets==4.3.2 # via ipython
|
||||
unidecode==1.0.23
|
||||
urllib3==1.25.3 # via botocore, requests, sentry-sdk
|
||||
watchtower==0.6.0
|
||||
wcwidth==0.1.7 # via prompt-toolkit, pytest
|
||||
werkzeug==0.15.4 # via flask
|
||||
wtforms==2.2.1
|
||||
yacron==0.9.0
|
||||
yarl==1.3.0 # via aiohttp
|
||||
zipp==0.5.1 # via importlib-metadata
|
||||
|
|
219
server.py
219
server.py
|
@ -1,50 +1,112 @@
|
|||
import os
|
||||
|
||||
from flask import Flask
|
||||
import arrow
|
||||
import sentry_sdk
|
||||
import stripe
|
||||
from flask import Flask, redirect, url_for, render_template, request, jsonify
|
||||
from flask_admin import Admin
|
||||
from flask_cors import cross_origin
|
||||
from flask_login import current_user
|
||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||
|
||||
from app.admin_model import SLModelView, SLAdminIndexView
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import (
|
||||
DB_URI,
|
||||
FLASK_SECRET,
|
||||
ENABLE_SENTRY,
|
||||
ENV,
|
||||
URL,
|
||||
SHA1,
|
||||
LYRA_ANALYTICS_ID,
|
||||
STRIPE_SECRET_KEY,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db, login_manager
|
||||
from app.developer.base import developer_bp
|
||||
from app.discover.base import discover_bp
|
||||
from app.extensions import db, login_manager, migrate
|
||||
from app.jose_utils import get_jwk_key
|
||||
from app.log import LOG
|
||||
from app.models import Client, User
|
||||
from app.models import Client, User, Scope, ClientUser, GenEmail, RedirectUri, PlanEnum
|
||||
from app.monitor.base import monitor_bp
|
||||
from app.oauth.base import oauth_bp
|
||||
from app.oauth_models import ScopeE
|
||||
from app.partner.base import partner_bp
|
||||
|
||||
if ENABLE_SENTRY:
|
||||
LOG.d("enable sentry")
|
||||
sentry_sdk.init(
|
||||
dsn="https://ad2187ed843340a1b4165bd8d5d6cdce@sentry.io/1478143",
|
||||
integrations=[FlaskIntegration()],
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.url_map.strict_slashes = False
|
||||
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite"
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.secret_key = "secret"
|
||||
app.secret_key = FLASK_SECRET
|
||||
|
||||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||
|
||||
init_extensions(app)
|
||||
register_blueprints(app)
|
||||
set_index_page(app)
|
||||
jinja2_filter(app)
|
||||
|
||||
setup_error_page(app)
|
||||
|
||||
setup_favicon_route(app)
|
||||
setup_openid_metadata(app)
|
||||
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def fake_data():
|
||||
LOG.d("create fake data")
|
||||
# Remove db if exist
|
||||
if os.path.exists("db.sqlite"):
|
||||
LOG.d("remove existing db file")
|
||||
os.remove("db.sqlite")
|
||||
|
||||
# Create all tables
|
||||
db.create_all()
|
||||
|
||||
# fake data
|
||||
client = Client(
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
redirect_uri="http://localhost:7000/callback",
|
||||
name="Continental",
|
||||
Scope.create(name=ScopeE.NAME.value)
|
||||
Scope.create(name=ScopeE.EMAIL.value)
|
||||
db.session.commit()
|
||||
|
||||
# Create a user
|
||||
user = User.create(
|
||||
email="nguyenkims+local@gmail.com",
|
||||
name="Son Local",
|
||||
activated=True,
|
||||
is_admin=True,
|
||||
is_developer=True,
|
||||
)
|
||||
db.session.add(client)
|
||||
|
||||
user = User(id=1, email="john@wick.com", name="John Wick")
|
||||
user.set_password("password")
|
||||
db.session.add(user)
|
||||
user.plan = PlanEnum.trial
|
||||
user.plan_expiration = arrow.now().shift(weeks=2)
|
||||
db.session.commit()
|
||||
|
||||
GenEmail.create_new_gen_email(user_id=user.id)
|
||||
|
||||
# Create a client
|
||||
client1 = Client.create_new(name="Demo", user_id=user.id)
|
||||
client1.home_url = "http://sl-client:7000"
|
||||
client1.oauth_client_id = "client-id"
|
||||
client1.oauth_client_secret = "client-secret"
|
||||
client1.published = True
|
||||
db.session.commit()
|
||||
|
||||
RedirectUri.create(client_id=client1.id, uri="http://sl-client:7000/callback")
|
||||
RedirectUri.create(client_id=client1.id, uri="http://sl-client:7000/implicit")
|
||||
RedirectUri.create(client_id=client1.id, uri="http://sl-client:7000/implicit-jso")
|
||||
db.session.commit()
|
||||
|
||||
|
||||
|
@ -59,18 +121,141 @@ def register_blueprints(app: Flask):
|
|||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(monitor_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(developer_bp)
|
||||
app.register_blueprint(partner_bp)
|
||||
|
||||
app.register_blueprint(oauth_bp, url_prefix="/oauth")
|
||||
app.register_blueprint(oauth_bp, url_prefix="/oauth2")
|
||||
|
||||
app.register_blueprint(discover_bp)
|
||||
|
||||
|
||||
def set_index_page(app):
|
||||
@app.route("/")
|
||||
def index():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
@app.after_request
|
||||
def after_request(res):
|
||||
# not logging /static call
|
||||
if not request.path.startswith("/static") and not request.path.startswith(
|
||||
"/admin/static"
|
||||
):
|
||||
LOG.debug(
|
||||
"%s %s %s %s %s",
|
||||
request.remote_addr,
|
||||
request.method,
|
||||
request.path,
|
||||
request.args,
|
||||
res.status_code,
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def setup_openid_metadata(app):
|
||||
@app.route("/.well-known/openid-configuration")
|
||||
@cross_origin()
|
||||
def openid_config():
|
||||
res = {
|
||||
"issuer": URL,
|
||||
"authorization_endpoint": URL + "/oauth2/authorize",
|
||||
"token_endpoint": URL + "/oauth2/token",
|
||||
"jwks_uri": URL + "/jwks",
|
||||
"response_types_supported": [
|
||||
"code",
|
||||
"token",
|
||||
"id_token",
|
||||
"id_token token",
|
||||
"id_token code",
|
||||
],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
# todo: add introspection and revocation endpoints
|
||||
"introspection_endpoint": URL + "/oauth2/token/introspection",
|
||||
"revocation_endpoint": URL + "/oauth2/token/revocation",
|
||||
}
|
||||
|
||||
return jsonify(res)
|
||||
|
||||
@app.route("/jwks")
|
||||
@cross_origin()
|
||||
def jwks():
|
||||
res = {"keys": [get_jwk_key()]}
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
def setup_error_page(app):
|
||||
@app.errorhandler(400)
|
||||
def page_not_found(e):
|
||||
return render_template("error/400.html"), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def page_not_found(e):
|
||||
return render_template("error/401.html", current_url=request.full_path), 401
|
||||
|
||||
@app.errorhandler(403)
|
||||
def page_not_found(e):
|
||||
return render_template("error/403.html"), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template("error/404.html"), 404
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def error_handler(e):
|
||||
LOG.exception(e)
|
||||
return render_template("error/500.html"), 500
|
||||
|
||||
|
||||
def setup_favicon_route(app):
|
||||
@app.route("/favicon.ico")
|
||||
def favicon():
|
||||
return redirect("/static/favicon.ico")
|
||||
|
||||
|
||||
def jinja2_filter(app):
|
||||
def format_datetime(value):
|
||||
dt = arrow.get(value)
|
||||
return dt.humanize()
|
||||
|
||||
app.jinja_env.filters["dt"] = format_datetime
|
||||
|
||||
@app.context_processor
|
||||
def inject_stage_and_region():
|
||||
return dict(
|
||||
YEAR=arrow.now().year,
|
||||
URL=URL,
|
||||
ENABLE_SENTRY=ENABLE_SENTRY,
|
||||
VERSION=SHA1,
|
||||
LYRA_ANALYTICS_ID=LYRA_ANALYTICS_ID,
|
||||
)
|
||||
|
||||
|
||||
def init_extensions(app: Flask):
|
||||
LOG.debug("init extensions")
|
||||
login_manager.init_app(app)
|
||||
db.init_app(app)
|
||||
migrate.init_app(app)
|
||||
|
||||
|
||||
def init_admin(app):
|
||||
admin = Admin(name="SimpleLogin", template_mode="bootstrap3")
|
||||
admin.init_app(app, index_view=SLAdminIndexView())
|
||||
admin.add_view(SLModelView(User, db.session))
|
||||
admin.add_view(SLModelView(Client, db.session))
|
||||
admin.add_view(SLModelView(GenEmail, db.session))
|
||||
admin.add_view(SLModelView(ClientUser, db.session))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
fake_data()
|
||||
if ENV == "local":
|
||||
LOG.d("reset db, add fake data")
|
||||
with app.app_context():
|
||||
fake_data()
|
||||
|
||||
app.run(debug=True, threaded=False)
|
||||
app.run(debug=True, host="0.0.0.0")
|
||||
|
|
65
shell.py
Normal file
65
shell.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
import flask_migrate
|
||||
import stripe
|
||||
from IPython import embed
|
||||
from sqlalchemy_utils import create_database, database_exists, drop_database
|
||||
|
||||
from app.config import DB_URI
|
||||
from app.models import *
|
||||
from app.oauth_models import ScopeE
|
||||
from server import create_app
|
||||
|
||||
|
||||
def create_db():
|
||||
if not database_exists(DB_URI):
|
||||
LOG.debug("db not exist, create database")
|
||||
create_database(DB_URI)
|
||||
|
||||
# Create all tables
|
||||
# Use flask-migrate instead of db.create_all()
|
||||
flask_migrate.upgrade()
|
||||
|
||||
scope_name = Scope.create(name=ScopeE.NAME.value)
|
||||
db.session.add(scope_name)
|
||||
scope_email = Scope.create(name=ScopeE.EMAIL.value)
|
||||
db.session.add(scope_email)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def add_real_data():
|
||||
"""after the db is reset, add some accounts
|
||||
TODO: remove this after adding alembic"""
|
||||
user = User.create(email="nguyenkims@gmail.com", name="Son GM", activated=True)
|
||||
user.set_password("password")
|
||||
db.session.commit()
|
||||
|
||||
# Create a client
|
||||
client1 = Client.create_new(name="Demo", user_id=user.id)
|
||||
client1.oauth_client_id = "client-id"
|
||||
client1.oauth_client_secret = "client-secret"
|
||||
db.session.commit()
|
||||
|
||||
RedirectUri.create(client_id=client1.id, uri="http://demo.sl.meo.ovh/callback")
|
||||
db.session.commit()
|
||||
|
||||
user2 = User.create(email="nguyenkims@hotmail.com", name="Son HM", activated=True)
|
||||
user2.set_password("password")
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def change_password(user_id, new_password):
|
||||
user = User.get(user_id)
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def reset_db():
|
||||
if database_exists(DB_URI):
|
||||
drop_database(DB_URI)
|
||||
create_db()
|
||||
add_real_data()
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
embed()
|
20346
static/assets/css/dashboard.css
Executable file
20346
static/assets/css/dashboard.css
Executable file
File diff suppressed because one or more lines are too long
20346
static/assets/css/dashboard.rtl.css
Executable file
20346
static/assets/css/dashboard.rtl.css
Executable file
File diff suppressed because one or more lines are too long
BIN
static/assets/fonts/feather/feather-webfont.eot
Executable file
BIN
static/assets/fonts/feather/feather-webfont.eot
Executable file
Binary file not shown.
1038
static/assets/fonts/feather/feather-webfont.svg
Executable file
1038
static/assets/fonts/feather/feather-webfont.svg
Executable file
File diff suppressed because it is too large
Load diff
After Width: | Height: | Size: 163 KiB |
BIN
static/assets/fonts/feather/feather-webfont.ttf
Executable file
BIN
static/assets/fonts/feather/feather-webfont.ttf
Executable file
Binary file not shown.
BIN
static/assets/fonts/feather/feather-webfont.woff
Executable file
BIN
static/assets/fonts/feather/feather-webfont.woff
Executable file
Binary file not shown.
1
static/assets/images/browsers/android-browser.svg
Executable file
1
static/assets/images/browsers/android-browser.svg
Executable file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.2 KiB |
1
static/assets/images/browsers/aol-explorer.svg
Executable file
1
static/assets/images/browsers/aol-explorer.svg
Executable file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -32 100 100" preserveAspectRatio="xMinYMin"><g transform="translate(-208.45127,-644.63366)"><g transform="matrix(0.2576927,0,0,0.2576927,155.23992,508.16265)"><path d="M420,564.1C385.5,564.1 359.4,590.9 359.4,624.1C359.4,659.1 386.6,684.1 420,684.1C453.4,684.1 480.5,659.1 480.5,624.1C480.5,590.9 454.5,564.1 420,564.1z M420,595.8C434.9,595.7 447.1,608.4 447.1,624.1C447.1,639.7 434.9,652.4 420,652.4C405.1,652.4 392.9,639.7 392.9,624.1C392.9,608.4 405.1,595.8 420,595.8z" style="stroke:none;stroke-width:0.43820944"/><path d="M507,397.4C507,409 497.6,418.4 486,418.4C474.4,418.4 465,409 465,397.4C465,385.8 474.4,376.4 486,376.4C497.6,376.4 507,385.8 507,397.4z" style="stroke:none;stroke-width:0.1" transform="translate(85.630073,265.696)"/><path style="stroke:none;stroke-width:1px" d="M531.5,680.1L498.5,680.1L498.5,531.1L531.5,531.1L531.5,680.1z"/><path d="M208.5,680.1L268.5,531.1L299.5,531.1L358.5,680.1L316.5,680.1L309.5,659.1L257.5,659.1L250.5,680.1L208.5,680.1z M299.5,628.1L268.5,628.1L284,578.1L299.5,628.1z" style="fill-rule:evenodd;stroke:none;stroke-width:0.2"/></g></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
1
static/assets/images/browsers/blackberry.svg
Executable file
1
static/assets/images/browsers/blackberry.svg
Executable file
|
@ -0,0 +1 @@
|
|||
<svg width="39" height="39" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin"><title>blackberry</title><desc>Created with Sketch.</desc><g><title>Layer 1</title><g fill-rule="evenodd" fill="none" id="Page-1"><path fill="#000" id="blackberry" d="m12.267,11.864c0,-1.264 -0.774,-2.864 -4.027,-2.864l-5.009,0l-1.424,6.588l5.222,0c4.077,0 5.238,-1.93 5.238,-3.724l0,0zm13.493,0c0,-1.264 -0.772,-2.864 -4.024,-2.864l-5.01,0l-1.423,6.587l5.219,0c4.079,0.001 5.238,-1.929 5.238,-3.723l0,0zm-15.3,9.915c0,-1.264 -0.774,-2.868 -4.027,-2.868l-5.009,0l-1.424,6.592l5.22,0c4.078,0 5.24,-1.935 5.24,-3.724zm13.493,0c0,-1.264 -0.775,-2.868 -4.025,-2.868l-5.009,0l-1.426,6.592l5.222,0c4.079,0 5.238,-1.935 5.238,-3.724l0,0zm14.117,-4.021c0,-1.265 -0.775,-2.868 -4.025,-2.868l-5.009,0l-1.426,6.591l5.22,0c4.079,0 5.24,-1.93 5.24,-3.723l0,0zm-1.946,10.323c0,-1.265 -0.773,-2.864 -4.025,-2.864l-5.009,0l-1.424,6.588l5.22,0c4.078,0 5.238,-1.935 5.238,-3.724zm-14.11,4.022c0,-1.27 -0.772,-2.873 -4.022,-2.873l-5.012,0l-1.424,6.591l5.22,0c4.079,0.001 5.238,-1.929 5.238,-3.718l0,0z"/></g></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
1
static/assets/images/browsers/camino.svg
Executable file
1
static/assets/images/browsers/camino.svg
Executable file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 20 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue